|
37 | 37 | * run, one variable apart — so it reddens from either side: remove the |
38 | 38 | * registration and the accepted half fails; widen it and the refused half |
39 | 39 | * does. |
| 40 | + * 5. [#16418] The principal-binding block does the same for |
| 41 | + * `verifyMcpAccessToken`: it mints a REAL `client_credentials` token from |
| 42 | + * this server and hands it to a real AuthManager verifying against this |
| 43 | + * server's JWKS. The refusal it pins used to be asserted against a |
| 44 | + * HAND-BUILT token with no `sub` at all — a shape the provider does not |
| 45 | + * mint — so that assertion passed for years while the method admitted |
| 46 | + * every real M2M token. A minted token is the only subject that can tell |
| 47 | + * those two apart, and the user leg beside it is the differential: same |
| 48 | + * server, same JWKS, same audience, one variable (which grant produced the |
| 49 | + * token). |
40 | 50 | */ |
41 | 51 |
|
42 | 52 | import { createRequire } from 'node:module'; |
| 53 | +import { createHash } from 'node:crypto'; |
43 | 54 | import path from 'node:path'; |
44 | 55 | import fs from 'node:fs'; |
45 | 56 |
|
@@ -291,6 +302,95 @@ function decodeJwtPayload(token: string): any { |
291 | 302 | return JSON.parse(Buffer.from(parts[1]!, 'base64url').toString('utf8')); |
292 | 303 | } |
293 | 304 |
|
| 305 | +/** |
| 306 | + * The at-rest form the installed provider expects for a client secret. 1.7.2 |
| 307 | + * defaults `storeClientSecret` to `"hashed"` whenever the jwt plugin is on |
| 308 | + * (it is here), and hashes with SHA-256 → unpadded base64url. Seeding the raw |
| 309 | + * secret instead produces `invalid_client`, i.e. NO token — which the mint |
| 310 | + * assertions below turn into a loud failure rather than a quiet "refused". |
| 311 | + */ |
| 312 | +function storedClientSecret(secret: string): string { |
| 313 | + return createHash('sha256').update(secret).digest('base64url'); |
| 314 | +} |
| 315 | + |
| 316 | +const M2M_CLIENT_ID = 'headless-integration-client'; |
| 317 | +const M2M_CLIENT_SECRET = 'headless-integration-secret'; |
| 318 | + |
| 319 | +/** |
| 320 | + * Registers a CONFIDENTIAL `client_credentials` client on the running AS and |
| 321 | + * links it to the MCP resource — the shape #16418's trace names: a client row |
| 322 | + * carrying `client_credentials_scopes`, plus the `oauthClientResource` link |
| 323 | + * `enforcePerClientResources` requires. |
| 324 | + * |
| 325 | + * ⚠️ Seeded through the AS's OWN adapter, not by pushing a row into the store: |
| 326 | + * the memory adapter persists under the schema's `fieldName` mapping |
| 327 | + * (`client_credentials_scopes`, not `clientCredentialsScopes`), so a raw push |
| 328 | + * is not found and the grant fails as "missing client" — a refusal for the |
| 329 | + * wrong reason. It is also NOT registered through DCR, because 1.7.2 refuses |
| 330 | + * `client_credentials` in an unauthenticated registration and only an |
| 331 | + * administrative registration may set the scope ceiling. |
| 332 | + */ |
| 333 | +async function seedClientCredentialsClient(server: { auth: any; pluginSchema?: any }) { |
| 334 | + const ctx = await server.auth.$context; |
| 335 | + await ctx.adapter.create({ |
| 336 | + model: 'oauthClient', |
| 337 | + data: { |
| 338 | + clientId: M2M_CLIENT_ID, |
| 339 | + clientSecret: storedClientSecret(M2M_CLIENT_SECRET), |
| 340 | + name: 'Headless integration', |
| 341 | + redirectUris: [REDIRECT_URI], |
| 342 | + grantTypes: ['client_credentials'], |
| 343 | + responseTypes: [], |
| 344 | + tokenEndpointAuthMethod: 'client_secret_post', |
| 345 | + scopes: ['data:read'], |
| 346 | + clientCredentialsScopes: ['data:read'], |
| 347 | + disabled: false, |
| 348 | + createdAt: new Date(), |
| 349 | + updatedAt: new Date(), |
| 350 | + }, |
| 351 | + }); |
| 352 | + await ctx.adapter.create({ |
| 353 | + model: 'oauthClientResource', |
| 354 | + data: { clientId: M2M_CLIENT_ID, resourceId: MCP_RESOURCE, createdAt: new Date() }, |
| 355 | + }); |
| 356 | +} |
| 357 | + |
| 358 | +/** Runs the real `client_credentials` grant and returns the minted token. */ |
| 359 | +async function mintClientCredentialsToken(server: { auth: any }): Promise<string> { |
| 360 | + const res = await server.auth.handler( |
| 361 | + new Request(`${ISSUER}/oauth2/token`, { |
| 362 | + method: 'POST', |
| 363 | + headers: { 'content-type': 'application/x-www-form-urlencoded' }, |
| 364 | + body: new URLSearchParams({ |
| 365 | + grant_type: 'client_credentials', |
| 366 | + client_id: M2M_CLIENT_ID, |
| 367 | + client_secret: M2M_CLIENT_SECRET, |
| 368 | + scope: 'data:read', |
| 369 | + resource: MCP_RESOURCE, |
| 370 | + }).toString(), |
| 371 | + }), |
| 372 | + ); |
| 373 | + const body: any = await res.json().catch(() => null); |
| 374 | + // ⛔ "the grant failed" must never be spellable as "the door refused it". |
| 375 | + expect(res.status, `the client_credentials grant did not mint a token: ${JSON.stringify(body)}`).toBe(200); |
| 376 | + expect(body?.access_token, 'no M2M access token was minted').toBeTruthy(); |
| 377 | + return body.access_token as string; |
| 378 | +} |
| 379 | + |
| 380 | +/** |
| 381 | + * An AuthManager whose JWKS comes from the RUNNING authorization server, so |
| 382 | + * `verifyMcpAccessToken` verifies signatures the same server produced. Issuer |
| 383 | + * and audience already agree by construction (both derive from BASE_URL). |
| 384 | + */ |
| 385 | +function managerVerifyingAgainst(server: { auth: any }): AuthManager { |
| 386 | + process.env.OS_MCP_SERVER_ENABLED = 'true'; |
| 387 | + const m = new AuthManager({ secret: 'test-secret-at-least-32-chars-long', baseUrl: BASE_URL }); |
| 388 | + vi.spyOn(m, 'getApi').mockResolvedValue({ |
| 389 | + getJwks: async () => await server.auth.api.getJwks(), |
| 390 | + } as any); |
| 391 | + return m; |
| 392 | +} |
| 393 | + |
294 | 394 | describe('oauthProvider option surface liveness (installed 1.7.2)', () => { |
295 | 395 | // Two-way control on the scanner itself: it must be able to answer BOTH |
296 | 396 | // "present" and "absent", or a 0-hit reading proves nothing. |
@@ -521,3 +621,86 @@ describe('MCP resource registration against the real provider (RFC 8707)', () => |
521 | 621 | expect(tokenBody?.access_token, 'no token may be minted for an unbound resource').toBeFalsy(); |
522 | 622 | }); |
523 | 623 | }); |
| 624 | + |
| 625 | +describe('[#16418] MCP is principal-bound — a minted client_credentials token resolves to NO principal', () => { |
| 626 | + it('mints a REAL M2M token whose `sub` is the client id and which carries no `sid` (the claim reading, off the token)', async () => { |
| 627 | + const opts = await captureProviderOptions(); |
| 628 | + const server = await bootRealAuthorizationServer(opts); |
| 629 | + await seedClientCredentialsClient(server); |
| 630 | + |
| 631 | + const payload = decodeJwtPayload(await mintClientCredentialsToken(server)); |
| 632 | + |
| 633 | + // Re-derive #3 from the card, kept live: the subject is read OFF THE |
| 634 | + // TOKEN, never inferred from the provider's source. This is the fact the |
| 635 | + // docblock used to deny ("carries no `sub`"). |
| 636 | + expect(payload.sub, 'the M2M token must carry a subject at all').toBeTruthy(); |
| 637 | + expect(payload.sub, "and that subject is the CLIENT — RFC 9068 §2.2.3.1's no-resource-owner shape").toBe( |
| 638 | + M2M_CLIENT_ID, |
| 639 | + ); |
| 640 | + expect(payload.client_id).toBe(M2M_CLIENT_ID); |
| 641 | + expect(payload.azp).toBe(M2M_CLIENT_ID); |
| 642 | + // Measured absence, recorded because it names the discriminator this fix |
| 643 | + // deliberately did NOT choose: `sid` separates the two shapes today, but |
| 644 | + // it is upstream-optional (already gated per client on ID tokens), so |
| 645 | + // relying on it would 401 every human the moment a bump gated it here. |
| 646 | + expect(payload.sid, 'no session exists behind a client_credentials grant').toBeUndefined(); |
| 647 | + }); |
| 648 | + |
| 649 | + it('DIFFERENTIAL: same server, same JWKS — the user token resolves, the M2M token does not', async () => { |
| 650 | + const opts = await captureProviderOptions(); |
| 651 | + const server = await bootRealAuthorizationServer(opts); |
| 652 | + await seedClientCredentialsClient(server); |
| 653 | + const manager = managerVerifyingAgainst(server); |
| 654 | + |
| 655 | + // -- machine leg ------------------------------------------------------- |
| 656 | + const m2mToken = await mintClientCredentialsToken(server); |
| 657 | + expect( |
| 658 | + await manager.verifyMcpAccessToken(m2mToken), |
| 659 | + 'a client_credentials token must assemble NO principal on the MCP surface — ' |
| 660 | + + 'headless callers use API keys (ADR-0101 D1), which is a separate chain entirely', |
| 661 | + ).toBeNull(); |
| 662 | + |
| 663 | + // -- human leg (the negative control) ---------------------------------- |
| 664 | + // The full flow on the SAME server: DCR → sign-up → authorize → consent → |
| 665 | + // token. If this half went red the refusal above would be worthless — a |
| 666 | + // method that refuses everything satisfies it. |
| 667 | + const reg = await registerDcrClient(server.auth); |
| 668 | + expect(reg.status, JSON.stringify(reg.body)).toBe(201); |
| 669 | + const cookie = await signUp(server.auth); |
| 670 | + const az = await authorizeWithResource(server.auth, reg.body.client_id, cookie, MCP_RESOURCE); |
| 671 | + expect(az.location).not.toContain('invalid_target'); |
| 672 | + const code = await consentToCode(server.auth, az.location, cookie); |
| 673 | + const tokenRes = await server.auth.handler( |
| 674 | + new Request(`${ISSUER}/oauth2/token`, { |
| 675 | + method: 'POST', |
| 676 | + headers: { 'content-type': 'application/x-www-form-urlencoded' }, |
| 677 | + body: new URLSearchParams({ |
| 678 | + grant_type: 'authorization_code', |
| 679 | + code, |
| 680 | + redirect_uri: REDIRECT_URI, |
| 681 | + client_id: reg.body.client_id, |
| 682 | + code_verifier: PKCE_VERIFIER, |
| 683 | + resource: MCP_RESOURCE, |
| 684 | + }).toString(), |
| 685 | + }), |
| 686 | + ); |
| 687 | + const tokenBody: any = await tokenRes.json().catch(() => null); |
| 688 | + expect(tokenRes.status, JSON.stringify(tokenBody)).toBe(200); |
| 689 | + const userToken: string = tokenBody.access_token; |
| 690 | + const userPayload = decodeJwtPayload(userToken); |
| 691 | + |
| 692 | + expect( |
| 693 | + await manager.verifyMcpAccessToken(userToken), |
| 694 | + 'an authorization-code token must still resolve — this narrows the M2M shape and nothing else', |
| 695 | + ).toEqual({ |
| 696 | + userId: userPayload.sub, |
| 697 | + scopes: ['openid', 'profile', 'email', 'offline_access', 'data:read'], |
| 698 | + clientId: reg.body.client_id, |
| 699 | + }); |
| 700 | + |
| 701 | + // The one variable between the two legs, stated as an assertion: the |
| 702 | + // human token's subject is NOT its client, the machine token's subject IS. |
| 703 | + expect(userPayload.sub).not.toBe(userPayload.client_id); |
| 704 | + expect(decodeJwtPayload(m2mToken).sub).toBe(decodeJwtPayload(m2mToken).client_id); |
| 705 | + }); |
| 706 | +}); |
0 commit comments