-
Notifications
You must be signed in to change notification settings - Fork 0
feat: connect planner via OAuth 2.1 / OIDC #32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mroderick
wants to merge
4
commits into
main
Choose a base branch
from
feature/magic-link-code-exchange
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
701d3ad
chore: add @better-auth/oauth-provider and @playwright/test dependencies
mroderick af0f60e
feat: add JWT and OAuth provider plugins with login flow, seeding, an…
mroderick 491c8cc
ci: add CI pipeline and Playwright e2e test for OAuth flow
mroderick 14c1035
docs: add architecture documentation covering OAuth 2.1 flows
mroderick File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,7 @@ lerna-debug.log* | |
| # test coverage and tap | ||
| coverage/ | ||
| .tap/ | ||
| test-results/ | ||
|
|
||
| # worktrees | ||
| .worktrees/ | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,3 +4,4 @@ jobs: 1 | |
| timeout: 30 | ||
| coverage-report: | ||
| - "text" | ||
| allow-incomplete-coverage: true | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,277 @@ | ||
| # Architecture | ||
|
|
||
| How the auth app and planner app work together to authenticate users. | ||
|
|
||
| ## Overview | ||
|
|
||
| The auth app (`auth.codebar.io`) is an OAuth 2.1 / OIDC provider built with | ||
| [Better Auth](https://better-auth.com). It does not serve user-facing content | ||
| beyond its login page. Users always authenticate through the **planner app** | ||
| (`codebar.io`) which delegates to the auth app via a custom OmniAuth strategy. | ||
|
|
||
| ``` | ||
| ┌─────────────────────┐ OAuth 2.1 + OIDC ┌─────────────────────┐ | ||
| │ Planner App │◄───────────────────────►│ Auth App │ | ||
| │ (codebar.io) │ authorization code │ (auth.codebar.io) │ | ||
| │ │ PKCE │ │ | ||
| │ OmniAuth::Codebar │ token exchange │ Better Auth │ | ||
| │ Rails session │ JWKS verification │ oauthProvider │ | ||
| │ │ │ GitHub OAuth │ | ||
| │ │ │ Magic Link │ | ||
| └─────────────────────┘ └─────────────────────┘ | ||
| ``` | ||
|
|
||
| The auth app offers two ways to authenticate: | ||
|
|
||
| - **GitHub OAuth** — user signs in with their GitHub account | ||
| - **Magic Link** — user enters their email and receives a one-time sign-in link | ||
|
|
||
| Both follow the same outer OAuth 2.1 flow. They differ only in how the user | ||
| authenticates on the auth app's login page. | ||
|
|
||
| ## Architecture Diagram | ||
|
|
||
| ```mermaid | ||
| graph TB | ||
| subgraph Browser["Browser"] | ||
| U[User] | ||
| end | ||
|
|
||
| subgraph Planner["codebar.io"] | ||
| R[Rails Router] | ||
| OM[OmniAuth::Codebar] | ||
| AC[AuthServicesController] | ||
| RS[Rails Session] | ||
|
|
||
| OM -- "callback_phase" --> AC | ||
| end | ||
|
|
||
| subgraph Auth["auth.codebar.io"] | ||
| BA[Better Auth] | ||
| GP[GitHub OAuth Plugin] | ||
| ML[Magic Link Plugin] | ||
| OP[oauthProvider Plugin] | ||
| JWKS[JWKS Endpoint] | ||
| LOGIN[Login Page] | ||
| end | ||
|
|
||
| subgraph External["External"] | ||
| GH[GitHub OAuth API] | ||
| SG[SendGrid] | ||
| end | ||
|
|
||
| U -- "/auth/codebar" --> R | ||
| R -- "request_phase" --> OM | ||
| OM -- "redirect to authorize" --> U | ||
| U -- "authorize + callback" --> BA | ||
| BA -- "login page" --> LOGIN | ||
| LOGIN -- "GitHub OAuth" --> GP | ||
| GP -- "OAuth flow" --> GH | ||
| LOGIN -- "Magic Link" --> ML | ||
| ML -- "send email" --> SG | ||
| BA -- "redirect with code" --> U | ||
| U -- "/auth/codebar/callback?code=..." --> R | ||
| R -- "callback_phase" --> OM | ||
| OM -- "POST /api/auth/oauth2/token" --> BA | ||
| OM -- "GET /api/auth/jwks" --> JWKS | ||
| OM -- "call_app!" --> AC | ||
| AC --> RS | ||
| ``` | ||
|
|
||
| ## Flow A: GitHub OAuth | ||
|
|
||
| The user clicks "Sign in with codebar" on the planner, authenticates via GitHub | ||
| on the auth app, and gets redirected back. | ||
|
|
||
| ```mermaid | ||
| sequenceDiagram | ||
| actor User | ||
| participant Planner as Planner (codebar.io) | ||
| participant Auth as Auth App (auth.codebar.io) | ||
| participant GitHub as GitHub | ||
|
|
||
| Note over User,Planner: User visits /auth/codebar | ||
| User->>Planner: GET /auth/codebar | ||
| Planner->>Planner: request_phase: generate PKCE verifier, state | ||
| Planner->>User: 302 Redirect to authorize endpoint | ||
| Note right of User: ?client_id=planner&response_type=code<br/>code_challenge=...&code_challenge_method=S256 | ||
|
|
||
| User->>Auth: GET /api/auth/oauth2/authorize | ||
| Auth->>Auth: No session → show login page | ||
| Auth-->>User: Login page (GitHub + Magic Link) | ||
|
|
||
| User->>Auth: Click "Sign in with GitHub" | ||
| Auth->>GitHub: Redirect to GitHub OAuth | ||
| User->>GitHub: Sign in (if needed) | ||
| GitHub-->>Auth: OAuth callback with code | ||
| Auth->>Auth: GitHub auth → create session | ||
| Auth->>User: 302 Redirect to planner callback | ||
| Note right of User: ?code=authorization_code&state=... | ||
|
|
||
| User->>Planner: GET /auth/codebar/callback?code=...&state=... | ||
| Planner->>Planner: callback_phase: verify state | ||
| Planner->>Auth: POST /api/auth/oauth2/token | ||
| Note right of Planner: grant_type=authorization_code<br/>code_verifier=...<br/>client_id=planner | ||
| Auth-->>Planner: { access_token, id_token, expires_in } | ||
|
|
||
| Planner->>Auth: GET /api/auth/jwks | ||
| Auth-->>Planner: { keys: [ JWK ] } | ||
|
|
||
| Planner->>Planner: verify JWT signature, iss, aud | ||
| Planner->>Planner: build omniauth.auth hash | ||
| Planner->>Planner: find or create member | ||
| Planner-->>User: Signed in (session cookie) | ||
| ``` | ||
|
|
||
| ## Flow B: Magic Link | ||
|
|
||
| The user clicks "Sign in with codebar", enters their email on the auth app, | ||
| clicks the magic link, and completes the OAuth flow. | ||
|
|
||
| ```mermaid | ||
| sequenceDiagram | ||
| actor User | ||
| participant Planner as Planner (codebar.io) | ||
| participant Auth as Auth App (auth.codebar.io) | ||
| participant Email as SendGrid | ||
|
|
||
| Note over User,Planner: User visits /auth/codebar | ||
| User->>Planner: GET /auth/codebar | ||
| Planner->>Planner: request_phase: generate PKCE verifier, state | ||
| Planner->>User: 302 Redirect to authorize endpoint | ||
|
|
||
| User->>Auth: GET /api/auth/oauth2/authorize | ||
| Auth-->>User: Login page (GitHub + Magic Link) | ||
|
|
||
| User->>Auth: Enter email → "Send Magic Link" | ||
| Auth->>Email: POST send magic link email | ||
| Email-->>User: Email with sign-in link | ||
|
|
||
| User->>Auth: Click magic link in email | ||
| Auth->>Auth: Verify token → create session | ||
| Auth->>User: 302 Redirect to planner callback | ||
| Note right of User: ?code=authorization_code&state=... | ||
|
|
||
| User->>Planner: GET /auth/codebar/callback?code=...&state=... | ||
|
|
||
| Planner->>Planner: callback_phase: verify state | ||
| Planner->>Auth: POST /api/auth/oauth2/token | ||
| Auth-->>Planner: { access_token, id_token, expires_in } | ||
|
|
||
| Planner->>Auth: GET /api/auth/jwks | ||
| Auth-->>Planner: { keys: [ JWK ] } | ||
|
|
||
| Planner->>Planner: verify JWT signature, iss, aud | ||
| Planner->>Planner: build omniauth.auth hash | ||
| Planner->>Planner: find or create member | ||
| Planner-->>User: Signed in (session cookie) | ||
| ``` | ||
|
|
||
| The flows converge at the token exchange. The only difference is how the user | ||
| authenticates on the auth app. | ||
|
|
||
| ## Key Components | ||
|
|
||
| ### Auth App (`codebar/auth`) | ||
|
|
||
| - **Better Auth** instance with plugins: `jwt`, `oauthProvider`, `magicLink`, | ||
| `admin` | ||
| - **oauthProvider plugin** — issues OAuth 2.1 authorization codes and tokens. | ||
| Configured with PKCE required, `planner` as the only valid audience, and | ||
| `allowDynamicClientRegistration: false`. | ||
| - **Login page** (`/login`) — offers "Sign in with GitHub" and "Send Magic | ||
| Link" options, served during the OAuth authorize phase when no session | ||
| exists. | ||
| - **JWKS endpoint** (`/api/auth/jwks`) — serves public keys for JWT signature | ||
| verification, fetched and cached by the planner. | ||
| - **Seed client** (`src/app/db/seed-client.js`) — registers the `planner` | ||
| OAuth client in the database via raw SQL. Runs during every deploy (release | ||
| phase) for idempotency. | ||
|
|
||
| ### Planner App (`codebar/planner`) | ||
|
|
||
| - **OmniAuth custom strategy** (`lib/omniauth/strategies/codebar.rb`) | ||
| — implements the OAuth 2.1 client with PKCE: | ||
| - **Request phase** — redirects to the auth app's authorize endpoint with | ||
| PKCE challenge, state, and OIDC scopes | ||
| - **Callback phase** — verifies state, exchanges the code for tokens, | ||
| verifies the JWT against JWKS, builds the `omniauth.auth` hash | ||
| - **Route** (`/auth/codebar`) — triggers the OmniAuth request phase | ||
| - **Route** (`/auth/codebar/callback`) — triggers the callback phase, which | ||
| calls `call_app!` → `AuthServicesController#create` | ||
| - **Controller** (`app/controllers/auth_services_controller.rb`) — creates or | ||
| finds a `Member` and establishes a Rails session | ||
|
|
||
| ## Environment Layout | ||
|
|
||
| | App | Domain | Heroku App | | ||
| | -------------------- | ------------------------------- | ------------------------------------------------- | | ||
| | Auth (production) | `auth.codebar.io` | `codebar-auth-production` | | ||
| | Planner (production) | `codebar.io` | `codebar-auth-production` (branch: `heroku/main`) | | ||
| | Planner (staging) | `codebar-staging.herokuapp.com` | `codebar-staging` | | ||
|
|
||
| There is no staging auth app. Auth changes are deployed directly to production | ||
| via the `heroku/main` branch. | ||
|
|
||
| ### Environment Variables | ||
|
|
||
| **Auth app:** | ||
|
|
||
| | Variable | Purpose | | ||
| | ----------------------- | ----------------------------------------------------- | | ||
| | `DATABASE_URL` | PostgreSQL connection string | | ||
| | `GITHUB_CLIENT_ID` | GitHub OAuth app client ID | | ||
| | `GITHUB_CLIENT_SECRET` | GitHub OAuth app client secret | | ||
| | `SENDGRID_API_KEY` | API key for magic link email delivery | | ||
| | `PLANNER_REDIRECT_URIS` | Comma-separated list of allowed callback URIs | | ||
| | `CODEBAR_AUTH_URL` | Self-referential base URL (`https://auth.codebar.io`) | | ||
|
|
||
| **Planner app:** | ||
|
|
||
| | Variable | Purpose | | ||
| | ------------------ | --------------------------------------------- | | ||
| | `CODEBAR_AUTH_URL` | Auth app base URL (`https://auth.codebar.io`) | | ||
| | `CODEBAR_AUDIENCE` | JWT audience (`planner`) | | ||
|
|
||
| ## Operational Details | ||
|
|
||
| ### OAuth Client Seeding | ||
|
|
||
| The `planner` OAuth client is registered directly in the database rather than | ||
| through Better Auth's admin API. The seed runs during the Heroku release phase | ||
| via `scripts/migrate.js`: | ||
|
|
||
| ``` | ||
| heroku-release.sh → node scripts/migrate.js → seedPlannerClient() | ||
| ``` | ||
|
|
||
| The client is configured with: | ||
|
|
||
| - **Public client** (no client secret) — PKCE is required for all flows | ||
| - **Multiple redirect URIs** — one per environment, configured via | ||
| `PLANNER_REDIRECT_URIS` | ||
| - **Idempotent** — uses `ON CONFLICT DO UPDATE` so redeploys refresh the row | ||
|
|
||
| ### Known Issues & Workarounds | ||
|
|
||
| **Cloudflare User-Agent filtering.** The auth app sits behind Cloudflare, | ||
| which rejects HTTP requests with the default Ruby User-Agent (`User-Agent: | ||
| Ruby`). The planner's OmniAuth strategy sets a descriptive User-Agent on every | ||
| outgoing request (`Codebar Planner/1.0`). | ||
|
|
||
| **Cross-site state cookie check.** Better Auth v1.6.20 added a signed cookie | ||
| check on the OAuth callback that fails in cross-site flows (planner → auth → | ||
| planner). The auth app disables this check with | ||
| `account.skipStateCookieCheck: true`. | ||
|
|
||
| **Redirect URIs as JSON array.** The `redirectUris` column in the database is | ||
| `jsonb`. The seed client uses `$1::jsonb` with `JSON.stringify()` to store | ||
| the redirect URIs as a proper JSON array. | ||
|
|
||
| ## Deployment | ||
|
|
||
| Auth app deploys are triggered by pushes to `heroku/main` (production). | ||
| The `scripts/heroku-release.sh` release phase runs `scripts/migrate.js` which | ||
| migrates the database schema and seeds the OAuth client. | ||
|
|
||
| See [deployment.md](./deployment.md) for manual deploy and rollback procedures. | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It already serves profile, logout and I think link/unlink capabilities today. Those will stay around.