mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
feat(mcp): seal refresh tokens of registered self-hosted clients so that deleting the registration also ends copies (D-283)
A portal-registered client (amc_) now receives its Entra refresh token only as a compact JWE (RFC 7516, dir + A256GCM, kid amc-rt-v1). The key is derived from OAuthStateSigningKey. At refresh, the registration comes from the envelope, never from the client_id parameter. Before, a copy of the token redeemed without client_id went to the organizations authority and skipped the registration check. A raw refresh token presented with an amc_ client id is accepted and returned sealed, so these clients migrate on their next refresh. All other clients are unchanged. jose becomes a production dependency; it was a dev-only transitive dependency before. The internal pointer is bumped to D-283/D-284. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
Submodule internal updated: 3887868889...f2cf9d172e
+4
-4
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<string> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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_<registrationId>`. 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) {
|
||||
|
||||
@@ -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<string> {
|
||||
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<OpenedRefreshToken | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user