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:
Oliver Kieselbach
2026-09-25 13:00:42 +02:00
parent eb47c4d69a
commit e2c91afda2
7 changed files with 267 additions and 8 deletions
+4 -4
View File
@@ -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;
}
}