Skip to content

feat(api): MCP OAuth authorization server, RFC-0044 phase 2 (#144) - #176

Merged
Madraka merged 5 commits into
mainfrom
feat/mcp-oauth-authorization-server
Nov 9, 2026
Merged

Madraka merged 5 commits into
mainfrom
feat/mcp-oauth-authorization-server

Conversation

@Madraka

@Madraka Madraka commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

RFC-0044 Phase 2: the deployment runs its own OAuth 2.1 authorization server, so the discovery documents from #152 now point at something that works. Refs #144. Registration (CIMD, DCR), "Connected agents", McpClientSetup, the guides and the e2e test stay in Phase 3.

Scope → code and tests

# Scope item (RFC section) Code Tests
1 Better Auth 1.7.17 → 1.7.18 (§7) pnpm-workspace.yaml: better-auth, @better-auth/core, @better-auth/api-key, @better-auth/kysely-adapter (was 1.7.12) → 1.7.18; @better-auth/utils 0.5.1 and better-call 1.4.5 are already what 1.7.18 needs. New entries @better-auth/mcp 1.7.18, @better-auth/oauth-provider 1.7.18, jose 6.2.12 packages/api/test/better-auth-pins.test.ts: plugins on better-auth's version; jose equal to the copy better-auth resolves
2 mcp() + jwt() in createInstance (§2) packages/api/src/auth/oauth.ts (oauthPlugins, lifetimes, mcpOauthFor, betterAuthLogger), auth/instance.ts shared suite below; workerd case in packages/cli/test/cloudflare-deploy.test.ts
JWT 1 h, refresh 30 d + rotation + 30 s reuse, code 5 min, aud = /mcp, iss = issuer oauth.ts constants; auth/access-token.ts verifies signature (EdDSA only), iss, aud, exp, typ: at+jwt with jose against the jwks table read through the instance, cached per isolate by kid "rotates refresh tokens…", "refuses a token for another resource (aud) or from another issuer (iss)"
Better Auth logger through Cloudhal's logging (#159 sweep) betterAuthLogger: drops the expected "Failed to validate API key", forwards the rest to AuthOptions.log (admin Worker: its log) —
3 Migration 0031_oauth (§7) packages/storage/migrations/0031_oauth.ts: DDL generated by getMigrations(auth.options).compileMigrations() on better-auth 1.7.18 (named in the comment; nothing in existing tables changes) + agent_grants and its index exactly as §7; registered in migrations/index.ts packages/storage/test/migrate.test.ts: full lists, a dedicated 0031 test on better-sqlite3 and D1 (Miniflare), do-sqlite.test.ts applies every migration
4 agent_grants and token → Principal (§5) auth/grants.ts (GrantStore, agentIdForGrant, last_used_at at most every 5 min), auth/token-principal.ts, auth/principal.ts (a Bearer JWT with typ: at+jwt never reaches the key lookup) "runs the code flow…", "refuses a revoked connection…", "refuses a removed member's token", "narrows the requested scopes by the member's installation role"
agent_owner row, consent narrowed by the installation role (#173 relies on it) auth/agent-consent.ts (giveConsent: narrow(requested, roleScopes(installationRole)), mode capped like a key, grant + agent_owner + connect_agent audit row in one batch) "narrows … by the member's installation role at consent" (installation Contributor who is an Editor on the site: content:write → content:draft:own, site:publish refused, no step-up challenge)
5 Consent screen (§4) apps/studio/src/routes/OAuthConsentPage.tsx (/oauth/consent in the account frame: client, identifying host, redirect host, loopback warning, step-up note, scopes as checkboxes narrowed by role with untickable ones, ModePicker capped by role, Approve/Deny); LoginPage forwards the signed request (oauth_query); template consent in scripts/design-lint.ts, DESIGN.md §2ai; lazy namespace oauthConsent (English by hand, 18 machine drafts via pnpm i18n:export / i18n:import, AI drafts awaiting review) apps/studio/test/oauth-consent.test.tsx, test/login-setup.test.ts; pnpm design:lint
/api/agent-grants list and revoke, ROUTE_SCOPES, playground deny list packages/api/src/oauth-server.ts (GET /, ?all=1 with agents:manage, DELETE /:id, GET/POST /consent), scopes.ts (member), playground.ts (RFC-0036 deny, plus both AS metadata paths) "keeps other members' connections invisible…", test/route-scopes.test.ts, test/playground.test.ts
6 Runtimes set ApiDeps.mcp.oauth (§10) admin Worker: adminAuth() with the request's origin; cloudhal studio: --base-url / CLOUDHAL_BASE_URL, otherwise OAuth off with the log line "OAuth for remote MCP clients is off: no public base URL …"; playground untouched (Decision 10) packages/cli/test/cloudflare-deploy.test.ts (full flow on workerd + D1)
7 Shared suite on sqlite + Miniflare D1 (§8) packages/api/src/testing-mcp-oauth-server.ts, harness testing-oauth-harness.ts, run by test/mcp-oauth-server.test.ts discovery → AS metadata; code + PKCE (S256 only; none and plain refused); sign-in from the authorization request; tampered request refused; token on /mcp; refresh rotation and reuse window; revoked grant 401; removed member 401; wrong aud / wrong iss 401; step-up 403 then a new consent; consent memory; installation-role narrowing; deny; fenced provider endpoints
8 Docs ADR-0010 MCP authorization paragraph amended (DCR fallback, no DPoP in v1); ADR-0011 §5–6 row → done; ADR-0004 addendum points at the new ADR; new ADR-0045 (docs/adr/0045-mcp-authorization-server.md) linking RFC-0044; ADR index —

Deviations from the RFC (and why)

  1. Consent goes through Cloudhal, not authClient.oauth2.consent. Studio posts to POST /api/agent-grants/consent, which narrows, caps the mode, calls the provider's /oauth2/consent server-side and then writes grant + agent_owner + audit. The provider only accepts consent scopes that are a subset of the request, so content:write → content:write:own cannot be expressed there; the token therefore carries the requested scopes the member kept and the grant row is the cap (principal = narrow(narrow(token.scope, grant.scopes), roleScopes(role)), §5 wrote intersect, which would drop the :own variant). The provider's own /oauth2/consent is fenced in createApi and refuses any HTTP caller without a per-instance server key.
  2. Provider endpoints fenced (more conservative than §2's table): only /oauth2/authorize, /oauth2/token, /oauth2/revoke and /jwks are reachable over HTTP. Client and consent CRUD, introspection, userinfo, registration (Phase 3) and the jwt() plugin's session-JWT /api/auth/token answer 404; jwt({ disableSettingJwtHeader: true }). clientPrivileges admits only the server's own calls.
  3. Pre-registered test client. Phase 2 has no registration endpoint, so tests use OauthServer.createClient (server-side only, no route); the workerd test writes the client row the way the provider stores it.
  4. /api/agent-grants ships list, revoke and consent only; PATCH :id (mode) and the elevation endpoints come with "Connected agents" in Phase 3. A new consent for the same client replaces scopes and mode and ends a stored elevation.
  5. Consent memory (Decision 8) is the provider's: a subset of granted scopes skips the screen and, in Phase 2, writes no audit row.
  6. jose pinned to 6.2.12, not the newest 6.2.15: better-auth's own jose resolves to 6.2.12, and a second jose produced a second @better-auth/core whose types no longer matched. The pins test now guards this.
  7. @better-auth/cimd not added (CIMD is Phase 3).
  8. Studio template: the RFC names a form template that the design lint does not have; the screen registers a new consent template (PageLayout + SectionCards + Checkbox.Group + ModePicker + Button) and sits in the account frame.
  9. CLOUDHAL_MCP_OAUTH=off switch on both runtimes (not in the RFC), and plain HTTP is accepted only on loopback (the plugin refuses a plain-HTTP resource elsewhere).
  10. Member removal revokes their grants too (the principal path already refuses them through inv. 2).
  11. Changesets are patch per the release instruction (the RFC said minor), for @cloudhal/storage, @cloudhal/api, @cloudhal/cloudflare, @cloudhal/cli (fixed group).
  12. Cost noted: mcp() seeds its oauthResource row at init (one indexed read per Better Auth instance, so per request on Workers).
  13. Shared suite file: §8 names testing-mcp-oauth.ts / test/mcp-oauth.test.ts; those keep Phase 1's discovery cases, and Phase 2's cases live next to them in testing-mcp-oauth-server.ts (+ harness) / test/mcp-oauth-server.test.ts, to stay under the size ratchet.
  14. design:shots naming: the script named PNGs after the whole route, query included, so a signed consent URL overflowed the file name (ENAMETOOLONG); it now names files by the path only (apps/studio/scripts/screenshots.ts, one line).
  15. Discovery and step-up from feat(api): MCP OAuth discovery on /mcp (RFC-0044 phase 1) #152/fix: review follow-ups for 0.6.5 (#168) #173 win over the RFC text: mcp-oauth.ts / mcp-mount.ts are unchanged; the plugin's own PRM handler is never reached (Hono only forwards /api/auth/*).

Decisions the RFC did not settle (conservative choice taken)

  • Access tokens pinned to EdDSA in verification (no algorithm confusion); Ed25519 confirmed on workerd (cloudflare-deploy.test.ts checks the header alg: EdDSA, typ: at+jwt).
  • A token principal is rate-limited like a key (600 requests per minute per connection, rate_limits).
  • The admin Worker's issuer is the request's origin (as Better Auth's baseURL already is); tokens from *.workers.dev do not work on a custom domain and vice versa.

Checks run locally

All one package at a time (turbo run test --concurrency=1), D1 suites included (Miniflare ran fine locally, nothing left to CI):

  • @cloudhal/storage ✅ (migrate tests on better-sqlite3, D1, do-sqlite)
  • @cloudhal/api ✅ 41 files / 509 tests, incl. the new OAuth suite (30 cases: 15 × better-sqlite3, 15 × D1)
  • @cloudhal/cloudflare ✅
  • @cloudhal/cli ✅ 61 files / 443 tests, incl. the new workerd OAuth flow in cloudflare-deploy.test.ts (Ed25519 JWT signed in workerd, D1, consent, /mcp via the SDK client)
  • @cloudhal/studio ✅ 81 files / 1305 tests (new oauth-consent.test.tsx incl. the Auto-mode consent rule, login-setup.test.ts, i18n tests after pnpm i18n:import)
  • pnpm lint 0 errors (warnings are the existing backlog; resolvePrincipal's pre-existing complexity warning went 31 → 33), pnpm size:check ✅ (baseline tightened for createApi and createAdminWorker), pnpm design:lint ✅ 402 files, tsc --noEmit ✅ in api, cli, cloudflare, storage, studio
  • pnpm design:shots of /oauth/consent with a real signed request against a throwaway cloudhal studio --base-url http://localhost:8792 at 1440 and 390 px: no sideways scroll, no console errors (screens checked by eye)

Translations: the 18 non-English oauthConsent.<locale>.ts files and the two new core keys are machine drafts awaiting review; reviewed is unchanged in locales.ts.

Review round 1 fixes (security review of #176)

Every item has a suite test that fails on the first commit of this PR (checked by running the new tests against the old production code).

# Finding Fix Test
1 Tokens issued before a revoke worked again after reconnecting Access tokens carry a private claim cloudhal_grant (a provider extension reads the live grant at issuance, auth/oauth.ts); the verifier requires it and tokenPrincipal accepts the token only when it names the live grant. A reconnect creates a new grant id. testing-mcp-oauth-hardening.ts "keeps a token issued before a revoke dead…"; test/access-token.test.ts (no claim → refused)
2 Member removal left the provider's consent and refresh tokens providerRevocation() (auth/grants.ts): plain SQL that deletes oauthConsent and revokes oauthRefreshToken, for one client or every client of the member, in the same batch as the grant change (Studio revoke and StaffStore.revokeGrantsOf on removal), so it can never half-apply. Belt and braces: before /oauth2/authorize, Cloudhal deletes the provider's stored consent when no live grant exists (auth/oauth-hooks.ts), so the screen always shows. "ends a removed member's refresh tokens and stored consent, so a return shows the screen" (refresh → invalid_grant; re-invite → consent screen)
3 Step-up loop after a role promotion The same authorize check also forgets the provider's consent when the member's current role would give a requested scope the grant does not hold. The grant is now written before the provider's consent call (its authorize step runs that check). See deviation below. "shows the screen again when a promotion lets a step-up widen the connection"
4 set_embargo through a token recorded a person's approval New agentCredential() in agent-runtime (credentials.ts: key, grant, or none). directApproval (moved to mcp-server/src/gate-approval.ts) gives a key its auto approval and a connection none: its embargo on live content is saved but waits for a person (status: "with-next-publish"). request_elevation uses the helper too. mcp-server/test/schedules.test.ts "set_embargo through an OAuth connection never records the person's approval"; agent-runtime/test/credentials.test.ts
5 Consent forward dropped the client address The forward copies the runtime's trusted address header (cf-connecting-ip on Workers, CLIENT_ADDRESS_HEADER on Node; addressHeader in auth/oauth-deps.ts). "rate-limits the consent forward per client address…" (the rateLimit row is `203.0.113.9
6 DPoP-bound tokens issued but not checked Fail closed: /oauth2/token refuses a DPoP proof (invalid_dpop_proof), the AS metadata no longer lists dpop_signing_alg_values_supported, the verifier refuses any token with cnf, and a JWT under the DPoP scheme goes to the token path (refused) instead of the key lookup. "neither advertises nor issues DPoP-bound tokens"; test/access-token.test.ts
7 No absolute refresh lifetime Enforced: the provider stores tokens under a hash Cloudhal computes too (storeTokens.hash, base64url SHA-256), and before /oauth2/token a refresh whose family began (authTime) more than 90 days ago is refused with invalid_grant. ADR-0045 Decision 2 now says 30 d sliding, 90 d absolute. "ends a refresh token family 90 days after the member authenticated, however often it rotated" (refreshes at day 29, 58, 87 succeed; day 92 fails)
8 Step-up pre-ticked new scopes The screen starts from the existing grant's scopes ∩ grantable; new ones are labelled "(new)" and unticked (useScopeLabels). apps/studio/test/oauth-consent.test.tsx "starts a step-up from what the connection may do today…"
9 Text promised Phase 3 "…for at most 90 days, until the connection is revoked or your access ends"; the feature reads "OAuth for MCP clients: MCP clients registered with this Studio sign in at /mcp with OAuth". pnpm i18n:export / i18n:import (18 drafts updated, still AI drafts). i18n tests in the Studio suite

keyId audit (item 4)

Place What it gates A token principal
mcp-server/src/gate-approval.ts directApproval (was schedule-tools.ts:526) an agent's auto approval of a reduction fixed: no approval, waits for a person
mcp-server/src/schedules.ts:332 autoReleaseVerdict auto-mode releases refused (!principal.keyId), the release waits for a person; unchanged
mcp-server/src/schedule-tools.ts:309 the auto approval after a passing verdict unreachable for a token (verdict refuses it)
mcp-server/src/schedules.ts:736 resolveRun, schedule-tools.ts:573 automationKey, :1426, :1559 automation runs need automationId, which only automation keys carry; a token is refused or takes the checked "another automation" path
mcp-server/src/elevation-tools.ts:83 (and :311 after it) elevations refused through agentCredential(); connection elevations are Phase 3
api/src/governance.ts:229 writerOf, api/src/own.ts:40 editsLiveCorrections Studio provenance, live corrections both test actor.kind === "human" first; a token is an agent
personApproval at schedule-tools.ts:289, :331, :928, :1182, :1296 releases a person approved each runs only after a person's approval (ctx.approval) or for non-agents; identical for keys and tokens
api/src/routes/{agent-keys,notifications,schedules}.ts, auth/* key management and automation jobs not principal checks

Deviation from the review's suggested fixes

  • Item 3: the review suggested recording in the provider's consent only the scopes that survived narrowing unchanged. That would drop e.g. content:write from a Contributor's token, and the token must still carry a scope that narrows to the grant's content:draft:own, so the Contributor's agent would lose its draft rights. Instead the provider's consent memory is checked against the grant and the member's current role before every authorize, which forces the screen exactly when the grant would change.
  • Item 2, order: with the provider cleanup in the same SQL batch as the grant change, there is no order to get wrong; the authorize check covers anything older.

Checks for this round

One package at a time, D1 included (nothing left to CI):

  • @cloudhal/api ✅ 42 files / 523 tests, the OAuth suites 42/42 (21 × better-sqlite3, 21 × Miniflare D1), test/access-token.test.ts
  • @cloudhal/agent-runtime ✅ (new credentials.test.ts), @cloudhal/mcp-server ✅ (new embargo case)
  • @cloudhal/studio ✅ (new step-up case; i18n tests after pnpm i18n:import)
  • packages/cli/test/cloudflare-deploy.test.ts ✅ (the full flow on workerd still passes with the grant claim and the token hash)
  • pnpm lint 0 errors, pnpm size:check ✅ (baseline tightened for schedule-tools.ts), pnpm design:lint ✅, typecheck ✅ in api, cli, cloudflare, mcp-server, agent-runtime, studio

Review round 2 fixes (verification of 632cf45)

Each new test fails on 632cf45 and passes now (run against the old production files).

# Finding Fix Test (testing-mcp-oauth-hardening.ts)
1 Refresh tokens and codes not bound to the grant generation Before /oauth2/token, a refresh token is refused when its family began before the live grant's createdAt (or there is no live grant). A family is every refresh token of one authorization code (authorizationCodeId, kept through rotation); it begins with the first. Comparison at whole seconds, since the provider stamps refresh rows that way. providerRevocation also deletes the member's pending authorization codes for the client (verification rows whose JSON value is an authorization_code). "never exchanges a code issued before a revoke, after the member connects again"; "never refreshes a family from before a revoke, even one that escaped the revoke batch"
2 Member removal ordering StaffStore.remove puts the grant revoke and the provider cleanup (consent, refresh tokens, codes) in the same batch as the membership delete, each statement guarded on the membership being gone. Sessions, keys, invites and requests still follow; none of them grants anything without a membership (I1, I5). "ends the connections in the same batch as the membership"
3 POST /oauth2/authorize skipped the consent check The hook reads the form body for a POST, the query otherwise, as the provider does. "shows the screen for a stored consent with no live grant (GET / POST authorize)" (the GET case is the hook-only test the verifier asked for)
4 DPoP still advertised on /api/auth/.well-known/oauth-authorization-server The provider's issuer-appended documents (/api/auth/.well-known/*) are fenced (404); clients use the RFC 8414 documents at the root. The DPoP test now asserts Cloudhal's own error (invalid_dpop_proof with DPOP_REFUSED), so it exercises checkTokenRequest. ADR-0045: "a request with a DPoP proof gets a 400 rather than a fallback to Bearer". "neither advertises nor issues DPoP-bound tokens"
5 90-day cap anchored to the sliding session Anchored to the refresh family's start (its first refresh token), not authTime. "refreshes a new connection of a member whose Studio session is older than 90 days"; the 29/58/87/92-day case still passes

Also: the giveConsent comment now says what a failed provider call leaves on a step-up of a live grant (the approved scopes and mode apply at once, capped by the tokens' own scope claims), and schedule-tools.ts:310 takes the key through agentCredential() (keyOf, gate-approval.ts).

Phase 3 (not in this PR): set_embargo through an OAuth connection saves the embargo but does not file an approval request; it answers with-next-publish. Filing a request (or a grant-bound auto approval, which needs a contract change since the auto approval names a keyId) belongs with "Connected agents" in Phase 3. I didn't post this on issue #144 myself, because posting there was outside what I could do from here. The text is in the session notes for the project manager to post.

Checks for round 2

  • Single files first: test/mcp-oauth-server.test.ts 54/54 on better-sqlite3 and Miniflare D1.
  • @cloudhal/api: in the full run, 25 D1 cases in api, assets-api, collections-api and roles failed with Miniflare's fetch failed (the local D1 process dropping connections under load). Each of those four files passes on its own (roles 46/46, api 12/12, assets 22/22, collections 12/12); the other 510 passed in the run. Please let CI's run confirm the D1 halves.
  • @cloudhal/mcp-server ✅; packages/cli/test/cloudflare-deploy.test.ts ✅ (workerd flow).
  • pnpm lint 0 errors, pnpm size:check ✅, pnpm design:lint ✅, typecheck ✅ in api, mcp-server, cli, cloudflare.

Review round 3 fixes (verification of b5968e1)

# Finding Fix Test (testing-mcp-oauth-hardening.ts)
1 Regression: after sign-in the stale-consent check read the sign-in body The hook mirrors the provider exactly: the form body only when method === "POST" and authorizeSettings.isAuthorize === true (absent authorizeSettings means an HTTP authorize); a resumed authorize (after sign-in or consent) reads the query. giveConsent's comment updated. "shows the screen after sign-in when the stored consent is stale" (fails on b5968e1, passes on 632cf45)
2 familyStart scanned the whole family Composite index oauthRefreshToken_family_idx ("authorizationCodeId", "createdAt") added to the unreleased migration 0031 (Cloudhal's, below the generated DDL). packages/storage/test/migrate.test.ts checks the index on better-sqlite3 and D1
3 Family fallback slid with the row No authorizationCodeId, no known start: the refresh is refused (invalid_grant). "refuses a refresh token whose family is unknown (fails closed)"
4 Code written just after a revoke Before /oauth2/token with authorization_code, the code's verification row (looked up by the hashed code) must belong to a live grant of its member and client and be no older than the grant (whole seconds). "refuses a code written just after a revoke, once the member connects again"
5 Tests Step-up of a live grant keeps the first refresh token working (pins the created_at CASE); the removal-batch test now creates a pending code and asserts it is deleted; a JSON-body POST /oauth2/authorize gets 415; the 90-day test is renamed after the family-start anchor. "keeps the first refresh token working after a step-up of the live grant", "ends the connections in the same batch as the membership", "refuses a JSON body on POST /oauth2/authorize"

Checks for round 3

  • Single files first: test/mcp-oauth-server.test.ts (64 cases, sqlite and D1), packages/storage/test/migrate.test.ts + do-sqlite.test.ts ✅. Each new case was run against the production files of b5968e1 (the sign-in case also against 632cf45, where it passes).
  • @cloudhal/storage ✅. @cloudhal/api: 528 passed in the full run; 17 D1 cases in assets-api, fields-api, rate-limits and roles failed with Miniflare's fetch failed (the local D1 process dropping connections under load, different files than last round). Each of those files passes on its own (22/22, 12/12, 4/4, 46/46). D1 halves left to CI.
  • @cloudhal/mcp-server ✅, pnpm lint 0 errors, pnpm size:check ✅, pnpm design:lint ✅, typecheck ✅.

🤖 Generated with Claude Code

Run the deployment's own OAuth 2.1 authorization server so remote MCP
clients can authorize against the Studio's accounts:

- Better Auth family 1.7.17 -> 1.7.18, plus @better-auth/mcp and
  @better-auth/oauth-provider 1.7.18 and jose 6.2.12 (the copy
  better-auth resolves); the pins test guards both.
- mcp() + jwt() in createInstance when AuthOptions.mcpOauth is set:
  code + PKCE (S256) and refresh grants, Ed25519 JWT access tokens
  (1 h, aud = /mcp, iss = <origin>/api/auth), refresh rotation with a
  30 s reuse window, 5 min codes. Better Auth logs go through
  AuthOptions.log; a wrong API key no longer logs an error.
- Migration 0031_oauth: the plugins' DDL from compileMigrations() on
  1.7.18 and Cloudhal's agent_grants table.
- /mcp verifies the token locally (signature, iss, aud, exp, typ) and
  reads the grant row: the same Principal a key produces, narrowed by
  the grant and the member's role; revoked grants and removed members
  get the invalid_token challenge.
- Consent on Studio's /oauth/consent screen through
  /api/agent-grants/consent: scopes narrowed by the installation role,
  mode capped, grant + agent_owner + audit in one batch. The provider's
  own consent, client CRUD and other non-flow endpoints are fenced.
- /api/agent-grants list and revoke; AS metadata at the path-inserted
  and root well-known URLs; the admin Worker and `cloudhal studio
  --base-url` turn it on, the playground stays out.
- Shared suite on better-sqlite3 and Miniflare D1, a full flow on
  workerd, ADR-0045, ADR-0004/0010/0011 updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
Comment thread packages/api/src/auth/grants.ts Fixed
Madraka and others added 4 commits November 9, 2026 03:02
Security review of RFC-0044 phase 2 (PR #176), nine items:

- Access tokens carry `cloudhal_grant` (a provider extension) and /mcp
  accepts a token only for the live grant it names, so tokens issued
  before a revoke stay dead after the client is connected again.
- Revoking a connection or removing a member deletes the provider's
  stored consent and revokes its refresh tokens in the same SQL batch
  as the grant change; before /oauth2/authorize, a stored consent with
  no live grant, or one the member's current role would widen, is
  forgotten so Studio's consent screen shows (no step-up loop after a
  promotion). The grant is written before the provider's consent call.
- agentCredential() in agent-runtime: an OAuth connection is an agent,
  never the person behind it. set_embargo through a token waits for a
  person instead of recording the person's approval; elevations stay
  key-only.
- The consent forward keeps the trusted client-address header, so the
  rate limit counts per client.
- No DPoP in v1: proofs refused at /oauth2/token, not advertised, and
  tokens with `cnf` refused on /mcp.
- Refresh token families end 90 days after the authentication they
  began with (the provider's token hash is now Cloudhal's too).
- The consent screen starts a step-up from the existing grant and marks
  new scopes; its wording no longer promises Phase 3 features.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
Second verification round of RFC-0044 phase 2 (PR #176):

- A refresh token is refused when its family (every refresh token of
  one authorization code) began before the live grant, and 90 days
  after the family's first token; the cap no longer follows the Studio
  session's sliding authTime.
- Ending a connection also deletes the member's pending authorization
  codes for the client.
- Member removal revokes grants and the provider side in the same
  batch as the membership delete, guarded on it.
- The authorize check reads a POST authorize's form body.
- The provider's issuer-appended metadata (/api/auth/.well-known/*),
  which listed DPoP, is fenced; the DPoP test asserts Cloudhal's own
  refusal.
- agentCredential() for the auto release key; giveConsent comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
Third verification round of RFC-0044 phase 2 (PR #176):

- The stale-consent check reads a POST body only for an HTTP
  POST /oauth2/authorize (authorizeSettings.isAuthorize), as the
  provider does; after sign-in it reads the resumed request's query
  again (regression from b5968e1).
- Before a code exchange, the code must belong to a live grant of its
  member and client and be no older than it.
- A refresh token without a family (no authorizationCodeId) is refused.
- Migration 0031 (unreleased) gains the composite index
  oauthRefreshToken (authorizationCodeId, createdAt) for the family
  lookup.
- Tests: sign-in after a stale consent, the code race, the closed
  fallback, a step-up keeping the first refresh token, pending codes
  removed with the member, 415 for a JSON authorize body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
CodeQL js/biased-cryptographic-random on newGrantId: mapping a random byte onto 62 characters with b % 62 made the first eight characters 5/4 as likely. Bytes at or above 248 are now dropped; a test checks the length, the alphabet and an even spread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
@Madraka
Madraka merged commit 64a9e9d into main Nov 9, 2026
10 checks passed
@Madraka
Madraka deleted the feat/mcp-oauth-authorization-server branch November 9, 2026 01:12
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