diff --git a/internal b/internal index 38878688..f2cf9d17 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit 3887868889e1ee901699dd831d0117887d8d4200 +Subproject commit f2cf9d172e3e49f8fac2bd7a7fc6a694e58e1532 diff --git a/src/McpServer/autopilot-monitor-mcp/package-lock.json b/src/McpServer/autopilot-monitor-mcp/package-lock.json index 867de332..3a0c50b6 100644 --- a/src/McpServer/autopilot-monitor-mcp/package-lock.json +++ b/src/McpServer/autopilot-monitor-mcp/package-lock.json @@ -16,6 +16,7 @@ "compression": "^1.8.2", "express": "^5.2.1", "fuse.js": "^7.5.0", + "jose": "^6.2.12", "zod": "^4.6.5" }, "bin": { @@ -2596,10 +2597,9 @@ "license": "ISC" }, "node_modules/jose": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", - "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", - "dev": true, + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" diff --git a/src/McpServer/autopilot-monitor-mcp/package.json b/src/McpServer/autopilot-monitor-mcp/package.json index e5d0007b..7f877745 100644 --- a/src/McpServer/autopilot-monitor-mcp/package.json +++ b/src/McpServer/autopilot-monitor-mcp/package.json @@ -27,6 +27,7 @@ "compression": "^1.8.2", "express": "^5.2.1", "fuse.js": "^7.5.0", + "jose": "^6.2.12", "zod": "^4.6.5" }, "devDependencies": { diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/oauth-tenant-client.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/oauth-tenant-client.test.ts index 90ccc214..083d787d 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/oauth-tenant-client.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/oauth-tenant-client.test.ts @@ -14,6 +14,7 @@ process.env.MCP_OAUTH_RATE_LIMIT_PER_MINUTE = '100000'; process.env.MCP_OAUTH_TOKEN_RATE_LIMIT_PER_MINUTE = '100000'; const { createOAuthRouter, signState, clearTenantClientCache, tenantClientRegistrationId } = await import('../oauth.js'); const { API_BASE_URL } = await import('../config.js'); +const { isSealedRefreshToken } = await import('../refresh-envelope.js'); const REG_ID = '0123456789abcdef0123456789abcdef'; const CLIENT_ID = `amc_${REG_ID}`; @@ -209,5 +210,96 @@ describe('/oauth/token with a tenant client', () => { const entra = outbound.find((o) => o.url.includes('/oauth2/v2.0/token'))!; expect(new URL(entra.url).pathname).toBe('/organizations/oauth2/v2.0/token'); expect(outbound.some((o) => o.url.includes('/client-registrations/'))).toBe(false); + expect(r.json.refresh_token).toBe('rt'); + }); +}); + +describe('sealed refresh tokens of a tenant client', () => { + /** A refresh token as a tenant client receives it from its code exchange. */ + async function sealedToken(): Promise { + stubOutbound({ lookup: 'found' }); + const r = await token({ grant_type: 'authorization_code', code: 'c1', code_verifier: 'v'.repeat(43), client_id: CLIENT_ID, redirect_uri: CALLBACK }); + vi.restoreAllMocks(); + clearTenantClientCache(); + expect(r.status).toBe(200); + return r.json.refresh_token as string; + } + + const entraRefreshToken = (outbound: Outbound[]) => + new URLSearchParams(outbound.find((o) => o.url.includes('/oauth2/v2.0/token'))!.body).get('refresh_token'); + + it('hands out the refresh token only sealed, never the Entra token', async () => { + const sealed = await sealedToken(); + expect(sealed).not.toBe('rt'); + expect(isSealedRefreshToken(sealed)).toBe(true); + }); + + it('redeems a sealed token without client_id at the registration tenant and seals the next one', async () => { + const sealed = await sealedToken(); + const outbound = stubOutbound({ lookup: 'found' }); + const r = await token({ grant_type: 'refresh_token', refresh_token: sealed }); + + expect(r.status).toBe(200); + const entra = outbound.find((o) => o.url.includes('/oauth2/v2.0/token'))!; + expect(new URL(entra.url).pathname).toBe(`/${TENANT}/oauth2/v2.0/token`); + expect(entraRefreshToken(outbound)).toBe('rt'); + expect(isSealedRefreshToken(r.json.refresh_token as string)).toBe(true); + }); + + it('accepts the sealed token together with its own client_id', async () => { + const sealed = await sealedToken(); + const outbound = stubOutbound({ lookup: 'found' }); + const r = await token({ grant_type: 'refresh_token', refresh_token: sealed, client_id: CLIENT_ID }); + + expect(r.status).toBe(200); + expect(entraRefreshToken(outbound)).toBe('rt'); + }); + + it('ends a sealed token once the registration is deleted, with or without client_id', async () => { + const sealed = await sealedToken(); + for (const form of [ + { grant_type: 'refresh_token', refresh_token: sealed }, + { grant_type: 'refresh_token', refresh_token: sealed, client_id: CLIENT_ID }, + ]) { + const outbound = stubOutbound({ lookup: 'missing' }); + const r = await token(form); + expect(r.status).toBe(400); + expect(r.json.error).toBe('invalid_client'); + expect(outbound.some((o) => o.url.includes('/oauth2/v2.0/token'))).toBe(false); + vi.restoreAllMocks(); + clearTenantClientCache(); + } + }); + + it('refuses a sealed token presented by another client before any outbound call', async () => { + const sealed = await sealedToken(); + for (const clientId of [`amc_${'f'.repeat(32)}`, 'some-dcr-client']) { + const outbound = stubOutbound({ lookup: 'found' }); + const r = await token({ grant_type: 'refresh_token', refresh_token: sealed, client_id: clientId }); + expect(r.status).toBe(400); + expect(r.json.error).toBe('invalid_grant'); + expect(outbound).toHaveLength(0); + vi.restoreAllMocks(); + } + }); + + it('refuses a tampered sealed token before any outbound call', async () => { + const parts = (await sealedToken()).split('.'); + parts[3] = (parts[3][0] === 'A' ? 'B' : 'A') + parts[3].slice(1); + const outbound = stubOutbound({ lookup: 'found' }); + const r = await token({ grant_type: 'refresh_token', refresh_token: parts.join('.') }); + + expect(r.status).toBe(400); + expect(r.json.error).toBe('invalid_grant'); + expect(outbound).toHaveLength(0); + }); + + it('seals the next token when a tenant client still holds a raw one', async () => { + const outbound = stubOutbound({ lookup: 'found' }); + const r = await token({ grant_type: 'refresh_token', refresh_token: 'rt', client_id: CLIENT_ID }); + + expect(r.status).toBe(200); + expect(entraRefreshToken(outbound)).toBe('rt'); + expect(isSealedRefreshToken(r.json.refresh_token as string)).toBe(true); }); }); diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/refresh-envelope.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/refresh-envelope.test.ts new file mode 100644 index 00000000..2f59c5bb --- /dev/null +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/refresh-envelope.test.ts @@ -0,0 +1,65 @@ +/** + * Sealed refresh tokens (refresh-envelope.ts): a compact JWE only this server opens, recognised by its + * key id, carrying the Entra refresh token and the registration it was issued through. + */ +import { describe, it, expect } from 'vitest'; +import crypto from 'node:crypto'; +import { CompactEncrypt, decodeProtectedHeader } from 'jose'; +import { isSealedRefreshToken, openRefreshToken, sealRefreshToken } from '../refresh-envelope.js'; + +const KEY = crypto.randomBytes(32); +const REG_ID = '0123456789abcdef0123456789abcdef'; +const ENTRA_RT = '1.AXoAqGnBexample-entra-refresh-token_with.dots'; + +async function jwe(payload: unknown, kid: string, key: Uint8Array = KEY): Promise { + return new CompactEncrypt(new TextEncoder().encode(JSON.stringify(payload))) + .setProtectedHeader({ alg: 'dir', enc: 'A256GCM', kid }) + .encrypt(key); +} + +describe('sealRefreshToken / openRefreshToken', () => { + it('round-trips the Entra token and the registration id', async () => { + const sealed = await sealRefreshToken(KEY, ENTRA_RT, REG_ID); + expect(await openRefreshToken(KEY, sealed)).toEqual({ refreshToken: ENTRA_RT, registrationId: REG_ID }); + }); + + it('is a standard compact JWE (dir + A256GCM) that does not carry the token in the clear', async () => { + const sealed = await sealRefreshToken(KEY, ENTRA_RT, REG_ID); + expect(sealed.split('.')).toHaveLength(5); + expect(decodeProtectedHeader(sealed)).toEqual({ alg: 'dir', enc: 'A256GCM', kid: 'amc-rt-v1' }); + expect(sealed).not.toContain(ENTRA_RT); + expect(Buffer.from(sealed.split('.')[3], 'base64url').toString('latin1')).not.toContain('AXoAqGnB'); + }); + + it('opens nothing under another key', async () => { + const sealed = await sealRefreshToken(crypto.randomBytes(32), ENTRA_RT, REG_ID); + expect(await openRefreshToken(KEY, sealed)).toBeNull(); + }); + + it('opens nothing that was tampered with', async () => { + const parts = (await sealRefreshToken(KEY, ENTRA_RT, REG_ID)).split('.'); + parts[3] = (parts[3][0] === 'A' ? 'B' : 'A') + parts[3].slice(1); + expect(await openRefreshToken(KEY, parts.join('.'))).toBeNull(); + }); + + it('opens nothing under a foreign key id or with an incomplete payload', async () => { + expect(await openRefreshToken(KEY, await jwe({ rt: ENTRA_RT, reg: REG_ID }, 'other'))).toBeNull(); + expect(await openRefreshToken(KEY, await jwe({ rt: ENTRA_RT }, 'amc-rt-v1'))).toBeNull(); + expect(await openRefreshToken(KEY, await jwe({ rt: ENTRA_RT, reg: 'not-a-registration' }, 'amc-rt-v1'))).toBeNull(); + expect(await openRefreshToken(KEY, await jwe({ reg: REG_ID }, 'amc-rt-v1'))).toBeNull(); + }); +}); + +describe('isSealedRefreshToken', () => { + it('recognises only an envelope with our key id', async () => { + expect(isSealedRefreshToken(await sealRefreshToken(KEY, ENTRA_RT, REG_ID))).toBe(true); + expect(isSealedRefreshToken(await jwe({ rt: ENTRA_RT, reg: REG_ID }, 'other'))).toBe(false); + }); + + it('leaves Entra refresh tokens, JWTs and garbage on the unsealed path', () => { + expect(isSealedRefreshToken(ENTRA_RT)).toBe(false); + expect(isSealedRefreshToken('eyJhbGciOiJSUzI1NiJ9.eyJ0aWQiOiJ4In0.sig')).toBe(false); + expect(isSealedRefreshToken('a.b.c.d.e')).toBe(false); + expect(isSealedRefreshToken('')).toBe(false); + }); +}); diff --git a/src/McpServer/autopilot-monitor-mcp/src/oauth.ts b/src/McpServer/autopilot-monitor-mcp/src/oauth.ts index f7a8508a..048b1425 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/oauth.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/oauth.ts @@ -28,6 +28,7 @@ import { API_BASE_URL, ENTRA_LOGIN_BASE_URL, getPublicBaseUrl } from './config.j import type { McpClientRegistrationLookupResponse } from './generated/wire-types.generated.js'; import { ClientMetadataError, isClientIdMetadataUrl, resolveClientMetadata } from './cimd.js'; import { MAX_CLIENT_NAME_LENGTH, MAX_REDIRECT_URIS_PER_CLIENT, MAX_REDIRECT_URI_LENGTH } from './oauth-limits.js'; +import { isSealedRefreshToken, openRefreshToken, sealRefreshToken } from './refresh-envelope.js'; export { MAX_CLIENT_NAME_LENGTH, MAX_REDIRECT_URIS_PER_CLIENT, MAX_REDIRECT_URI_LENGTH }; @@ -279,6 +280,13 @@ const OAUTH_CLIENT_ID_KEY: Buffer = crypto .update('autopilot-mcp/oauth-client-id/v1') .digest(); +// Key of the sealed refresh tokens handed to tenant-bound clients (refresh-envelope.ts): derived and +// domain-separated the same way, 32 bytes for A256GCM. Rotating the root secret ends every sealed token. +const OAUTH_REFRESH_ENVELOPE_KEY: Buffer = crypto + .createHmac('sha256', OAUTH_STATE_SIGNING_KEY) + .update('autopilot-mcp/oauth-refresh-envelope/v1') + .digest(); + interface ClientIdPayload { typ: 'client'; redirectUris: string[]; @@ -563,7 +571,9 @@ async function resolveRegisteredClient( // portal and receives client_id `amc_`. The registration is that client's allowlist // entry — the global vendor allowlist keeps governing dynamic registration — and it binds the flow to // the registering tenant: authorize and token go to that tenant's Entra authority, and the token -// endpoint discards a token whose tid is not the registration's. Registrations live in the backend; +// endpoint discards a token whose tid is not the registration's. Such a client receives its refresh +// token only sealed with the registration id (refresh-envelope.ts), so the registration governs every +// refresh, whether or not the request names the client. Registrations live in the backend; // this server reads one through an anonymous lookup by its unguessable id (no user token exists // before the exchange) and caches the answer briefly, so a deletion or the operator switch takes // effect within TENANT_CLIENT_CACHE_MS. @@ -1039,7 +1049,26 @@ export function createOAuthRouter(): Router { } // A tenant-bound client exchanges at its tenant's authority, and only while its registration exists. - const registrationId = tenantClientRegistrationId(params.client_id); + // Its refresh tokens are sealed with the registration id: at refresh the registration comes from the + // envelope, so a copy redeemed without (or with another) client_id still meets the registration check. + // The first grant keys off client_id, which RFC 6749 §4.1.3 requires of a public client's code exchange. + let registrationId = tenantClientRegistrationId(params.client_id); + let refreshToken = params.refresh_token; + if (params.grant_type === 'refresh_token' && typeof refreshToken === 'string' && isSealedRefreshToken(refreshToken)) { + const opened = await openRefreshToken(OAUTH_REFRESH_ENVELOPE_KEY, refreshToken); + if (!opened) { + console.error('[oauth/token] sealed refresh token rejected: cannot be opened'); + res.status(400).json({ error: 'invalid_grant', error_description: 'refresh token is invalid' }); + return; + } + if (params.client_id && registrationId !== opened.registrationId) { + console.error(`[oauth/token] sealed refresh token of tenant client ${opened.registrationId} presented by another client_id`); + res.status(400).json({ error: 'invalid_grant', error_description: 'refresh token was issued to another client' }); + return; + } + registrationId = opened.registrationId; + refreshToken = opened.refreshToken; + } let boundTenantId: string | null = null; if (registrationId) { const client = await lookupTenantClient(registrationId); @@ -1065,7 +1094,7 @@ export function createOAuthRouter(): Router { body.set('grant_type', params.grant_type); if (params.code) body.set('code', params.code); - if (params.refresh_token) body.set('refresh_token', params.refresh_token); + if (refreshToken) body.set('refresh_token', refreshToken); if (params.code_verifier) body.set('code_verifier', params.code_verifier); // Always use server-defined SCOPES (see authorize endpoint comment) body.set('scope', SCOPES); @@ -1105,6 +1134,11 @@ export function createOAuthRouter(): Router { res.status(400).json({ error: 'invalid_grant', error_description: 'the token was issued for another organization than this client registration' }); return; } + // Never hand a tenant-bound client the raw Entra refresh token. A raw token it still holds from + // before sealing existed is accepted above and comes back sealed, so clients migrate on refresh. + if (typeof data.refresh_token === 'string' && registrationId) { + data.refresh_token = await sealRefreshToken(OAUTH_REFRESH_ENVELOPE_KEY, data.refresh_token, registrationId); + } } res.status(tokenResponse.status).json(tokenResponse.status === 200 ? data : sanitizeTokenErrorBody(data)); } catch (err) { diff --git a/src/McpServer/autopilot-monitor-mcp/src/refresh-envelope.ts b/src/McpServer/autopilot-monitor-mcp/src/refresh-envelope.ts new file mode 100644 index 00000000..f33df482 --- /dev/null +++ b/src/McpServer/autopilot-monitor-mcp/src/refresh-envelope.ts @@ -0,0 +1,67 @@ +/** + * Sealed refresh tokens for tenant-bound clients (portal registrations, D-280 / D-283). + * + * A self-hosted client keeps its users' refresh tokens in its own database. An Entra refresh token is + * bound to user and app but not to a tenant, and the proxy's public token endpoint adds the app's + * client secret for every caller — so a raw token copied out of that database could be redeemed + * without the client's `client_id`, past the registration check, and deleting the registration would + * stop the client but not the copy. The proxy therefore hands such a client its refresh token only + * sealed: a compact JWE (RFC 7516, `dir` + `A256GCM`) carrying the Entra token together with the + * registration id. Only this server can open it, and at refresh the registration comes from the + * envelope, never from a request parameter — a deleted registration ends every token issued through it. + * + * Encrypted, not merely signed: a signed envelope would expose the raw Entra token, which is + * redeemable on its own. Stateless like the signed client_id: the key is derived from + * OAuthStateSigningKey (oauth.ts), so any replica opens any envelope, and rotating that secret ends + * every sealed token (the users sign in again). Access and ID tokens are untouched Entra JWTs. + */ +import { CompactEncrypt, compactDecrypt, decodeProtectedHeader } from 'jose'; + +/** Key id in the protected header; a later key change adds a second id instead of breaking the first. */ +const ENVELOPE_KID = 'amc-rt-v1'; +const REGISTRATION_ID = /^[0-9a-f]{32}$/; + +export interface OpenedRefreshToken { + /** The Entra refresh token. */ + refreshToken: string; + /** The portal registration the token was issued through. */ + registrationId: string; +} + +/** + * True for a value this server sealed — a five-part compact JWE with our key id. Anything else + * (an Entra refresh token, garbage) is not an envelope and takes the unsealed path. + */ +export function isSealedRefreshToken(value: string): boolean { + if (value.split('.').length !== 5) return false; + try { + return decodeProtectedHeader(value).kid === ENVELOPE_KID; + } catch { + return false; + } +} + +export async function sealRefreshToken(key: Uint8Array, refreshToken: string, registrationId: string): Promise { + const payload = new TextEncoder().encode(JSON.stringify({ rt: refreshToken, reg: registrationId })); + return new CompactEncrypt(payload) + .setProtectedHeader({ alg: 'dir', enc: 'A256GCM', kid: ENVELOPE_KID }) + .encrypt(key); +} + +/** The sealed token and its registration, or null for a tampered, foreign-key or malformed envelope. */ +export async function openRefreshToken(key: Uint8Array, value: string): Promise { + try { + const { plaintext, protectedHeader } = await compactDecrypt(value, key, { + keyManagementAlgorithms: ['dir'], + contentEncryptionAlgorithms: ['A256GCM'], + }); + if (protectedHeader.kid !== ENVELOPE_KID) return null; + const parsed = JSON.parse(new TextDecoder().decode(plaintext)) as { rt?: unknown; reg?: unknown }; + if (typeof parsed.rt !== 'string' || !parsed.rt || typeof parsed.reg !== 'string' || !REGISTRATION_ID.test(parsed.reg)) { + return null; + } + return { refreshToken: parsed.rt, registrationId: parsed.reg }; + } catch { + return null; + } +}