OAuth provider for first-party apps (Mindmap first) - #654
Open
nhyiramante1 wants to merge 13 commits into
Open
nhyiramante1 wants to merge 13 commits into
nhyiramante1 wants to merge 13 commits into
Conversation
The backend exits at startup in production when MINDMAP_OAUTH_CLIENT_ID or
MINDMAP_OAUTH_REDIRECT_URIS is missing, and the Jenkins deploy passes neither.
Merging Track A as-is would have taken production down.
Neither value is a secret, so pin them in docker-compose-prod.yml with the
usual ${VAR:-default} form: the host can still override, but an empty deploy
environment now boots with the correct public client instead of exiting.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review found that disabling registration only closes /oauth2/register. The OAuth provider plugin also exposes session-authenticated client management (create-client, update-client, delete-client, get-clients, rotate-secret), gated only by the clientPrivileges hook. With no hook, any signed-in user, anonymous demo users included, could create a public openai:chat client with their own redirect URI, and the proxy accepted a signed token from any azp. - auth.ts: clientPrivileges denies every action. The fixed client is provisioned through the adapter, which the hook does not affect. - app.ts: the proxy requires azp === the configured Mindmap client id, as an independent check that holds even if client creation reopens. - oauth-clients.ts: provisioning clears clientSecret so a pre-existing row with this id cannot keep a secret once it becomes a public client. Tests: client management is refused for a signed-in user and leaves the client table unchanged; tokens with a foreign azp or wrong issuer get 401; a stale secret is cleared on re-provisioning. All three fail without the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A genuinely signed token with an edited payload (later expiry, real user, every claim valid) is rejected, so the signature is the only possible cause. A control test shows the same helper's untampered token is accepted. - An authorization code exchanged with the wrong PKCE verifier returns a 4xx and no access token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kcarnold
reviewed
Sep 28, 2026
kcarnold
left a comment
Contributor
There was a problem hiding this comment.
A few quick comments as I'm scrolling through.
…clients Addresses review on #654. - No authorization decision depends on a token's shape any more. A presented bearer must authenticate as itself (session token, tool token, or on the proxy an accepted OAuth token) or the request is rejected; it never falls back to a cookie or to demo access. The realtime route now refuses any bearer that doesn't resolve to a user, which also closes a gap where a non-JWT garbage bearer there was treated as sessionless demo traffic. - resolveProxyIdentity reuses resolveUser instead of re-resolving tool tokens. - Accepted OAuth clients are a list (trustedOAuthClients / acceptedOAuthClientIds in config.ts). One entry today, still configured by the MINDMAP_OAUTH_* env vars; provisioning loops over the list and the proxy checks membership. - User rows are read through the typed internalAdapter.findUserById, and one allowlistFields() helper narrows the plugin/additionalFields columns at runtime, replacing the casts in both the session and OAuth paths. Tests: any non-authenticating bearer on realtime gets 401, with or without a cookie (fails against the previous code); trusted-client list config. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CodeQL (js/clear-text-logging, high) traced MINDMAP_OAUTH_CLIENT_ID into the redirect-URI validation error, which migrate.ts logs on failure. The id is a public identifier, but name the client by its hardcoded display name instead so no env value reaches the log. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Production runs on the k8s helm chart, not docker compose, so these defaults never reached the live server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The oauth-provider resource client fetched /api/auth/jwks over HTTP from the server's own public origin, a path the tests bypassed by injecting an in-memory JWKS. Verify with the jwt plugin's auth.api.verifyJWT, which reads the signing keys from our database, and drop the test-only verifier injection so tests exercise the production path. Infrastructure errors now surface as 500s instead of being reported as an invalid token. Also pin that a jwt-plugin session JWT (GET /api/auth/token) is refused at the proxy, and reword the resource guard comment: it prevents unusable opaque tokens, it is not a security boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With auth enabled and MINDMAP_OAUTH_* unset, the server exited at startup, which would crash-loop the k8s deployment (it doesn't set them). And because the migrate initContainer runs without NODE_ENV, the dev defaults would have registered a localhost redirect in the production database. Now there are no code defaults (get_env.py supplies dev values); unset config logs a warning and leaves Mindmap login disabled; provisioning runs only at server startup, not in migrate.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The access token carries `sid`, the Better Auth session that approved it. The proxy now requires that session to exist, be unexpired, and belong to the token's subject, so signing out of Writing Tools revokes Mindmap's token immediately rather than leaving it valid for up to 12 hours. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This change makes the backend an OAuth provider; "standalone" describes Mindmap, its first client. Rename the spec to docs/oauth-provider.md and the integration test to oauth-provider.integration.test.ts, and reword their headings accordingly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 tasks
kcarnold
reviewed
Sep 29, 2026
Nothing on the page depends on which client sent the user: it signs them in to Writing Tools and resumes the authorization request unchanged. Replace the "Connect Mindmap" title, heading and explanation with Writing Tools wording so any trusted client can use it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The authorization path already checks a generic list, but config.ts still exported mindmapOAuthClientId/mindmapOAuthRedirectUris and the config test imported them. Replace them with unexported generic env readers (envString, envList) that the list entry calls with its own env var names, so Mindmap appears only as a configured entry in trustedOAuthClients(). The config test now goes through trustedOAuthClients()/acceptedOAuthClientIds() with the same assertions: no defaults in any environment, duplicate redirects removed, incomplete entries dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Summary
Makes the backend an OAuth 2.1 provider for first-party apps: Better Auth's
oauthProviderissues tokens under/api/auth/oauth2/*, and the OpenAI proxy is the resource server that accepts them. The first and only client is the standalone Mindmap (https://mindmap.thoughtful-ai.com), which can then make AI calls billed to the signed-in user.Mindmap's side lives in
AIToolsLab/mindmap. Mindmap gets no document access: users paste text in by hand. The only capability granted isopenai:chat. Seedocs/oauth-provider.md.This is not a port of #594. It keeps only the generic OAuth pieces, and the room model is not carried over.
What it does
@better-auth/oauth-provider1.6.22 plusjwt().MINDMAP_OAUTH_CLIENT_ID/MINDMAP_OAUTH_REDIRECT_URISand provisioned idempotently through Better Auth's adapter when the server starts. There's no secret and no code defaults (scripts/get_env.pysupplies the dev values)./api/openai/chat/completionsand/api/openai/responses) accepts the client's bearer token. It checks the token in-process (auth.api.verifyJWTagainst the jwt plugin's keys in our database, with no HTTP fetch of our own JWKS):<origin>/api/auth, and audience = the backend origin (via RFC 8707resource);openai:chat, andazpmust be a configured client;sid, which authorized the token, must still exist, be unexpired, and belong tosub. Signing out in the browser that did the Mindmap login therefore revokes the token straight away.resolveUser, so they can't reach logging, consent, erasure, Realtime or/api/handoff. A presented bearer authenticates only that bearer, never a co-sent cookie session.X-Writing-Tools-Error: platform-auth, which CORS exposes./oauth2/register. The plugin also exposes session-authenticatedcreate-client/update-client/delete-client/get-clients/rotate-secret, soclientPrivileges: () => falsedenies them.Deployment
Safe to merge before infra changes. Both Mindmap variables are optional. If either is unset, the server logs a warning, provisions nothing, and accepts no OAuth tokens; everything else runs normally.
To enable Mindmap in prod, add to the app container in the k8s helm chart (
Infrastructure_k8s_thoughtfulai,thoughtfulai-appvalues):MINDMAP_OAUTH_CLIENT_ID=writing-tools-mindmapMINDMAP_OAUTH_REDIRECT_URIS=https://mindmap.thoughtful-ai.com/Only the main container needs these; the
migrate-authinitContainer no longer provisions clients. Staging doesn't need them.BETTER_AUTH_URLmust be the origin the Mindmap bundle targets (https://app.thoughtful-ai.com, which the chart already sets). Issuer and audience checks compare against it exactly.Emergency cut-off: unset the variables and redeploy. Every Mindmap token is then rejected, since tokens aren't stored and the proxy only accepts configured clients.
Dependency note
The rebase onto
mainconflicted with the@hono/node-serverv2 bump; resolved by keeping both. npm then wanted@better-auth/core1.7.6 to satisfy the new plugin's peer range, which clashes with the pinnedbetter-auth1.6.22. The plugin's lockfile entry was carried over from the pre-rebase commit, where it resolved against 1.6.22.npm ciaccepts it, and there is a single deduplicated@better-auth/core@1.6.22.npm auditreports 5 advisories. Four (hono, vitest, nanoid via vitest) come frommain, not this PR. The fifth is on the new plugin: GHSA-p2fr-6hmx-4528. The token endpoint lets a client choose any allow-listedresource, even one it wasn't authorized for. The 1.6.x line won't be patched; the fix is in 1.7.x, a breaking change with a schema migration. This deployment isn't exposed: it matches the advisory's workarounds exactly.validAudienceshas a single entry, and our guard requires exactly oneresourceequal to the origin at the token endpoint. There are no refresh tokens, and the proxy accepts only that one audience. Worth upgrading when we move to better-auth 1.7.Tests
npx tsc --noEmitis clean, andnpx vitest runpasses 131/131, run outside a directory with a localbackend/.env(a real key there overrides the test env; being fixed separately). The integration suite (oauth-provider.integration.test.ts) runs the production verification path, and covers:resourceon both the authorize and token requestsazp, wrong-issuer and tampered-signature tokens get 401 and never reach the providerGET /api/auth/token) are refused at the proxysidare refused, and signing out revokes an issued tokenNote: on a cold first run after a fresh
npm ci,erasure.test.tscan time out (5s limit, slow first import); it passes on rerun. It's unrelated to this change, but CI may see it.🤖 Generated with Claude Code