Skip to content

OAuth provider for first-party apps (Mindmap first) - #654

Open
nhyiramante1 wants to merge 13 commits into
mainfrom
feat/mindmap-standalone-oauth
Open

nhyiramante1 wants to merge 13 commits into
mainfrom
feat/mindmap-standalone-oauth

Conversation

@nhyiramante1

@nhyiramante1 nhyiramante1 commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Makes the backend an OAuth 2.1 provider for first-party apps: Better Auth's oauthProvider issues 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 is openai:chat. See docs/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

  • Adds @better-auth/oauth-provider 1.6.22 plus jwt().
  • Authorization code + PKCE (S256) only. Exact redirect URI match, 12-hour JWT access tokens, no refresh tokens. Consent is skipped for this first-party client only.
  • One fixed public client, configured by MINDMAP_OAUTH_CLIENT_ID / MINDMAP_OAUTH_REDIRECT_URIS and provisioned idempotently through Better Auth's adapter when the server starts. There's no secret and no code defaults (scripts/get_env.py supplies the dev values).
  • The OpenAI proxy (/api/openai/chat/completions and /api/openai/responses) accepts the client's bearer token. It checks the token in-process (auth.api.verifyJWT against the jwt plugin's keys in our database, with no HTTP fetch of our own JWKS):
    • signature, expiry, issuer <origin>/api/auth, and audience = the backend origin (via RFC 8707 resource);
    • scope openai:chat, and azp must be a configured client;
    • session liveness: the session named by sid, which authorized the token, must still exist, be unexpired, and belong to sub. Signing out in the browser that did the Mindmap login therefore revokes the token straight away.
  • OAuth tokens are accepted only by the proxy, never by the shared 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.
  • 401 vs 403:
    • An invalid, expired, revoked or foreign token gets 401. It never falls through to the demo key or a cookie session.
    • A valid token for an account outside the beta allowlist gets 403, checked before any spend.
    • Both carry X-Writing-Tools-Error: platform-auth, which CORS exposes.
  • Client management is closed. Registration flags alone only close /oauth2/register. The plugin also exposes session-authenticated create-client / update-client / delete-client / get-clients / rotate-secret, so clientPrivileges: () => false denies 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-app values):

    • MINDMAP_OAUTH_CLIENT_ID=writing-tools-mindmap
    • MINDMAP_OAUTH_REDIRECT_URIS=https://mindmap.thoughtful-ai.com/

    Only the main container needs these; the migrate-auth initContainer no longer provisions clients. Staging doesn't need them.

  • BETTER_AUTH_URL must 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 main conflicted with the @hono/node-server v2 bump; resolved by keeping both. npm then wanted @better-auth/core 1.7.6 to satisfy the new plugin's peer range, which clashes with the pinned better-auth 1.6.22. The plugin's lockfile entry was carried over from the pre-rebase commit, where it resolved against 1.6.22. npm ci accepts it, and there is a single deduplicated @better-auth/core@1.6.22.

npm audit reports 5 advisories. Four (hono, vitest, nanoid via vitest) come from main, not this PR. The fifth is on the new plugin: GHSA-p2fr-6hmx-4528. The token endpoint lets a client choose any allow-listed resource, 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. validAudiences has a single entry, and our guard requires exactly one resource equal 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 --noEmit is clean, and npx vitest run passes 131/131, run outside a directory with a local backend/.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:

  • the full login round trip, with the resulting token accepted by both proxies
  • a byte-exact resource on both the authorize and token requests
  • missing PKCE, a wrong PKCE verifier, wrong redirect URIs and unknown clients are refused
  • invalid, wrong-audience, wrong-scope, expired, foreign-azp, wrong-issuer and tampered-signature tokens get 401 and never reach the provider
  • jwt-plugin session JWTs (GET /api/auth/token) are refused at the proxy
  • tokens with a missing, unknown or other user's sid are refused, and signing out revokes an issued token
  • unset Mindmap config means provisioning does nothing and tokens are refused
  • the allowlist returns 403
  • OAuth tokens are rejected on every non-proxy route
  • client management is refused, with the client table left unchanged
  • provisioning is idempotent, respects an operational disable, and clears a stale secret

Note: on a cold first run after a fresh npm ci, erasure.test.ts can 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

nhyiramante1 and others added 4 commits September 27, 2026 22:41
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 kcarnold left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few quick comments as I'm scrolling through.

Comment thread backend/src/app.ts Outdated
Comment thread backend/src/app.ts Outdated
Comment thread backend/src/app.ts Outdated
Comment thread backend/src/app.ts Outdated
nhyiramante1 and others added 7 commits September 29, 2026 08:41
…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>
@kcarnold kcarnold changed the title Scoped OAuth login for the standalone Mindmap (Track A) OAuth provider for first-party apps (Mindmap first) Sep 29, 2026
Comment thread backend/src/routes/oauth-login.ts Outdated
Comment thread backend/src/app.ts Outdated
nhyiramante1 and others added 2 commits October 1, 2026 23:15
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants