feat: connect planner via OAuth 2.1 / OIDC#32
Conversation
2c4d394 to
9826035
Compare
f302a42 to
5ff85f2
Compare
| smoke: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| persist-credentials: false | ||
| - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | ||
| with: | ||
| node-version-file: ".nvmrc" | ||
| - run: npm ci | ||
| - run: | | ||
| npm run prettier:check | ||
| npm run lint | ||
| npm run fallow |
There was a problem hiding this comment.
Maybe pull these into another pr to fix?
714f6dc to
215a38d
Compare
82dbb02 to
8e0c04b
Compare
|
@till, would you mind reviewing this one? |
till
left a comment
There was a problem hiding this comment.
I started, but I need to look more.
| smoke: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| persist-credentials: false | ||
| - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | ||
| with: | ||
| node-version-file: ".nvmrc" | ||
| - run: npm ci | ||
| - run: | | ||
| npm run prettier:check | ||
| npm run lint | ||
| npm run fallow |
There was a problem hiding this comment.
Maybe pull these into another pr to fix?
| - run: npm ci | ||
| - run: | | ||
| npm run prettier:check | ||
| npm run lint |
There was a problem hiding this comment.
I don't understand these changes.
There was a problem hiding this comment.
Yeah, that was really not well implemented.
I added an e2e test, but the workflow file took a beating in the process. I've added a commit, that makes the final diff read well.
Once review is complete, I'll rebase the confusion away.
e31a213 to
326ae91
Compare
1c48d90 to
430959a
Compare
6ad9514 to
6fe91f7
Compare
…d tests Core changes: - Configure Better Auth with jwt, oauthProvider, magicLink plugins - Route login flow through OAuth authorize endpoint with callbackURL - Add skipStateCookieCheck for cross-site redirect compatibility - Add database seeding for planner OAuth client (jsonb, idempotent, multi-URI) - Add heroku-release.sh running migrate.js on every deploy - Add dev helper for magic link capture in non-production - Add test helper with isolated PG schema per test Tests cover: provider seeding, authorize, token exchange with PKCE, JWKS verification, and full integration flow.
Adds a CI workflow with unit tests, coverage, and a Playwright e2e job. The e2e test covers the full magic link OAuth flow end-to-end.
cc6b9fe to
14c1035
Compare
|
I have deployed both PRs for I also reviewed the PRs against OWASP top 10 for 2025, and fixed the few minor things that surfaced. This unfortunately meant that I had to make some changes since I originally marked this as ready for review. I chose to rewrite the git history, in order to make the PRs easier to follow ... i.e. without any side quests. They should read fairly linearly now. Testing on Heroku To verify the OAuth 2.1 flow end-to-end: Flow A: Sign in with GitHub
Flow B: Sign in with Magic Link
If something goes wrongCheck the staging app logs: heroku logs --tail -a codebar-stagingOAuth errors from the planner side show as (codebar) Authentication failure! <error_type>. The auth |
till
left a comment
There was a problem hiding this comment.
This is a lot. But LMK, what you think, not hard no's on anything. I will have a look again.
|
|
||
| ## Overview | ||
|
|
||
| The auth app (`auth.codebar.io`) is an OAuth 2.1 / OIDC provider built with |
There was a problem hiding this comment.
It already serves profile, logout and I think link/unlink capabilities today. Those will stay around.
There was a problem hiding this comment.
You're right. That sentence is misleading.
I've pushed a better version in 1a4ed9f
| */ | ||
|
|
||
| // ponytail: raw SQL because Better Auth doesn't expose a public API to | ||
| // create OAuth clients without admin auth. If the schema changes, update here. |
There was a problem hiding this comment.
Just curious, why would we not use admin-api?
There was a problem hiding this comment.
It requires a user session, so we would have create the OAuth client using the endpoint after the code has been deployed.
That seemed a bit clunky to me, so I opted for a simpler solution.
Admittedly, getting all this working in Heroku had a few other steps that were clumsy to me, so in the grand scheme of things, it wouldn't have added that much more.
| const error = c.req.query("error"); | ||
| const success = c.req.query("success"); | ||
| const redirectUrl = validateRedirectUrl(c.req.query("redirect_url"), ""); | ||
| function getCallbackURL(c) { |
There was a problem hiding this comment.
Maybe this should be somewhere else, like in the utlities. This will is pretty large already.
There was a problem hiding this comment.
Sure. I've extracted it to a different file in afbcbac
|
|
||
| app.route("/demo", demoHandler); | ||
|
|
||
| // Dev-only endpoint for Playwright tests to retrieve captured magic links |
There was a problem hiding this comment.
Similar, maybe spin them out to another file.
There was a problem hiding this comment.
You're right. I've created another fixup commit: 7e02c87
|
|
||
| if (!apiKey) { | ||
| console.log(`Magic Link for ${email}: ${url}`); | ||
| devMagicLinks.push({ |
There was a problem hiding this comment.
This should be more obvious, e.g. not rely on apiKey to be set (which could be a mistake), but instead use the same environment guard.
| export const AUTH_DEFAULT_PORT = 3001; | ||
| export const PLANNER_DEFAULT_PORT = 3000; | ||
|
|
||
| const parseRedirectUris = () => |
There was a problem hiding this comment.
Does the auth app have a staging? Otherwise, how would this work.
There was a problem hiding this comment.
It doesn't have a staging environment. The allowed redirect uris are in PLANNER_REDIRECT_URIS.
It is explained in the architecture doc, which is easy to overlook.
I've added a comment in a fixup commit 6977b55
| const testInstance = await getTestInstance(); | ||
| const app = createApp(testInstance.auth, testInstance.db); | ||
|
|
||
| const params = new URLSearchParams({ |
There was a problem hiding this comment.
I'd put this into a helper and make it more descriptive. But the params repeats itself.
Maybe generally, I haven't look at it all in detail, but it could use some test helpers for all the tests.
There was a problem hiding this comment.
That's a good point.
I've created another fixup for this in 37e1b9a
| @@ -0,0 +1,17 @@ | |||
| import { defineConfig, devices } from "@playwright/test"; | |||
| // extra signed cookie check fails on Heroku/Cloudflare because the | ||
| // `__Secure-better-auth.state` cookie (SameSite=Lax) doesn't survive | ||
| // the cross-site redirect from GitHub back to the callback. | ||
| // This is a known pattern: https://github.com/better-auth/better-auth/issues/4969 |
There was a problem hiding this comment.
The issue is closed and from last year, is this even applicable?
There was a problem hiding this comment.
Good catch on the issue being old and about a different problem. I investigated whether it's still needed, and it is. Here's what I found:
The account.skipStateCookieCheck option controls a second signed cookie check in parseGenericState's database strategy. The state is already validated against the verification table — the cookie is a redundant additional check that sets a __Secure-better-auth.state cookie (SameSite=Lax, httpOnly) and verifies it on callback.
This fails in the GitHub OAuth flow (user signs in with GitHub on the login page) because Cloudflare strips __Secure- prefix cookies on ingress. The same issue is present behind Heroku's router. The database verification passes fine, but the cookie check throws "State mismatch" before the user ever reaches the oauthProvider flow.
The option is not deprecated — it's still wired in v1.6.20 (create-context.mjs:130), and even Better Auth's own oauth-proxy plugin hardcodes skipStateCookieCheck: true when parsing state for cross-domain flows.
I updated the ponytail comment to explain this properly and dropped the stale issue link.
| // Dev-only endpoint for Playwright tests to retrieve captured magic links | ||
| // Guarded by NODE_ENV check AND remote address — requests from non-local | ||
| // sources are rejected even if NODE_ENV isn't production (e.g. staging). | ||
| if (process.env.NODE_ENV !== "production") { |
There was a problem hiding this comment.
Could also fetch this in config and re-use it here. So the code is not using process.env everywhere?
There was a problem hiding this comment.
Good call. We had three raw process.env.NODE_ENV references across auth.js and app.js. After the other fixups (environment guards in sendMagicLink and trustedOrigins), this was the last one that couldn't be derived from config values.
I added isProduction: process.env.NODE_ENV === "production" to src/config.js and replaced all three process.env.NODE_ENV checks with appConfig.isProduction / !appConfig.isProduction. The changes are in 9cb7eb9.
The only remaining direct process.env reads in the app code are the env vars that are genuinely per-environment config (database URLs, API keys, etc.) — those are already centralised through getter functions in config.js.
781e320 to
090aa86
Compare
090aa86 to
afbcbac
Compare
bd17b15 to
8594ee4
Compare
Summary
Connect the auth service to the planner via OAuth 2.1 / OIDC using Better Auth's built-in
oauthProviderandjwtplugins. The planner acts as a first-party OAuth client.Commits
chore: add @better-auth/oauth-provider and @playwright/test dependencies@better-auth/oauth-providerruntime dep, bumpbetter-authto 1.6.20, add@playwright/testdev depfeat: add JWT and OAuth provider plugins with login flow, seeding, and testsjwt/oauthProvider/magicLinkplugins, OAuth authorize login flow, PKCE seeding, test infrastructure with schema isolation, 49 new unit/integration testsci: add CI pipeline and Playwright e2e test for OAuth flowdocs: add architecture documentation covering OAuth 2.1 flowsChanges
Auth configuration (
src/auth.js)jwt()plugin — issuesid_tokens withemailandnameclaims for the planner (RS256)oauthProvider()plugin — exposes OAuth 2.1 authorize/token endpoints (/api/auth/oauth2/*)account.skipStateCookieCheck: true— cross-site OAuth callback compatibilitytrustedOriginswith localhost + auth base URLConfig (
src/config.js)AUTH_DEFAULT_PORTandPLANNER_DEFAULT_PORTconstantsplanner_redirect_urisandallowed_redirectsfromPLANNER_REDIRECT_URISenv var (comma-separated, supports multiple environments)Login flow (
src/app/routes/auth.js,src/app/components/login.js)redirect_url-based flow withcallbackURLpointing to the OAuth authorize endpointshowLogin,showMagicLinkForm,sendMagicLink,startGitHubOAuth)getCallbackURLpreserves OAuth query params (PKCE, state, etc.) from the authorize requestcallbackURLDatabase seeding
src/app/db/seed-client.js— inserts the planner OAuth client via raw SQL withON CONFLICT DO UPDATEfor idempotency. Accepts redirect URIs as JSON array via$1::jsonb. Validates schemaName to prevent SQL injection.scripts/migrate.js— runs migrations and seeds the planner clientscripts/heroku-release.sh— runsnode scripts/migrate.json every deployDev test helpers
src/dev/magic-links.js— captures sent magic links whenSENDGRID_API_KEYis unsetsrc/app/app.js— exposes/api/test/magic-linksGET/DELETE in non-production, magic link endpoint localhost-restrictedCI
e2ejob running Playwright after unit testschromium-headless-shellfor smaller downloadpermissions: contents: readat workflow levelDocumentation
docs/architecture.md— architecture overview with sequence diagrams, environment layout, env vars, and operational detailsTesting
158 unit tests, 0 failures. New tests cover:
redirect_uri