diff --git a/.changeset/cimd-internal-clients.md b/.changeset/cimd-internal-clients.md new file mode 100644 index 0000000000..fa006d7423 --- /dev/null +++ b/.changeset/cimd-internal-clients.md @@ -0,0 +1,34 @@ +--- +'@backstage/plugin-auth-backend': minor +--- + +Added experimental support for Client ID Metadata Documents (CIMD). + +This allows Backstage to act as an OAuth 2.0 authorization server that supports the [IETF Client ID Metadata Document draft](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/). External OAuth clients can use HTTPS URLs as their `client_id`, and Backstage will fetch metadata from those URLs to validate the client. + +**Configuration example:** + +```yaml +auth: + experimentalClientIdMetadataDocuments: + enabled: true + # Optional: restrict which `client_id` URLs are allowed (defaults to ['*']) + allowedClientIdPatterns: + - 'https://example.com/*' + - 'https://*.trusted-domain.com/*' + # Optional: restrict which redirect URIs are allowed (defaults to ['*']) + allowedRedirectUriPatterns: + - 'http://localhost:*' + - 'https://*.example.com/*' +``` + +Clients using CIMD must host a JSON metadata document at their `client_id` URL containing at minimum: + +```json +{ + "client_id": "https://example.com/.well-known/oauth-client/my-app", + "client_name": "My Application", + "redirect_uris": ["http://localhost:8080/callback"], + "token_endpoint_auth_method": "none" +} +``` diff --git a/.changeset/mcp-oauth-protected-resource.md b/.changeset/mcp-oauth-protected-resource.md new file mode 100644 index 0000000000..8788567cc9 --- /dev/null +++ b/.changeset/mcp-oauth-protected-resource.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-mcp-actions-backend': patch +--- + +Added OAuth Protected Resource Metadata endpoint (`/.well-known/oauth-protected-resource`) per RFC 9728. This allows MCP clients to discover the authorization server for the resource. + +Also enabled OAuth well-known endpoints when CIMD (Client ID Metadata Documents) is configured, not just when DCR is enabled. diff --git a/app-config.yaml b/app-config.yaml index ac66d223b4..08cd0b693c 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -218,6 +218,13 @@ auth: enabled: true allowedRedirectUriPatterns: - cursor://* + - http://localhost:* + - http://127.0.0.1:* + experimentalClientIdMetadataDocuments: + enabled: true + allowedRedirectUriPatterns: + - http://127.0.0.1:* + - http://localhost:* ### Add auth.keyStore.provider to more granularly control how to store JWK data when running # the auth-backend. diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index 0dd531c34e..e3e0c46b2f 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -157,5 +157,36 @@ export interface Config { */ tokenExpiration?: HumanDuration | string; }; + + /** + * Configuration for Client ID Metadata Documents (CIMD) + * + * @see https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ + */ + experimentalClientIdMetadataDocuments?: { + /** + * Whether to enable Client ID Metadata Documents support + * Defaults to false + */ + enabled?: boolean; + + /** + * A list of allowed URI patterns for client_id URLs. + * Uses glob-style pattern matching where `*` matches any characters. + * Defaults to ['*'] which allows any client_id URL. + * + * @example ['https://example.com/*', 'https://*.trusted-domain.com/*'] + */ + allowedClientIdPatterns?: string[]; + + /** + * A list of allowed URI patterns for redirect URIs. + * Uses glob-style pattern matching where `*` matches any characters. + * Defaults to ['*'] which allows any redirect URI. + * + * @example ['http://localhost:*', 'http://127.0.0.1:*\/callback'] + */ + allowedRedirectUriPatterns?: string[]; + }; }; } diff --git a/plugins/auth-backend/migrations/20251217120000_drop_oidc_clients_fk.js b/plugins/auth-backend/migrations/20251217120000_drop_oidc_clients_fk.js new file mode 100644 index 0000000000..f08032269d --- /dev/null +++ b/plugins/auth-backend/migrations/20251217120000_drop_oidc_clients_fk.js @@ -0,0 +1,44 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * Drop the foreign key constraint on oauth_authorization_sessions.client_id + * to allow CIMD (Client ID Metadata Document) clients which are not stored + * in the oidc_clients table. + * + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.alterTable('oauth_authorization_sessions', table => { + table.dropForeign(['client_id']); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + // Delete sessions with CIMD client_ids (not in oidc_clients) before re-adding FK + await knex('oauth_authorization_sessions') + .whereNotIn('client_id', knex('oidc_clients').select('client_id')) + .delete(); + + await knex.schema.alterTable('oauth_authorization_sessions', table => { + table.foreign('client_id').references('client_id').inTable('oidc_clients'); + }); +}; diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index cb0ce48a86..51e2b53a8c 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -57,6 +57,7 @@ "express": "^4.22.0", "express-promise-router": "^4.1.0", "express-session": "^1.17.1", + "ipaddr.js": "^2.3.0", "jose": "^5.0.0", "knex": "^3.0.0", "lodash": "^4.17.21", @@ -78,6 +79,7 @@ "@types/express": "^4.17.6", "@types/express-session": "^1.17.2", "@types/passport": "^1.0.3", + "msw": "^1.0.0", "supertest": "^7.0.0" }, "configSchema": "config.d.ts" diff --git a/plugins/auth-backend/src/migrations.test.ts b/plugins/auth-backend/src/migrations.test.ts index e2ab2390b6..5dec51ed85 100644 --- a/plugins/auth-backend/src/migrations.test.ts +++ b/plugins/auth-backend/src/migrations.test.ts @@ -385,4 +385,67 @@ describe('migrations', () => { await knex.destroy(); }, ); + + it.each(databases.eachSupportedId())( + '20251217120000_drop_oidc_clients_fk.js, %p', + async databaseId => { + const knex = await databases.init(databaseId); + + await migrateUntilBefore(knex, '20251217120000_drop_oidc_clients_fk.js'); + + // Create a client for DCR sessions + await knex + .insert({ + client_id: 'dcr-client-id', + client_secret: 'test-client-secret', + client_name: 'DCR Client', + response_types: JSON.stringify(['code']), + grant_types: JSON.stringify(['authorization_code']), + redirect_uris: JSON.stringify(['https://example.com/callback']), + }) + .into('oidc_clients'); + + // Create a DCR session (has matching client in oidc_clients) + await knex + .insert({ + id: 'dcr-session', + client_id: 'dcr-client-id', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + status: 'pending', + expires_at: new Date(Date.now() + 3600000), + }) + .into('oauth_authorization_sessions'); + + // Apply migration - drops FK constraint + await migrateUpOnce(knex); + + // Now we can insert a CIMD session (URL-based client_id not in oidc_clients) + await knex + .insert({ + id: 'cimd-session', + client_id: 'https://example.com/.well-known/oauth-client/cli', + redirect_uri: 'http://localhost:8080/callback', + response_type: 'code', + status: 'pending', + expires_at: new Date(Date.now() + 3600000), + }) + .into('oauth_authorization_sessions'); + + // Verify both sessions exist + await expect( + knex('oauth_authorization_sessions').select('id').orderBy('id'), + ).resolves.toEqual([{ id: 'cimd-session' }, { id: 'dcr-session' }]); + + // Rollback - should delete CIMD sessions and re-add FK + await migrateDownOnce(knex); + + // CIMD session should be deleted, DCR session should remain + await expect( + knex('oauth_authorization_sessions').select('id'), + ).resolves.toEqual([{ id: 'dcr-session' }]); + + await knex.destroy(); + }, + ); }); diff --git a/plugins/auth-backend/src/service/CimdClient.test.ts b/plugins/auth-backend/src/service/CimdClient.test.ts new file mode 100644 index 0000000000..b61a9c88cf --- /dev/null +++ b/plugins/auth-backend/src/service/CimdClient.test.ts @@ -0,0 +1,545 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { registerMswTestHooks } from '@backstage/backend-test-utils'; +import { isCimdUrl, validateCimdUrl, fetchCimdMetadata } from './CimdClient'; +import * as dns from 'node:dns/promises'; + +jest.mock('dns/promises'); +const mockDnsLookup = dns.lookup as jest.MockedFunction; + +const server = setupServer(); +registerMswTestHooks(server); + +describe('CimdClient', () => { + const originalNodeEnv = process.env.NODE_ENV; + + beforeEach(() => { + jest.resetAllMocks(); + // Default to public IP for DNS lookups + mockDnsLookup.mockResolvedValue([ + { address: '93.184.216.34', family: 4 }, + ] as any); + // Set development mode for tests that need localhost HTTP + (process.env as Record).NODE_ENV = + 'development'; + }); + + afterEach(() => { + (process.env as Record).NODE_ENV = + originalNodeEnv; + }); + + describe('isCimdUrl', () => { + it('should return true for valid CIMD URLs', () => { + expect(isCimdUrl('https://example.com/oauth-metadata.json')).toBe(true); + expect(isCimdUrl('https://example.com/path/to/metadata')).toBe(true); + expect( + isCimdUrl('https://sub.example.com/.well-known/oauth-client'), + ).toBe(true); + }); + + it('should return false for URLs without path', () => { + expect(isCimdUrl('https://example.com')).toBe(false); + expect(isCimdUrl('https://example.com/')).toBe(false); + }); + + it('should return false for non-HTTPS URLs on public hosts', () => { + expect(isCimdUrl('http://example.com/metadata')).toBe(false); + }); + + it('should return true for HTTP localhost URLs (development)', () => { + expect( + isCimdUrl( + 'http://localhost:7007/api/auth/.well-known/oauth-client/cli', + ), + ).toBe(true); + expect( + isCimdUrl( + 'http://127.0.0.1:7007/api/auth/.well-known/oauth-client/cli', + ), + ).toBe(true); + expect(isCimdUrl('http://localhost/path')).toBe(true); + }); + + it('should return false for HTTP localhost URLs in production', () => { + (process.env as Record).NODE_ENV = + 'production'; + expect(isCimdUrl('http://localhost:7007/path')).toBe(false); + expect(isCimdUrl('http://127.0.0.1:7007/path')).toBe(false); + }); + + it('should return false for non-URL strings', () => { + expect(isCimdUrl('not-a-url')).toBe(false); + expect(isCimdUrl('uuid-like-client-id')).toBe(false); + expect(isCimdUrl('')).toBe(false); + }); + + it('should return false for URLs with query strings', () => { + expect(isCimdUrl('https://example.com/metadata?foo=bar')).toBe(false); + }); + + it('should return false for URLs with dot path segments', () => { + expect(isCimdUrl('https://example.com/./metadata')).toBe(false); + expect(isCimdUrl('https://example.com/../metadata')).toBe(false); + }); + + it('should return false for URLs with fragments', () => { + expect(isCimdUrl('https://example.com/metadata#section')).toBe(false); + }); + }); + + describe('validateCimdUrl', () => { + it('should return URL for valid CIMD URLs', () => { + const url = validateCimdUrl('https://example.com/metadata.json'); + expect(url.href).toBe('https://example.com/metadata.json'); + }); + + it('should throw for non-HTTPS URLs on public hosts', () => { + expect(() => validateCimdUrl('http://example.com/metadata')).toThrow( + 'must use HTTPS', + ); + }); + + it('should allow HTTP for localhost URLs (development)', () => { + const url1 = validateCimdUrl( + 'http://localhost:7007/api/auth/.well-known/oauth-client/cli', + ); + expect(url1.href).toBe( + 'http://localhost:7007/api/auth/.well-known/oauth-client/cli', + ); + + const url2 = validateCimdUrl( + 'http://127.0.0.1:7007/api/auth/.well-known/oauth-client/cli', + ); + expect(url2.href).toBe( + 'http://127.0.0.1:7007/api/auth/.well-known/oauth-client/cli', + ); + }); + + it('should throw for HTTP localhost URLs in production', () => { + (process.env as Record).NODE_ENV = + 'production'; + expect(() => validateCimdUrl('http://localhost:7007/path')).toThrow( + 'must use HTTPS', + ); + expect(() => validateCimdUrl('http://127.0.0.1:7007/path')).toThrow( + 'must use HTTPS', + ); + }); + + it('should throw for URLs without path', () => { + expect(() => validateCimdUrl('https://example.com')).toThrow( + 'must have a path component', + ); + expect(() => validateCimdUrl('https://example.com/')).toThrow( + 'must have a path component', + ); + }); + + it('should throw for URLs with fragments', () => { + expect(() => + validateCimdUrl('https://example.com/metadata#fragment'), + ).toThrow('must not contain a fragment'); + }); + + it('should throw for URLs with credentials', () => { + expect(() => + validateCimdUrl('https://user:pass@example.com/metadata'), + ).toThrow('must not contain credentials'); + }); + + it('should throw for URLs with query strings', () => { + expect(() => + validateCimdUrl('https://example.com/metadata?foo=bar'), + ).toThrow('must not contain a query string'); + }); + + it('should throw for URLs with dot path segments', () => { + expect(() => validateCimdUrl('https://example.com/./metadata')).toThrow( + 'must not contain dot segments', + ); + expect(() => validateCimdUrl('https://example.com/../metadata')).toThrow( + 'must not contain dot segments', + ); + expect(() => + validateCimdUrl('https://example.com/path/../other'), + ).toThrow('must not contain dot segments'); + }); + + it('should throw for invalid URLs', () => { + expect(() => validateCimdUrl('not-a-url')).toThrow('not a valid URL'); + }); + }); + + describe('fetchCimdMetadata', () => { + const validMetadata = { + client_id: 'https://example.com/oauth-metadata.json', + client_name: 'Test Client', + redirect_uris: ['http://localhost:8080/callback'], + }; + + it('should fetch and return valid metadata', async () => { + server.use( + rest.get( + 'https://example.com/oauth-metadata.json', + (_req, res, ctx) => { + return res(ctx.json(validMetadata)); + }, + ), + ); + + const result = await fetchCimdMetadata({ + clientId: 'https://example.com/oauth-metadata.json', + }); + + expect(result).toEqual({ + clientId: 'https://example.com/oauth-metadata.json', + clientName: 'Test Client', + redirectUris: ['http://localhost:8080/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + scope: undefined, + }); + }); + + it('should use client_id as client_name if not provided', async () => { + const metadataWithoutName = { + client_id: 'https://example.com/oauth-metadata.json', + redirect_uris: ['http://localhost:8080/callback'], + }; + + server.use( + rest.get( + 'https://example.com/oauth-metadata.json', + (_req, res, ctx) => { + return res(ctx.json(metadataWithoutName)); + }, + ), + ); + + const result = await fetchCimdMetadata({ + clientId: 'https://example.com/oauth-metadata.json', + }); + + expect(result.clientName).toBe('https://example.com/oauth-metadata.json'); + }); + + describe('SSRF protection', () => { + it('should throw for private IP addresses (192.168.x.x)', async () => { + mockDnsLookup.mockResolvedValue([ + { address: '192.168.1.1', family: 4 }, + ] as any); + + await expect( + fetchCimdMetadata({ + clientId: 'https://internal.example.com/metadata', + }), + ).rejects.toThrow('Invalid client_id URL'); + }); + + it('should throw for loopback addresses (127.x.x.x)', async () => { + mockDnsLookup.mockResolvedValue([ + { address: '127.0.0.1', family: 4 }, + ] as any); + + await expect( + fetchCimdMetadata({ + clientId: 'https://localhost.example.com/metadata', + }), + ).rejects.toThrow('Invalid client_id URL'); + }); + + it('should throw for 10.x.x.x addresses', async () => { + mockDnsLookup.mockResolvedValue([ + { address: '10.0.0.1', family: 4 }, + ] as any); + + await expect( + fetchCimdMetadata({ + clientId: 'https://internal.example.com/metadata', + }), + ).rejects.toThrow('Invalid client_id URL'); + }); + + it('should throw for 172.16-31.x.x addresses', async () => { + mockDnsLookup.mockResolvedValue([ + { address: '172.16.0.1', family: 4 }, + ] as any); + + await expect( + fetchCimdMetadata({ + clientId: 'https://internal.example.com/metadata', + }), + ).rejects.toThrow('Invalid client_id URL'); + }); + + it('should throw for IPv6 loopback', async () => { + mockDnsLookup.mockResolvedValue([{ address: '::1', family: 6 }] as any); + + await expect( + fetchCimdMetadata({ + clientId: 'https://internal.example.com/metadata', + }), + ).rejects.toThrow('Invalid client_id URL'); + }); + }); + + describe('HTTP error handling', () => { + it('should throw for network errors', async () => { + server.use( + rest.get('https://example.com/oauth-metadata.json', (_req, res) => { + return res.networkError('Connection refused'); + }), + ); + + await expect( + fetchCimdMetadata({ + clientId: 'https://example.com/oauth-metadata.json', + }), + ).rejects.toThrow('Failed to fetch client metadata'); + }); + + it('should throw for non-OK response', async () => { + server.use( + rest.get( + 'https://example.com/oauth-metadata.json', + (_req, res, ctx) => { + return res(ctx.status(404)); + }, + ), + ); + + await expect( + fetchCimdMetadata({ + clientId: 'https://example.com/oauth-metadata.json', + }), + ).rejects.toThrow('Failed to fetch client metadata'); + }); + }); + + describe('metadata validation', () => { + it('should throw for invalid JSON', async () => { + server.use( + rest.get( + 'https://example.com/oauth-metadata.json', + (_req, res, ctx) => { + return res(ctx.body('not json')); + }, + ), + ); + + await expect( + fetchCimdMetadata({ + clientId: 'https://example.com/oauth-metadata.json', + }), + ).rejects.toThrow('Invalid client metadata document'); + }); + + it('should throw for client_id mismatch', async () => { + const mismatchedMetadata = { + client_id: 'https://different.com/metadata', + client_name: 'Test Client', + redirect_uris: ['http://localhost:8080/callback'], + }; + + server.use( + rest.get( + 'https://example.com/oauth-metadata.json', + (_req, res, ctx) => { + return res(ctx.json(mismatchedMetadata)); + }, + ), + ); + + await expect( + fetchCimdMetadata({ + clientId: 'https://example.com/oauth-metadata.json', + }), + ).rejects.toThrow('Client ID mismatch in metadata document'); + }); + + it('should throw for missing redirect_uris', async () => { + const noRedirectUris = { + client_id: 'https://example.com/oauth-metadata.json', + client_name: 'Test Client', + }; + + server.use( + rest.get( + 'https://example.com/oauth-metadata.json', + (_req, res, ctx) => { + return res(ctx.json(noRedirectUris)); + }, + ), + ); + + await expect( + fetchCimdMetadata({ + clientId: 'https://example.com/oauth-metadata.json', + }), + ).rejects.toThrow('at least one redirect_uri'); + }); + + it('should throw for empty redirect_uris', async () => { + const emptyRedirectUris = { + client_id: 'https://example.com/oauth-metadata.json', + client_name: 'Test Client', + redirect_uris: [], + }; + + server.use( + rest.get( + 'https://example.com/oauth-metadata.json', + (_req, res, ctx) => { + return res(ctx.json(emptyRedirectUris)); + }, + ), + ); + + await expect( + fetchCimdMetadata({ + clientId: 'https://example.com/oauth-metadata.json', + }), + ).rejects.toThrow('at least one redirect_uri'); + }); + }); + + describe('security constraints', () => { + it('should throw for metadata containing client_secret', async () => { + const withSecret = { + client_id: 'https://example.com/oauth-metadata.json', + client_name: 'Test Client', + redirect_uris: ['http://localhost:8080/callback'], + client_secret: 'should-not-be-here', + }; + + server.use( + rest.get( + 'https://example.com/oauth-metadata.json', + (_req, res, ctx) => { + return res(ctx.json(withSecret)); + }, + ), + ); + + await expect( + fetchCimdMetadata({ + clientId: 'https://example.com/oauth-metadata.json', + }), + ).rejects.toThrow('Client metadata must not contain client_secret'); + }); + + it('should throw for metadata containing client_secret_expires_at', async () => { + const withSecretExpiry = { + client_id: 'https://example.com/oauth-metadata.json', + client_name: 'Test Client', + redirect_uris: ['http://localhost:8080/callback'], + client_secret_expires_at: 12345, + }; + + server.use( + rest.get( + 'https://example.com/oauth-metadata.json', + (_req, res, ctx) => { + return res(ctx.json(withSecretExpiry)); + }, + ), + ); + + await expect( + fetchCimdMetadata({ + clientId: 'https://example.com/oauth-metadata.json', + }), + ).rejects.toThrow('Client metadata must not contain client_secret'); + }); + + it('should throw for forbidden token_endpoint_auth_method', async () => { + const withForbiddenAuth = { + client_id: 'https://example.com/oauth-metadata.json', + client_name: 'Test Client', + redirect_uris: ['http://localhost:8080/callback'], + token_endpoint_auth_method: 'client_secret_basic', + }; + + server.use( + rest.get( + 'https://example.com/oauth-metadata.json', + (_req, res, ctx) => { + return res(ctx.json(withForbiddenAuth)); + }, + ), + ); + + await expect( + fetchCimdMetadata({ + clientId: 'https://example.com/oauth-metadata.json', + }), + ).rejects.toThrow('forbidden auth method'); + }); + + it('should allow token_endpoint_auth_method: none', async () => { + const withNoneAuth = { + client_id: 'https://example.com/oauth-metadata.json', + client_name: 'Test Client', + redirect_uris: ['http://localhost:8080/callback'], + token_endpoint_auth_method: 'none', + }; + + server.use( + rest.get( + 'https://example.com/oauth-metadata.json', + (_req, res, ctx) => { + return res(ctx.json(withNoneAuth)); + }, + ), + ); + + const result = await fetchCimdMetadata({ + clientId: 'https://example.com/oauth-metadata.json', + }); + + expect(result.clientId).toBe('https://example.com/oauth-metadata.json'); + }); + + it('should allow token_endpoint_auth_method: private_key_jwt', async () => { + const withPrivateKeyAuth = { + client_id: 'https://example.com/oauth-metadata.json', + client_name: 'Test Client', + redirect_uris: ['http://localhost:8080/callback'], + token_endpoint_auth_method: 'private_key_jwt', + }; + + server.use( + rest.get( + 'https://example.com/oauth-metadata.json', + (_req, res, ctx) => { + return res(ctx.json(withPrivateKeyAuth)); + }, + ), + ); + + const result = await fetchCimdMetadata({ + clientId: 'https://example.com/oauth-metadata.json', + }); + + expect(result.clientId).toBe('https://example.com/oauth-metadata.json'); + }); + }); + }); +}); diff --git a/plugins/auth-backend/src/service/CimdClient.ts b/plugins/auth-backend/src/service/CimdClient.ts new file mode 100644 index 0000000000..c610de3708 --- /dev/null +++ b/plugins/auth-backend/src/service/CimdClient.ts @@ -0,0 +1,258 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InputError, isError } from '@backstage/errors'; +import { lookup } from 'node:dns/promises'; +import ipaddr from 'ipaddr.js'; + +const FETCH_TIMEOUT_MS = 10000; +const MAX_RESPONSE_BYTES = 64 * 1024; + +/** Auth methods that require a client secret - forbidden for CIMD clients */ +const FORBIDDEN_AUTH_METHODS = [ + 'client_secret_basic', + 'client_secret_post', + 'client_secret_jwt', +]; + +/** + * Raw metadata document from a CIMD URL. + * Note: client_secret fields are included for validation (must NOT be present). + */ +interface CimdMetadata { + client_id: string; + client_name?: string; + redirect_uris: string[]; + response_types?: string[]; + grant_types?: string[]; + scope?: string; + token_endpoint_auth_method?: string; + client_secret?: string; + client_secret_expires_at?: number; +} + +/** Validated CIMD client info */ +export interface CimdClientInfo { + clientId: string; + clientName: string; + redirectUris: string[]; + responseTypes: string[]; + grantTypes: string[]; + scope?: string; +} + +/** + * Validates and parses a CIMD URL per the IETF draft specification. + * Requires HTTPS for production, but allows HTTP for localhost (development). + * + * @see https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ + * @throws InputError if the URL is invalid per the CIMD spec + */ +export function validateCimdUrl(clientId: string): URL { + // Per IETF draft: MUST NOT contain single-dot or double-dot path segments + // Check before URL parsing since the URL constructor normalizes these away + if (/\/\.\.?(\/|$)/.test(clientId)) { + throw new InputError( + 'Invalid client_id: path must not contain dot segments', + ); + } + + let url: URL; + try { + url = new URL(clientId); + } catch { + throw new InputError('Invalid client_id: not a valid URL'); + } + + const isHttps = url.protocol === 'https:'; + const isLocalHttp = + url.protocol === 'http:' && + (url.hostname === 'localhost' || url.hostname === '127.0.0.1') && + process.env.NODE_ENV === 'development'; + + if (!isHttps && !isLocalHttp) { + throw new InputError( + 'Invalid client_id: must use HTTPS (or HTTP for localhost in development)', + ); + } + + if (url.pathname === '' || url.pathname === '/') { + throw new InputError('Invalid client_id: must have a path component'); + } + + if (url.hash) { + throw new InputError('Invalid client_id: must not contain a fragment'); + } + + if (url.username || url.password) { + throw new InputError('Invalid client_id: must not contain credentials'); + } + + // Per IETF draft: SHOULD NOT include a query string + // We reject this for stricter compliance and security + if (url.search) { + throw new InputError('Invalid client_id: must not contain a query string'); + } + + return url; +} + +/** + * Checks if a client_id is a valid CIMD URL. + * Requires HTTPS for production, but allows HTTP for localhost (development). + */ +export function isCimdUrl(clientId: string): boolean { + try { + validateCimdUrl(clientId); + return true; + } catch { + return false; + } +} + +/** + * SSRF (Server-Side Request Forgery) Protection + * + * When fetching CIMD metadata from client-provided URLs, we must prevent + * attackers from tricking Backstage into accessing internal resources. + * For example, an attacker could provide a URL that resolves to: + * - 127.0.0.1 (localhost services) + * - 10.x.x.x, 172.16-31.x.x, 192.168.x.x (internal network) + * - Cloud metadata endpoints (169.254.169.254) + * + * We use ipaddr.js to check if resolved IPs are in non-public ranges. + * Only 'unicast' (public internet) addresses are allowed. + * + * @see https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ + * Section 5.1 - Security Considerations + */ +function isNonPublicIp(ip: string): boolean { + try { + const addr = ipaddr.parse(ip); + const range = addr.range(); + // Only allow public unicast addresses + return range !== 'unicast'; + } catch { + // If we can't parse the IP, treat it as non-public and block it + return true; + } +} + +async function validateHostNotPrivate(hostname: string): Promise { + try { + const addresses = await lookup(hostname, { all: true }); + const nonPublicAddr = addresses.find(addr => isNonPublicIp(addr.address)); + if (nonPublicAddr) { + throw new InputError('Invalid client_id URL'); + } + } catch (error) { + if (isError(error) && error.name === 'InputError') throw error; + throw new InputError('Failed to fetch client metadata'); + } +} + +function validateMetadata( + metadata: CimdMetadata, + expectedClientId: string, +): void { + if (metadata.client_id !== expectedClientId) { + throw new InputError('Client ID mismatch in metadata document'); + } + + if ( + !Array.isArray(metadata.redirect_uris) || + metadata.redirect_uris.length === 0 + ) { + throw new InputError('Metadata must include at least one redirect_uri'); + } + + for (const uri of metadata.redirect_uris) { + if (!URL.canParse(uri)) { + throw new InputError(`Invalid redirect_uri in metadata: ${uri}`); + } + } + + if ( + metadata.client_secret !== undefined || + metadata.client_secret_expires_at !== undefined + ) { + throw new InputError('Client metadata must not contain client_secret'); + } + + if ( + metadata.token_endpoint_auth_method && + FORBIDDEN_AUTH_METHODS.includes(metadata.token_endpoint_auth_method) + ) { + throw new InputError('Client metadata uses forbidden auth method'); + } +} + +/** + * Fetches and validates a CIMD metadata document. + * @throws InputError if fetching or validation fails + */ +export async function fetchCimdMetadata(opts: { + clientId: string; + validatedUrl?: URL; +}): Promise { + const url = opts.validatedUrl ?? validateCimdUrl(opts.clientId); + + // Skip SSRF validation for localhost in development only + const isLocalhostDev = + (url.hostname === 'localhost' || url.hostname === '127.0.0.1') && + process.env.NODE_ENV === 'development'; + if (!isLocalhostDev) { + await validateHostNotPrivate(url.hostname); + } + + let response: Response; + try { + response = await fetch(url.toString(), { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + } catch { + throw new InputError('Failed to fetch client metadata'); + } + + if (!response.ok) { + throw new InputError('Failed to fetch client metadata'); + } + + const contentLength = Number(response.headers.get('content-length')); + if (contentLength > MAX_RESPONSE_BYTES) { + throw new InputError('Client metadata document too large'); + } + + let metadata: CimdMetadata; + try { + metadata = await response.json(); + } catch { + throw new InputError('Invalid client metadata document'); + } + + validateMetadata(metadata, opts.clientId); + + return { + clientId: metadata.client_id, + clientName: metadata.client_name || metadata.client_id, + redirectUris: metadata.redirect_uris, + responseTypes: metadata.response_types || ['code'], + grantTypes: metadata.grant_types || ['authorization_code'], + scope: metadata.scope, + }; +} diff --git a/plugins/auth-backend/src/service/OidcRouter.test.ts b/plugins/auth-backend/src/service/OidcRouter.test.ts index 0f86b8f413..fb621fb6f9 100644 --- a/plugins/auth-backend/src/service/OidcRouter.test.ts +++ b/plugins/auth-backend/src/service/OidcRouter.test.ts @@ -35,6 +35,22 @@ import { AuthDatabase } from '../database/AuthDatabase'; import { OidcService } from '../service/OidcService'; import { TokenIssuer } from '../identity/types'; import { OfflineAccessService } from './OfflineAccessService'; +import { CimdClientInfo, isCimdUrl } from './CimdClient'; + +jest.mock('./CimdClient', () => { + const actual = jest.requireActual('./CimdClient'); + return { + ...actual, + fetchCimdMetadata: jest.fn(), + }; +}); + +import * as CimdClient from './CimdClient'; + +const mockFetchCimdMetadata = + CimdClient.fetchCimdMetadata as jest.MockedFunction< + typeof CimdClient.fetchCimdMetadata + >; jest.setTimeout(60_000); @@ -43,6 +59,10 @@ describe('OidcRouter', () => { const MOCK_USER_ENTITY_REF = 'user:default/test-user'; const databases = TestDatabases.create(); + afterEach(() => { + mockFetchCimdMetadata.mockReset(); + }); + async function createRouter(databaseId: TestDatabaseId) { const knex = await databases.init(databaseId); @@ -401,9 +421,9 @@ describe('OidcRouter', () => { }) .expect(302); - expect(response.header.location).toMatch( - /^http:\/\/localhost:3000\/oauth2\/authorize\/[a-f0-9-]+$/, - ); + const location = new URL(response.header.location); + expect(location.origin).toBe('http://localhost:3000'); + expect(location.pathname).toMatch(/^\/oauth2\/authorize\/[a-f0-9-]+$/); }); it('should get auth session details', async () => { @@ -513,11 +533,11 @@ describe('OidcRouter', () => { .set('Authorization', `Bearer ${MOCK_USER_TOKEN}`) .expect(200); - expect(response.body).toEqual({ - redirectUrl: expect.stringMatching( - /^https:\/\/example\.com\/callback\?code=[\w-]+&state=test-state$/, - ), - }); + const redirectUrl = new URL(response.body.redirectUrl); + expect(redirectUrl.origin).toBe('https://example.com'); + expect(redirectUrl.pathname).toBe('/callback'); + expect(redirectUrl.searchParams.get('code')).toBeDefined(); + expect(redirectUrl.searchParams.get('state')).toBe('test-state'); }); it('should reject auth session', async () => { @@ -572,11 +592,14 @@ describe('OidcRouter', () => { .post(`/api/auth/v1/sessions/${authSession.id}/reject`) .expect(200); - expect(response.body).toEqual({ - redirectUrl: expect.stringMatching( - /^https:\/\/example\.com\/callback\?error=access_denied&error_description=User\+denied\+the\+request&state=test-state$/, - ), - }); + const redirectUrl = new URL(response.body.redirectUrl); + expect(redirectUrl.origin).toBe('https://example.com'); + expect(redirectUrl.pathname).toBe('/callback'); + expect(redirectUrl.searchParams.get('error')).toBe('access_denied'); + expect(redirectUrl.searchParams.get('error_description')).toBe( + 'User denied the request', + ); + expect(redirectUrl.searchParams.get('state')).toBe('test-state'); }); }); @@ -1096,5 +1119,134 @@ describe('OidcRouter', () => { .expect(400); }); }); + + describe('CIMD authorization', () => { + it('should enable authorization routes when only CIMD is enabled (not DCR)', async () => { + const cimdClientId = 'https://example.com/oauth-client'; + const cimdMetadata: CimdClientInfo = { + clientId: cimdClientId, + clientName: 'Test CLI Client', + redirectUris: ['http://localhost:8080/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + scope: 'openid', + }; + mockFetchCimdMetadata.mockResolvedValue(cimdMetadata); + + // Verify isCimdUrl works correctly + expect(isCimdUrl(cimdClientId)).toBe(true); + + const knex = await databases.init(databaseId); + + await knex.migrate.latest({ + directory: resolvePackagePath( + '@backstage/plugin-auth-backend', + 'migrations', + ), + }); + + const authDatabase = AuthDatabase.create({ + getClient: async () => knex, + }); + + const oidcDatabase = await OidcDatabase.create({ + database: authDatabase, + }); + + const userInfoDatabase = await UserInfoDatabase.create({ + database: authDatabase, + }); + + const mockTokenIssuer = { + issueToken: jest.fn(), + listPublicKeys: jest.fn(), + } as unknown as jest.Mocked; + + const mockAuth = mockServices.auth.mock(); + const mockHttpAuth = mockServices.httpAuth.mock(); + // Only CIMD enabled, NOT DCR + const mockConfig = mockServices.rootConfig({ + data: { + auth: { + experimentalClientIdMetadataDocuments: { + enabled: true, + }, + // DCR is NOT enabled + }, + }, + }); + + const oidcRouter = OidcRouter.create({ + auth: mockAuth, + tokenIssuer: mockTokenIssuer, + baseUrl: 'http://localhost:7007/api/auth', + appUrl: 'http://localhost:3000', + logger: mockServices.logger.mock(), + userInfo: userInfoDatabase, + oidc: oidcDatabase, + httpAuth: mockHttpAuth, + config: mockConfig, + }); + + const { server } = await startTestBackend({ + features: [ + createBackendPlugin({ + pluginId: 'auth', + register(reg) { + reg.registerInit({ + deps: { httpRouter: coreServices.httpRouter }, + async init({ httpRouter }) { + httpRouter.use(oidcRouter.getRouter()); + httpRouter.addAuthPolicy({ + path: '/', + allow: 'unauthenticated', + }); + }, + }); + }, + }), + ], + }); + + const codeVerifier = 'test-code-verifier-for-pkce'; + const codeChallenge = crypto + .createHash('sha256') + .update(codeVerifier) + .digest('base64url'); + + // /v1/authorize should work with CIMD-only config + const authorizeResponse = await request(server) + .get('/api/auth/v1/authorize') + .query({ + client_id: cimdClientId, + redirect_uri: 'http://localhost:8080/callback', + response_type: 'code', + scope: 'openid', + state: 'test-state', + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }); + + expect(authorizeResponse.status).toBe(302); + + // Should redirect to consent screen + expect(mockFetchCimdMetadata).toHaveBeenCalledWith({ + clientId: cimdClientId, + validatedUrl: expect.any(URL), + }); + const location = new URL(authorizeResponse.header.location); + expect(location.origin).toBe('http://localhost:3000'); + expect(location.pathname).toMatch(/^\/oauth2\/authorize\/[a-f0-9-]+$/); + + // /v1/register should NOT be available (DCR disabled) + await request(server) + .post('/api/auth/v1/register') + .send({ + client_name: 'Test Client', + redirect_uris: ['https://example.com/callback'], + }) + .expect(404); + }); + }); }); }); diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts index 00861e09e4..2a651f47d3 100644 --- a/plugins/auth-backend/src/service/OidcRouter.ts +++ b/plugins/auth-backend/src/service/OidcRouter.ts @@ -32,6 +32,10 @@ import { z } from 'zod'; import { fromZodError } from 'zod-validation-error'; import { OidcError } from './OidcError'; +function ensureTrailingSlash(url: string): string { + return url.endsWith('/') ? url : `${url}/`; +} + const authorizeQuerySchema = z.object({ client_id: z.string().min(1), redirect_uri: z.string().url(), @@ -81,12 +85,13 @@ function validateRequest(schema: z.ZodSchema, data: unknown): T { return parseResult.data; } -async function authenticateClient( - req: { headers: { authorization?: string } }, - oidc: OidcService, - bodyClientId?: string, - bodyClientSecret?: string, -): Promise<{ clientId: string; clientSecret: string }> { +async function authenticateClient(opts: { + req: { headers: { authorization?: string } }; + oidc: OidcService; + bodyClientId?: string; + bodyClientSecret?: string; +}): Promise<{ clientId: string; clientSecret: string }> { + const { req, oidc, bodyClientId, bodyClientSecret } = opts; let clientId: string | undefined; let clientSecret: string | undefined; @@ -223,8 +228,11 @@ export class OidcRouter { const dcrEnabled = this.config.getOptionalBoolean( 'auth.experimentalDynamicClientRegistration.enabled', ); + const cimdEnabled = this.config.getOptionalBoolean( + 'auth.experimentalClientIdMetadataDocuments.enabled', + ); - if (dcrEnabled) { + if (dcrEnabled || cimdEnabled) { // Authorization endpoint // https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest // Handles the initial authorization request from the client, validates parameters, @@ -439,12 +447,12 @@ export class OidcRouter { let authenticatedClientId: string | undefined; if (hasCredentials) { - const { clientId: authedId } = await authenticateClient( + const { clientId: authedId } = await authenticateClient({ req, - this.oidc, + oidc: this.oidc, bodyClientId, bodyClientSecret, - ); + }); authenticatedClientId = authedId; } @@ -520,12 +528,12 @@ export class OidcRouter { client_secret: bodyClientSecret, } = validateRequest(revokeRequestBodySchema, req.body ?? {}); - await authenticateClient( + await authenticateClient({ req, - this.oidc, + oidc: this.oidc, bodyClientId, bodyClientSecret, - ); + }); try { await this.oidc.revokeRefreshToken(token); @@ -546,9 +554,3 @@ export class OidcRouter { return router; } } -function ensureTrailingSlash(appUrl: string): string { - if (appUrl.endsWith('/')) { - return appUrl; - } - return `${appUrl}/`; -} diff --git a/plugins/auth-backend/src/service/OidcService.test.ts b/plugins/auth-backend/src/service/OidcService.test.ts index 6242795565..903e352471 100644 --- a/plugins/auth-backend/src/service/OidcService.test.ts +++ b/plugins/auth-backend/src/service/OidcService.test.ts @@ -18,6 +18,7 @@ import { TestDatabaseId, TestDatabases, } from '@backstage/backend-test-utils'; +import { JsonObject } from '@backstage/types'; import { OidcService } from './OidcService'; import { BackstageCredentials, @@ -30,13 +31,33 @@ import { OidcDatabase } from '../database/OidcDatabase'; import { UserInfoDatabase } from '../database/UserInfoDatabase'; import crypto from 'node:crypto'; import { AnyJWK, TokenIssuer } from '../identity/types'; +import { CimdClientInfo } from './CimdClient'; + +jest.mock('./CimdClient', () => ({ + ...jest.requireActual('./CimdClient'), + fetchCimdMetadata: jest.fn(), +})); + +import * as CimdClient from './CimdClient'; + +const mockFetchCimdMetadata = + CimdClient.fetchCimdMetadata as jest.MockedFunction< + typeof CimdClient.fetchCimdMetadata + >; jest.setTimeout(60_000); describe('OidcService', () => { const databases = TestDatabases.create(); - async function createOidcService(databaseId: TestDatabaseId) { + interface CreateOidcServiceOptions { + databaseId: TestDatabaseId; + config?: JsonObject; + } + + async function createOidcService(options: CreateOidcServiceOptions) { + const { databaseId, config: configData = {} } = options; + const knex = await databases.init(databaseId); await knex.migrate.latest({ @@ -63,7 +84,7 @@ describe('OidcService', () => { getUserInfo: jest.fn(), } as unknown as jest.Mocked; - const mockConfig = mockServices.rootConfig.mock(); + const config = mockServices.rootConfig({ data: configData }); return { service: OidcService.create({ @@ -72,13 +93,12 @@ describe('OidcService', () => { baseUrl: 'http://mock-base-url', userInfo: mockUserInfo, oidc: oidcDatabase, - config: mockConfig, + config, }), mocks: { auth: mockAuth, tokenIssuer: mockTokenIssuer, userInfo: mockUserInfo, - config: mockConfig, }, }; } @@ -86,7 +106,7 @@ describe('OidcService', () => { describe.each(databases.eachSupportedId())('%p', databaseId => { describe('getConfiguration', () => { it('should return OIDC configuration', async () => { - const { service } = await createOidcService(databaseId); + const { service } = await createOidcService({ databaseId }); const config = service.getConfiguration(); @@ -124,7 +144,7 @@ describe('OidcService', () => { describe('listPublicKeys', () => { it('should return public keys from token issuer', async () => { - const { service, mocks } = await createOidcService(databaseId); + const { service, mocks } = await createOidcService({ databaseId }); const mockKeys = [{ kid: 'key-1', use: 'sig' }] as AnyJWK[]; mocks.tokenIssuer.listPublicKeys.mockResolvedValue({ keys: mockKeys }); @@ -137,7 +157,7 @@ describe('OidcService', () => { describe('getUserInfo', () => { it('should return user info for valid token', async () => { - const { service, mocks } = await createOidcService(databaseId); + const { service, mocks } = await createOidcService({ databaseId }); const mockCredentials: BackstageCredentials = { principal: { type: 'user', @@ -172,7 +192,7 @@ describe('OidcService', () => { }); it('should throw error for non-user principal', async () => { - const { service, mocks } = await createOidcService(databaseId); + const { service, mocks } = await createOidcService({ databaseId }); const mockCredentials: BackstageCredentials = { principal: { @@ -196,7 +216,7 @@ describe('OidcService', () => { describe('registerClient', () => { it('should create a new client with generated credentials', async () => { - const { service } = await createOidcService(databaseId); + const { service } = await createOidcService({ databaseId }); const client = await service.registerClient({ clientName: 'Test Client', @@ -220,14 +240,16 @@ describe('OidcService', () => { }); it('should throw an error for invalid redirect URI', async () => { - const { - service, - mocks: { config }, - } = await createOidcService(databaseId); - - config.getOptionalStringArray.mockReturnValue([ - 'https://example.com/*', - ]); + const { service } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalDynamicClientRegistration: { + allowedRedirectUriPatterns: ['https://example.com/*'], + }, + }, + }, + }); await expect( service.registerClient({ @@ -238,12 +260,16 @@ describe('OidcService', () => { }); it('should create a new client with valid redirect URI', async () => { - const { - service, - mocks: { config }, - } = await createOidcService(databaseId); - - config.getOptionalStringArray.mockReturnValue(['cursor:*']); + const { service } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalDynamicClientRegistration: { + allowedRedirectUriPatterns: ['cursor:*'], + }, + }, + }, + }); const client = await service.registerClient({ clientName: 'Test Client', @@ -258,7 +284,7 @@ describe('OidcService', () => { }); it('should create a client with default values', async () => { - const { service } = await createOidcService(databaseId); + const { service } = await createOidcService({ databaseId }); const client = await service.registerClient({ clientName: 'Test Client', @@ -277,7 +303,7 @@ describe('OidcService', () => { describe('createAuthorizationSession', () => { it('should create a authorization session for valid client', async () => { - const { service } = await createOidcService(databaseId); + const { service } = await createOidcService({ databaseId }); const client = await service.registerClient({ clientName: 'Test Client', @@ -301,7 +327,7 @@ describe('OidcService', () => { }); it('should throw error for invalid client', async () => { - const { service } = await createOidcService(databaseId); + const { service } = await createOidcService({ databaseId }); await expect( service.createAuthorizationSession({ @@ -313,7 +339,7 @@ describe('OidcService', () => { }); it('should throw error for invalid redirect URI', async () => { - const { service } = await createOidcService(databaseId); + const { service } = await createOidcService({ databaseId }); const client = await service.registerClient({ clientName: 'Test Client', @@ -330,7 +356,7 @@ describe('OidcService', () => { }); it('should throw error for unsupported response type', async () => { - const { service } = await createOidcService(databaseId); + const { service } = await createOidcService({ databaseId }); const client = await service.registerClient({ clientName: 'Test Client', @@ -347,7 +373,7 @@ describe('OidcService', () => { }); it('should handle PKCE parameters', async () => { - const { service } = await createOidcService(databaseId); + const { service } = await createOidcService({ databaseId }); const client = await service.registerClient({ clientName: 'Test Client', @@ -366,7 +392,7 @@ describe('OidcService', () => { }); it('should throw error for invalid PKCE method', async () => { - const { service } = await createOidcService(databaseId); + const { service } = await createOidcService({ databaseId }); const client = await service.registerClient({ clientName: 'Test Client', @@ -387,7 +413,7 @@ describe('OidcService', () => { describe('approveAuthorizationSession', () => { it('should approve a valid authorization session', async () => { - const { service } = await createOidcService(databaseId); + const { service } = await createOidcService({ databaseId }); const client = await service.registerClient({ clientName: 'Test Client', @@ -412,7 +438,7 @@ describe('OidcService', () => { }); it('should throw error for invalid authorization session', async () => { - const { service } = await createOidcService(databaseId); + const { service } = await createOidcService({ databaseId }); await expect( service.approveAuthorizationSession({ @@ -423,7 +449,7 @@ describe('OidcService', () => { }); it('should throw error when trying to approve an already approved session', async () => { - const { service } = await createOidcService(databaseId); + const { service } = await createOidcService({ databaseId }); const client = await service.registerClient({ clientName: 'Test Client', @@ -450,7 +476,7 @@ describe('OidcService', () => { }); it('should throw error when trying to approve an already rejected session', async () => { - const { service } = await createOidcService(databaseId); + const { service } = await createOidcService({ databaseId }); const client = await service.registerClient({ clientName: 'Test Client', @@ -479,7 +505,7 @@ describe('OidcService', () => { describe('getAuthorizationSession', () => { it('should return authorization session details', async () => { - const { service } = await createOidcService(databaseId); + const { service } = await createOidcService({ databaseId }); const client = await service.registerClient({ clientName: 'Test Client', @@ -512,7 +538,7 @@ describe('OidcService', () => { }); it('should throw error when trying to get an already approved session', async () => { - const { service } = await createOidcService(databaseId); + const { service } = await createOidcService({ databaseId }); const client = await service.registerClient({ clientName: 'Test Client', @@ -538,7 +564,7 @@ describe('OidcService', () => { }); it('should throw error when trying to get an already rejected session', async () => { - const { service } = await createOidcService(databaseId); + const { service } = await createOidcService({ databaseId }); const client = await service.registerClient({ clientName: 'Test Client', @@ -566,7 +592,7 @@ describe('OidcService', () => { describe('rejectAuthorizationSession', () => { it('should reject a authorization session', async () => { - const { service } = await createOidcService(databaseId); + const { service } = await createOidcService({ databaseId }); const client = await service.registerClient({ clientName: 'Test Client', @@ -592,7 +618,7 @@ describe('OidcService', () => { }); it('should throw error for invalid authorization session', async () => { - const { service } = await createOidcService(databaseId); + const { service } = await createOidcService({ databaseId }); await expect( service.rejectAuthorizationSession({ @@ -603,7 +629,7 @@ describe('OidcService', () => { }); it('should throw error when trying to reject an already approved session', async () => { - const { service } = await createOidcService(databaseId); + const { service } = await createOidcService({ databaseId }); const client = await service.registerClient({ clientName: 'Test Client', @@ -630,7 +656,7 @@ describe('OidcService', () => { }); it('should throw error when trying to reject an already rejected session', async () => { - const { service } = await createOidcService(databaseId); + const { service } = await createOidcService({ databaseId }); const client = await service.registerClient({ clientName: 'Test Client', @@ -659,7 +685,7 @@ describe('OidcService', () => { describe('exchangeCodeForToken', () => { it('should exchange valid code for tokens', async () => { - const { service, mocks } = await createOidcService(databaseId); + const { service, mocks } = await createOidcService({ databaseId }); const mockToken = 'mock-jwt-token'; mocks.tokenIssuer.issueToken.mockResolvedValue({ token: mockToken }); @@ -699,7 +725,7 @@ describe('OidcService', () => { }); it('should exchange valid code for tokens with custom expiration', async () => { - const { service, mocks } = await createOidcService(databaseId); + const { service, mocks } = await createOidcService({ databaseId }); const mockToken = 'mock-jwt-token'; mocks.tokenIssuer.issueToken.mockResolvedValue({ token: mockToken }); @@ -739,7 +765,7 @@ describe('OidcService', () => { }); it('should throw error for invalid grant type', async () => { - const { service } = await createOidcService(databaseId); + const { service } = await createOidcService({ databaseId }); await expect( service.exchangeCodeForToken({ @@ -752,7 +778,7 @@ describe('OidcService', () => { }); it('should handle PKCE verification', async () => { - const { service, mocks } = await createOidcService(databaseId); + const { service, mocks } = await createOidcService({ databaseId }); const mockToken = 'mock-jwt-token'; mocks.tokenIssuer.issueToken.mockResolvedValue({ token: mockToken }); @@ -794,7 +820,7 @@ describe('OidcService', () => { }); it('should throw error for invalid PKCE verifier', async () => { - const { service } = await createOidcService(databaseId); + const { service } = await createOidcService({ databaseId }); const client = await service.registerClient({ clientName: 'Test Client', @@ -828,5 +854,472 @@ describe('OidcService', () => { ).rejects.toThrow('Invalid code verifier'); }); }); + + describe('CIMD (Client ID Metadata Document) support', () => { + const cimdClientId = 'https://example.com/oauth-metadata.json'; + const cimdMetadata: CimdClientInfo = { + clientId: cimdClientId, + clientName: 'CIMD Test Client', + redirectUris: ['http://localhost:8080/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + scope: 'openid', + }; + + beforeEach(() => { + mockFetchCimdMetadata.mockResolvedValue(cimdMetadata); + }); + + afterEach(() => { + mockFetchCimdMetadata.mockReset(); + }); + + const pkceCodeVerifier = 'test-code-verifier-for-pkce'; + const pkceCodeChallenge = crypto + .createHash('sha256') + .update(pkceCodeVerifier) + .digest('base64url'); + const pkceParams = { + codeChallenge: pkceCodeChallenge, + codeChallengeMethod: 'S256' as const, + }; + + describe('getConfiguration', () => { + it('should include client_id_metadata_document_supported when CIMD is enabled', async () => { + const { service } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalClientIdMetadataDocuments: { enabled: true }, + }, + }, + }); + + const config = service.getConfiguration(); + + expect(config.client_id_metadata_document_supported).toBe(true); + }); + + it('should not include client_id_metadata_document_supported when CIMD is disabled', async () => { + const { service } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalClientIdMetadataDocuments: { enabled: false }, + }, + }, + }); + + const config = service.getConfiguration(); + + expect(config).not.toHaveProperty( + 'client_id_metadata_document_supported', + ); + }); + }); + + describe('createAuthorizationSession with CIMD', () => { + it('should create authorization session for CIMD client', async () => { + const { service } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalClientIdMetadataDocuments: { enabled: true }, + }, + }, + }); + + const authSession = await service.createAuthorizationSession({ + clientId: cimdClientId, + redirectUri: 'http://localhost:8080/callback', + responseType: 'code', + scope: 'openid', + ...pkceParams, + }); + + expect(authSession).toEqual({ + id: expect.any(String), + clientName: 'CIMD Test Client', + scope: 'openid', + redirectUri: 'http://localhost:8080/callback', + }); + expect(mockFetchCimdMetadata).toHaveBeenCalledWith({ + clientId: cimdClientId, + validatedUrl: expect.any(URL), + }); + }); + + it('should throw error when CIMD is disabled but URL client_id is provided', async () => { + const { service } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalClientIdMetadataDocuments: { enabled: false }, + }, + }, + }); + + await expect( + service.createAuthorizationSession({ + clientId: cimdClientId, + redirectUri: 'http://localhost:8080/callback', + responseType: 'code', + }), + ).rejects.toThrow('Client ID metadata documents not enabled'); + }); + + it('should throw error when client_id does not match allowedClientIdPatterns', async () => { + const { service } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalClientIdMetadataDocuments: { + enabled: true, + allowedClientIdPatterns: ['https://trusted.com/*'], + }, + }, + }, + }); + + await expect( + service.createAuthorizationSession({ + clientId: cimdClientId, // https://example.com/oauth-metadata.json + redirectUri: 'http://localhost:8080/callback', + responseType: 'code', + }), + ).rejects.toThrow('Invalid client_id'); + }); + + it('should accept client_id matching allowedClientIdPatterns', async () => { + const { service } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalClientIdMetadataDocuments: { + enabled: true, + allowedClientIdPatterns: ['https://example.com/*'], + }, + }, + }, + }); + + const authSession = await service.createAuthorizationSession({ + clientId: cimdClientId, + redirectUri: 'http://localhost:8080/callback', + responseType: 'code', + ...pkceParams, + }); + + expect(authSession).toEqual( + expect.objectContaining({ + id: expect.any(String), + clientName: 'CIMD Test Client', + }), + ); + }); + + it('should throw error for redirect_uri not in CIMD metadata', async () => { + const { service } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalClientIdMetadataDocuments: { enabled: true }, + }, + }, + }); + + await expect( + service.createAuthorizationSession({ + clientId: cimdClientId, + redirectUri: 'http://unauthorized.com/callback', + responseType: 'code', + }), + ).rejects.toThrow('Redirect URI not registered'); + }); + + it('should throw error when redirect_uri does not match allowedRedirectUriPatterns', async () => { + const { service } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalClientIdMetadataDocuments: { + enabled: true, + allowedRedirectUriPatterns: ['https://*.example.com/*'], + }, + }, + }, + }); + + await expect( + service.createAuthorizationSession({ + clientId: cimdClientId, + redirectUri: 'http://localhost:8080/callback', + responseType: 'code', + }), + ).rejects.toThrow('Invalid redirect_uri'); + }); + + it('should reject redirect_uri when CIMD metadata uses wildcard patterns', async () => { + mockFetchCimdMetadata.mockResolvedValue({ + ...cimdMetadata, + redirectUris: ['http://localhost:*/callback'], + }); + + const { service } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalClientIdMetadataDocuments: { + enabled: true, + allowedRedirectUriPatterns: ['http://localhost:*'], + }, + }, + }, + }); + + await expect( + service.createAuthorizationSession({ + clientId: cimdClientId, + redirectUri: 'http://localhost:8080/callback', + responseType: 'code', + ...pkceParams, + }), + ).rejects.toThrow('Redirect URI not registered'); + }); + + it('should reject redirect_uri not exactly matching CIMD metadata', async () => { + const { service } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalClientIdMetadataDocuments: { + enabled: true, + allowedRedirectUriPatterns: ['http://localhost:*'], + }, + }, + }, + }); + + await expect( + service.createAuthorizationSession({ + clientId: cimdClientId, + redirectUri: 'http://localhost:8080/other-path', + responseType: 'code', + ...pkceParams, + }), + ).rejects.toThrow('Redirect URI not registered'); + }); + + it('should require PKCE for CIMD clients', async () => { + const { service } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalClientIdMetadataDocuments: { enabled: true }, + }, + }, + }); + + await expect( + service.createAuthorizationSession({ + clientId: cimdClientId, + redirectUri: 'http://localhost:8080/callback', + responseType: 'code', + }), + ).rejects.toThrow('PKCE is required for public clients'); + }); + }); + + describe('getAuthorizationSession with CIMD', () => { + it('should return session details for CIMD client', async () => { + const { service } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalClientIdMetadataDocuments: { enabled: true }, + }, + }, + }); + + const authSession = await service.createAuthorizationSession({ + clientId: cimdClientId, + redirectUri: 'http://localhost:8080/callback', + responseType: 'code', + scope: 'openid', + state: 'test-state', + ...pkceParams, + }); + + const details = await service.getAuthorizationSession({ + sessionId: authSession.id, + }); + + expect(details).toEqual( + expect.objectContaining({ + id: authSession.id, + clientId: cimdClientId, + clientName: 'CIMD Test Client', + redirectUri: 'http://localhost:8080/callback', + scope: 'openid', + state: 'test-state', + }), + ); + }); + }); + + describe('full CIMD authorization flow', () => { + it('should complete full authorization flow for CIMD client', async () => { + const { service, mocks } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalClientIdMetadataDocuments: { enabled: true }, + }, + }, + }); + const mockToken = 'mock-jwt-token'; + mocks.tokenIssuer.issueToken.mockResolvedValue({ token: mockToken }); + + const authSession = await service.createAuthorizationSession({ + clientId: cimdClientId, + redirectUri: 'http://localhost:8080/callback', + responseType: 'code', + scope: 'openid', + ...pkceParams, + }); + + const approveResult = await service.approveAuthorizationSession({ + sessionId: authSession.id, + userEntityRef: 'user:default/test', + }); + + expect(approveResult.redirectUrl).toMatch( + /^http:\/\/localhost:8080\/callback\?code=.+$/, + ); + + const code = new URL(approveResult.redirectUrl).searchParams.get( + 'code', + )!; + const tokenResult = await service.exchangeCodeForToken({ + code, + redirectUri: 'http://localhost:8080/callback', + grantType: 'authorization_code', + codeVerifier: pkceCodeVerifier, + expiresIn: 3600, + }); + + expect(tokenResult).toEqual({ + accessToken: mockToken, + tokenType: 'Bearer', + expiresIn: 3600, + idToken: mockToken, + scope: 'openid', + }); + }); + + it('should complete CIMD flow with PKCE', async () => { + const { service, mocks } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalClientIdMetadataDocuments: { enabled: true }, + }, + }, + }); + const mockToken = 'mock-jwt-token'; + mocks.tokenIssuer.issueToken.mockResolvedValue({ token: mockToken }); + + const codeVerifier = 'test-code-verifier-for-pkce'; + const codeChallenge = crypto + .createHash('sha256') + .update(codeVerifier) + .digest('base64url'); + + // Create authorization session with PKCE + const authSession = await service.createAuthorizationSession({ + clientId: cimdClientId, + redirectUri: 'http://localhost:8080/callback', + responseType: 'code', + codeChallenge, + codeChallengeMethod: 'S256', + }); + + // Approve the session + const approveResult = await service.approveAuthorizationSession({ + sessionId: authSession.id, + userEntityRef: 'user:default/test', + }); + + // Exchange code for token with verifier + const code = new URL(approveResult.redirectUrl).searchParams.get( + 'code', + )!; + const tokenResult = await service.exchangeCodeForToken({ + code, + redirectUri: 'http://localhost:8080/callback', + grantType: 'authorization_code', + codeVerifier, + expiresIn: 3600, + }); + + expect(tokenResult.accessToken).toBe(mockToken); + }); + }); + + describe('coexistence of CIMD and DCR', () => { + it('should use DCR for non-URL client_id when both are enabled', async () => { + const { service } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalClientIdMetadataDocuments: { enabled: true }, + experimentalDynamicClientRegistration: { enabled: true }, + }, + }, + }); + + // Register a DCR client + const dcrClient = await service.registerClient({ + clientName: 'DCR Client', + redirectUris: ['https://example.com/callback'], + }); + + // Create session with DCR client + const authSession = await service.createAuthorizationSession({ + clientId: dcrClient.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + }); + + expect(authSession.clientName).toBe('DCR Client'); + expect(mockFetchCimdMetadata).not.toHaveBeenCalled(); + }); + + it('should use CIMD for URL client_id when both are enabled', async () => { + const { service } = await createOidcService({ + databaseId, + config: { + auth: { + experimentalClientIdMetadataDocuments: { enabled: true }, + experimentalDynamicClientRegistration: { enabled: true }, + }, + }, + }); + + const authSession = await service.createAuthorizationSession({ + clientId: cimdClientId, + redirectUri: 'http://localhost:8080/callback', + responseType: 'code', + ...pkceParams, + }); + + expect(authSession.clientName).toBe('CIMD Test Client'); + expect(mockFetchCimdMetadata).toHaveBeenCalledWith({ + clientId: cimdClientId, + validatedUrl: expect.any(URL), + }); + }); + }); + }); }); }); diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts index 991cc2bcb9..675dbcfd55 100644 --- a/plugins/auth-backend/src/service/OidcService.ts +++ b/plugins/auth-backend/src/service/OidcService.ts @@ -28,6 +28,7 @@ import { DateTime } from 'luxon'; import matcher from 'matcher'; import { OfflineAccessService } from './OfflineAccessService'; import { readDcrTokenExpiration } from './readTokenExpiration'; +import { validateCimdUrl, fetchCimdMetadata } from './CimdClient'; export class OidcService { private readonly auth: AuthService; @@ -80,6 +81,7 @@ export class OidcService { const dcrEnabled = this.config.getOptionalBoolean( 'auth.experimentalDynamicClientRegistration.enabled', ); + const { enabled: cimdEnabled } = this.getCimdConfig(); return { issuer: this.baseUrl, @@ -107,6 +109,7 @@ export class OidcService { token_endpoint_auth_methods_supported: [ 'client_secret_basic', 'client_secret_post', + ...(cimdEnabled ? ['none'] : []), ], claims_supported: ['sub', 'ent'], grant_types_supported: [ @@ -119,6 +122,7 @@ export class OidcService { registration_endpoint: `${this.baseUrl}/v1/register`, revocation_endpoint: `${this.baseUrl}/v1/revoke`, }), + ...(cimdEnabled && { client_id_metadata_document_supported: true }), }; } @@ -204,7 +208,13 @@ export class OidcService { throw new InputError('Only authorization code flow is supported'); } - const client = await this.resolveClient(clientId, redirectUri); + const client = await this.resolveClient({ clientId, redirectUri }); + + if (client.requiresPkce && !codeChallenge) { + throw new InputError( + 'PKCE is required for public clients. Provide a code_challenge parameter.', + ); + } if (codeChallenge) { if ( @@ -239,42 +249,105 @@ export class OidcService { }; } - private async getClientName(clientId: string): Promise { - const client = await this.oidc.getClient({ clientId }); - if (!client) { - throw new InputError('Invalid client_id'); - } - return client.clientName; + private getCimdConfig() { + return { + enabled: + this.config.getOptionalBoolean( + 'auth.experimentalClientIdMetadataDocuments.enabled', + ) ?? false, + allowedClientIdPatterns: this.config.getOptionalStringArray( + 'auth.experimentalClientIdMetadataDocuments.allowedClientIdPatterns', + ) ?? ['*'], + allowedRedirectUriPatterns: this.config.getOptionalStringArray( + 'auth.experimentalClientIdMetadataDocuments.allowedRedirectUriPatterns', + ) ?? ['*'], + }; } - private async resolveClient( - clientId: string, - redirectUri: string, - ): Promise<{ clientName: string; redirectUris: string[] }> { - const client = await this.oidc.getClient({ clientId }); + private async resolveClient(opts: { + clientId: string; + redirectUri?: string; + }) { + let cimdUrl: URL | undefined; + try { + cimdUrl = validateCimdUrl(opts.clientId); + } catch { + // Not a valid CIMD URL, fall through to DCR + } + + if (cimdUrl) { + return this.resolveCimdClient({ ...opts, cimdUrl }); + } + return this.resolveDcrClient(opts); + } + + private async resolveCimdClient(opts: { + clientId: string; + cimdUrl: URL; + redirectUri?: string; + }) { + const cimd = this.getCimdConfig(); + + if (!cimd.enabled) { + throw new InputError('Client ID metadata documents not enabled'); + } + + if ( + !cimd.allowedClientIdPatterns.some(pattern => + matcher.isMatch(opts.clientId, pattern), + ) + ) { + throw new InputError('Invalid client_id'); + } + + const cimdClient = await fetchCimdMetadata({ + clientId: opts.clientId, + validatedUrl: opts.cimdUrl, + }); + + if (opts.redirectUri) { + if ( + !cimd.allowedRedirectUriPatterns.some(pattern => + matcher.isMatch(opts.redirectUri!, pattern), + ) + ) { + throw new InputError('Invalid redirect_uri'); + } + + if (!cimdClient.redirectUris.includes(opts.redirectUri)) { + throw new InputError('Redirect URI not registered'); + } + } + + return { + clientName: cimdClient.clientName, + redirectUris: cimdClient.redirectUris, + requiresPkce: true, + }; + } + + private async resolveDcrClient(opts: { + clientId: string; + redirectUri?: string; + }) { + const client = await this.oidc.getClient({ clientId: opts.clientId }); if (!client) { throw new InputError('Invalid client_id'); } - if (!client.redirectUris.includes(redirectUri)) { + if (opts.redirectUri && !client.redirectUris.includes(opts.redirectUri)) { throw new InputError('Invalid redirect_uri'); } return { clientName: client.clientName, redirectUris: client.redirectUris, + requiresPkce: false, }; } - public async approveAuthorizationSession(opts: { - sessionId: string; - userEntityRef: string; - }) { - const { sessionId, userEntityRef } = opts; - - const session = await this.oidc.getAuthorizationSession({ - id: sessionId, - }); + private async getValidPendingSession(sessionId: string) { + const session = await this.oidc.getAuthorizationSession({ id: sessionId }); if (!session) { throw new NotFoundError('Invalid authorization session'); @@ -288,6 +361,16 @@ export class OidcService { throw new NotFoundError('Authorization session not found or expired'); } + return session; + } + + public async approveAuthorizationSession(opts: { + sessionId: string; + userEntityRef: string; + }) { + const { sessionId, userEntityRef } = opts; + const session = await this.getValidPendingSession(sessionId); + await this.oidc.updateAuthorizationSession({ id: session.id, userEntityRef, @@ -316,24 +399,11 @@ export class OidcService { } public async getAuthorizationSession(opts: { sessionId: string }) { - const session = await this.oidc.getAuthorizationSession({ - id: opts.sessionId, + const session = await this.getValidPendingSession(opts.sessionId); + const { clientName } = await this.resolveClient({ + clientId: session.clientId, }); - if (!session) { - throw new NotFoundError('Invalid authorization session'); - } - - if (DateTime.fromJSDate(session.expiresAt) < DateTime.now()) { - throw new InputError('Authorization session expired'); - } - - if (session.status !== 'pending') { - throw new NotFoundError('Authorization session not found or expired'); - } - - const clientName = await this.getClientName(session.clientId); - return { id: session.id, clientId: session.clientId, @@ -355,22 +425,7 @@ export class OidcService { userEntityRef: string; }) { const { sessionId, userEntityRef } = opts; - - const session = await this.oidc.getAuthorizationSession({ - id: sessionId, - }); - - if (!session) { - throw new NotFoundError('Invalid authorization session'); - } - - if (DateTime.fromJSDate(session.expiresAt) < DateTime.now()) { - throw new InputError('Authorization session expired'); - } - - if (session.status !== 'pending') { - throw new NotFoundError('Authorization session not found or expired'); - } + const session = await this.getValidPendingSession(sessionId); await this.oidc.updateAuthorizationSession({ id: session.id, @@ -431,11 +486,11 @@ export class OidcService { } if ( - !this.verifyPkce( - session.codeChallenge, + !this.verifyPkce({ + codeChallenge: session.codeChallenge, codeVerifier, - session.codeChallengeMethod, - ) + method: session.codeChallengeMethod, + }) ) { throw new AuthenticationError('Invalid code verifier'); } @@ -532,19 +587,21 @@ export class OidcService { await this.offlineAccess.revokeRefreshToken(token); } - private verifyPkce( - codeChallenge: string, - codeVerifier: string, - method?: string, - ): boolean { - if (!method || method === 'plain') { - return codeChallenge === codeVerifier; + private verifyPkce(opts: { + codeChallenge: string; + codeVerifier: string; + method?: string; + }): boolean { + if (!opts.method || opts.method === 'plain') { + return opts.codeChallenge === opts.codeVerifier; } - if (method === 'S256') { - const hash = crypto.createHash('sha256').update(codeVerifier).digest(); - const base64urlHash = hash.toString('base64url'); - return codeChallenge === base64urlHash; + if (opts.method === 'S256') { + const hash = crypto + .createHash('sha256') + .update(opts.codeVerifier) + .digest('base64url'); + return opts.codeChallenge === hash; } return false; diff --git a/plugins/mcp-actions-backend/package.json b/plugins/mcp-actions-backend/package.json index 501a1d09e5..3a0fd5c366 100644 --- a/plugins/mcp-actions-backend/package.json +++ b/plugins/mcp-actions-backend/package.json @@ -49,6 +49,8 @@ "@backstage/backend-defaults": "workspace:^", "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "@types/express": "^4.17.6" + "@types/express": "^4.17.6", + "@types/supertest": "^2.0.8", + "supertest": "^7.0.0" } } diff --git a/plugins/mcp-actions-backend/src/plugin.test.ts b/plugins/mcp-actions-backend/src/plugin.test.ts index 47dd89c9fc..0bbe5d5340 100644 --- a/plugins/mcp-actions-backend/src/plugin.test.ts +++ b/plugins/mcp-actions-backend/src/plugin.test.ts @@ -21,6 +21,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import request from 'supertest'; describe('Mcp Backend', () => { const mockPluginWithActions = createBackendPlugin({ @@ -162,4 +163,91 @@ describe('Mcp Backend', () => { }, ]); }); + + describe('OAuth well-known endpoints', () => { + it('should not expose oauth endpoints when neither DCR nor CIMD is enabled', async () => { + const { server } = await startTestBackend({ + features: [ + mcpPlugin, + mockPluginWithActions, + mockServices.rootConfig.factory({ + data: { + backend: { + actions: { + pluginSources: ['local'], + }, + }, + }, + }), + ], + }); + + const response = await request(server).get( + '/.well-known/oauth-protected-resource', + ); + expect(response.status).toBe(404); + }); + + it('should expose oauth-protected-resource when DCR is enabled', async () => { + const { server } = await startTestBackend({ + features: [ + mcpPlugin, + mockPluginWithActions, + mockServices.rootConfig.factory({ + data: { + backend: { + actions: { + pluginSources: ['local'], + }, + }, + auth: { + experimentalDynamicClientRegistration: { + enabled: true, + }, + }, + }, + }), + ], + }); + + const response = await request(server).get( + '/.well-known/oauth-protected-resource', + ); + expect(response.status).toBe(200); + expect(response.body.resource).toMatch(/\/api\/mcp-actions$/); + expect(response.body.authorization_servers).toHaveLength(1); + expect(response.body.authorization_servers[0]).toMatch(/\/api\/auth$/); + }); + + it('should expose oauth-protected-resource when CIMD is enabled', async () => { + const { server } = await startTestBackend({ + features: [ + mcpPlugin, + mockPluginWithActions, + mockServices.rootConfig.factory({ + data: { + backend: { + actions: { + pluginSources: ['local'], + }, + }, + auth: { + experimentalClientIdMetadataDocuments: { + enabled: true, + }, + }, + }, + }), + ], + }); + + const response = await request(server).get( + '/.well-known/oauth-protected-resource', + ); + expect(response.status).toBe(200); + expect(response.body.resource).toMatch(/\/api\/mcp-actions$/); + expect(response.body.authorization_servers).toHaveLength(1); + expect(response.body.authorization_servers[0]).toMatch(/\/api\/auth$/); + }); + }); }); diff --git a/plugins/mcp-actions-backend/src/plugin.ts b/plugins/mcp-actions-backend/src/plugin.ts index d04df6cdf5..34a9501fed 100644 --- a/plugins/mcp-actions-backend/src/plugin.ts +++ b/plugins/mcp-actions-backend/src/plugin.ts @@ -79,13 +79,18 @@ export const mcpPlugin = createBackendPlugin({ httpRouter.use(router); - if ( + const oauthEnabled = config.getOptionalBoolean( 'auth.experimentalDynamicClientRegistration.enabled', - ) - ) { + ) || + config.getOptionalBoolean( + 'auth.experimentalClientIdMetadataDocuments.enabled', + ); + + if (oauthEnabled) { + // OAuth Authorization Server Metadata (RFC 8414) // This should be replaced with throwing a WWW-Authenticate header, but that doesn't seem to be supported by - // many of the MCP client as of yet. So this seems to be the oldest version of the spec thats implemented. + // many of the MCP clients as of yet. So this seems to be the oldest version of the spec that's implemented. rootRouter.use( '/.well-known/oauth-authorization-server', async (_, res) => { @@ -93,10 +98,26 @@ export const mcpPlugin = createBackendPlugin({ const oidcResponse = await fetch( `${authBaseUrl}/.well-known/openid-configuration`, ); - res.json(await oidcResponse.json()); }, ); + + // Protected Resource Metadata (RFC 9728) + // https://datatracker.ietf.org/doc/html/rfc9728 + // This allows MCP clients to discover the authorization server for this resource + rootRouter.use( + '/.well-known/oauth-protected-resource', + async (_, res) => { + const [authBaseUrl, mcpBaseUrl] = await Promise.all([ + discovery.getBaseUrl('auth'), + discovery.getBaseUrl('mcp-actions'), + ]); + res.json({ + resource: mcpBaseUrl, + authorization_servers: [authBaseUrl], + }); + }, + ); } }, }); diff --git a/yarn.lock b/yarn.lock index 458e4ad3e1..4ce3930168 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4762,12 +4762,14 @@ __metadata: express: "npm:^4.22.0" express-promise-router: "npm:^4.1.0" express-session: "npm:^1.17.1" + ipaddr.js: "npm:^2.3.0" jose: "npm:^5.0.0" knex: "npm:^3.0.0" lodash: "npm:^4.17.21" luxon: "npm:^3.0.0" matcher: "npm:^4.0.0" minimatch: "npm:^9.0.0" + msw: "npm:^1.0.0" passport: "npm:^0.7.0" supertest: "npm:^7.0.0" uuid: "npm:^11.0.0" @@ -6276,8 +6278,10 @@ __metadata: "@cfworker/json-schema": "npm:^4.1.1" "@modelcontextprotocol/sdk": "npm:^1.25.2" "@types/express": "npm:^4.17.6" + "@types/supertest": "npm:^2.0.8" express: "npm:^4.22.0" express-promise-router: "npm:^4.1.0" + supertest: "npm:^7.0.0" zod: "npm:^3.25.76" languageName: unknown linkType: soft @@ -34796,10 +34800,10 @@ __metadata: languageName: node linkType: hard -"ipaddr.js@npm:^2.1.0": - version: 2.2.0 - resolution: "ipaddr.js@npm:2.2.0" - checksum: 10/9e1cdd9110b3bca5d910ab70d7fb1933e9c485d9b92cb14ef39f30c412ba3fe02a553921bf696efc7149cc653453c48ccf173adb996ec27d925f1f340f872986 +"ipaddr.js@npm:^2.1.0, ipaddr.js@npm:^2.3.0": + version: 2.3.0 + resolution: "ipaddr.js@npm:2.3.0" + checksum: 10/be3d01bc2e20fc2dc5349b489ea40883954b816ce3e57aa48ad943d4e7c4ace501f28a7a15bde4b96b6b97d0fbb28d599ff2f87399f3cda7bd728889402eed3b languageName: node linkType: hard