|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#25224 cut 9] The MCP access-token verifier of `AuthManager` — the local, |
| 5 | + * fail-closed check of an OAuth 2.1 Bearer access token this deployment's |
| 6 | + * authorization server minted, and the one forced keyring re-read a token |
| 7 | + * signed by an unknown key earns — moved out of `auth-manager.ts` whole, with |
| 8 | + * the one module-level declaration that only these bodies read: the reader of |
| 9 | + * the key set `/jwks` publishes. |
| 10 | + * |
| 11 | + * A MOVE, not a redesign. Each function below is the body of the |
| 12 | + * `AuthManager` method of the same name as it stood, re-indented one level |
| 13 | + * and otherwise unedited, and `readPublishedJwks` is its text as it stood in |
| 14 | + * `auth-manager.ts`: the same signature, issuer and audience checks, the same |
| 15 | + * refusal of a token no human delegated, the same organization claim, the |
| 16 | + * same single-flight re-read and its floor. |
| 17 | + * |
| 18 | + * ## How the moved bodies reach the class |
| 19 | + * |
| 20 | + * The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts` |
| 21 | + * header carries the full reasoning), which `auth-manager-actor-guards.ts`, |
| 22 | + * `auth-manager-sign-in-lockout.ts`, `auth-manager-password-session-policy.ts`, |
| 23 | + * `auth-manager-database-hooks.ts`, `auth-manager-email-sms-delivery.ts`, |
| 24 | + * `auth-manager-plugin-assembly.ts`, `auth-manager-database-secret.ts` and |
| 25 | + * `auth-manager-membership-admission.ts` follow, applied per method. The class |
| 26 | + * keeps both methods — the published `dist/*.d.ts` lists each one — and each |
| 27 | + * body is one literal of exactly the {@link McpAccessTokenHost} members that |
| 28 | + * method reads, plus a `.call`. `verifyMcpAccessToken` is no longer `async`: |
| 29 | + * it returns the moved function's own promise, so a caller awaits the same |
| 30 | + * promise the body settles, with no extra wrapper. |
| 31 | + * |
| 32 | + * A method goes as a closure that calls it on the manager at call time, so a |
| 33 | + * test double or spy set on the manager is still the one a body reaches — |
| 34 | + * `getApi`, which the verifier suites replace with a key-set double, and |
| 35 | + * `rereadKeyringForUnknownKey` included, which `verifyMcpAccessToken` asks |
| 36 | + * through the manager rather than calling the function here directly. |
| 37 | + * `config` goes as a GETTER: the class reassigns it after construction |
| 38 | + * (`setRuntimeBaseUrl` and `applyConfigPatch`), so it is read off the manager |
| 39 | + * when the body reads it, as before. `unknownKeyReread` goes as a get/set |
| 40 | + * pair, because `rereadKeyringForUnknownKey` stores the re-read it starts on |
| 41 | + * the manager: the floor and the read in flight stay the ones the manager |
| 42 | + * holds, shared by every token it verifies. |
| 43 | + * |
| 44 | + * `readPublishedJwks` moves with the bodies, because only they read it. |
| 45 | + * `OAUTH_ORGANIZATION_CLAIM` stays: the plugin assembly that mints the claim |
| 46 | + * (`buildPluginList`, still in the class) reads it too, and it is one |
| 47 | + * spelling for both ends. So `auth-manager.ts` exports it and this module |
| 48 | + * imports it by VALUE, for one read inside `verifyMcpAccessToken`, when a |
| 49 | + * token is verified. `index.ts` lists `auth-manager.ts`'s published names one |
| 50 | + * by one, so that export is not published. The two modules import each |
| 51 | + * other, and that is safe because nothing reads the binding while they are |
| 52 | + * first evaluated: this module's top level is imports, an interface and |
| 53 | + * function declarations. |
| 54 | + * |
| 55 | + * Nothing re-exports this module, so the published surface does not move. |
| 56 | + */ |
| 57 | + |
| 58 | +import type { JSONWebKeySet } from 'jose'; |
| 59 | +import { OAUTH_ORGANIZATION_CLAIM, type AuthManager } from './auth-manager.js'; |
| 60 | +import { |
| 61 | + retireHeldKeyrings, |
| 62 | + UNKNOWN_SIGNING_KEY_REREAD_FLOOR_MS, |
| 63 | +} from './signing-key-read-cache.js'; |
| 64 | + |
| 65 | +/** |
| 66 | + * [#24186] The key set the MCP access-token verifier checks signatures |
| 67 | + * against: the document better-auth's `getJwks` endpoint answers, which is |
| 68 | + * what `/jwks` publishes — public members only, read through the signer's |
| 69 | + * keyring reader (`signing-key-read-cache.ts`). `undefined` when the instance |
| 70 | + * carries no jwt plugin or the answer is not a key set. |
| 71 | + */ |
| 72 | +async function readPublishedJwks(api: unknown): Promise<JSONWebKeySet | undefined> { |
| 73 | + const jwks = await (api as { getJwks?: () => Promise<unknown> } | null | undefined)?.getJwks?.(); |
| 74 | + return jwks && Array.isArray((jwks as { keys?: unknown }).keys) ? (jwks as JSONWebKeySet) : undefined; |
| 75 | +} |
| 76 | + |
| 77 | +/** |
| 78 | + * The `AuthManager` members the functions below read — every one of them, and |
| 79 | + * no other; each function's `this` is the `Pick` of the members IT reads. Each |
| 80 | + * is typed AS the class member (an indexed access on the class), so a change |
| 81 | + * to a member's signature on the class is a change here too, and naming a |
| 82 | + * member the class does not have is a compile error. See the module header for |
| 83 | + * why `config` is a getter and `unknownKeyReread` an accessor. |
| 84 | + */ |
| 85 | +export interface McpAccessTokenHost { |
| 86 | + readonly config: AuthManager['config']; |
| 87 | + unknownKeyReread: AuthManager['unknownKeyReread']; |
| 88 | + getApi: AuthManager['getApi']; |
| 89 | + getAuthIssuer: AuthManager['getAuthIssuer']; |
| 90 | + getMcpResourceUrl: AuthManager['getMcpResourceUrl']; |
| 91 | + isMcpOAuthEnabled: AuthManager['isMcpOAuthEnabled']; |
| 92 | + rereadKeyringForUnknownKey: AuthManager['rereadKeyringForUnknownKey']; |
| 93 | +} |
| 94 | + |
| 95 | +/** The body of `AuthManager#verifyMcpAccessToken`; the method's docblock there is its contract. */ |
| 96 | +export async function verifyMcpAccessToken( |
| 97 | + this: Pick<McpAccessTokenHost, 'getApi' | 'getAuthIssuer' | 'getMcpResourceUrl' | 'isMcpOAuthEnabled' | 'rereadKeyringForUnknownKey'>, |
| 98 | + token: string, |
| 99 | +): Promise<{ userId: string; scopes: string[]; clientId?: string; organizationId?: string } | null> { |
| 100 | + try { |
| 101 | + if (!token || token.split('.').length !== 3) return null; // not a JWS compact token |
| 102 | + if (!this.isMcpOAuthEnabled()) return null; |
| 103 | + |
| 104 | + const { createLocalJWKSet, jwtVerify, errors } = await import('jose'); |
| 105 | + |
| 106 | + const api = await this.getApi(); |
| 107 | + const jwks = await readPublishedJwks(api); |
| 108 | + if (!jwks) return null; |
| 109 | + const expected = { issuer: this.getAuthIssuer(), audience: this.getMcpResourceUrl() }; |
| 110 | + const verifyAgainst = async (keys: JSONWebKeySet) => |
| 111 | + (await jwtVerify(token, createLocalJWKSet(keys), expected)).payload; |
| 112 | + |
| 113 | + let payload: Awaited<ReturnType<typeof verifyAgainst>>; |
| 114 | + try { |
| 115 | + payload = await verifyAgainst(jwks); |
| 116 | + } catch (error) { |
| 117 | + // Only "no key in the set matches this token" earns the fresh read: a |
| 118 | + // key minted where this process could not see it. Every other failure |
| 119 | + // is the token's own, and a re-read could not change it. |
| 120 | + if (!(error instanceof errors.JWKSNoMatchingKey)) throw error; |
| 121 | + const fresh = await this.rereadKeyringForUnknownKey(api); |
| 122 | + if (!fresh) return null; |
| 123 | + payload = await verifyAgainst(fresh); |
| 124 | + } |
| 125 | + |
| 126 | + const subject = typeof payload.sub === 'string' && payload.sub ? payload.sub : undefined; |
| 127 | + if (!subject) return null; |
| 128 | + |
| 129 | + // The two spellings of "which client is presenting this", read |
| 130 | + // independently rather than through a `??` chain: a token that carries |
| 131 | + // both and disagrees with itself must be refused on EITHER match, and |
| 132 | + // collapsing them first would let the losing spelling smuggle the |
| 133 | + // client id past the comparison below. |
| 134 | + const clientIdClaim = |
| 135 | + typeof (payload as any).client_id === 'string' && (payload as any).client_id |
| 136 | + ? ((payload as any).client_id as string) |
| 137 | + : undefined; |
| 138 | + const azp = |
| 139 | + typeof (payload as any).azp === 'string' && (payload as any).azp |
| 140 | + ? ((payload as any).azp as string) |
| 141 | + : undefined; |
| 142 | + // No client identity at all → the human/machine discriminator has no |
| 143 | + // input. Fail closed rather than admit an unclassifiable token. |
| 144 | + if (!clientIdClaim && !azp) return null; |
| 145 | + // `sub` IS the client → RFC 9068 §2.2.3.1's "no resource owner was |
| 146 | + // involved" shape, i.e. a client_credentials grant. No principal. |
| 147 | + if (subject === clientIdClaim || subject === azp) return null; |
| 148 | + |
| 149 | + const scopes = |
| 150 | + typeof payload.scope === 'string' |
| 151 | + ? payload.scope.split(' ').filter(Boolean) |
| 152 | + : []; |
| 153 | + // The bound organization, when the token carries one. A claim that is |
| 154 | + // PRESENT but is not a non-empty string is no organization this |
| 155 | + // authorization server mints, so the token is refused rather than read |
| 156 | + // as "no organization": the check that cannot run must not pass. |
| 157 | + const organizationClaim = (payload as Record<string, unknown>)[OAUTH_ORGANIZATION_CLAIM]; |
| 158 | + if ( |
| 159 | + organizationClaim !== undefined |
| 160 | + && (typeof organizationClaim !== 'string' || !organizationClaim) |
| 161 | + ) return null; |
| 162 | + const organizationId = organizationClaim as string | undefined; |
| 163 | + return { |
| 164 | + userId: subject, |
| 165 | + scopes, |
| 166 | + ...(azp ? { clientId: azp } : {}), |
| 167 | + ...(organizationId ? { organizationId } : {}), |
| 168 | + }; |
| 169 | + } catch { |
| 170 | + return null; // unknown/expired/wrong-audience/garbage → no principal |
| 171 | + } |
| 172 | +} |
| 173 | + |
| 174 | +/** The body of `AuthManager#rereadKeyringForUnknownKey`; the method's docblock there is its contract. */ |
| 175 | +export function rereadKeyringForUnknownKey(this: Pick<McpAccessTokenHost, 'config' | 'unknownKeyReread'>, api: unknown): Promise<JSONWebKeySet | undefined> { |
| 176 | + const last = this.unknownKeyReread; |
| 177 | + if (last?.pending) return last.pending; |
| 178 | + const now = Date.now(); |
| 179 | + if (last && now - last.startedAtMs < UNKNOWN_SIGNING_KEY_REREAD_FLOOR_MS) { |
| 180 | + return Promise.resolve(undefined); |
| 181 | + } |
| 182 | + retireHeldKeyrings(this.config.dataEngine); |
| 183 | + const pending = readPublishedJwks(api); |
| 184 | + const reread: { readonly startedAtMs: number; pending?: Promise<JSONWebKeySet | undefined> } = { |
| 185 | + startedAtMs: now, |
| 186 | + pending, |
| 187 | + }; |
| 188 | + this.unknownKeyReread = reread; |
| 189 | + const settle = () => { |
| 190 | + reread.pending = undefined; |
| 191 | + }; |
| 192 | + pending.then(settle, settle); |
| 193 | + return pending; |
| 194 | +} |
0 commit comments