Skip to content

feat(auth): make Sim an OAuth 2.1 provider and move the CLI onto it - #7488

Draft
waleedlatif1 wants to merge 1 commit into
stagingfrom
feat/oauth-provider
Draft

feat(auth): make Sim an OAuth 2.1 provider and move the CLI onto it#7488
waleedlatif1 wants to merge 1 commit into
stagingfrom
feat/oauth-provider

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Sim becomes an OAuth 2.1 authorization server, so other software can sign a person in and act as them with their consent. sim login uses it by default, replacing the permanent personal API key it used to mint with a short-lived, scoped, revocable token.

Built on @better-auth/oauth-provider@1.6.27, pinned to match the better-auth already in the tree — no upgrade required.

Why

Today the only programmatic credential is an API key: permanent, unscoped, and useful to nothing but the CLI. This makes "a Sim login" something other software can obtain — the CLI and SDKs first, then MCP clients, partner integrations, and eventually "Sign in with Sim".

Design decisions worth reviewing

Opaque tokens, not JWTs. A JWT carries no jti and cannot be revoked; revoking an app in settings or running sim logout has to take effect on the very next request. Verification is one indexed read, the same cost as the API-key path. The cost is that client secrets are stored reversibly — with no JWKS the plugin signs ID tokens with the client secret, so it must read it back. Setting storeClientSecret: 'hashed' is refused at construction and would take the app down on boot.

Write scope comes from the HTTP method, not minimumRole. Several POST routes only read (search, query, count), so deriving the required scope from an operation's role let a read-only token perform writes. Those four routes opt out with readOnly: true, and a definition-time guard rejects that flag on a method that is already safe.

A new principal kind admitted exactly where personal_api_key is. check:principal-kind-parity fails the build if the two drift apart. Its own header is explicit about what it cannot see — it reads inline principalKinds literals, not switch (principal.kind) comparisons, which is the class of site that mis-admits silently.

The consent card fails closed. It refuses to render a grant for a request Sim did not issue: an unsigned query, a repeated parameter (URLSearchParams.get reads the first, the server the last), or a client the server declines to name. The first-party CLI never skips consent.

Clients are operator-created rows. Dynamic registration is off, and clientPrivileges: () => false closes every one of the plugin's client CRUD endpoints at the source — the route-level blocklist is defence in depth, not the only thing standing between a signed-in user and /oauth2/create-client.

The CLI asks only for what it uses. No openid profile email: a public client with no secret can never receive an id_token, and the CLI reads no profile or email. Asking would put three permissions on the consent card that the app cannot honour.

CLI

Authorization code + PKCE S256 on a loopback listener (RFC 8252), state checked, refresh rotated. The pairing-code handoff stays for terminals whose browser cannot reach a local port, selected automatically over SSH. --read-only and --callback-port are refused on that path rather than silently ignored, since it can honour neither.

Concurrent sim processes are serialised by a lock file carrying an owner token, so a reclaimed stale lock can never be removed by the waiter that observed it going live again. The lock is re-entrant by async context — a flag would let two genuinely concurrent refreshes both believe they held it, which is the exact failure it exists to prevent.

Operations

OAUTH_PROVIDER_ENABLED=false turns the whole thing off; discovery 404s and the CLI falls back on its own.

Two migrations: 0321 adds four tables and seeds the CLI client; 0322 adds a UNIQUE NULLS NOT DISTINCT constraint on the consent grant key (the plugin's consent write is a non-atomic read-then-create) plus the expires_at indexes the cleanup sweep uses. Requires Postgres 15+.

Verification

  • turbo run type-check 26/26 · check:audits 46/46 · check:application-graph clean
  • 41,332 app tests · 901 CLI tests · 120 db · 40 testing
  • Verified against a running server: discovery and its CORS header, client-registration paths blocked, both 401 challenges, the authorize route separating provider requests from connector links, the sign-in bounce, the unsigned and tampered consent refusals, and the consent uniqueness constraint against live Postgres
  • Security-relevant tests were checked by reverting each control and confirming they go red

Not done here

  • The token cleanup sweep needs scheduling. GET /api/cron/cleanup-oauth-tokens ships and is documented, but neither shipped deployment schedules the retention endpoints. Without it, each CLI login leaves a lapsed access-token row behind every hour.
  • The consent card, its lookup-failure state, and the settings page have not been reviewed visually. Their logic is tested; the rendering is not.

Sim becomes an authorization server, so other software can sign a person in
and act as them with their consent. `sim login` uses it by default, replacing
a permanent personal API key with a short-lived, scoped, revocable token.

Built on `@better-auth/oauth-provider@1.6.27`, pinned to match the Better Auth
already in the tree — no upgrade required.

Tokens are opaque rather than JWTs, so revoking an app in settings or running
`sim logout` takes effect on the very next request. Access tokens last an
hour; refresh tokens rotate on every use and expire after thirty days.

A new principal kind, `oauth_access_token`, is admitted exactly where
`personal_api_key` is and nowhere else; `check:principal-kind-parity` fails
the build if the two ever drift apart. Write scope is derived from the HTTP
method at v2 admission rather than from an operation's `minimumRole`, because
several POST routes only read — a role-derived rule let a read-only token
write.

The consent card refuses to render for a request Sim did not issue: an
unsigned query, a repeated parameter, or a client the server declines to name.

Clients are operator-created rows. Dynamic registration is off, and every one
of the plugin's client CRUD endpoints is closed at the source.

Off with `OAUTH_PROVIDER_ENABLED=false`, which the CLI detects and falls back
to the existing pairing-code handoff.
@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
docs Ready Ready Preview Sep 4, 2026 9:25pm UTC

Request Review

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.

1 participant