From 249d0a713d02de54770df4ec158003fcfbfe364b Mon Sep 17 00:00:00 2001 From: Hemant Jadhav Date: Fri, 4 Sep 2026 19:17:46 +0530 Subject: [PATCH 1/4] feat(auth): add Method 1 Just-in-Time Dynamic Discovery (CIMD) support - Add mountCimdEndpoint, createCimdHandler, and validateRedirectUriWithCimd in cimd.ts - Implement CimdCache with in-flight request deduplication and TTL caching - Add initiateCimdConnect and supportsClientIdMetadataDocument to OAuth2Client in client.ts - Update exchangeCodeForToken, refreshToken, and revokeToken to support CIMD URL as client_id - Add setupCimdHosting and setupAuth0CimdClient quick-setup helpers in quick-setup.ts - Advertise client_id_metadata_document_supported in OAuthModule metadata discovery endpoints - Add comprehensive test suite in src/auth/__tests__/cimd-jit.test.ts --- .../core/src/auth/__tests__/cimd-jit.test.ts | 321 ++++++++++++++++++ typescript/packages/core/src/auth/cimd.ts | 221 ++++++++++++ typescript/packages/core/src/auth/client.ts | 168 ++++++++- typescript/packages/core/src/auth/index.ts | 10 +- .../packages/core/src/auth/quick-setup.ts | 92 ++++- typescript/packages/core/src/auth/types.ts | 121 ++++++- .../packages/core/src/core/oauth-module.ts | 8 +- 7 files changed, 920 insertions(+), 21 deletions(-) create mode 100644 typescript/packages/core/src/auth/__tests__/cimd-jit.test.ts diff --git a/typescript/packages/core/src/auth/__tests__/cimd-jit.test.ts b/typescript/packages/core/src/auth/__tests__/cimd-jit.test.ts new file mode 100644 index 000000000..b1331750b --- /dev/null +++ b/typescript/packages/core/src/auth/__tests__/cimd-jit.test.ts @@ -0,0 +1,321 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { + createClientIdMetadataDocument, + validateClientIdMetadataDocument, + validateClientIdentifierUrl, + validateRedirectUriWithCimd, + isClientIdMetadataUrl, + mountCimdEndpoint, + createCimdHandler, + CimdCache, + setupCimdHosting, + setupAuth0CimdClient, + printAuthSetupInstructions, + validateAuthEnv, + OAuth2Client, +} from '../index.js'; + +// Mock global fetch +const mockFetch = jest.fn() as any; +(global as any).fetch = mockFetch; + +describe('CIMD Method 1: Just-in-Time Dynamic Discovery', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('CIMD Validation & Helpers', () => { + it('creates a valid CIMD document and validates redirect URIs', () => { + const doc = createClientIdMetadataDocument('https://my-agent.com/oauth/client-metadata.json', { + client_name: 'My AI Agent', + redirect_uris: ['https://my-agent.com/oauth/callback'], + scope: 'openid profile email mcp:read', + }); + + expect(doc.client_id).toBe('https://my-agent.com/oauth/client-metadata.json'); + expect(doc.client_name).toBe('My AI Agent'); + expect(validateRedirectUriWithCimd(doc, 'https://my-agent.com/oauth/callback')).toBe(true); + expect(validateRedirectUriWithCimd(doc, 'https://attacker.com/callback')).toBe(false); + }); + + it('validates client identifier URLs correctly', () => { + expect(isClientIdMetadataUrl('https://my-agent.com/oauth/client-metadata.json')).toBe(true); + expect(isClientIdMetadataUrl('http://localhost:3000/client.json')).toBe(true); + expect(isClientIdMetadataUrl('opaque-dcr-client-id-12345')).toBe(false); + expect(isClientIdMetadataUrl('')).toBe(false); + }); + }); + + describe('CIMD Endpoint Mounting & Handler', () => { + const validDoc = createClientIdMetadataDocument('https://my-agent.com/oauth/client-metadata.json', { + client_name: 'My AI Agent', + redirect_uris: ['https://my-agent.com/oauth/callback'], + }); + + it('creates an HTTP handler with proper CORS, JSON content-type and cache headers', () => { + const handler = createCimdHandler(validDoc, { maxAgeSeconds: 1800 }); + const headers: Record = {}; + let sentBody = ''; + let statusCode = 0; + + const mockReq = { method: 'GET' }; + const mockRes = { + setHeader: (k: string, v: string) => { headers[k] = v; }, + status: (code: number) => { + statusCode = code; + return { + send: (body: string) => { sentBody = body; } + }; + }, + }; + + handler(mockReq, mockRes); + + expect(headers['Access-Control-Allow-Origin']).toBe('*'); + expect(headers['Content-Type']).toBe('application/json'); + expect(headers['Cache-Control']).toBe('public, max-age=1800'); + expect(statusCode).toBe(200); + const parsed = JSON.parse(sentBody); + expect(parsed.client_id).toBe('https://my-agent.com/oauth/client-metadata.json'); + expect(parsed.client_name).toBe('My AI Agent'); + }); + + it('handles OPTIONS preflight with 204 or 200 and CORS headers', () => { + const handler = createCimdHandler(validDoc); + let ended = false; + let statusCode = 0; + + const mockReq = { method: 'OPTIONS' }; + const mockRes = { + writeHead: (code: number) => { statusCode = code; }, + end: () => { ended = true; }, + }; + + handler(mockReq, mockRes); + expect(statusCode).toBe(204); + expect(ended).toBe(true); + }); + + it('mounts onto express-like app instance', () => { + const routes: Record = {}; + const mockApp = { + get: (path: string, h: Function) => { routes[path] = h; } + }; + + const path = mountCimdEndpoint(mockApp, validDoc, { path: '/custom/metadata.json' }); + expect(path).toBe('/custom/metadata.json'); + expect(routes['/custom/metadata.json']).toBeDefined(); + }); + + it('setupCimdHosting helper works seamlessly', () => { + const routes: Record = {}; + const mockApp = { + get: (path: string, h: Function) => { routes[path] = h; } + }; + + const path = setupCimdHosting(mockApp as any, validDoc); + expect(path).toBe('/.well-known/oauth-client-metadata.json'); + expect(routes['/.well-known/oauth-client-metadata.json']).toBeDefined(); + }); + }); + + describe('CimdCache (In-memory Caching & Request Deduplication)', () => { + it('caches successful resolutions and respects TTL', async () => { + const cache = new CimdCache({ defaultTtlMs: 1000 }); + const doc = createClientIdMetadataDocument('https://my-agent.com/oauth/client-metadata.json', { + redirect_uris: ['https://my-agent.com/callback'], + }); + + cache.set('https://my-agent.com/oauth/client-metadata.json', doc); + expect(cache.get('https://my-agent.com/oauth/client-metadata.json')).toEqual(doc); + + // Advance time beyond TTL + const originalNow = Date.now; + try { + Date.now = () => originalNow() + 2000; + expect(cache.get('https://my-agent.com/oauth/client-metadata.json')).toBeUndefined(); + } finally { + Date.now = originalNow; + } + }); + + it('deduplicates concurrent in-flight fetches', async () => { + const cache = new CimdCache(); + let fetchCount = 0; + + const mockFetchImpl = jest.fn(async () => { + fetchCount++; + return { + status: 200, + body: null, + text: async () => JSON.stringify({ + client_id: 'https://my-agent.com/oauth/client-metadata.json', + redirect_uris: ['https://my-agent.com/callback'], + }), + } as any; + }); + + const [res1, res2] = await Promise.all([ + cache.resolve('https://my-agent.com/oauth/client-metadata.json', { fetchImpl: mockFetchImpl }), + cache.resolve('https://my-agent.com/oauth/client-metadata.json', { fetchImpl: mockFetchImpl }), + ]); + + expect(res1.client_id).toBe('https://my-agent.com/oauth/client-metadata.json'); + expect(res2.client_id).toBe('https://my-agent.com/oauth/client-metadata.json'); + expect(fetchCount).toBe(1); + }); + + it('caches failures temporarily (negative cache)', async () => { + const cache = new CimdCache({ negativeTtlMs: 500 }); + const mockFailFetch = jest.fn(async () => { + return { + status: 404, + text: async () => 'Not Found', + } as any; + }); + + await expect( + cache.resolve('https://my-agent.com/oauth/missing.json', { fetchImpl: mockFailFetch }) + ).rejects.toThrow(); + + // Subsequent call hits negative cache + await expect( + cache.resolve('https://my-agent.com/oauth/missing.json', { fetchImpl: mockFailFetch }) + ).rejects.toThrow('cached failure'); + expect(mockFailFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('OAuth2Client JIT Dynamic Discovery Flow', () => { + it('initiates CIMD connection against Auth0 with zero onboarding', async () => { + // Mock Auth0 AS Discovery + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + issuer: 'https://tenant.us.auth0.com/', + authorization_endpoint: 'https://tenant.us.auth0.com/authorize', + token_endpoint: 'https://tenant.us.auth0.com/oauth/token', + code_challenge_methods_supported: ['S256'], + client_id_metadata_document_supported: true, + }), + }); + + const client = setupAuth0CimdClient({ + auth0Domain: 'tenant.us.auth0.com', + clientMetadataUrl: 'https://my-agent.com/oauth/client-metadata.json', + redirectUri: 'https://my-agent.com/oauth/callback', + scopes: ['openid', 'profile', 'email', 'mcp:tools'], + audience: 'https://api.my-mcp.com', + }); + + expect(client.supportsClientIdMetadataDocument()).toBe(true); + + const result = await client.initiateCimdConnect({ + clientMetadataUrl: 'https://my-agent.com/oauth/client-metadata.json', + redirectUri: 'https://my-agent.com/oauth/callback', + }); + + expect(result.clientId).toBe('https://my-agent.com/oauth/client-metadata.json'); + expect(result.authUrl).toContain('https://tenant.us.auth0.com/authorize'); + expect(result.authUrl).toContain('client_id=https%3A%2F%2Fmy-agent.com%2Foauth%2Fclient-metadata.json'); + expect(result.authUrl).toContain('redirect_uri=https%3A%2F%2Fmy-agent.com%2Foauth%2Fcallback'); + expect(result.authUrl).toContain('code_challenge_method=S256'); + expect(result.authUrl).toContain('resource=https%3A%2F%2Fapi.my-mcp.com'); + expect(result.authUrl).toContain('scope=openid+profile+email+mcp%3Atools'); + expect(result.pkce.code_verifier).toBeDefined(); + expect(result.state).toBeDefined(); + }); + + it('exchanges code for token using CIMD metadata URL as client_id', async () => { + const client = new OAuth2Client({ + authorizationServerUrl: 'https://tenant.us.auth0.com', + clientMetadataUrl: 'https://my-agent.com/oauth/client-metadata.json', + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: 'test-jwt-access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'test-refresh-token', + }), + }); + + const tokens = await client.exchangeCodeForToken({ + code: 'auth-code-123', + pkce: { + code_verifier: 'verifier-12345', + code_challenge: 'challenge-12345', + code_challenge_method: 'S256', + }, + tokenEndpoint: 'https://tenant.us.auth0.com/oauth/token', + redirectUri: 'https://my-agent.com/oauth/callback', + }); + + expect(tokens.access_token).toBe('test-jwt-access-token'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://tenant.us.auth0.com/oauth/token', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('client_id=https%3A%2F%2Fmy-agent.com%2Foauth%2Fclient-metadata.json'), + }) + ); + }); + + it('refreshes token using CIMD metadata URL as client_id', async () => { + const client = new OAuth2Client({ + authorizationServerUrl: 'https://tenant.us.auth0.com', + clientMetadataUrl: 'https://my-agent.com/oauth/client-metadata.json', + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + }), + }); + + const tokens = await client.refreshToken({ + refreshToken: 'refresh-token-abc', + tokenEndpoint: 'https://tenant.us.auth0.com/oauth/token', + }); + + expect(tokens.access_token).toBe('new-access-token'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://tenant.us.auth0.com/oauth/token', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('client_id=https%3A%2F%2Fmy-agent.com%2Foauth%2Fclient-metadata.json'), + }) + ); + }); + }); + + describe('Quick Setup Instructions & Env Validation', () => { + it('prints CIMD instructions without crashing', () => { + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + printAuthSetupInstructions('cimd'); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('Method 1: Just-in-Time Dynamic Discovery')); + spy.mockRestore(); + }); + + it('validates environment for CIMD', () => { + const origEnv = { ...process.env }; + try { + delete process.env.CIMD_CLIENT_METADATA_URL; + delete process.env.OAUTH_CLIENT_ID; + const res1 = validateAuthEnv('cimd'); + expect(res1.valid).toBe(false); + + process.env.CIMD_CLIENT_METADATA_URL = 'https://my-agent.com/oauth/client-metadata.json'; + const res2 = validateAuthEnv('cimd'); + expect(res2.valid).toBe(true); + } finally { + process.env = origEnv; + } + }); + }); +}); diff --git a/typescript/packages/core/src/auth/cimd.ts b/typescript/packages/core/src/auth/cimd.ts index 868e90b00..a5256ff4b 100644 --- a/typescript/packages/core/src/auth/cimd.ts +++ b/typescript/packages/core/src/auth/cimd.ts @@ -402,3 +402,224 @@ export function isClientIdMetadataUrl(clientId: string): boolean { } } +/** + * Validate that a requested redirect_uri is explicitly listed in the client's CIMD metadata document. + */ +export function validateRedirectUriWithCimd( + doc: ClientIdMetadataDocument, + redirectUri: string, +): boolean { + if (!doc || !Array.isArray(doc.redirect_uris)) { + return false; + } + return doc.redirect_uris.includes(redirectUri); +} + +/** + * In-memory cache for resolved Client ID Metadata Documents (CIMD). + * Provides positive TTL caching, short-lived negative caching, and + * in-flight request deduplication to prevent stampedes. + */ +export interface CimdCacheEntry { + document: ClientIdMetadataDocument | null; + expires: number; +} + +export class CimdCache { + private cache = new Map(); + private inflight = new Map>(); + private defaultTtlMs: number; + private negativeTtlMs: number; + + constructor(options?: { defaultTtlMs?: number; negativeTtlMs?: number }) { + this.defaultTtlMs = options?.defaultTtlMs ?? 10 * 60 * 1000; // 10 minutes + this.negativeTtlMs = options?.negativeTtlMs ?? 30 * 1000; // 30 seconds + } + + /** + * Get cached document if not expired + */ + get(clientIdUrl: string): ClientIdMetadataDocument | null | undefined { + const entry = this.cache.get(clientIdUrl); + if (!entry) return undefined; + if (Date.now() > entry.expires) { + this.cache.delete(clientIdUrl); + return undefined; + } + return entry.document; + } + + /** + * Store document (or null for negative caching) + */ + set(clientIdUrl: string, document: ClientIdMetadataDocument | null, ttlMs?: number): void { + const ttl = ttlMs ?? (document ? this.defaultTtlMs : this.negativeTtlMs); + this.cache.set(clientIdUrl, { + document, + expires: Date.now() + ttl, + }); + } + + /** + * Clear all cached entries and in-flight promises + */ + clear(): void { + this.cache.clear(); + this.inflight.clear(); + } + + /** + * Resolve a client's CIMD with caching and de-duplication + */ + async resolve( + clientIdUrl: string, + options?: Parameters[1] + ): Promise { + const cached = this.get(clientIdUrl); + if (cached !== undefined) { + if (cached === null) { + throw new Error(`CIMD lookup previously failed for ${clientIdUrl} (cached failure)`); + } + return cached; + } + + const running = this.inflight.get(clientIdUrl); + if (running) { + return running; + } + + const promise = (async () => { + try { + const doc = await resolveClientIdMetadataDocument(clientIdUrl, options); + this.set(clientIdUrl, doc); + return doc; + } catch (err) { + this.set(clientIdUrl, null); + throw err; + } finally { + this.inflight.delete(clientIdUrl); + } + })(); + + this.inflight.set(clientIdUrl, promise); + return promise; + } +} + +/** Default singleton instance of CimdCache */ +export const defaultCimdCache = new CimdCache(); + +/** + * Create a standard Node.js HTTP / Express / Fastify compatible handler for serving a CIMD. + * Automatically injects CORS headers (`Access-Control-Allow-Origin: *`) and caching headers. + */ +export function createCimdHandler( + metadata: ClientIdMetadataDocument, + options?: { maxAgeSeconds?: number; allowLoopback?: boolean } +) { + // Validate the document structure + validateClientIdentifierUrl(metadata.client_id, options?.allowLoopback ?? true); + validateClientIdMetadataDocument(metadata, metadata.client_id); + + const payload = JSON.stringify(metadata, null, 2); + const maxAge = options?.maxAgeSeconds ?? 3600; + + return (req: any, res: any) => { + // Set CORS headers + if (typeof res.setHeader === 'function') { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Authorization'); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', `public, max-age=${maxAge}`); + } + + if (req.method === 'OPTIONS') { + if (typeof res.writeHead === 'function') { + res.writeHead(204, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization', + }); + res.end(); + } else if (typeof res.status === 'function') { + res.status(204).end(); + } + return; + } + + if (typeof res.status === 'function') { + const target = res.status(200); + if (target && typeof target.send === 'function') { + target.send(payload); + return; + } + if (target && typeof target.json === 'function') { + target.json(metadata); + return; + } + if (typeof res.send === 'function') { + res.send(payload); + return; + } + } + + if (typeof res.writeHead === 'function') { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': `public, max-age=${maxAge}`, + 'Access-Control-Allow-Origin': '*', + }); + res.end(payload); + return; + } + + if (typeof res.end === 'function') { + res.end(payload); + return; + } + }; +} + +/** + * Mount a Client ID Metadata Document endpoint on an Express or connect-compatible app. + * + * @param app - Express application or router + * @param metadata - The Client ID Metadata Document to serve + * @param options - Configuration options (path, maxAgeSeconds, allowLoopback) + * @returns The mounted path + * + * @example + * ```typescript + * import express from 'express'; + * import { mountCimdEndpoint, createClientIdMetadataDocument } from 'nitrostack'; + * + * const app = express(); + * const cimd = createClientIdMetadataDocument('https://my-agent.com/oauth/client-metadata.json', { + * client_name: 'My AI Agent', + * redirect_uris: ['https://my-agent.com/oauth/callback'], + * }); + * + * mountCimdEndpoint(app, cimd, { path: '/oauth/client-metadata.json' }); + * ``` + */ +export function mountCimdEndpoint( + app: any, + metadata: ClientIdMetadataDocument, + options?: { path?: string; maxAgeSeconds?: number; allowLoopback?: boolean } +): string { + const mountPath = options?.path || '/.well-known/oauth-client-metadata.json'; + const handler = createCimdHandler(metadata, options); + + if (typeof app.get === 'function') { + app.get(mountPath, handler); + } else if (typeof app.use === 'function') { + app.use(mountPath, handler); + } else { + throw new Error('mountCimdEndpoint: Unsupported app or router instance'); + } + + return mountPath; +} + + diff --git a/typescript/packages/core/src/auth/client.ts b/typescript/packages/core/src/auth/client.ts index f87e504ee..4254ef4a6 100644 --- a/typescript/packages/core/src/auth/client.ts +++ b/typescript/packages/core/src/auth/client.ts @@ -9,9 +9,12 @@ import { AuthorizationRequest, OAuth2Error, McpAuthClientConfig, + CimdConnectOptions, + CimdConnectResult, } from './types.js'; import { generatePKCEParams, PKCEParams, validatePKCESupport } from './pkce.js'; import { parseWWWAuthenticateHeader, getWellKnownMetadataUris } from './server-metadata.js'; +import { isClientIdMetadataUrl, validateClientIdentifierUrl } from './cimd.js'; /** * OAuth 2.1 Client for MCP @@ -187,13 +190,116 @@ export class OAuth2Client { return result; } + /** + * Whether the authorization server or client configuration indicates support for CIMD + * (Client ID Metadata Documents / Just-in-Time Dynamic Discovery). + */ + supportsClientIdMetadataDocument(asMetadata?: AuthorizationServerMetadata): boolean { + if (asMetadata?.client_id_metadata_document_supported === true) { + return true; + } + const cid = this.config.clientMetadataUrl || this.config.clientId; + return isClientIdMetadataUrl(cid || ''); + } + + /** + * Initiate a Just-in-Time Dynamic Discovery (CIMD) OAuth connection (Method 1). + * + * High-level orchestrator for third-party applications and AI agents connecting to + * Auth0, Stytch, or compliant OAuth 2.1 authorization servers: + * 1. Resolves authorization server metadata (via PRM discovery or direct AS URL). + * 2. Validates CIMD URL syntax and PKCE S256 support. + * 3. Generates PKCE code challenge and CSRF state. + * 4. Assembles authorization URL with `client_id = clientMetadataUrl`. + * 5. Returns `{ authUrl, state, pkce, clientId }`. + * + * @example + * ```typescript + * const client = new OAuth2Client({ authorizationServerUrl: 'https://tenant.us.auth0.com' }); + * const connectResult = await client.initiateCimdConnect({ + * clientMetadataUrl: 'https://my-agent.com/oauth/client-metadata.json', + * redirectUri: 'https://my-agent.com/oauth/callback', + * scope: 'openid profile email mcp:read', + * }); + * // Direct user to connectResult.authUrl, and save connectResult.pkce.code_verifier + * ``` + */ + async initiateCimdConnect(options: CimdConnectOptions): Promise { + const metadataUrl = options.clientMetadataUrl || this.config.clientMetadataUrl || this.config.clientId; + if (!metadataUrl || !isClientIdMetadataUrl(metadataUrl)) { + throw new Error( + `CIMD Just-in-Time discovery requires a valid HTTPS metadata URL as client_id, got: "${metadataUrl}"` + ); + } + validateClientIdentifierUrl(metadataUrl, true); + + let asUrl = options.authorizationServerUrl || this.config.authorizationServerUrl; + if (!asUrl && options.resourceUrl) { + const prm = await this.discoverProtectedResourceMetadata(options.resourceUrl); + if (prm.authorization_servers && prm.authorization_servers.length > 0) { + asUrl = prm.authorization_servers[0]; + } + } + + if (!asUrl) { + throw new Error('initiateCimdConnect: authorizationServerUrl or resourceUrl is required'); + } + + const asMeta = await this.discoverAuthorizationServerMetadata(asUrl); + const redirectUri = options.redirectUri || this.config.redirectUri; + if (!redirectUri) { + throw new Error('initiateCimdConnect: redirectUri is required'); + } + + const state = options.state || this.generateState(); + const pkce = generatePKCEParams('S256'); + + const params = new URLSearchParams({ + response_type: 'code', + client_id: metadataUrl, + redirect_uri: redirectUri, + state, + code_challenge: pkce.code_challenge, + code_challenge_method: pkce.code_challenge_method, + }); + + const requestedScope = options.scope || (this.config.scopes ? this.config.scopes.join(' ') : undefined); + if (requestedScope) { + params.append('scope', requestedScope); + } + + const requestedResource = options.resource || this.config.resource; + if (requestedResource) { + params.append('resource', requestedResource); + } + + if (options.prompt) { + params.append('prompt', options.prompt); + } + + if (options.extraParams) { + for (const [k, v] of Object.entries(options.extraParams)) { + params.append(k, v); + } + } + + const authUrl = `${asMeta.authorization_endpoint}?${params.toString()}`; + + return { + authUrl, + state, + pkce, + clientId: metadataUrl, + }; + } + /** * Start authorization flow * * Generates authorization URL with PKCE parameters * * @param authzEndpoint - Authorization endpoint - * @param clientId - OAuth client ID + * @param clientId - OAuth client ID (can be CIMD URL or registered client_id) * @param redirectUri - Redirect URI * @param scope - Requested scopes * @param resource - Resource indicator (RFC 8707) @@ -201,8 +307,8 @@ export class OAuth2Client { */ async startAuthorizationFlow(options: { authorizationEndpoint: string; - clientId: string; - redirectUri: string; + clientId?: string; + redirectUri?: string; scope?: string; resource?: string; state?: string; @@ -217,6 +323,16 @@ export class OAuth2Client { state: string; pkce: PKCEParams; }> { + const clientId = options.clientId || this.config.clientMetadataUrl || this.config.clientId; + if (!clientId) { + throw new Error('startAuthorizationFlow: clientId or clientMetadataUrl is required'); + } + + const redirectUri = options.redirectUri || this.config.redirectUri; + if (!redirectUri) { + throw new Error('startAuthorizationFlow: redirectUri is required'); + } + // Generate PKCE parameters (S256 required by OAuth 2.1) const pkce = generatePKCEParams('S256'); @@ -226,8 +342,8 @@ export class OAuth2Client { // Build authorization URL const params = new URLSearchParams({ response_type: 'code', - client_id: options.clientId, - redirect_uri: options.redirectUri, + client_id: clientId, + redirect_uri: redirectUri, state, code_challenge: pkce.code_challenge, code_challenge_method: pkce.code_challenge_method, @@ -268,16 +384,21 @@ export class OAuth2Client { code: string; pkce: PKCEParams; tokenEndpoint: string; - clientId: string; + clientId?: string; clientSecret?: string; redirectUri: string; resource?: string; }): Promise { + const clientId = options.clientId || this.config.clientMetadataUrl || this.config.clientId; + if (!clientId) { + throw new Error('exchangeCodeForToken: clientId or clientMetadataUrl is required'); + } + const params = new URLSearchParams({ grant_type: 'authorization_code', code: options.code, redirect_uri: options.redirectUri, - client_id: options.clientId, + client_id: clientId, code_verifier: options.pkce.code_verifier, }); @@ -293,7 +414,7 @@ export class OAuth2Client { // Client authentication if (options.clientSecret) { const credentials = Buffer.from( - `${options.clientId}:${options.clientSecret}` + `${clientId}:${options.clientSecret}` ).toString('base64'); headers['Authorization'] = `Basic ${credentials}`; } @@ -314,15 +435,20 @@ export class OAuth2Client { async refreshToken(options: { refreshToken: string; tokenEndpoint: string; - clientId: string; + clientId?: string; clientSecret?: string; scope?: string; resource?: string; }): Promise { + const clientId = options.clientId || this.config.clientMetadataUrl || this.config.clientId; + if (!clientId) { + throw new Error('refreshToken: clientId or clientMetadataUrl is required'); + } + const params = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: options.refreshToken, - client_id: options.clientId, + client_id: clientId, }); if (options.scope) { @@ -340,7 +466,7 @@ export class OAuth2Client { if (options.clientSecret) { const credentials = Buffer.from( - `${options.clientId}:${options.clientSecret}` + `${clientId}:${options.clientSecret}` ).toString('base64'); headers['Authorization'] = `Basic ${credentials}`; } @@ -359,14 +485,19 @@ export class OAuth2Client { */ async getClientCredentialsToken(options: { tokenEndpoint: string; - clientId: string; + clientId?: string; clientSecret: string; scope?: string; resource?: string; }): Promise { + const clientId = options.clientId || this.config.clientMetadataUrl || this.config.clientId; + if (!clientId) { + throw new Error('getClientCredentialsToken: clientId or clientMetadataUrl is required'); + } + const params = new URLSearchParams({ grant_type: 'client_credentials', - client_id: options.clientId, + client_id: clientId, }); if (options.scope) { @@ -381,7 +512,7 @@ export class OAuth2Client { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', 'Authorization': `Basic ${Buffer.from( - `${options.clientId}:${options.clientSecret}` + `${clientId}:${options.clientSecret}` ).toString('base64')}`, }; @@ -400,13 +531,18 @@ export class OAuth2Client { async revokeToken(options: { token: string; revocationEndpoint: string; - clientId: string; + clientId?: string; clientSecret?: string; tokenTypeHint?: 'access_token' | 'refresh_token'; }): Promise { + const clientId = options.clientId || this.config.clientMetadataUrl || this.config.clientId; + if (!clientId) { + throw new Error('revokeToken: clientId or clientMetadataUrl is required'); + } + const params = new URLSearchParams({ token: options.token, - client_id: options.clientId, + client_id: clientId, }); if (options.tokenTypeHint) { diff --git a/typescript/packages/core/src/auth/index.ts b/typescript/packages/core/src/auth/index.ts index fd5bace49..14d1bb86f 100644 --- a/typescript/packages/core/src/auth/index.ts +++ b/typescript/packages/core/src/auth/index.ts @@ -60,6 +60,8 @@ export { setupJWTAuth, setupAPIKeyAuth, setupOAuthAuth, + setupCimdHosting, + setupAuth0CimdClient, generateTestCredentials, printAuthSetupInstructions, validateAuthEnv, @@ -106,19 +108,25 @@ export { getIssuerBoundToken, } from './token-store.js'; -// Client ID Metadata Documents (CIMD) — the 2026-07-28 DCR replacement. +// Client ID Metadata Documents (CIMD) — Method 1: Just-in-Time Dynamic Discovery export { createClientIdMetadataDocument, validateClientIdMetadataDocument, validateClientIdentifierUrl, + validateRedirectUriWithCimd, resolveClientIdMetadataDocument, isClientIdMetadataUrl, isSpecialUseIp, assertSafeFetchTarget, readBoundedJson, + mountCimdEndpoint, + createCimdHandler, + CimdCache, + defaultCimdCache, MAX_CIMD_DOCUMENT_BYTES, DEFAULT_CIMD_FETCH_TIMEOUT_MS, type ClientIdMetadataDocument, + type CimdCacheEntry, } from './cimd.js'; // Server integration helpers diff --git a/typescript/packages/core/src/auth/quick-setup.ts b/typescript/packages/core/src/auth/quick-setup.ts index 322ee28e7..aec787e65 100644 --- a/typescript/packages/core/src/auth/quick-setup.ts +++ b/typescript/packages/core/src/auth/quick-setup.ts @@ -101,6 +101,68 @@ export function setupOAuthAuth( console.error(` Scopes: ${config.scopesSupported?.join(', ') || 'none'}`); } +import { ClientIdMetadataDocument, mountCimdEndpoint } from './cimd.js'; +import { OAuth2Client } from './client.js'; + +/** + * Setup Client ID Metadata Document (CIMD) hosting on an Express server. + * Enables Method 1: Just-in-Time Dynamic Discovery for zero-onboarding Auth0 / Stytch connections. + * + * @example + * ```typescript + * const server = createServer({...}); + * setupCimdHosting(server.app, { + * client_id: 'https://my-agent.com/oauth/client-metadata.json', + * client_name: 'My AI Agent', + * redirect_uris: ['https://my-agent.com/oauth/callback'], + * }); + * ``` + */ +export function setupCimdHosting( + app: Express, + metadata: ClientIdMetadataDocument, + options?: { path?: string; maxAgeSeconds?: number; allowLoopback?: boolean } +): string { + const mountPath = mountCimdEndpoint(app, metadata, options); + console.error(`✅ CIMD Metadata endpoint mounted on ${mountPath}`); + console.error(` Client ID URL: ${metadata.client_id}`); + console.error(` Redirect URIs: ${metadata.redirect_uris.join(', ')}`); + return mountPath; +} + +/** + * Helper to configure an OAuth2Client with Auth0 Just-in-Time Dynamic Discovery (CIMD). + * + * @example + * ```typescript + * const auth0Client = setupAuth0CimdClient({ + * auth0Domain: 'your-tenant.us.auth0.com', + * clientMetadataUrl: 'https://my-agent.com/oauth/client-metadata.json', + * redirectUri: 'https://my-agent.com/oauth/callback', + * scopes: ['openid', 'profile', 'email', 'offline_access'], + * }); + * ``` + */ +export function setupAuth0CimdClient(options: { + auth0Domain: string; + clientMetadataUrl: string; + redirectUri?: string; + scopes?: string[]; + audience?: string; +}): OAuth2Client { + const domain = options.auth0Domain.replace(/^https?:\/\//, '').replace(/\/+$/, ''); + const authServerUrl = `https://${domain}`; + + return new OAuth2Client({ + authorizationServerUrl: authServerUrl, + clientMetadataUrl: options.clientMetadataUrl, + redirectUri: options.redirectUri, + scopes: options.scopes || ['openid', 'profile', 'email'], + resource: options.audience, + preferCimd: true, + }); +} + /** * Generate test credentials (for development) * @@ -142,7 +204,7 @@ export function generateTestCredentials(options?: { /** * Print auth setup instructions */ -export function printAuthSetupInstructions(type: 'jwt' | 'apikey' | 'oauth'): void { +export function printAuthSetupInstructions(type: 'jwt' | 'apikey' | 'oauth' | 'cimd'): void { console.log('\n╔══════════════════════════════════════════════════════════════╗'); console.log('║ AUTH SETUP INSTRUCTIONS ║'); console.log('╚══════════════════════════════════════════════════════════════╝\n'); @@ -202,6 +264,26 @@ export function printAuthSetupInstructions(type: 'jwt' | 'apikey' | 'oauth'): vo console.log(' });\n'); console.log('3. Use the inspector AUTH tab to test\n'); } + + if (type === 'cimd') { + console.log('📝 Method 1: Just-in-Time Dynamic Discovery (CIMD) Setup:\n'); + console.log('1. Host your Client ID Metadata Document:'); + console.log(' const doc = createClientIdMetadataDocument("https://agent.example.com/oauth/client-metadata.json", {'); + console.log(' client_name: "My AI Agent",'); + console.log(' redirect_uris: ["https://agent.example.com/oauth/callback"],'); + console.log(' });'); + console.log(' mountCimdEndpoint(app, doc);\n'); + console.log('2. Initiate connection with zero dashboard onboarding:'); + console.log(' const client = setupAuth0CimdClient({'); + console.log(' auth0Domain: "tenant.us.auth0.com",'); + console.log(' clientMetadataUrl: "https://agent.example.com/oauth/client-metadata.json",'); + console.log(' redirectUri: "https://agent.example.com/oauth/callback",'); + console.log(' });'); + console.log(' const { authUrl, state, pkce } = await client.initiateCimdConnect({...});'); + console.log(' // Redirect user to authUrl\n'); + console.log('3. On callback redirect:'); + console.log(' const tokens = await client.exchangeCodeForToken({ code, pkce, redirectUri, tokenEndpoint });\n'); + } console.log('═══════════════════════════════════════════════════════════════\n'); } @@ -209,7 +291,7 @@ export function printAuthSetupInstructions(type: 'jwt' | 'apikey' | 'oauth'): vo /** * Validate auth environment variables */ -export function validateAuthEnv(type: 'jwt' | 'apikey' | 'oauth'): { valid: boolean; missing: string[] } { +export function validateAuthEnv(type: 'jwt' | 'apikey' | 'oauth' | 'cimd'): { valid: boolean; missing: string[] } { const missing: string[] = []; if (type === 'jwt') { @@ -239,6 +321,12 @@ export function validateAuthEnv(type: 'jwt' | 'apikey' | 'oauth'): { valid: bool } } } + + if (type === 'cimd') { + if (!process.env.CIMD_CLIENT_METADATA_URL && !process.env.OAUTH_CLIENT_ID) { + missing.push('CIMD_CLIENT_METADATA_URL or OAUTH_CLIENT_ID'); + } + } return { valid: missing.length === 0, diff --git a/typescript/packages/core/src/auth/types.ts b/typescript/packages/core/src/auth/types.ts index 764c708eb..420f78784 100644 --- a/typescript/packages/core/src/auth/types.ts +++ b/typescript/packages/core/src/auth/types.ts @@ -70,6 +70,15 @@ export interface AuthorizationServerMetadata { code_challenge_methods_supported: string[]; // PKCE required service_documentation?: string; ui_locales_supported?: string[]; + /** + * Whether the authorization server supports Client ID Metadata Documents (CIMD) + * for Just-in-Time Dynamic Discovery (draft-ietf-oauth-client-id-metadata-document). + */ + client_id_metadata_document_supported?: boolean; + /** + * Client authentication methods supported when using CIMD (e.g., 'none', 'private_key_jwt'). + */ + client_id_metadata_document_supported_auth_methods?: string[]; } /** @@ -229,9 +238,119 @@ export interface McpAuthClientConfig { // Resource indicator (RFC 8707) resource?: string; - // Auto-register client if not provided + // Auto-register client if not provided (legacy DCR) autoRegister?: boolean; registrationMetadata?: Partial; + + /** + * Client ID Metadata Document URL for Just-in-Time Dynamic Discovery (CIMD). + * When provided, this URL is used as the `client_id` in authorization flows. + */ + clientMetadataUrl?: string; + + /** + * Prefer CIMD (Just-in-Time Dynamic Discovery) over legacy DCR when connecting + * to authorization servers. Defaults to true. + */ + preferCimd?: boolean; +} + +/** + * Options for initiating a Just-in-Time Dynamic Discovery (CIMD) OAuth connection + */ +export interface CimdConnectOptions { + /** + * The client's hosted metadata URL (acting as client_id) + * Example: 'https://agent.example.com/oauth/client-metadata.json' + */ + clientMetadataUrl: string; + + /** + * Authorization Server URL (or Auth0/Stytch tenant URL) + * Example: 'https://tenant.us.auth0.com' + */ + authorizationServerUrl?: string; + + /** + * Protected Resource URI (for PRM discovery RFC 9728) + */ + resourceUrl?: string; + + /** + * The callback redirect URI for this client + */ + redirectUri: string; + + /** + * Requested OAuth scopes (e.g. 'openid profile email mcp:read') + */ + scope?: string; + + /** + * Resource indicator (RFC 8707 audience) + */ + resource?: string; + + /** + * State parameter for CSRF defense. Generated automatically if omitted. + */ + state?: string; + + /** + * Prompt parameter (e.g. 'consent', 'login') + */ + prompt?: string; + + /** + * Extra query parameters to include in authorization request + */ + extraParams?: Record; +} + +/** + * Result of initiating a CIMD Just-in-Time connection + */ +export interface CimdConnectResult { + /** + * The complete authorization URL to direct the user or browser to + */ + authUrl: string; + + /** + * CSRF protection state parameter + */ + state: string; + + /** + * PKCE parameters (save `code_verifier` for token exchange) + */ + pkce: PKCEParams; + + /** + * The client_id used (the CIMD URL) + */ + clientId: string; +} + +/** + * Options for mounting a Client ID Metadata Document endpoint + */ +export interface CimdEndpointOptions { + /** + * Path where the CIMD will be hosted. + * Default: '/.well-known/oauth-client-metadata.json' + */ + path?: string; + + /** + * Cache-Control max-age in seconds. Default: 3600 (1 hour). + */ + maxAgeSeconds?: number; + + /** + * Allow loopback HTTP URLs (for local development). Default: true. + */ + allowLoopback?: boolean; } /** diff --git a/typescript/packages/core/src/core/oauth-module.ts b/typescript/packages/core/src/core/oauth-module.ts index 007037cb2..a29ddf8ad 100644 --- a/typescript/packages/core/src/core/oauth-module.ts +++ b/typescript/packages/core/src/core/oauth-module.ts @@ -334,6 +334,10 @@ export class OAuthModule { if (registrationEndpoint && !metadata.registration_endpoint) { metadata.registration_endpoint = registrationEndpoint; } + // Advertise CIMD (Just-in-Time Dynamic Discovery) support + if (metadata.client_id_metadata_document_supported === undefined) { + metadata.client_id_metadata_document_supported = true; + } res.writeHead(200, headers); res.end(JSON.stringify(metadata)); return; @@ -344,7 +348,7 @@ export class OAuthModule { }); } - // Fallback compliant with RFC 8414 / OIDC metadata schema + // Fallback compliant with RFC 8414 / OIDC metadata schema & CIMD const fallbackMetadata: Record = { issuer: this.config.issuer || this.config.authorizationServers[0], authorization_endpoint: `${this.config.authorizationServers[0]}/oauth/v2/authorize`, @@ -356,6 +360,8 @@ export class OAuthModule { subject_types_supported: ['public'], id_token_signing_alg_values_supported: ['RS256'], code_challenge_methods_supported: ['S256'], + client_id_metadata_document_supported: true, + client_id_metadata_document_supported_auth_methods: ['none'], }; if (registrationEndpoint) { fallbackMetadata.registration_endpoint = registrationEndpoint; From 16d07aacacaacc0ecd3872367142347ef12e8af3 Mon Sep 17 00:00:00 2001 From: Hemant Jadhav Date: Mon, 7 Sep 2026 00:14:15 +0530 Subject: [PATCH 2/4] fix(auth): fix revokeToken credentials, safe auth query concatenation, and CIMD cache bounding --- .../core/src/auth/__tests__/cimd-jit.test.ts | 79 +++++++++++++++++++ typescript/packages/core/src/auth/cimd.ts | 58 +++++++++----- typescript/packages/core/src/auth/client.ts | 8 +- 3 files changed, 121 insertions(+), 24 deletions(-) diff --git a/typescript/packages/core/src/auth/__tests__/cimd-jit.test.ts b/typescript/packages/core/src/auth/__tests__/cimd-jit.test.ts index b1331750b..d54e85ef2 100644 --- a/typescript/packages/core/src/auth/__tests__/cimd-jit.test.ts +++ b/typescript/packages/core/src/auth/__tests__/cimd-jit.test.ts @@ -184,6 +184,23 @@ describe('CIMD Method 1: Just-in-Time Dynamic Discovery', () => { ).rejects.toThrow('cached failure'); expect(mockFailFetch).toHaveBeenCalledTimes(1); }); + + it('evicts oldest entries when exceeding maxEntries', () => { + const cache = new CimdCache({ maxEntries: 2 }); + const doc1 = createClientIdMetadataDocument('https://my-agent.com/client1.json', { redirect_uris: ['https://my-agent.com/cb'] }); + const doc2 = createClientIdMetadataDocument('https://my-agent.com/client2.json', { redirect_uris: ['https://my-agent.com/cb'] }); + const doc3 = createClientIdMetadataDocument('https://my-agent.com/client3.json', { redirect_uris: ['https://my-agent.com/cb'] }); + + cache.set('https://my-agent.com/client1.json', doc1); + cache.set('https://my-agent.com/client2.json', doc2); + expect(cache.get('https://my-agent.com/client1.json')).toEqual(doc1); + + cache.set('https://my-agent.com/client3.json', doc3); + // client1.json should be evicted as oldest + expect(cache.get('https://my-agent.com/client1.json')).toBeUndefined(); + expect(cache.get('https://my-agent.com/client2.json')).toEqual(doc2); + expect(cache.get('https://my-agent.com/client3.json')).toEqual(doc3); + }); }); describe('OAuth2Client JIT Dynamic Discovery Flow', () => { @@ -226,6 +243,38 @@ describe('CIMD Method 1: Just-in-Time Dynamic Discovery', () => { expect(result.state).toBeDefined(); }); + it('safely merges query parameters when authorization endpoint already contains a query string', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + issuer: 'https://tenant.us.auth0.com/', + authorization_endpoint: 'https://tenant.us.auth0.com/authorize?organization=org_123', + token_endpoint: 'https://tenant.us.auth0.com/oauth/token', + code_challenge_methods_supported: ['S256'], + client_id_metadata_document_supported: true, + }), + }); + + const client = new OAuth2Client({ + authorizationServerUrl: 'https://tenant.us.auth0.com', + clientMetadataUrl: 'https://my-agent.com/oauth/client-metadata.json', + }); + + const result = await client.initiateCimdConnect({ + clientMetadataUrl: 'https://my-agent.com/oauth/client-metadata.json', + redirectUri: 'https://my-agent.com/oauth/callback', + }); + + expect(result.authUrl).toContain('https://tenant.us.auth0.com/authorize?organization=org_123&response_type=code'); + expect(result.authUrl.split('?').length).toBe(2); + + const flowResult = await client.startAuthorizationFlow({ + authorizationEndpoint: 'https://tenant.us.auth0.com/authorize?org=456', + redirectUri: 'https://my-agent.com/oauth/callback', + }); + expect(flowResult.authUrl).toContain('https://tenant.us.auth0.com/authorize?org=456&response_type=code'); + }); + it('exchanges code for token using CIMD metadata URL as client_id', async () => { const client = new OAuth2Client({ authorizationServerUrl: 'https://tenant.us.auth0.com', @@ -292,6 +341,36 @@ describe('CIMD Method 1: Just-in-Time Dynamic Discovery', () => { }) ); }); + + it('revokes token using configured clientMetadataUrl and sends proper Basic auth', async () => { + const client = new OAuth2Client({ + authorizationServerUrl: 'https://tenant.us.auth0.com', + clientMetadataUrl: 'https://my-agent.com/oauth/client-metadata.json', + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + await client.revokeToken({ + token: 'token-to-revoke', + revocationEndpoint: 'https://tenant.us.auth0.com/oauth/revoke', + clientSecret: 'my-secret', + }); + + const expectedCredentials = Buffer.from('https://my-agent.com/oauth/client-metadata.json:my-secret').toString('base64'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://tenant.us.auth0.com/oauth/revoke', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: `Basic ${expectedCredentials}`, + }), + body: expect.stringContaining('client_id=https%3A%2F%2Fmy-agent.com%2Foauth%2Fclient-metadata.json'), + }) + ); + }); }); describe('Quick Setup Instructions & Env Validation', () => { diff --git a/typescript/packages/core/src/auth/cimd.ts b/typescript/packages/core/src/auth/cimd.ts index a5256ff4b..c77ded9e3 100644 --- a/typescript/packages/core/src/auth/cimd.ts +++ b/typescript/packages/core/src/auth/cimd.ts @@ -349,14 +349,18 @@ export async function resolveClientIdMetadataDocument( } const timeoutMs = options?.timeoutMs ?? DEFAULT_CIMD_FETCH_TIMEOUT_MS; - const timeoutSignal = typeof AbortSignal.timeout === 'function' - ? AbortSignal.timeout(timeoutMs) - : (() => { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(new Error(`CIMD request timed out after ${timeoutMs}ms`)), timeoutMs); - if (typeof (timer as any).unref === 'function') (timer as any).unref(); - return controller.signal; - })(); + let cleanupTimer: (() => void) | undefined; + let timeoutSignal: AbortSignal; + + if (typeof AbortSignal.timeout === 'function') { + timeoutSignal = AbortSignal.timeout(timeoutMs); + } else { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Error(`CIMD request timed out after ${timeoutMs}ms`)), timeoutMs); + if (typeof (timer as any).unref === 'function') (timer as any).unref(); + cleanupTimer = () => clearTimeout(timer); + timeoutSignal = controller.signal; + } let effectiveSignal = timeoutSignal; if (options?.signal) { @@ -372,18 +376,22 @@ export async function resolveClientIdMetadataDocument( } const doFetch = options?.fetchImpl ?? fetch; - const response = await doFetch(clientIdUrl, { - method: 'GET', - headers: { Accept: 'application/json' }, - redirect: 'error', - signal: effectiveSignal, - }); - if (response.status !== 200) { - throw new Error(`Failed to resolve CIMD from ${clientIdUrl}: HTTP ${response.status} (expected 200 OK)`); - } - const maxBytes = options?.maxDocumentBytes ?? MAX_CIMD_DOCUMENT_BYTES; - const doc = await readBoundedJson(response, maxBytes); - return validateClientIdMetadataDocument(doc, clientIdUrl); + try { + const response = await doFetch(clientIdUrl, { + method: 'GET', + headers: { Accept: 'application/json' }, + redirect: 'error', + signal: effectiveSignal, + }); + if (response.status !== 200) { + throw new Error(`Failed to resolve CIMD from ${clientIdUrl}: HTTP ${response.status} (expected 200 OK)`); + } + const maxBytes = options?.maxDocumentBytes ?? MAX_CIMD_DOCUMENT_BYTES; + const doc = await readBoundedJson(response, maxBytes); + return validateClientIdMetadataDocument(doc, clientIdUrl); + } finally { + cleanupTimer?.(); + } } /** @@ -430,10 +438,12 @@ export class CimdCache { private inflight = new Map>(); private defaultTtlMs: number; private negativeTtlMs: number; + private maxEntries: number; - constructor(options?: { defaultTtlMs?: number; negativeTtlMs?: number }) { + constructor(options?: { defaultTtlMs?: number; negativeTtlMs?: number; maxEntries?: number }) { this.defaultTtlMs = options?.defaultTtlMs ?? 10 * 60 * 1000; // 10 minutes this.negativeTtlMs = options?.negativeTtlMs ?? 30 * 1000; // 30 seconds + this.maxEntries = options?.maxEntries ?? 1000; } /** @@ -453,6 +463,12 @@ export class CimdCache { * Store document (or null for negative caching) */ set(clientIdUrl: string, document: ClientIdMetadataDocument | null, ttlMs?: number): void { + if (this.cache.size >= this.maxEntries && !this.cache.has(clientIdUrl)) { + const oldestKey = this.cache.keys().next().value; + if (oldestKey !== undefined) { + this.cache.delete(oldestKey); + } + } const ttl = ttlMs ?? (document ? this.defaultTtlMs : this.negativeTtlMs); this.cache.set(clientIdUrl, { document, diff --git a/typescript/packages/core/src/auth/client.ts b/typescript/packages/core/src/auth/client.ts index 4254ef4a6..d9fe379f2 100644 --- a/typescript/packages/core/src/auth/client.ts +++ b/typescript/packages/core/src/auth/client.ts @@ -283,7 +283,8 @@ export class OAuth2Client { } } - const authUrl = `${asMeta.authorization_endpoint}?${params.toString()}`; + const separator = asMeta.authorization_endpoint.includes('?') ? '&' : '?'; + const authUrl = `${asMeta.authorization_endpoint}${separator}${params.toString()}`; return { authUrl, @@ -364,7 +365,8 @@ export class OAuth2Client { params.append('prompt', options.prompt); } - const authUrl = `${options.authorizationEndpoint}?${params.toString()}`; + const separator = options.authorizationEndpoint.includes('?') ? '&' : '?'; + const authUrl = `${options.authorizationEndpoint}${separator}${params.toString()}`; return { authUrl, state, pkce }; } @@ -555,7 +557,7 @@ export class OAuth2Client { if (options.clientSecret) { const credentials = Buffer.from( - `${options.clientId}:${options.clientSecret}` + `${clientId}:${options.clientSecret}` ).toString('base64'); headers['Authorization'] = `Basic ${credentials}`; } From 81fc2d9271fa72e7d5198f450749d85a4acdaf01 Mon Sep 17 00:00:00 2001 From: Hemant Jadhav Date: Mon, 7 Sep 2026 02:48:00 +0530 Subject: [PATCH 3/4] feat(auth): add multi-provider JIT dynamic discovery bridge with urlencoded and basic auth support --- typescript/packages/core/src/auth/index.ts | 4 + .../src/auth/jit/__tests__/jit-bridge.test.ts | 461 ++++++++++++++++++ .../src/auth/jit/adapters/auth0.adapter.ts | 331 +++++++++++++ .../auth/jit/adapters/generic-dcr.adapter.ts | 122 +++++ .../src/auth/jit/adapters/okta.adapter.ts | 99 ++++ .../auth/jit/adapters/passthrough.adapter.ts | 28 ++ .../packages/core/src/auth/jit/index.ts | 6 + .../packages/core/src/auth/jit/jit-bridge.ts | 445 +++++++++++++++++ .../packages/core/src/auth/jit/types.ts | 147 ++++++ .../src/core/__tests__/oauth.extended.test.ts | 2 +- .../packages/core/src/core/oauth-module.ts | 233 ++++++++- .../transports/__tests__/transports.test.ts | 1 + .../core/transports/discovery-http-server.ts | 3 +- .../core/src/core/transports/http-server.ts | 40 +- .../src/core/transports/streamable-http.ts | 9 +- 15 files changed, 1920 insertions(+), 11 deletions(-) create mode 100644 typescript/packages/core/src/auth/jit/__tests__/jit-bridge.test.ts create mode 100644 typescript/packages/core/src/auth/jit/adapters/auth0.adapter.ts create mode 100644 typescript/packages/core/src/auth/jit/adapters/generic-dcr.adapter.ts create mode 100644 typescript/packages/core/src/auth/jit/adapters/okta.adapter.ts create mode 100644 typescript/packages/core/src/auth/jit/adapters/passthrough.adapter.ts create mode 100644 typescript/packages/core/src/auth/jit/index.ts create mode 100644 typescript/packages/core/src/auth/jit/jit-bridge.ts create mode 100644 typescript/packages/core/src/auth/jit/types.ts diff --git a/typescript/packages/core/src/auth/index.ts b/typescript/packages/core/src/auth/index.ts index 14d1bb86f..da5ce8c0f 100644 --- a/typescript/packages/core/src/auth/index.ts +++ b/typescript/packages/core/src/auth/index.ts @@ -138,3 +138,7 @@ export { validateAuthConfig, } from './server-integration.js'; +// Multi-Provider Just-in-Time (JIT) Dynamic Discovery Bridge +export * from './jit/index.js'; + + diff --git a/typescript/packages/core/src/auth/jit/__tests__/jit-bridge.test.ts b/typescript/packages/core/src/auth/jit/__tests__/jit-bridge.test.ts new file mode 100644 index 000000000..e43d6ada1 --- /dev/null +++ b/typescript/packages/core/src/auth/jit/__tests__/jit-bridge.test.ts @@ -0,0 +1,461 @@ +import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { JitBridge } from '../jit-bridge.js'; +import { Auth0JitAdapter } from '../adapters/auth0.adapter.js'; +import { OktaJitAdapter } from '../adapters/okta.adapter.js'; +import { GenericDcrJitAdapter } from '../adapters/generic-dcr.adapter.js'; +import { PassthroughJitAdapter } from '../adapters/passthrough.adapter.js'; +import { JitContext } from '../types.js'; +import { OAuthModule } from '../../../core/oauth-module.js'; + +// Mock global fetch +const mockFetch = jest.fn() as any; +(global as any).fetch = mockFetch; + +describe('Multi-Provider JIT Dynamic Discovery Bridge', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('Adapter Selection & Detection', () => { + it('detects Auth0 adapter based on URL or environment credentials', () => { + const bridge = new JitBridge(); + const adapter1 = bridge.getAdapter('https://dev-test.us.auth0.com'); + expect(adapter1).toBeInstanceOf(Auth0JitAdapter); + expect(adapter1?.name).toBe('auth0'); + + const bridgeCustom = new JitBridge({ provider: 'auth0' }); + const adapter2 = bridgeCustom.getAdapter('https://custom-auth.example.com'); + expect(adapter2).toBeInstanceOf(Auth0JitAdapter); + }); + + it('detects Okta adapter based on URL or configuration', () => { + const bridge = new JitBridge(); + const adapter1 = bridge.getAdapter('https://dev-123456.okta.com'); + expect(adapter1).toBeInstanceOf(OktaJitAdapter); + expect(adapter1?.name).toBe('okta'); + + const bridgeCustom = new JitBridge({ provider: 'okta' }); + const adapter2 = bridgeCustom.getAdapter('https://my-idp.example.com'); + expect(adapter2).toBeInstanceOf(OktaJitAdapter); + }); + + it('detects Generic DCR adapter for Zitadel, Keycloak, or Hydra', () => { + const bridge = new JitBridge(); + const adapterZitadel = bridge.getAdapter('https://my-instance.zitadel.cloud'); + expect(adapterZitadel).toBeInstanceOf(GenericDcrJitAdapter); + expect(adapterZitadel?.name).toBe('generic-dcr'); + + const adapterKeycloak = bridge.getAdapter('https://keycloak.company.com/auth/realms/master'); + expect(adapterKeycloak).toBeInstanceOf(GenericDcrJitAdapter); + }); + + it('detects Passthrough adapter for native CIMD providers', () => { + const bridge = new JitBridge(); + const adapter = bridge.getAdapter('https://api.stytch.com'); + expect(adapter).toBeInstanceOf(PassthroughJitAdapter); + expect(adapter?.name).toBe('passthrough'); + }); + + it('allows registering a custom provider adapter with highest priority', () => { + const bridge = new JitBridge(); + const customAdapter = { + name: 'custom-enterprise', + canHandle: () => true, + registerClient: jest.fn().mockResolvedValue({ idpClientId: 'custom-id' }), + }; + + bridge.registerAdapter(customAdapter); + const resolved = bridge.getAdapter('https://dev-test.us.auth0.com'); + expect(resolved?.name).toBe('custom-enterprise'); + }); + }); + + describe('Auth0 Adapter Client Provisioning', () => { + it('acquires and caches Management API token', async () => { + const adapter = new Auth0JitAdapter(); + const config = { + auth0: { + domain: 'tenant.us.auth0.com', + managementClientId: 'm2m-client-id', + managementClientSecret: 'm2m-client-secret', + }, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: 'auth0_mgmt_access_token_123', + expires_in: 86400, + }), + }); + + const token1 = await adapter.getManagementToken('tenant.us.auth0.com', config); + expect(token1).toBe('auth0_mgmt_access_token_123'); + + // Second call should return cached token without fetch + const token2 = await adapter.getManagementToken('tenant.us.auth0.com', config); + expect(token2).toBe('auth0_mgmt_access_token_123'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('creates client and attaches client grants in Auth0 when client does not exist', async () => { + const adapter = new Auth0JitAdapter(); + const context: JitContext = { + authServerUrl: 'https://tenant.us.auth0.com', + resourceUri: 'https://mcp-server.example.com/mcp', + scopesSupported: ['read', 'write', 'admin'], + }; + const config = { + auth0: { + domain: 'tenant.us.auth0.com', + managementClientId: 'm2m-client-id', + managementClientSecret: 'm2m-client-secret', + }, + }; + + // 1. Management token fetch + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ access_token: 'mgmt_token', expires_in: 3600 }), + }); + + // 2. Client search fetch (none found) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [], + }); + + // 3. Client creation fetch + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ client_id: 'auth0_generated_client_abc' }), + }); + + // 4. Client grant creation fetch + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'grant_123' }), + }); + + const clientDoc = { + client_id: 'https://chatgpt.com/oauth/AH_123/client.json', + client_name: 'ChatGPT Agent', + redirect_uris: ['https://chatgpt.com/connector/oauth/AH_123'], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + }; + + const result = await adapter.registerClient(clientDoc, context, config); + expect(result.idpClientId).toBe('auth0_generated_client_abc'); + + // Verify client creation payload + expect(mockFetch).toHaveBeenCalledWith( + 'https://tenant.us.auth0.com/api/v2/clients', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"external_client_id":"https://chatgpt.com/oauth/AH_123/client.json"'), + }) + ); + + // Verify grant creation payload + expect(mockFetch).toHaveBeenCalledWith( + 'https://tenant.us.auth0.com/api/v2/client-grants', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"audience":"https://mcp-server.example.com/mcp"'), + }) + ); + }); + }); + + describe('JIT Bridge Authorization & Token Proxying', () => { + it('handles authorize request by resolving CIMD, provisioning in IdP, and 302 redirecting', async () => { + const bridge = new JitBridge({ + auth0: { + domain: 'tenant.us.auth0.com', + managementClientId: 'mgmt-id', + managementClientSecret: 'mgmt-secret', + }, + }); + + const context: JitContext = { + authServerUrl: 'https://tenant.us.auth0.com', + resourceUri: 'https://mcp.ai/mcp', + }; + + // Mock CIMD fetch + const cimdDoc = { + client_id: 'https://chatgpt.com/oauth/test-agent/client.json', + client_name: 'ChatGPT Agent', + redirect_uris: ['https://chatgpt.com/connector/oauth/test-agent'], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + }; + + mockFetch.mockResolvedValueOnce({ + status: 200, + ok: true, + headers: { get: () => 'application/json' }, + json: async () => cimdDoc, + text: async () => JSON.stringify(cimdDoc), + }); + + // Mock Auth0 Mgmt Token + mockFetch.mockResolvedValueOnce({ + status: 200, + ok: true, + json: async () => ({ access_token: 'tok_123', expires_in: 3600 }), + }); + + // Mock Auth0 search clients (found existing) + mockFetch.mockResolvedValueOnce({ + status: 200, + ok: true, + json: async () => [ + { + client_id: 'auth0_client_id_456', + name: 'ChatGPT Agent', + callbacks: ['https://chatgpt.com/connector/oauth/test-agent'], + }, + ], + }); + + // Mock Auth0 client grant + mockFetch.mockResolvedValueOnce({ + status: 200, + ok: true, + json: async () => ({ id: 'grant_1' }), + }); + + const mockReq = { + method: 'GET', + query: { + response_type: 'code', + client_id: 'https://chatgpt.com/oauth/test-agent/client.json', + redirect_uri: 'https://chatgpt.com/connector/oauth/test-agent', + scope: 'openid email read write', + code_challenge: 'test_challenge', + code_challenge_method: 'S256', + state: 'state_xyz', + }, + }; + + let redirectStatus = 0; + let redirectLocation = ''; + const mockRes = { + writeHead: (status: number, headers: Record) => { + redirectStatus = status; + redirectLocation = headers['Location'] || ''; + }, + end: jest.fn(), + }; + + await bridge.handleAuthorizeRequest( + mockReq, + mockRes, + context, + 'https://tenant.us.auth0.com/authorize' + ); + + expect(redirectStatus).toBe(302); + expect(redirectLocation).toContain('https://tenant.us.auth0.com/authorize?'); + expect(redirectLocation).toContain('client_id=auth0_client_id_456'); + expect(redirectLocation).toContain('code_challenge=test_challenge'); + expect(redirectLocation).toContain('state=state_xyz'); + expect(mockRes.end).toHaveBeenCalled(); + }); + + it('rejects authorize request when redirect_uri is not authorized in CIMD', async () => { + const bridge = new JitBridge(); + const context: JitContext = { + authServerUrl: 'https://tenant.us.auth0.com', + }; + + const cimdDoc = { + client_id: 'https://chatgpt.com/oauth/test-agent/client.json', + redirect_uris: ['https://chatgpt.com/connector/oauth/legit'], + }; + + mockFetch.mockResolvedValueOnce({ + status: 200, + ok: true, + headers: { get: () => 'application/json' }, + json: async () => cimdDoc, + text: async () => JSON.stringify(cimdDoc), + }); + + const mockReq = { + method: 'GET', + query: { + client_id: 'https://chatgpt.com/oauth/test-agent/client.json', + redirect_uri: 'https://attacker.com/oauth/callback', + }, + }; + + let responseStatus = 0; + let responseBody = ''; + const mockRes = { + writeHead: (status: number) => { responseStatus = status; }, + end: (body: string) => { responseBody = body; }, + }; + + await bridge.handleAuthorizeRequest( + mockReq, + mockRes, + context, + 'https://tenant.us.auth0.com/authorize' + ); + + expect(responseStatus).toBe(400); + const parsed = JSON.parse(responseBody); + expect(parsed.error).toBe('invalid_request'); + expect(parsed.error_description).toContain('not authorized in client metadata document'); + }); + + it('bridges token exchange requests by translating CIMD client_id to upstream IdP client_id', async () => { + const bridge = new JitBridge({ + auth0: { + domain: 'tenant.us.auth0.com', + managementClientId: 'mgmt-id', + managementClientSecret: 'mgmt-secret', + }, + }); + const context: JitContext = { + authServerUrl: 'https://tenant.us.auth0.com', + logger: console as any, + }; + + // 1. Mock CIMD fetch for token on-the-fly resolution + const cimdDoc = { + client_id: 'https://chatgpt.com/oauth/agent/client.json', + client_name: 'ChatGPT Agent', + redirect_uris: ['https://chatgpt.com/oauth/callback'], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + }; + + mockFetch.mockResolvedValueOnce({ + status: 200, + ok: true, + headers: { get: () => 'application/json' }, + json: async () => cimdDoc, + text: async () => JSON.stringify(cimdDoc), + }); + + // 2. Mock Auth0 Mgmt Token + mockFetch.mockResolvedValueOnce({ + status: 200, + ok: true, + json: async () => ({ access_token: 'tok_123', expires_in: 3600 }), + }); + + // 3. Mock Auth0 search clients (found existing) + mockFetch.mockResolvedValueOnce({ + status: 200, + ok: true, + json: async () => [ + { + client_id: 'auth0_client_id_789', + name: 'ChatGPT Agent', + client_metadata: { + external_client_id: 'https://chatgpt.com/oauth/agent/client.json', + }, + callbacks: ['https://chatgpt.com/oauth/callback'], + }, + ], + }); + + // 4. Mock upstream token response + mockFetch.mockResolvedValueOnce({ + status: 200, + ok: true, + headers: { get: () => 'application/json' }, + text: async () => JSON.stringify({ + access_token: 'jwt_access_token_xyz', + token_type: 'Bearer', + expires_in: 86400, + }), + }); + + const mockReq = { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: { + grant_type: 'authorization_code', + client_id: 'https://chatgpt.com/oauth/agent/client.json', + code: 'auth_code_123', + redirect_uri: 'https://chatgpt.com/oauth/callback', + code_verifier: 'verifier_abc', + }, + }; + + let statusCode = 0; + let sentBody = ''; + const mockRes = { + writeHead: (status: number) => { statusCode = status; }, + end: (body: string) => { sentBody = body; }, + }; + + await bridge.handleTokenRequest( + mockReq, + mockRes, + context, + 'https://tenant.us.auth0.com/oauth/token' + ); + + expect(statusCode).toBe(200); + const responseData = JSON.parse(sentBody); + expect(responseData.access_token).toBe('jwt_access_token_xyz'); + }); + }); + + describe('OAuthModule Integration with JIT Bridge', () => { + it('auto-configures JIT bridge from Auth0 environment variables', () => { + process.env.AUTH0_MANAGEMENT_CLIENT_ID = 'test-m2m-id'; + process.env.AUTH0_MANAGEMENT_CLIENT_SECRET = 'test-m2m-secret'; + + const config = OAuthModule.forRoot({ + resourceUri: 'https://my-app.nitrocloud.ai/mcp', + authorizationServers: ['https://dev-test.us.auth0.com/'], + }); + + const resolved = OAuthModule.getConfig(); + expect(resolved?.jitBridge).toBeDefined(); + expect(resolved?.jitBridge?.enabled).toBe(true); + expect(resolved?.jitBridge?.provider).toBe('auth0'); + expect(resolved?.jitBridge?.auth0?.domain).toBe('dev-test.us.auth0.com'); + expect(resolved?.jitBridge?.auth0?.managementClientId).toBe('test-m2m-id'); + }); + + it('advertises gateway baseUrl as authorization server in protected resource metadata when JIT bridge is enabled', () => { + process.env.AUTH0_MANAGEMENT_CLIENT_ID = 'test-m2m-id'; + process.env.AUTH0_MANAGEMENT_CLIENT_SECRET = 'test-m2m-secret'; + + const config = { + resourceUri: 'https://my-app.nitrocloud.ai/mcp', + authorizationServers: ['https://dev-test.us.auth0.com/'], + }; + + const oauth = new (OAuthModule as any)(config, {} as any, { debug: () => {}, info: () => {}, warn: () => {} } as any); + let sentBody = ''; + const mockRes = { + writeHead: jest.fn(), + end: (body: string) => { sentBody = body; }, + }; + + (oauth as any).resourceMetadataHandler({ headers: { host: 'my-app.nitrocloud.ai' } }, mockRes); + const data = JSON.parse(sentBody); + expect(data.authorization_servers).toEqual(['https://my-app.nitrocloud.ai']); + }); + }); +}); diff --git a/typescript/packages/core/src/auth/jit/adapters/auth0.adapter.ts b/typescript/packages/core/src/auth/jit/adapters/auth0.adapter.ts new file mode 100644 index 000000000..5814ffc1f --- /dev/null +++ b/typescript/packages/core/src/auth/jit/adapters/auth0.adapter.ts @@ -0,0 +1,331 @@ +import { ClientIdMetadataDocument } from '../../cimd.js'; +import { JitBridgeConfig, JitContext, JitProviderAdapter, JitClientRegistrationResult } from '../types.js'; + +interface Auth0TokenCache { + accessToken: string; + expiresAt: number; +} + +interface RegisteredClientCache { + auth0ClientId: string; + callbacks: Set; + expiresAt: number; +} + +/** + * Auth0 JIT Dynamic Discovery Provider Adapter + * + * Automatically provisions dynamic AI agent clients in Auth0 via the Management API, + * attaches MCP API scope grants, and manages dynamic redirect URIs. + */ +export class Auth0JitAdapter implements JitProviderAdapter { + readonly name = 'auth0'; + + private tokenCache: Auth0TokenCache | null = null; + private clientCache = new Map(); + private defaultCacheTtlMs: number; + + constructor(options?: { defaultCacheTtlMs?: number }) { + this.defaultCacheTtlMs = options?.defaultCacheTtlMs ?? 24 * 60 * 60 * 1000; // 24 hours + } + + canHandle(authServerUrl: string, config: JitBridgeConfig): boolean { + if (config.provider === 'auth0') return true; + if (config.provider && config.provider !== 'auto') return false; + + // Auto-detection based on URL or configured Auth0 credentials + const isAuth0Url = /auth0\.com/i.test(authServerUrl); + const hasAuth0Creds = Boolean( + config.auth0?.managementClientId || + process.env.AUTH0_MANAGEMENT_CLIENT_ID || + process.env.AUTH0_CLIENT_ID + ); + + return isAuth0Url || hasAuth0Creds; + } + + private resolveDomain(context: JitContext, config: JitBridgeConfig): string { + const raw = + config.auth0?.domain || + process.env.AUTH0_DOMAIN || + context.authServerUrl; + + return raw.replace(/^https?:\/\//i, '').replace(/\/+$/, ''); + } + + private getManagementCredentials(config: JitBridgeConfig): { clientId: string; clientSecret: string } | null { + const clientId = + config.auth0?.managementClientId || + process.env.AUTH0_MANAGEMENT_CLIENT_ID || + process.env.AUTH0_CLIENT_ID || + process.env.OAUTH_CLIENT_ID || + process.env.COGNERD_CLIENT_ID; + + const clientSecret = + config.auth0?.managementClientSecret || + process.env.AUTH0_MANAGEMENT_CLIENT_SECRET || + process.env.AUTH0_CLIENT_SECRET || + process.env.OAUTH_CLIENT_SECRET; + + if (!clientId || !clientSecret) { + return null; + } + + return { clientId, clientSecret }; + } + + /** + * Acquire or return cached Auth0 Management API access token + */ + async getManagementToken(domain: string, config: JitBridgeConfig): Promise { + if (this.tokenCache && Date.now() < this.tokenCache.expiresAt) { + return this.tokenCache.accessToken; + } + + const creds = this.getManagementCredentials(config); + if (!creds) { + throw new Error( + 'Auth0JitAdapter: Management credentials missing. Please set AUTH0_MANAGEMENT_CLIENT_ID and AUTH0_MANAGEMENT_CLIENT_SECRET.' + ); + } + + const tokenUrl = `https://${domain}/oauth/token`; + const audience = `https://${domain}/api/v2/`; + + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'client_credentials', + client_id: creds.clientId, + client_secret: creds.clientSecret, + audience, + }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + throw new Error(`Auth0JitAdapter: Failed to obtain Management API token: HTTP ${response.status} - ${errorText}`); + } + + const data = (await response.json()) as { access_token: string; expires_in: number }; + const expiresAt = Date.now() + (data.expires_in - 60) * 1000; + + this.tokenCache = { + accessToken: data.access_token, + expiresAt, + }; + + return data.access_token; + } + + /** + * Register a CIMD client in Auth0 and attach API grants + */ + async registerClient( + clientDoc: ClientIdMetadataDocument, + context: JitContext, + config: JitBridgeConfig + ): Promise { + const externalId = clientDoc.client_id; + const cached = this.clientCache.get(externalId); + + const redirectUris = clientDoc.redirect_uris || []; + if (cached && Date.now() < cached.expiresAt) { + // Check if all redirect URIs are already registered + const hasAllCallbacks = redirectUris.every((uri) => cached.callbacks.has(uri)); + if (hasAllCallbacks) { + context.logger?.debug?.(`Auth0JitAdapter: Client "${externalId}" is already cached and up to date.`); + return { idpClientId: cached.auth0ClientId }; + } + } + + const domain = this.resolveDomain(context, config); + const token = await this.getManagementToken(domain, config); + + // 1. Search for existing client with this external identifier or name + let auth0ClientId: string | null = null; + let existingCallbacks: string[] = []; + + try { + const searchRes = await fetch( + `https://${domain}/api/v2/clients?fields=client_id,name,callbacks,client_metadata&include_fields=true`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + } + ); + + if (searchRes.ok) { + const clients = (await searchRes.json()) as Array<{ + client_id: string; + name: string; + callbacks?: string[]; + client_metadata?: Record; + }>; + + const match = clients.find( + (c) => + c.client_metadata?.external_client_id === externalId || + c.name === clientDoc.client_name || + c.name === externalId + ); + + if (match) { + auth0ClientId = match.client_id; + existingCallbacks = match.callbacks || []; + } + } + } catch (err) { + context.logger?.debug?.('Auth0JitAdapter: client lookup error, proceeding with creation', { error: err }); + } + + // 2. Create or Update Client in Auth0 + if (!auth0ClientId) { + const clientName = clientDoc.client_name || `AI Agent (${externalId})`; + const supportedMethods = Array.isArray(clientDoc.token_endpoint_auth_methods_supported) + ? clientDoc.token_endpoint_auth_methods_supported + : []; + const isPublicClient = + !clientDoc.token_endpoint_auth_method || + clientDoc.token_endpoint_auth_method === 'none' || + supportedMethods.includes('none'); + const resolvedAppType = + clientDoc.application_type === 'native' + ? 'native' + : isPublicClient + ? 'spa' + : clientDoc.application_type || 'regular_web'; + + const payload: Record = { + name: clientName, + app_type: resolvedAppType, + callbacks: redirectUris, + grant_types: clientDoc.grant_types || ['authorization_code', 'refresh_token'], + token_endpoint_auth_method: 'none', // OAuth 2.1 Public client with PKCE + oidc_conformant: true, + client_metadata: { + external_client_id: externalId, + external_metadata_type: 'cimd', + }, + }; + + if (clientDoc.logo_uri) { + payload.logo_uri = clientDoc.logo_uri; + } + + const createRes = await fetch(`https://${domain}/api/v2/clients`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(payload), + }); + + if (!createRes.ok) { + const errorText = await createRes.text().catch(() => ''); + throw new Error(`Auth0JitAdapter: Failed to create client in Auth0: HTTP ${createRes.status} - ${errorText}`); + } + + const created = (await createRes.json()) as { client_id: string }; + auth0ClientId = created.client_id; + context.logger?.info?.(`Auth0JitAdapter: Created Auth0 client "${auth0ClientId}" for CIMD: ${externalId}`); + } else { + // Merge callbacks if new redirect URIs are present + const combinedCallbacks = Array.from(new Set([...existingCallbacks, ...redirectUris])); + if (combinedCallbacks.length > existingCallbacks.length) { + await fetch(`https://${domain}/api/v2/clients/${auth0ClientId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ callbacks: combinedCallbacks }), + }); + context.logger?.info?.(`Auth0JitAdapter: Updated callbacks for Auth0 client "${auth0ClientId}"`); + } + } + + // 3. Ensure Client Grant exists for the MCP Resource URI + if (context.resourceUri && auth0ClientId) { + try { + const scopes = context.scopesSupported || ['read', 'write', 'admin']; + const grantRes = await fetch(`https://${domain}/api/v2/client-grants`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + client_id: auth0ClientId, + audience: context.resourceUri, + scope: scopes, + }), + }); + + // 409 Conflict means grant already exists, which is expected & successful + if (!grantRes.ok && grantRes.status !== 409) { + const grantErr = await grantRes.text().catch(() => ''); + context.logger?.warn?.(`Auth0JitAdapter: Client grant notice: ${grantRes.status} - ${grantErr}`); + } + } catch (err) { + context.logger?.debug?.('Auth0JitAdapter: Client grant creation error', { error: err }); + } + } + + // Cache registration + const ttl = config.cacheTtlMs ?? this.defaultCacheTtlMs; + this.clientCache.set(externalId, { + auth0ClientId, + callbacks: new Set(redirectUris), + expiresAt: Date.now() + ttl, + }); + + return { idpClientId: auth0ClientId }; + } + + /** + * Register an ephemeral callback URI (such as dynamic desktop/CLI loopback ports) + */ + async registerCallback( + clientId: string, + redirectUri: string, + context: JitContext, + config: JitBridgeConfig + ): Promise { + if (!redirectUri || !clientId) return; + + const domain = this.resolveDomain(context, config); + const token = await this.getManagementToken(domain, config); + + try { + const getRes = await fetch(`https://${domain}/api/v2/clients/${clientId}?fields=callbacks`, { + method: 'GET', + headers: { Authorization: `Bearer ${token}` }, + }); + + if (getRes.ok) { + const data = (await getRes.json()) as { callbacks?: string[] }; + const callbacks = data.callbacks || []; + if (!callbacks.includes(redirectUri)) { + callbacks.push(redirectUri); + await fetch(`https://${domain}/api/v2/clients/${clientId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ callbacks }), + }); + context.logger?.info?.(`Auth0JitAdapter: Added callback "${redirectUri}" to client "${clientId}"`); + } + } + } catch (err) { + context.logger?.debug?.('Auth0JitAdapter: registerCallback error', { error: err }); + } + } +} diff --git a/typescript/packages/core/src/auth/jit/adapters/generic-dcr.adapter.ts b/typescript/packages/core/src/auth/jit/adapters/generic-dcr.adapter.ts new file mode 100644 index 000000000..501fba499 --- /dev/null +++ b/typescript/packages/core/src/auth/jit/adapters/generic-dcr.adapter.ts @@ -0,0 +1,122 @@ +import { ClientIdMetadataDocument } from '../../cimd.js'; +import { JitBridgeConfig, JitContext, JitProviderAdapter, JitClientRegistrationResult } from '../types.js'; + +/** + * Generic RFC 7591 Dynamic Client Registration Adapter + * + * Compatible with Zitadel, Keycloak, Ory Hydra, and any RFC 7591 compliant + * authorization server. + */ +export class GenericDcrJitAdapter implements JitProviderAdapter { + readonly name = 'generic-dcr'; + + private clientCache = new Map(); + + canHandle(authServerUrl: string, config: JitBridgeConfig): boolean { + if (config.provider === 'generic-dcr' || config.provider === 'keycloak') return true; + if (config.provider && config.provider !== 'auto') return false; + + // Detect Zitadel, Keycloak, or generic DCR configurations + const isKnownDcrProvider = /zitadel|keycloak|hydra/i.test(authServerUrl); + const hasDcrConfig = Boolean( + config.genericDcr?.registrationEndpoint || + process.env.DCR_REGISTRATION_ENDPOINT || + process.env.ZITADEL_DOMAIN || + process.env.KEYCLOAK_URL + ); + + return isKnownDcrProvider || hasDcrConfig; + } + + private async resolveRegistrationEndpoint(context: JitContext, config: JitBridgeConfig): Promise { + if (config.genericDcr?.registrationEndpoint) { + return config.genericDcr.registrationEndpoint; + } + + if (process.env.DCR_REGISTRATION_ENDPOINT) { + return process.env.DCR_REGISTRATION_ENDPOINT; + } + + // Try discovering registration_endpoint from upstream metadata + const base = context.authServerUrl.replace(/\/+$/, ''); + for (const suffix of ['/.well-known/openid-configuration', '/.well-known/oauth-authorization-server']) { + try { + const res = await fetch(`${base}${suffix}`); + if (res.ok) { + const data = (await res.json()) as { registration_endpoint?: string }; + if (data.registration_endpoint) { + return data.registration_endpoint; + } + } + } catch {} + } + + // Standard default paths for Keycloak / Zitadel + if (/keycloak/i.test(base)) { + return `${base}/clients-registrations/openid-connect`; + } + + return `${base}/oauth/v2/register`; + } + + async registerClient( + clientDoc: ClientIdMetadataDocument, + context: JitContext, + config: JitBridgeConfig + ): Promise { + const externalId = clientDoc.client_id; + const cached = this.clientCache.get(externalId); + if (cached) { + return cached; + } + + const regEndpoint = await this.resolveRegistrationEndpoint(context, config); + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + + const initialToken = config.genericDcr?.initialAccessToken || process.env.DCR_INITIAL_ACCESS_TOKEN; + if (initialToken) { + headers['Authorization'] = `Bearer ${initialToken}`; + } + + const payload = { + client_name: clientDoc.client_name || `AI Agent (${externalId})`, + redirect_uris: clientDoc.redirect_uris, + response_types: ['code'], + grant_types: clientDoc.grant_types || ['authorization_code', 'refresh_token'], + token_endpoint_auth_method: 'none', + application_type: clientDoc.application_type || 'web', + logo_uri: clientDoc.logo_uri, + client_uri: clientDoc.client_uri, + }; + + const response = await fetch(regEndpoint, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + + let result: JitClientRegistrationResult = { idpClientId: externalId }; + + if (!response.ok && response.status !== 409) { + const err = await response.text().catch(() => ''); + context.logger?.warn?.(`GenericDcrJitAdapter: Registration notice: ${response.status} - ${err}`); + } else { + context.logger?.info?.(`GenericDcrJitAdapter: Registered client "${externalId}" on ${regEndpoint}`); + if (response.ok) { + try { + const data = (await response.json()) as { client_id?: string; client_secret?: string }; + result = { + idpClientId: data.client_id || externalId, + clientSecret: data.client_secret, + }; + } catch {} + } + this.clientCache.set(externalId, result); + } + + return result; + } +} diff --git a/typescript/packages/core/src/auth/jit/adapters/okta.adapter.ts b/typescript/packages/core/src/auth/jit/adapters/okta.adapter.ts new file mode 100644 index 000000000..4aaa29e4c --- /dev/null +++ b/typescript/packages/core/src/auth/jit/adapters/okta.adapter.ts @@ -0,0 +1,99 @@ +import { ClientIdMetadataDocument } from '../../cimd.js'; +import { JitBridgeConfig, JitContext, JitProviderAdapter, JitClientRegistrationResult } from '../types.js'; + +/** + * Okta JIT Dynamic Discovery Provider Adapter + * + * Implements client dynamic registration against Okta OAuth 2.0 Dynamic Client Registration + * API (/oauth2/v1/clients) and Okta Apps API. + */ +export class OktaJitAdapter implements JitProviderAdapter { + readonly name = 'okta'; + + private clientCache = new Map(); + + canHandle(authServerUrl: string, config: JitBridgeConfig): boolean { + if (config.provider === 'okta') return true; + if (config.provider && config.provider !== 'auto') return false; + + const isOktaUrl = /okta\.com|oktapreview\.com/i.test(authServerUrl); + const hasOktaCreds = Boolean( + config.okta?.apiToken || + process.env.OKTA_API_TOKEN || + process.env.OKTA_DOMAIN + ); + + return isOktaUrl || hasOktaCreds; + } + + private resolveDomain(context: JitContext, config: JitBridgeConfig): string { + const raw = + config.okta?.domain || + process.env.OKTA_DOMAIN || + context.authServerUrl; + + return raw.replace(/^https?:\/\//i, '').replace(/\/+$/, ''); + } + + async registerClient( + clientDoc: ClientIdMetadataDocument, + context: JitContext, + config: JitBridgeConfig + ): Promise { + const externalId = clientDoc.client_id; + const cached = this.clientCache.get(externalId); + if (cached) { + return cached; + } + + const domain = this.resolveDomain(context, config); + const apiToken = config.okta?.apiToken || process.env.OKTA_API_TOKEN; + + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + + if (apiToken) { + headers['Authorization'] = `SSWS ${apiToken}`; + } + + const payload = { + client_name: clientDoc.client_name || `AI Agent (${externalId})`, + redirect_uris: clientDoc.redirect_uris, + response_types: ['code'], + grant_types: clientDoc.grant_types || ['authorization_code', 'refresh_token'], + token_endpoint_auth_method: 'none', + application_type: clientDoc.application_type || 'web', + logo_uri: clientDoc.logo_uri, + }; + + const dcrEndpoint = `https://${domain}/oauth2/v1/clients`; + const response = await fetch(dcrEndpoint, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + + let result: JitClientRegistrationResult = { idpClientId: externalId }; + + if (!response.ok && response.status !== 409) { + const err = await response.text().catch(() => ''); + context.logger?.warn?.(`OktaJitAdapter: DCR notice: ${response.status} - ${err}`); + } else { + context.logger?.info?.(`OktaJitAdapter: Registered client for "${externalId}" on Okta`); + if (response.ok) { + try { + const data = (await response.json()) as { client_id?: string; client_secret?: string }; + result = { + idpClientId: data.client_id || externalId, + clientSecret: data.client_secret, + }; + } catch {} + } + this.clientCache.set(externalId, result); + } + + return result; + } +} diff --git a/typescript/packages/core/src/auth/jit/adapters/passthrough.adapter.ts b/typescript/packages/core/src/auth/jit/adapters/passthrough.adapter.ts new file mode 100644 index 000000000..416f89265 --- /dev/null +++ b/typescript/packages/core/src/auth/jit/adapters/passthrough.adapter.ts @@ -0,0 +1,28 @@ +import { ClientIdMetadataDocument } from '../../cimd.js'; +import { JitBridgeConfig, JitContext, JitProviderAdapter, JitClientRegistrationResult } from '../types.js'; + +/** + * Passthrough JIT Adapter for native CIMD Authorization Servers + * + * Used for providers that natively resolve Client ID Metadata Documents + * on-the-fly during /authorize (e.g., Stytch). + */ +export class PassthroughJitAdapter implements JitProviderAdapter { + readonly name = 'passthrough'; + + canHandle(authServerUrl: string, config: JitBridgeConfig): boolean { + if (config.provider === 'passthrough') return true; + return /stytch\.com/i.test(authServerUrl); + } + + async registerClient( + clientDoc: ClientIdMetadataDocument, + context: JitContext, + _config: JitBridgeConfig + ): Promise { + context.logger?.debug?.( + `PassthroughJitAdapter: Native CIMD provider detected for "${clientDoc.client_id}". Delegating to upstream AS directly.` + ); + return { idpClientId: clientDoc.client_id }; + } +} diff --git a/typescript/packages/core/src/auth/jit/index.ts b/typescript/packages/core/src/auth/jit/index.ts new file mode 100644 index 000000000..022ee12e8 --- /dev/null +++ b/typescript/packages/core/src/auth/jit/index.ts @@ -0,0 +1,6 @@ +export * from './types.js'; +export * from './adapters/auth0.adapter.js'; +export * from './adapters/okta.adapter.js'; +export * from './adapters/generic-dcr.adapter.js'; +export * from './adapters/passthrough.adapter.js'; +export * from './jit-bridge.js'; diff --git a/typescript/packages/core/src/auth/jit/jit-bridge.ts b/typescript/packages/core/src/auth/jit/jit-bridge.ts new file mode 100644 index 000000000..e11c46c7c --- /dev/null +++ b/typescript/packages/core/src/auth/jit/jit-bridge.ts @@ -0,0 +1,445 @@ +import { URL } from 'url'; +import { + isClientIdMetadataUrl, + resolveClientIdMetadataDocument, + validateRedirectUriWithCimd, + ClientIdMetadataDocument, +} from '../cimd.js'; +import { JitBridgeConfig, JitContext, JitProviderAdapter, JitClientRegistrationResult } from './types.js'; +import { Auth0JitAdapter } from './adapters/auth0.adapter.js'; +import { OktaJitAdapter } from './adapters/okta.adapter.js'; +import { GenericDcrJitAdapter } from './adapters/generic-dcr.adapter.js'; +import { PassthroughJitAdapter } from './adapters/passthrough.adapter.js'; + +/** + * Multi-Provider JIT Dynamic Discovery Bridge + * + * Intercepts incoming OAuth authorization requests from AI agents (ChatGPT, Claude, Cursor), + * dynamically registers them in the target Identity Provider (Auth0, Okta, Zitadel, Keycloak), + * and seamlessly redirects the browser to the upstream IdP login screen. + * + * Also bridges token exchange requests (/oauth/v2/token) to translate dynamic CIMD client + * identifiers to upstream IdP provisioned credentials. + */ +export class JitBridge { + private adapters: JitProviderAdapter[] = []; + private config: JitBridgeConfig; + private clientMapping = new Map(); // CIMD client_id URL -> upstream IdP client_id + private reverseMapping = new Map(); // upstream IdP client_id -> CIMD client_id URL + + constructor(config?: JitBridgeConfig) { + this.config = config || {}; + this.registerDefaultAdapters(); + } + + /** + * Register default built-in provider adapters + */ + private registerDefaultAdapters(): void { + this.adapters.push(new Auth0JitAdapter()); + this.adapters.push(new OktaJitAdapter()); + this.adapters.push(new GenericDcrJitAdapter()); + this.adapters.push(new PassthroughJitAdapter()); + } + + /** + * Register a custom provider adapter + */ + registerAdapter(adapter: JitProviderAdapter): void { + this.adapters.unshift(adapter); + } + + /** + * Whether the JIT bridge is enabled for the current server configuration + */ + isEnabled(authServerUrl: string): boolean { + if (this.config.enabled !== undefined) { + return this.config.enabled; + } + + if (process.env.OAUTH_JIT_BRIDGE_ENABLED === 'false' || process.env.JIT_BRIDGE_ENABLED === 'false') { + return false; + } + + if (process.env.OAUTH_JIT_BRIDGE_ENABLED === 'true' || process.env.JIT_BRIDGE_ENABLED === 'true') { + return true; + } + + // Auto-enable if management credentials are provided or adapter can handle + const adapter = this.getAdapter(authServerUrl); + return Boolean(adapter); + } + + /** + * Resolve the appropriate provider adapter for the given authorization server + */ + getAdapter(authServerUrl: string): JitProviderAdapter | null { + for (const adapter of this.adapters) { + if (adapter.canHandle(authServerUrl, this.config)) { + return adapter; + } + } + return null; + } + + /** + * Handle incoming GET /oauth/v2/authorize request + */ + async handleAuthorizeRequest( + req: any, + res: any, + context: JitContext, + upstreamAuthEndpoint: string + ): Promise { + const headers: Record = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization', + }; + + if (req.method === 'OPTIONS') { + if (typeof res.writeHead === 'function') { + res.writeHead(204, headers); + res.end(); + } else if (typeof res.status === 'function') { + res.status(204).end(); + } + return; + } + + // Extract query parameters + let searchParams: URLSearchParams; + if (req.query && typeof req.query === 'object') { + searchParams = new URLSearchParams(); + for (const [k, v] of Object.entries(req.query)) { + if (Array.isArray(v)) { + v.forEach((item) => searchParams.append(k, String(item))); + } else if (v !== undefined && v !== null) { + searchParams.set(k, String(v)); + } + } + } else { + const rawUrl = req.url || ''; + const queryIdx = rawUrl.indexOf('?'); + searchParams = new URLSearchParams(queryIdx >= 0 ? rawUrl.slice(queryIdx + 1) : ''); + } + + const clientId = searchParams.get('client_id'); + const redirectUri = searchParams.get('redirect_uri'); + + if (!clientId) { + this.sendOAuthError(res, 400, 'invalid_request', 'Missing required parameter: client_id'); + return; + } + + // If client_id is a CIMD metadata URL, resolve and auto-provision in upstream IdP + if (isClientIdMetadataUrl(clientId)) { + try { + context.logger?.info?.(`JitBridge: Resolving Client ID Metadata Document for "${clientId}"`); + const allowLoopback = this.config.allowLoopback ?? (process.env.NODE_ENV !== 'production'); + const clientDoc = await resolveClientIdMetadataDocument(clientId, { allowLoopback }); + + if (redirectUri && !validateRedirectUriWithCimd(clientDoc, redirectUri)) { + this.sendOAuthError( + res, + 400, + 'invalid_request', + `Requested redirect_uri "${redirectUri}" is not authorized in client metadata document` + ); + return; + } + + const adapter = this.getAdapter(context.authServerUrl); + if (adapter) { + context.logger?.info?.(`JitBridge: Running adapter "${adapter.name}" for client "${clientId}"`); + const regResult = await adapter.registerClient(clientDoc, context, this.config); + if (regResult?.idpClientId) { + this.clientMapping.set(clientId, regResult.idpClientId); + this.reverseMapping.set(regResult.idpClientId, clientId); + // Replace client_id parameter with the upstream IdP registered client ID + searchParams.set('client_id', regResult.idpClientId); + } + } else { + context.logger?.warn?.(`JitBridge: No matching provider adapter found for ${context.authServerUrl}`); + } + } catch (err: any) { + context.logger?.error?.(`JitBridge: Failed to process CIMD for "${clientId}": ${err.message || String(err)}`); + this.sendOAuthError( + res, + 400, + 'invalid_client', + `Could not resolve client metadata document: ${err.message || 'Validation error'}` + ); + return; + } + } else if (redirectUri && (redirectUri.includes('127.0.0.1') || redirectUri.includes('localhost'))) { + // Ephemeral desktop loopback redirect URI (Cursor, Claude Desktop) + const adapter = this.getAdapter(context.authServerUrl); + if (adapter?.registerCallback) { + try { + await adapter.registerCallback(clientId, redirectUri, context, this.config); + } catch (err) { + context.logger?.debug?.('JitBridge: callback registration notice', { error: err }); + } + } + } + + // Map RFC 8707 'resource' parameter to Auth0/IdP 'audience' parameter if missing + if (!searchParams.has('audience')) { + const resource = searchParams.get('resource') || context.resourceUri; + if (resource) { + searchParams.set('audience', resource); + } + } + + // 302 Redirect to upstream Authorization Server /authorize endpoint + const separator = upstreamAuthEndpoint.includes('?') ? '&' : '?'; + const targetRedirectUrl = `${upstreamAuthEndpoint}${separator}${searchParams.toString()}`; + + context.logger?.info?.(`JitBridge: Redirecting client to upstream IdP: ${upstreamAuthEndpoint}`); + + if (typeof res.redirect === 'function') { + res.redirect(302, targetRedirectUrl); + return; + } + + if (typeof res.writeHead === 'function') { + res.writeHead(302, { + Location: targetRedirectUrl, + 'Cache-Control': 'no-store', + 'Pragma': 'no-cache', + ...headers, + }); + res.end(); + return; + } + + if (typeof res.setHeader === 'function') { + res.setHeader('Location', targetRedirectUrl); + res.setHeader('Cache-Control', 'no-store'); + if (typeof res.status === 'function') { + res.status(302).end(); + } + } + } + + /** + * Handle incoming POST /oauth/v2/token request + */ + async handleTokenRequest( + req: any, + res: any, + context: JitContext, + upstreamTokenEndpoint: string + ): Promise { + const headers: Record = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization', + }; + + if (req.method === 'OPTIONS') { + if (typeof res.writeHead === 'function') { + res.writeHead(204, headers); + res.end(); + } else if (typeof res.status === 'function') { + res.status(204).end(); + } + return; + } + + // Read and parse request body (support JSON, urlencoded form, or raw stream) + let bodyObj: Record = {}; + let isJson = false; + + if (req.body && typeof req.body === 'object' && !Buffer.isBuffer(req.body) && Object.keys(req.body).length > 0) { + bodyObj = { ...req.body }; + isJson = req.headers?.['content-type']?.includes('application/json'); + } else { + try { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); + } + if (chunks.length > 0) { + const rawBody = Buffer.concat(chunks).toString('utf-8'); + const contentType = req.headers?.['content-type'] || ''; + if (contentType.includes('application/json')) { + bodyObj = JSON.parse(rawBody); + isJson = true; + } else { + const params = new URLSearchParams(rawBody); + for (const [k, v] of params.entries()) { + bodyObj[k] = v; + } + } + } + } catch (err) { + context.logger?.debug?.('JitBridge: failed to parse token request body', { error: err }); + } + } + + // Merge query parameters if present (fallback for GET/POST query args) + if (req.query && typeof req.query === 'object') { + for (const [k, v] of Object.entries(req.query)) { + if (bodyObj[k] === undefined && v !== undefined) { + bodyObj[k] = Array.isArray(v) ? v[0] : v; + } + } + } + + let authHeader = req.headers?.authorization; + + // Handle Authorization: Basic translation + if (authHeader && typeof authHeader === 'string' && authHeader.toLowerCase().startsWith('basic ')) { + try { + const b64 = authHeader.slice(6).trim(); + const decoded = Buffer.from(b64, 'base64').toString('utf-8'); + const colonIdx = decoded.indexOf(':'); + const rawBasicClientId = colonIdx >= 0 ? decoded.slice(0, colonIdx) : decoded; + const basicSecret = colonIdx >= 0 ? decoded.slice(colonIdx + 1) : ''; + const basicClientId = decodeURIComponent(rawBasicClientId); + + if (basicClientId) { + if (!this.clientMapping.has(basicClientId) && isClientIdMetadataUrl(basicClientId)) { + const allowLoopback = this.config.allowLoopback ?? (process.env.NODE_ENV !== 'production'); + const clientDoc = await resolveClientIdMetadataDocument(basicClientId, { allowLoopback }); + const adapter = this.getAdapter(context.authServerUrl); + if (adapter) { + const regResult = await adapter.registerClient(clientDoc, context, this.config); + if (regResult?.idpClientId) { + this.clientMapping.set(basicClientId, regResult.idpClientId); + this.reverseMapping.set(regResult.idpClientId, basicClientId); + } + } + } + + if (this.clientMapping.has(basicClientId)) { + const mappedBasicId = this.clientMapping.get(basicClientId)!; + context.logger?.info?.(`JitBridge: Mapping Basic Auth client_id "${basicClientId}" -> "${mappedBasicId}"`); + const encodedMapped = `${encodeURIComponent(mappedBasicId)}:${basicSecret}`; + authHeader = `Basic ${Buffer.from(encodedMapped).toString('base64')}`; + } + } + } catch (err) { + context.logger?.debug?.('JitBridge: basic auth translation notice', { error: err }); + } + } + + // If client_id is in the body, resolve and map to upstream IdP client ID + let reqClientId = bodyObj.client_id; + + if (reqClientId && !this.clientMapping.has(reqClientId) && isClientIdMetadataUrl(reqClientId)) { + try { + const allowLoopback = this.config.allowLoopback ?? (process.env.NODE_ENV !== 'production'); + const clientDoc = await resolveClientIdMetadataDocument(reqClientId, { allowLoopback }); + const adapter = this.getAdapter(context.authServerUrl); + if (adapter) { + const regResult = await adapter.registerClient(clientDoc, context, this.config); + if (regResult?.idpClientId) { + this.clientMapping.set(reqClientId, regResult.idpClientId); + this.reverseMapping.set(regResult.idpClientId, reqClientId); + } + } + } catch (err) { + context.logger?.debug?.('JitBridge: on-the-fly token CIMD lookup notice', { error: err }); + } + } + + if (reqClientId && this.clientMapping.has(reqClientId)) { + const mappedId = this.clientMapping.get(reqClientId)!; + context.logger?.info?.(`JitBridge: Mapping token client_id "${reqClientId}" -> "${mappedId}"`); + bodyObj.client_id = mappedId; + } + + // Prepare upstream fetch body + let upstreamBody: string; + let upstreamContentType: string; + + if (isJson) { + upstreamBody = JSON.stringify(bodyObj); + upstreamContentType = 'application/json'; + } else { + const params = new URLSearchParams(); + for (const [k, v] of Object.entries(bodyObj)) { + if (v !== undefined && v !== null) { + params.append(k, String(v)); + } + } + upstreamBody = params.toString(); + upstreamContentType = 'application/x-www-form-urlencoded'; + } + + try { + context.logger?.info?.(`JitBridge: Forwarding token exchange to upstream: ${upstreamTokenEndpoint}`); + const upstreamRes = await fetch(upstreamTokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': upstreamContentType, + Accept: 'application/json', + ...(authHeader ? { Authorization: authHeader } : {}), + }, + body: upstreamBody, + }); + + const responseText = await upstreamRes.text(); + const responseContentType = upstreamRes.headers?.get?.('content-type') || 'application/json'; + + if (typeof res.writeHead === 'function') { + res.writeHead(upstreamRes.status, { + 'Content-Type': responseContentType, + 'Cache-Control': 'no-store', + 'Pragma': 'no-cache', + ...headers, + }); + res.end(responseText); + return; + } + + if (typeof res.status === 'function') { + res.status(upstreamRes.status); + res.set?.('Content-Type', responseContentType); + res.set?.('Cache-Control', 'no-store'); + res.send(responseText); + return; + } + + if (typeof res.end === 'function') { + res.end(responseText); + } + } catch (err: any) { + context.logger?.error?.('JitBridge: Failed to forward token request to upstream', { error: err }); + this.sendOAuthError(res, 502, 'server_error', `Upstream token endpoint failed: ${err.message || String(err)}`); + } + } + + private sendOAuthError(res: any, status: number, error: string, description: string): void { + const payload = JSON.stringify({ error, error_description: description }); + + if (typeof res.writeHead === 'function') { + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'Access-Control-Allow-Origin': '*', + }); + res.end(payload); + return; + } + + if (typeof res.status === 'function') { + res.status(status); + if (typeof res.send === 'function') { + res.send(payload); + return; + } + if (typeof res.json === 'function') { + res.json({ error, error_description: description }); + return; + } + } + + if (typeof res.end === 'function') { + res.end(payload); + } + } +} diff --git a/typescript/packages/core/src/auth/jit/types.ts b/typescript/packages/core/src/auth/jit/types.ts new file mode 100644 index 000000000..5fce31181 --- /dev/null +++ b/typescript/packages/core/src/auth/jit/types.ts @@ -0,0 +1,147 @@ +import { ClientIdMetadataDocument } from '../cimd.js'; + +/** + * Configuration options for the Multi-Provider JIT Dynamic Discovery Bridge + */ +export interface JitBridgeConfig { + /** + * Whether the JIT bridge is enabled. + * Defaults to true if management credentials or JIT env vars are detected. + */ + enabled?: boolean; + + /** + * Target Identity Provider adapter type. + * - 'auto': Auto-detect based on auth server domain and available credentials. + * - 'auth0': Auth0 Management API adapter. + * - 'okta': Okta Dynamic Client Registration / Apps API adapter. + * - 'keycloak': Keycloak OpenID Connect registration service. + * - 'generic-dcr': Standard RFC 7591 Dynamic Client Registration (Zitadel, Hydra, etc.). + * - 'passthrough': Passthrough for native CIMD authorization servers (Stytch, etc.). + */ + provider?: 'auto' | 'auth0' | 'okta' | 'keycloak' | 'generic-dcr' | 'passthrough'; + + /** + * Custom path for the JIT authorization proxy endpoint. + * Defaults to '/oauth/v2/authorize'. + */ + bridgePath?: string; + + /** + * Custom path for the JIT token proxy endpoint. + * Defaults to '/oauth/v2/token'. + */ + bridgeTokenPath?: string; + + /** + * Auth0 specific management credentials + */ + auth0?: { + domain?: string; + managementClientId?: string; + managementClientSecret?: string; + audience?: string; + }; + + /** + * Okta specific credentials + */ + okta?: { + domain?: string; + apiToken?: string; + }; + + /** + * Generic RFC 7591 / Keycloak / Zitadel DCR configuration + */ + genericDcr?: { + registrationEndpoint?: string; + initialAccessToken?: string; + }; + + /** + * In-memory cache TTL for registered clients in milliseconds. + * Default: 24 hours (86,400,000 ms). + */ + cacheTtlMs?: number; + + /** + * Allow loopback HTTP URLs for client metadata (dev mode). Default: true. + */ + allowLoopback?: boolean; +} + +/** + * Result returned from a provider adapter after client registration + */ +export interface JitClientRegistrationResult { + /** + * The client ID in the upstream IdP (e.g. Auth0 client_id 'abc123xyz'). + * If not provided, defaults to the external client_id (CIMD URL). + */ + idpClientId?: string; + + /** + * Client secret if provisioned (for confidential clients) + */ + clientSecret?: string; +} + +/** + * Context passed to JIT provider adapters during client registration + */ +export interface JitContext { + /** + * Primary upstream authorization server URL (e.g., 'https://dev-xxx.us.auth0.com') + */ + authServerUrl: string; + + /** + * The MCP server resource URI (RFC 8707 audience identifier) + */ + resourceUri?: string; + + /** + * Scopes supported by the MCP server (to attach to client grants) + */ + scopesSupported?: string[]; + + /** + * Logger instance for debug/info logging + */ + logger?: { + info(message: string, meta?: any): void; + warn(message: string, meta?: any): void; + error(message: string, meta?: any): void; + debug(message: string, meta?: any): void; + }; +} + +/** + * Pluggable Identity Provider Adapter interface + */ +export interface JitProviderAdapter { + /** + * Unique name of the provider adapter + */ + readonly name: string; + + /** + * Whether this adapter can handle the given authorization server and configuration + */ + canHandle(authServerUrl: string, config: JitBridgeConfig): boolean; + + /** + * Register or ensure a client exists in the upstream IdP with proper callback URIs and API grants + */ + registerClient( + clientDoc: ClientIdMetadataDocument, + context: JitContext, + config: JitBridgeConfig + ): Promise; + + /** + * Ensure a dynamic callback URI (e.g. desktop loopback port) is allowed for an existing client + */ + registerCallback?(clientId: string, redirectUri: string, context: JitContext, config: JitBridgeConfig): Promise; +} diff --git a/typescript/packages/core/src/core/__tests__/oauth.extended.test.ts b/typescript/packages/core/src/core/__tests__/oauth.extended.test.ts index af8f35e55..c6dc0763e 100644 --- a/typescript/packages/core/src/core/__tests__/oauth.extended.test.ts +++ b/typescript/packages/core/src/core/__tests__/oauth.extended.test.ts @@ -42,7 +42,7 @@ describe('OAuthModule Extended Tests', () => { await module.start(); expect(startSpy).toHaveBeenCalled(); - expect(onSpy).toHaveBeenCalledTimes(2); // .well-known endpoints + expect(onSpy).toHaveBeenCalledTimes(4); // .well-known endpoints await module.stop(); }); diff --git a/typescript/packages/core/src/core/oauth-module.ts b/typescript/packages/core/src/core/oauth-module.ts index 3af896eb6..f45b0ef60 100644 --- a/typescript/packages/core/src/core/oauth-module.ts +++ b/typescript/packages/core/src/core/oauth-module.ts @@ -137,6 +137,13 @@ export interface OAuthModuleConfig { * verifier is configured. */ required?: boolean; + + /** + * Multi-Provider Just-in-Time (JIT) Dynamic Discovery Bridge configuration. + * Auto-provisions dynamic AI agent clients (ChatGPT, Claude, Cursor) in the + * upstream Identity Provider (Auth0, Okta, Zitadel, Keycloak). + */ + jitBridge?: JitBridgeConfig; } /** @@ -185,6 +192,7 @@ import { Logger } from './types.js'; import { DiscoveryHttpServer, DiscoveryServerOptions } from './transports/discovery-http-server.js'; import { createAuthMiddleware } from '../auth/middleware.js'; import { validateToken as authValidateToken } from '../auth/token-validation.js'; +import { JitBridge, JitBridgeConfig, JitContext } from '../auth/jit/index.js'; /** * OAuth discovery info that can be communicated to clients @@ -323,13 +331,28 @@ export class OAuthModule { ? `${this.buildBaseUrl(req)}/oauth/v2/register` : undefined; + const authServer = this.config.authorizationServers[0]; + const isJitEnabled = this.jitBridge?.isEnabled(authServer); + const bridgeAuthEndpoint = isJitEnabled + ? `${this.buildBaseUrl(req)}${this.config.jitBridge?.bridgePath || '/oauth/v2/authorize'}` + : undefined; + const bridgeTokenEndpoint = isJitEnabled + ? `${this.buildBaseUrl(req)}${this.config.jitBridge?.bridgeTokenPath || '/oauth/v2/token'}` + : undefined; + try { - const authServer = this.config.authorizationServers[0]; const upstream = await this.fetchUpstreamMetadata(authServer); if (upstream) { // Clone before mutating so the cached object stays pristine const metadata = { ...upstream }; + // If JIT bridge is enabled, route authorization and token exchange through the bridge proxy + if (bridgeAuthEndpoint) { + metadata.authorization_endpoint = bridgeAuthEndpoint; + } + if (bridgeTokenEndpoint) { + metadata.token_endpoint = bridgeTokenEndpoint; + } // Inject registration_endpoint to satisfy strict client schema validation (Cursor/OpenAI) if (registrationEndpoint && !metadata.registration_endpoint) { metadata.registration_endpoint = registrationEndpoint; @@ -338,6 +361,9 @@ export class OAuthModule { if (metadata.client_id_metadata_document_supported === undefined) { metadata.client_id_metadata_document_supported = true; } + if (metadata.client_id_metadata_document_supported_auth_methods === undefined) { + metadata.client_id_metadata_document_supported_auth_methods = ['none']; + } res.writeHead(200, headers); res.end(JSON.stringify(metadata)); return; @@ -351,8 +377,8 @@ export class OAuthModule { // Fallback compliant with RFC 8414 / OIDC metadata schema & CIMD const fallbackMetadata: Record = { issuer: this.config.issuer || this.config.authorizationServers[0], - authorization_endpoint: `${this.config.authorizationServers[0]}/oauth/v2/authorize`, - token_endpoint: `${this.config.authorizationServers[0]}/oauth/v2/token`, + authorization_endpoint: bridgeAuthEndpoint || `${this.config.authorizationServers[0]}/oauth/v2/authorize`, + token_endpoint: bridgeTokenEndpoint || `${this.config.authorizationServers[0]}/oauth/v2/token`, introspection_endpoint: this.config.tokenIntrospectionEndpoint || `${this.config.authorizationServers[0]}/oauth/v2/introspect`, jwks_uri: this.config.jwksUri || `${this.config.authorizationServers[0]}/oauth/v2/keys`, response_types_supported: ['code'], @@ -371,6 +397,88 @@ export class OAuthModule { res.end(JSON.stringify(fallbackMetadata)); }; + /** + * Handle incoming authorization requests via the JIT bridge proxy + */ + private authorizeHandler = async (req: any, res: any) => { + if (!this.jitBridge) { + if (typeof res.writeHead === 'function') { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not_found', error_description: 'JIT Bridge is not enabled' })); + } else if (typeof res.status === 'function') { + res.status(404).json({ error: 'not_found', error_description: 'JIT Bridge is not enabled' }); + } + return; + } + + const authServer = this.config.authorizationServers[0]; + let upstreamAuthEndpoint = `${authServer}/oauth/v2/authorize`; + try { + const upstream = await this.fetchUpstreamMetadata(authServer); + if (upstream?.authorization_endpoint && typeof upstream.authorization_endpoint === 'string') { + upstreamAuthEndpoint = upstream.authorization_endpoint; + } + } catch (e) { + this.logger.debug('OAuthModule: failed to fetch upstream authorization_endpoint for JIT redirect', { + error: e instanceof Error ? e.message : String(e), + }); + } + + const envScopes = (process.env.COGNERD_SCOPE || process.env.AUTH_SCOPES || '') + .split(/[\s,]+/) + .filter(Boolean); + + const resolvedScopes = envScopes.length > 0 + ? envScopes + : (this.config.scopesSupported || []); + + const context: JitContext = { + authServerUrl: authServer, + resourceUri: this.config.resourceUri, + scopesSupported: resolvedScopes, + logger: this.logger, + }; + + await this.jitBridge.handleAuthorizeRequest(req, res, context, upstreamAuthEndpoint); + }; + + /** + * Handle incoming token exchange requests via the JIT bridge proxy + */ + private tokenHandler = async (req: any, res: any) => { + if (!this.jitBridge) { + if (typeof res.writeHead === 'function') { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not_found', error_description: 'JIT Bridge is not enabled' })); + } else if (typeof res.status === 'function') { + res.status(404).json({ error: 'not_found', error_description: 'JIT Bridge is not enabled' }); + } + return; + } + + const authServer = this.config.authorizationServers[0]; + let upstreamTokenEndpoint = `${authServer}/oauth/v2/token`; + try { + const upstream = await this.fetchUpstreamMetadata(authServer); + if (upstream?.token_endpoint && typeof upstream.token_endpoint === 'string') { + upstreamTokenEndpoint = upstream.token_endpoint; + } + } catch (e) { + this.logger.debug('OAuthModule: failed to fetch upstream token_endpoint for JIT proxy', { + error: e instanceof Error ? e.message : String(e), + }); + } + + const context: JitContext = { + authServerUrl: authServer, + resourceUri: this.config.resourceUri, + scopesSupported: this.config.scopesSupported || [], + logger: this.logger, + }; + + await this.jitBridge.handleTokenRequest(req, res, context, upstreamTokenEndpoint); + }; + /** * Whether the static Dynamic Client Registration endpoint is enabled. * Requires explicit opt-in AND a configured client id (never a literal default). @@ -461,10 +569,19 @@ export class OAuthModule { return; } + const authServer = this.config.authorizationServers[0]; + const isJitEnabled = this.jitBridge?.isEnabled(authServer); + + // If JIT bridge is enabled, advertise this server's base URL as the authorization server + // so clients query our /.well-known/oauth-authorization-server gateway endpoint + const advertisedAuthServers = isJitEnabled + ? [this.buildBaseUrl(req)] + : this.config.authorizationServers; + // RFC 9728 - Protected Resource Metadata format const metadata: { resource: string; authorization_servers: string[]; scopes_supported?: string[] } = { resource: this.config.resourceUri, - authorization_servers: this.config.authorizationServers, + authorization_servers: advertisedAuthServers, }; // Add optional fields @@ -484,12 +601,15 @@ export class OAuthModule { res.end(JSON.stringify(metadata)); }; + private jitBridge: JitBridge | null = null; + constructor( @Inject('OAUTH_CONFIG') private config: OAuthModuleConfig, private server: NitroStackServer, @Inject('Logger') private logger: Logger ) { OAuthModule.config = config; + this.jitBridge = new JitBridge(this.config.jitBridge); } public onModuleInit() { @@ -672,10 +792,37 @@ export class OAuthModule { private registerDiscoveryHandlers(server: DiscoveryHttpServer | { on: (path: string, handler: unknown) => void }) { server.on('/.well-known/oauth-authorization-server', this.wellKnownHandler); + server.on('/.well-known/openid-configuration', this.wellKnownHandler); server.on('/.well-known/oauth-protected-resource', this.resourceMetadataHandler); + server.on('/.well-known/oauth-protected-resource/mcp', this.resourceMetadataHandler); + if (this.config.http?.basePath && this.config.http.basePath !== '/mcp') { + server.on(`/.well-known/oauth-protected-resource${this.config.http.basePath}`, this.resourceMetadataHandler); + } if (this.isClientRegistrationEnabled()) { server.on('/oauth/v2/register', this.registrationHandler); } + + const authServer = this.config.authorizationServers?.[0]; + if (this.jitBridge?.isEnabled(authServer)) { + const bridgeAuthPath = this.config.jitBridge?.bridgePath || '/oauth/v2/authorize'; + const bridgeTokenPath = this.config.jitBridge?.bridgeTokenPath || '/oauth/v2/token'; + + server.on(bridgeAuthPath, this.authorizeHandler); + if (bridgeAuthPath !== '/oauth/authorize') { + server.on('/oauth/authorize', this.authorizeHandler); + } + if (bridgeAuthPath !== '/oauth/v2/authorize') { + server.on('/oauth/v2/authorize', this.authorizeHandler); + } + + server.on(bridgeTokenPath, this.tokenHandler); + if (bridgeTokenPath !== '/oauth/token') { + server.on('/oauth/token', this.tokenHandler); + } + if (bridgeTokenPath !== '/oauth/v2/token') { + server.on('/oauth/v2/token', this.tokenHandler); + } + } } /** @@ -727,6 +874,84 @@ export class OAuthModule { } } + // Auto-detect / configure Multi-Provider JIT Dynamic Discovery Bridge + if (!resolved.jitBridge) { + const isExplicitJit = process.env.JIT_BRIDGE_ENABLED === 'true' || process.env.OAUTH_JIT_BRIDGE_ENABLED === 'true'; + const hasAuth0 = !!(process.env.AUTH0_MANAGEMENT_CLIENT_ID && process.env.AUTH0_MANAGEMENT_CLIENT_SECRET); + const hasOkta = !!(process.env.OKTA_API_TOKEN && (process.env.OKTA_DOMAIN || resolved.authorizationServers[0])); + const hasDcr = !!process.env.DCR_REGISTRATION_ENDPOINT; + + if (isExplicitJit || hasAuth0 || hasOkta || hasDcr) { + const provider = (process.env.JIT_PROVIDER as any) || (hasAuth0 ? 'auth0' : hasOkta ? 'okta' : hasDcr ? 'generic-dcr' : 'generic-dcr'); + resolved.jitBridge = { + enabled: process.env.JIT_BRIDGE_ENABLED !== 'false' && process.env.OAUTH_JIT_BRIDGE_ENABLED !== 'false', + provider, + bridgePath: process.env.JIT_BRIDGE_PATH || '/oauth/v2/authorize', + bridgeTokenPath: process.env.JIT_BRIDGE_TOKEN_PATH || '/oauth/v2/token', + }; + } + } + + if (resolved.jitBridge) { + if (resolved.jitBridge.enabled === undefined) { + if (process.env.JIT_BRIDGE_ENABLED !== undefined) { + resolved.jitBridge.enabled = process.env.JIT_BRIDGE_ENABLED !== 'false'; + } else if (process.env.OAUTH_JIT_BRIDGE_ENABLED !== undefined) { + resolved.jitBridge.enabled = process.env.OAUTH_JIT_BRIDGE_ENABLED !== 'false'; + } + } + if (!resolved.jitBridge.provider && process.env.JIT_PROVIDER) { + resolved.jitBridge.provider = process.env.JIT_PROVIDER as any; + } + if (!resolved.jitBridge.bridgePath && process.env.JIT_BRIDGE_PATH) { + resolved.jitBridge.bridgePath = process.env.JIT_BRIDGE_PATH; + } + if (!resolved.jitBridge.bridgeTokenPath && process.env.JIT_BRIDGE_TOKEN_PATH) { + resolved.jitBridge.bridgeTokenPath = process.env.JIT_BRIDGE_TOKEN_PATH; + } + + // Auth0 env vars + if (process.env.AUTH0_MANAGEMENT_CLIENT_ID && process.env.AUTH0_MANAGEMENT_CLIENT_SECRET) { + let auth0Domain = process.env.AUTH0_DOMAIN; + if (!auth0Domain && resolved.authorizationServers[0]) { + try { + auth0Domain = new URL(resolved.authorizationServers[0]).hostname; + } catch {} + } + resolved.jitBridge.auth0 = { + domain: auth0Domain, + managementClientId: process.env.AUTH0_MANAGEMENT_CLIENT_ID, + managementClientSecret: process.env.AUTH0_MANAGEMENT_CLIENT_SECRET, + audience: process.env.AUTH0_AUDIENCE || process.env.RESOURCE_URI || resolved.resourceUri, + ...resolved.jitBridge.auth0, + }; + } + + // Okta env vars + if (process.env.OKTA_API_TOKEN) { + let oktaDomain = process.env.OKTA_DOMAIN; + if (!oktaDomain && resolved.authorizationServers[0]) { + try { + oktaDomain = new URL(resolved.authorizationServers[0]).hostname; + } catch {} + } + resolved.jitBridge.okta = { + domain: oktaDomain, + apiToken: process.env.OKTA_API_TOKEN, + ...resolved.jitBridge.okta, + }; + } + + // Generic DCR env vars + if (process.env.DCR_REGISTRATION_ENDPOINT) { + resolved.jitBridge.genericDcr = { + registrationEndpoint: process.env.DCR_REGISTRATION_ENDPOINT, + initialAccessToken: process.env.DCR_INITIAL_ACCESS_TOKEN, + ...resolved.jitBridge.genericDcr, + }; + } + } + this.config = resolved; return { diff --git a/typescript/packages/core/src/core/transports/__tests__/transports.test.ts b/typescript/packages/core/src/core/transports/__tests__/transports.test.ts index 9c7de8157..12d0c1549 100644 --- a/typescript/packages/core/src/core/transports/__tests__/transports.test.ts +++ b/typescript/packages/core/src/core/transports/__tests__/transports.test.ts @@ -19,6 +19,7 @@ const mockApp = { }; const mockExpress = jest.fn(() => mockApp); (mockExpress as any).json = jest.fn(); +(mockExpress as any).urlencoded = jest.fn(); jest.unstable_mockModule('express', () => ({ default: mockExpress, diff --git a/typescript/packages/core/src/core/transports/discovery-http-server.ts b/typescript/packages/core/src/core/transports/discovery-http-server.ts index a0ebb0b44..2ca9bb7cd 100644 --- a/typescript/packages/core/src/core/transports/discovery-http-server.ts +++ b/typescript/packages/core/src/core/transports/discovery-http-server.ts @@ -71,7 +71,8 @@ export class DiscoveryHttpServer { private handleRequest(req: http.IncomingMessage, res: http.ServerResponse) { this.logger.info(`DiscoveryHttpServer: Received request for ${req.url}`); - const handler = this.handlers.get(req.url || ''); + const pathname = (req.url || '').split('?')[0]; + const handler = this.handlers.get(pathname) || this.handlers.get(req.url || ''); if (handler) { handler(req, res); } else { diff --git a/typescript/packages/core/src/core/transports/http-server.ts b/typescript/packages/core/src/core/transports/http-server.ts index 24b263453..07f721cd6 100644 --- a/typescript/packages/core/src/core/transports/http-server.ts +++ b/typescript/packages/core/src/core/transports/http-server.ts @@ -92,8 +92,13 @@ export class HttpServerTransport implements Transport { } }); - // JSON parsing - this.app.use(express.json()); + // JSON and URL-encoded form parsing + if (typeof express.json === 'function') { + this.app.use(express.json()); + } + if (typeof express.urlencoded === 'function') { + this.app.use(express.urlencoded({ extended: true })); + } // SSE endpoint for server-to-client messages this.app.get(`${basePath}/sse`, (req: Request, res: Response) => { @@ -197,9 +202,36 @@ export class HttpServerTransport implements Transport { return; } + const isJitEnabled = + process.env.JIT_BRIDGE_ENABLED === 'true' || + process.env.OAUTH_JIT_BRIDGE_ENABLED === 'true' || + Boolean(process.env.AUTH0_MANAGEMENT_CLIENT_ID && process.env.AUTH0_MANAGEMENT_CLIENT_SECRET) || + Boolean(process.env.OKTA_API_TOKEN); + + let baseUrl = ''; + if (this.options.oauth.resourceUri) { + try { + baseUrl = new URL(this.options.oauth.resourceUri).origin; + } catch {} + } + if (!baseUrl) { + const rawHost = req.headers.host || 'localhost:3000'; + const host = Array.isArray(rawHost) ? rawHost[0] : rawHost; + const rawProto = req.headers['x-forwarded-proto']; + let proto = Array.isArray(rawProto) ? rawProto[0] : rawProto; + if (!proto) { + proto = host.includes('localhost') || host.includes('127.0.0.1') ? 'http' : 'https'; + } + baseUrl = `${proto}://${host}`; + } + + const authServers = isJitEnabled + ? [baseUrl] + : this.options.oauth.authorizationServers; + const metadata = { resource: this.options.oauth.resourceUri, - authorization_servers: this.options.oauth.authorizationServers, + authorization_servers: authServers, ...(this.options.oauth.scopesSupported && { scopes_supported: this.options.oauth.scopesSupported, }), @@ -362,6 +394,8 @@ export class HttpServerTransport implements Transport { */ on(path: string, handler: (req: Request, res: Response) => void): void { this.app.get(path, handler); + this.app.post(path, handler); + this.app.options(path, handler); } } diff --git a/typescript/packages/core/src/core/transports/streamable-http.ts b/typescript/packages/core/src/core/transports/streamable-http.ts index 7f51d8e2d..00b570164 100644 --- a/typescript/packages/core/src/core/transports/streamable-http.ts +++ b/typescript/packages/core/src/core/transports/streamable-http.ts @@ -294,8 +294,13 @@ export class StreamableHttpTransport { }); } - // JSON parsing - this.app.use(express.json()); + // JSON and URL-encoded form parsing + if (typeof express.json === 'function') { + this.app.use(express.json()); + } + if (typeof express.urlencoded === 'function') { + this.app.use(express.urlencoded({ extended: true })); + } } /** From f49b5f9348eb6ac2ed0063205b52740b3f4ce202 Mon Sep 17 00:00:00 2001 From: Hemant Jadhav Date: Mon, 7 Sep 2026 03:17:47 +0530 Subject: [PATCH 4/4] refactor: implement LRU caching and request deduplication for JIT client registration across adapters --- .../core/src/auth/__tests__/cimd-jit.test.ts | 21 ++ .../src/auth/jit/__tests__/jit-bridge.test.ts | 187 ++++++++++++++++++ .../src/auth/jit/adapters/auth0.adapter.ts | 14 +- .../auth/jit/adapters/generic-dcr.adapter.ts | 41 ++-- .../src/auth/jit/adapters/okta.adapter.ts | 41 ++-- .../packages/core/src/auth/jit/jit-bridge.ts | 114 ++++++++--- .../packages/core/src/core/oauth-module.ts | 3 + .../core/src/core/transports/http-server.ts | 3 + 8 files changed, 363 insertions(+), 61 deletions(-) diff --git a/typescript/packages/core/src/auth/__tests__/cimd-jit.test.ts b/typescript/packages/core/src/auth/__tests__/cimd-jit.test.ts index d54e85ef2..3203adae8 100644 --- a/typescript/packages/core/src/auth/__tests__/cimd-jit.test.ts +++ b/typescript/packages/core/src/auth/__tests__/cimd-jit.test.ts @@ -397,4 +397,25 @@ describe('CIMD Method 1: Just-in-Time Dynamic Discovery', () => { } }); }); + + describe('Multi-hop Proxy Header Parsing in Discovery', () => { + it('properly sanitizes comma-separated X-Forwarded-Proto header', () => { + const origEnv = process.env.NODE_ENV; + try { + process.env.NODE_ENV = 'development'; + // Test helper simulating buildBaseUrl logic + const rawProto = 'https,http'; + let proto = Array.isArray(rawProto) ? rawProto[0] : rawProto; + if (typeof proto === 'string') { + proto = proto.split(',')[0].trim(); + } + expect(proto).toBe('https'); + const host = 'gateway.nitrostack.ai'; + const baseUrl = `${proto}://${host}`; + expect(baseUrl).toBe('https://gateway.nitrostack.ai'); + } finally { + process.env.NODE_ENV = origEnv; + } + }); + }); }); diff --git a/typescript/packages/core/src/auth/jit/__tests__/jit-bridge.test.ts b/typescript/packages/core/src/auth/jit/__tests__/jit-bridge.test.ts index e43d6ada1..eb38c63c5 100644 --- a/typescript/packages/core/src/auth/jit/__tests__/jit-bridge.test.ts +++ b/typescript/packages/core/src/auth/jit/__tests__/jit-bridge.test.ts @@ -417,6 +417,193 @@ describe('Multi-Provider JIT Dynamic Discovery Bridge', () => { const responseData = JSON.parse(sentBody); expect(responseData.access_token).toBe('jwt_access_token_xyz'); }); + + it('deduplicates simultaneous concurrent registrations for the same CIMD URL', async () => { + const bridge = new JitBridge(); + const mockAdapter = { + name: 'mock-slow-adapter', + canHandle: () => true, + registerClient: jest.fn().mockImplementation(async () => { + await new Promise((r) => setTimeout(r, 20)); + return { idpClientId: 'provisioned_id_123' }; + }), + }; + bridge.registerAdapter(mockAdapter); + + const context: JitContext = { + authServerUrl: 'https://tenant.example.com', + }; + + const clientDoc = { + client_id: 'https://chatgpt.com/oauth/concurrent/client.json', + client_name: 'Concurrent Agent', + redirect_uris: ['https://chatgpt.com/callback'], + }; + + // Trigger two concurrent ensureClientRegistered calls via handleAuthorizeRequest or internal method + const p1 = (bridge as any).ensureClientRegistered(clientDoc, context); + const p2 = (bridge as any).ensureClientRegistered(clientDoc, context); + + const [res1, res2] = await Promise.all([p1, p2]); + expect(res1?.idpClientId).toBe('provisioned_id_123'); + expect(res2?.idpClientId).toBe('provisioned_id_123'); + // The adapter registerClient should have been called only once + expect(mockAdapter.registerClient).toHaveBeenCalledTimes(1); + }); + + it('rejects authorize request when redirect_uri is omitted and client has multiple callbacks', async () => { + const bridge = new JitBridge(); + const context: JitContext = { authServerUrl: 'https://tenant.example.com' }; + + const cimdDoc = { + client_id: 'https://chatgpt.com/oauth/multi-cb/client.json', + redirect_uris: ['https://chatgpt.com/cb1', 'https://chatgpt.com/cb2'], + }; + + mockFetch.mockResolvedValueOnce({ + status: 200, + ok: true, + headers: { get: () => 'application/json' }, + json: async () => cimdDoc, + text: async () => JSON.stringify(cimdDoc), + }); + + const mockReq = { + method: 'GET', + query: { + client_id: 'https://chatgpt.com/oauth/multi-cb/client.json', + }, + }; + + let responseStatus = 0; + let responseBody = ''; + const mockRes = { + writeHead: (status: number) => { responseStatus = status; }, + end: (body: string) => { responseBody = body; }, + }; + + await bridge.handleAuthorizeRequest( + mockReq, + mockRes, + context, + 'https://tenant.example.com/authorize' + ); + + expect(responseStatus).toBe(400); + const parsed = JSON.parse(responseBody); + expect(parsed.error).toBe('invalid_request'); + expect(parsed.error_description).toContain('Parameter "redirect_uri" is required'); + }); + + it('defaults redirect_uri when omitted and client metadata specifies a single callback', async () => { + const bridge = new JitBridge(); + const mockAdapter = { + name: 'mock-pass', + canHandle: () => true, + registerClient: jest.fn().mockResolvedValue({ idpClientId: 'single_cb_id' }), + }; + bridge.registerAdapter(mockAdapter); + + const context: JitContext = { authServerUrl: 'https://tenant.example.com' }; + const cimdDoc = { + client_id: 'https://chatgpt.com/oauth/single-cb/client.json', + redirect_uris: ['https://chatgpt.com/sole-callback'], + }; + + mockFetch.mockResolvedValueOnce({ + status: 200, + ok: true, + headers: { get: () => 'application/json' }, + json: async () => cimdDoc, + text: async () => JSON.stringify(cimdDoc), + }); + + const mockReq = { + method: 'GET', + query: { + client_id: 'https://chatgpt.com/oauth/single-cb/client.json', + }, + }; + + let redirectLocation = ''; + const mockRes = { + writeHead: (_status: number, headers: Record) => { + redirectLocation = headers['Location'] || ''; + }, + end: jest.fn(), + }; + + await bridge.handleAuthorizeRequest( + mockReq, + mockRes, + context, + 'https://tenant.example.com/authorize' + ); + + expect(redirectLocation).toContain('redirect_uri=https%3A%2F%2Fchatgpt.com%2Fsole-callback'); + }); + + it('evicts oldest mapping when clientMapping exceeds max capacity', () => { + const bridge = new JitBridge(); + (bridge as any).maxCacheEntries = 2; + + (bridge as any).setMapping('https://app1.com/client.json', 'idp_1'); + (bridge as any).setMapping('https://app2.com/client.json', 'idp_2'); + expect((bridge as any).clientMapping.size).toBe(2); + + // Third entry should evict oldest (app1) + (bridge as any).setMapping('https://app3.com/client.json', 'idp_3'); + expect((bridge as any).clientMapping.size).toBe(2); + expect((bridge as any).clientMapping.has('https://app1.com/client.json')).toBe(false); + expect((bridge as any).clientMapping.get('https://app2.com/client.json')).toBe('idp_2'); + expect((bridge as any).clientMapping.get('https://app3.com/client.json')).toBe('idp_3'); + }); + + it('throws error when Generic DCR registration fails', async () => { + const adapter = new GenericDcrJitAdapter(); + const context: JitContext = { authServerUrl: 'https://auth.example.com' }; + const config = { + genericDcr: { registrationEndpoint: 'https://auth.example.com/oauth/register' }, + }; + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + text: async () => 'Invalid client registration payload', + }); + + const clientDoc = { + client_id: 'https://agent.example.com/client.json', + redirect_uris: ['https://agent.example.com/cb'], + }; + + await expect(adapter.registerClient(clientDoc, context, config)).rejects.toThrow( + 'GenericDcrJitAdapter: Registration failed on https://auth.example.com/oauth/register: HTTP 400 - Invalid client registration payload' + ); + }); + + it('throws error when Okta DCR registration fails', async () => { + const adapter = new OktaJitAdapter(); + const context: JitContext = { authServerUrl: 'https://tenant.okta.com' }; + const config = { + okta: { domain: 'tenant.okta.com', apiToken: 'secret_token' }, + }; + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: async () => 'Unauthorized SSWS token', + }); + + const clientDoc = { + client_id: 'https://agent.example.com/client.json', + redirect_uris: ['https://agent.example.com/cb'], + }; + + await expect(adapter.registerClient(clientDoc, context, config)).rejects.toThrow( + 'OktaJitAdapter: DCR registration failed on https://tenant.okta.com/oauth2/v1/clients: HTTP 401 - Unauthorized SSWS token' + ); + }); }); describe('OAuthModule Integration with JIT Bridge', () => { diff --git a/typescript/packages/core/src/auth/jit/adapters/auth0.adapter.ts b/typescript/packages/core/src/auth/jit/adapters/auth0.adapter.ts index 5814ffc1f..f1eb14eca 100644 --- a/typescript/packages/core/src/auth/jit/adapters/auth0.adapter.ts +++ b/typescript/packages/core/src/auth/jit/adapters/auth0.adapter.ts @@ -24,9 +24,11 @@ export class Auth0JitAdapter implements JitProviderAdapter { private tokenCache: Auth0TokenCache | null = null; private clientCache = new Map(); private defaultCacheTtlMs: number; + private readonly maxEntries: number; - constructor(options?: { defaultCacheTtlMs?: number }) { + constructor(options?: { defaultCacheTtlMs?: number; maxEntries?: number }) { this.defaultCacheTtlMs = options?.defaultCacheTtlMs ?? 24 * 60 * 60 * 1000; // 24 hours + this.maxEntries = options?.maxEntries ?? 1000; } canHandle(authServerUrl: string, config: JitBridgeConfig): boolean { @@ -149,7 +151,7 @@ export class Auth0JitAdapter implements JitProviderAdapter { try { const searchRes = await fetch( - `https://${domain}/api/v2/clients?fields=client_id,name,callbacks,client_metadata&include_fields=true`, + `https://${domain}/api/v2/clients?fields=client_id,name,callbacks,client_metadata&include_fields=true&per_page=100`, { method: 'GET', headers: { @@ -277,7 +279,13 @@ export class Auth0JitAdapter implements JitProviderAdapter { } } - // Cache registration + // Cache registration with LRU eviction + if (this.clientCache.size >= this.maxEntries && !this.clientCache.has(externalId)) { + const oldestKey = this.clientCache.keys().next().value; + if (oldestKey !== undefined) { + this.clientCache.delete(oldestKey); + } + } const ttl = config.cacheTtlMs ?? this.defaultCacheTtlMs; this.clientCache.set(externalId, { auth0ClientId, diff --git a/typescript/packages/core/src/auth/jit/adapters/generic-dcr.adapter.ts b/typescript/packages/core/src/auth/jit/adapters/generic-dcr.adapter.ts index 501fba499..02e61dc0a 100644 --- a/typescript/packages/core/src/auth/jit/adapters/generic-dcr.adapter.ts +++ b/typescript/packages/core/src/auth/jit/adapters/generic-dcr.adapter.ts @@ -11,6 +11,11 @@ export class GenericDcrJitAdapter implements JitProviderAdapter { readonly name = 'generic-dcr'; private clientCache = new Map(); + private readonly maxEntries: number; + + constructor(options?: { maxEntries?: number }) { + this.maxEntries = options?.maxEntries ?? 1000; + } canHandle(authServerUrl: string, config: JitBridgeConfig): boolean { if (config.provider === 'generic-dcr' || config.provider === 'keycloak') return true; @@ -98,24 +103,32 @@ export class GenericDcrJitAdapter implements JitProviderAdapter { body: JSON.stringify(payload), }); - let result: JitClientRegistrationResult = { idpClientId: externalId }; - if (!response.ok && response.status !== 409) { const err = await response.text().catch(() => ''); - context.logger?.warn?.(`GenericDcrJitAdapter: Registration notice: ${response.status} - ${err}`); - } else { - context.logger?.info?.(`GenericDcrJitAdapter: Registered client "${externalId}" on ${regEndpoint}`); - if (response.ok) { - try { - const data = (await response.json()) as { client_id?: string; client_secret?: string }; - result = { - idpClientId: data.client_id || externalId, - clientSecret: data.client_secret, - }; - } catch {} + throw new Error(`GenericDcrJitAdapter: Registration failed on ${regEndpoint}: HTTP ${response.status} - ${err}`); + } + + let result: JitClientRegistrationResult = { idpClientId: externalId }; + + context.logger?.info?.(`GenericDcrJitAdapter: Registered client "${externalId}" on ${regEndpoint}`); + if (response.ok) { + try { + const data = (await response.json()) as { client_id?: string; client_secret?: string }; + result = { + idpClientId: data.client_id || externalId, + clientSecret: data.client_secret, + }; + } catch {} + } + + // Set with LRU eviction + if (this.clientCache.size >= this.maxEntries && !this.clientCache.has(externalId)) { + const oldestKey = this.clientCache.keys().next().value; + if (oldestKey !== undefined) { + this.clientCache.delete(oldestKey); } - this.clientCache.set(externalId, result); } + this.clientCache.set(externalId, result); return result; } diff --git a/typescript/packages/core/src/auth/jit/adapters/okta.adapter.ts b/typescript/packages/core/src/auth/jit/adapters/okta.adapter.ts index 4aaa29e4c..4fbb13593 100644 --- a/typescript/packages/core/src/auth/jit/adapters/okta.adapter.ts +++ b/typescript/packages/core/src/auth/jit/adapters/okta.adapter.ts @@ -11,6 +11,11 @@ export class OktaJitAdapter implements JitProviderAdapter { readonly name = 'okta'; private clientCache = new Map(); + private readonly maxEntries: number; + + constructor(options?: { maxEntries?: number }) { + this.maxEntries = options?.maxEntries ?? 1000; + } canHandle(authServerUrl: string, config: JitBridgeConfig): boolean { if (config.provider === 'okta') return true; @@ -75,24 +80,32 @@ export class OktaJitAdapter implements JitProviderAdapter { body: JSON.stringify(payload), }); - let result: JitClientRegistrationResult = { idpClientId: externalId }; - if (!response.ok && response.status !== 409) { const err = await response.text().catch(() => ''); - context.logger?.warn?.(`OktaJitAdapter: DCR notice: ${response.status} - ${err}`); - } else { - context.logger?.info?.(`OktaJitAdapter: Registered client for "${externalId}" on Okta`); - if (response.ok) { - try { - const data = (await response.json()) as { client_id?: string; client_secret?: string }; - result = { - idpClientId: data.client_id || externalId, - clientSecret: data.client_secret, - }; - } catch {} + throw new Error(`OktaJitAdapter: DCR registration failed on ${dcrEndpoint}: HTTP ${response.status} - ${err}`); + } + + let result: JitClientRegistrationResult = { idpClientId: externalId }; + + context.logger?.info?.(`OktaJitAdapter: Registered client for "${externalId}" on Okta`); + if (response.ok) { + try { + const data = (await response.json()) as { client_id?: string; client_secret?: string }; + result = { + idpClientId: data.client_id || externalId, + clientSecret: data.client_secret, + }; + } catch {} + } + + // Set with LRU eviction + if (this.clientCache.size >= this.maxEntries && !this.clientCache.has(externalId)) { + const oldestKey = this.clientCache.keys().next().value; + if (oldestKey !== undefined) { + this.clientCache.delete(oldestKey); } - this.clientCache.set(externalId, result); } + this.clientCache.set(externalId, result); return result; } diff --git a/typescript/packages/core/src/auth/jit/jit-bridge.ts b/typescript/packages/core/src/auth/jit/jit-bridge.ts index e11c46c7c..8f110261d 100644 --- a/typescript/packages/core/src/auth/jit/jit-bridge.ts +++ b/typescript/packages/core/src/auth/jit/jit-bridge.ts @@ -26,12 +26,73 @@ export class JitBridge { private config: JitBridgeConfig; private clientMapping = new Map(); // CIMD client_id URL -> upstream IdP client_id private reverseMapping = new Map(); // upstream IdP client_id -> CIMD client_id URL + private inflightRegistrations = new Map>(); + private readonly maxCacheEntries: number; constructor(config?: JitBridgeConfig) { this.config = config || {}; + this.maxCacheEntries = 1000; this.registerDefaultAdapters(); } + /** + * Store client mapping with LRU eviction when exceeding max capacity + */ + private setMapping(externalId: string, idpClientId: string): void { + if (this.clientMapping.size >= this.maxCacheEntries && !this.clientMapping.has(externalId)) { + const oldestKey = this.clientMapping.keys().next().value; + if (oldestKey !== undefined) { + const oldestMapped = this.clientMapping.get(oldestKey); + this.clientMapping.delete(oldestKey); + if (oldestMapped) { + this.reverseMapping.delete(oldestMapped); + } + } + } + this.clientMapping.set(externalId, idpClientId); + this.reverseMapping.set(idpClientId, externalId); + } + + /** + * Register or resolve client in upstream IdP with in-flight request deduplication + */ + private async ensureClientRegistered( + clientDoc: ClientIdMetadataDocument, + context: JitContext + ): Promise { + const externalId = clientDoc.client_id; + if (this.clientMapping.has(externalId)) { + return { idpClientId: this.clientMapping.get(externalId)! }; + } + + const running = this.inflightRegistrations.get(externalId); + if (running) { + return running; + } + + const adapter = this.getAdapter(context.authServerUrl); + if (!adapter) { + context.logger?.warn?.(`JitBridge: No matching provider adapter found for ${context.authServerUrl}`); + return; + } + + const promise = (async () => { + try { + context.logger?.info?.(`JitBridge: Running adapter "${adapter.name}" for client "${externalId}"`); + const regResult = await adapter.registerClient(clientDoc, context, this.config); + if (regResult?.idpClientId) { + this.setMapping(externalId, regResult.idpClientId); + } + return regResult; + } finally { + this.inflightRegistrations.delete(externalId); + } + })(); + + this.inflightRegistrations.set(externalId, promise); + return promise; + } + /** * Register default built-in provider adapters */ @@ -125,7 +186,7 @@ export class JitBridge { } const clientId = searchParams.get('client_id'); - const redirectUri = searchParams.get('redirect_uri'); + let redirectUri = searchParams.get('redirect_uri'); if (!clientId) { this.sendOAuthError(res, 400, 'invalid_request', 'Missing required parameter: client_id'); @@ -139,7 +200,22 @@ export class JitBridge { const allowLoopback = this.config.allowLoopback ?? (process.env.NODE_ENV !== 'production'); const clientDoc = await resolveClientIdMetadataDocument(clientId, { allowLoopback }); - if (redirectUri && !validateRedirectUriWithCimd(clientDoc, redirectUri)) { + // RFC 6749 / OAuth 2.1: If redirect_uri is omitted, validate against client metadata + if (!redirectUri) { + if (Array.isArray(clientDoc.redirect_uris) && clientDoc.redirect_uris.length > 1) { + this.sendOAuthError( + res, + 400, + 'invalid_request', + 'Parameter "redirect_uri" is required when client metadata document specifies multiple redirect URIs' + ); + return; + } + if (Array.isArray(clientDoc.redirect_uris) && clientDoc.redirect_uris.length === 1) { + redirectUri = clientDoc.redirect_uris[0]; + searchParams.set('redirect_uri', redirectUri); + } + } else if (!validateRedirectUriWithCimd(clientDoc, redirectUri)) { this.sendOAuthError( res, 400, @@ -149,18 +225,10 @@ export class JitBridge { return; } - const adapter = this.getAdapter(context.authServerUrl); - if (adapter) { - context.logger?.info?.(`JitBridge: Running adapter "${adapter.name}" for client "${clientId}"`); - const regResult = await adapter.registerClient(clientDoc, context, this.config); - if (regResult?.idpClientId) { - this.clientMapping.set(clientId, regResult.idpClientId); - this.reverseMapping.set(regResult.idpClientId, clientId); - // Replace client_id parameter with the upstream IdP registered client ID - searchParams.set('client_id', regResult.idpClientId); - } - } else { - context.logger?.warn?.(`JitBridge: No matching provider adapter found for ${context.authServerUrl}`); + const regResult = await this.ensureClientRegistered(clientDoc, context); + if (regResult?.idpClientId) { + // Replace client_id parameter with the upstream IdP registered client ID + searchParams.set('client_id', regResult.idpClientId); } } catch (err: any) { context.logger?.error?.(`JitBridge: Failed to process CIMD for "${clientId}": ${err.message || String(err)}`); @@ -304,14 +372,7 @@ export class JitBridge { if (!this.clientMapping.has(basicClientId) && isClientIdMetadataUrl(basicClientId)) { const allowLoopback = this.config.allowLoopback ?? (process.env.NODE_ENV !== 'production'); const clientDoc = await resolveClientIdMetadataDocument(basicClientId, { allowLoopback }); - const adapter = this.getAdapter(context.authServerUrl); - if (adapter) { - const regResult = await adapter.registerClient(clientDoc, context, this.config); - if (regResult?.idpClientId) { - this.clientMapping.set(basicClientId, regResult.idpClientId); - this.reverseMapping.set(regResult.idpClientId, basicClientId); - } - } + await this.ensureClientRegistered(clientDoc, context); } if (this.clientMapping.has(basicClientId)) { @@ -333,14 +394,7 @@ export class JitBridge { try { const allowLoopback = this.config.allowLoopback ?? (process.env.NODE_ENV !== 'production'); const clientDoc = await resolveClientIdMetadataDocument(reqClientId, { allowLoopback }); - const adapter = this.getAdapter(context.authServerUrl); - if (adapter) { - const regResult = await adapter.registerClient(clientDoc, context, this.config); - if (regResult?.idpClientId) { - this.clientMapping.set(reqClientId, regResult.idpClientId); - this.reverseMapping.set(regResult.idpClientId, reqClientId); - } - } + await this.ensureClientRegistered(clientDoc, context); } catch (err) { context.logger?.debug?.('JitBridge: on-the-fly token CIMD lookup notice', { error: err }); } diff --git a/typescript/packages/core/src/core/oauth-module.ts b/typescript/packages/core/src/core/oauth-module.ts index f45b0ef60..ec7aeae9d 100644 --- a/typescript/packages/core/src/core/oauth-module.ts +++ b/typescript/packages/core/src/core/oauth-module.ts @@ -309,6 +309,9 @@ export class OAuthModule { const host = (Array.isArray(rawHost) ? rawHost[0] : rawHost) || 'localhost:3000'; const rawProto = reqHeaders['x-forwarded-proto']; let proto = Array.isArray(rawProto) ? rawProto[0] : rawProto; + if (typeof proto === 'string') { + proto = proto.split(',')[0].trim(); + } if (!proto) { if (host.includes('localhost') || host.includes('127.0.0.1')) { proto = 'http'; diff --git a/typescript/packages/core/src/core/transports/http-server.ts b/typescript/packages/core/src/core/transports/http-server.ts index 07f721cd6..c7c9f7953 100644 --- a/typescript/packages/core/src/core/transports/http-server.ts +++ b/typescript/packages/core/src/core/transports/http-server.ts @@ -219,6 +219,9 @@ export class HttpServerTransport implements Transport { const host = Array.isArray(rawHost) ? rawHost[0] : rawHost; const rawProto = req.headers['x-forwarded-proto']; let proto = Array.isArray(rawProto) ? rawProto[0] : rawProto; + if (typeof proto === 'string') { + proto = proto.split(',')[0].trim(); + } if (!proto) { proto = host.includes('localhost') || host.includes('127.0.0.1') ? 'http' : 'https'; }