fix(auth-backend): harden default allowed patterns for CIMD and DCR (#34260)

* fix(auth-backend): harden default allowed patterns for CIMD and DCR

Signed-off-by: benjdlambert <ben@blam.sh>

* address PR review feedback for OIDC defaults

- narrow CLI client ID pattern to exact cli.json path
- add BREAKING prefix to changeset
- add IPv6 [::1] to docs examples
- add loopback redirect URI tests for IPv6 and 127.0.0.1

Signed-off-by: benjdlambert <ben@blam.sh>

* remove dead ['*'] fallback when features are disabled

The restrictive defaults are now always used regardless of the enabled
flag, since the patterns are only consulted on code paths that require
the feature to be enabled.

Signed-off-by: benjdlambert <ben@blam.sh>

* add default pattern tests and fix docs cli example

Signed-off-by: benjdlambert <ben@blam.sh>

* use URL constructor for CLI client ID

Signed-off-by: benjdlambert <ben@blam.sh>

* use string templating for cliClientId to match OidcRouter

Signed-off-by: benjdlambert <ben@blam.sh>

* fix docs: remove misleading CLI client_id URL example

Signed-off-by: benjdlambert <ben@blam.sh>

---------

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
Ben Lambert
2026-05-19 09:45:31 +02:00
committed by GitHub
parent 50d97e714e
commit 29d398b57c
8 changed files with 404 additions and 93 deletions
+37
View File
@@ -0,0 +1,37 @@
---
'@backstage/plugin-auth-backend': minor
---
**BREAKING**: Hardened the default allowed patterns for CIMD and DCR to replace the previous permissive `['*']` wildcards with specific defaults for known MCP clients. If you previously relied on the default `['*']` patterns, you will need to explicitly configure the patterns you need in your `app-config.yaml`.
**CIMD (`experimentalClientIdMetadataDocuments`):**
- `allowedClientIdPatterns` now defaults to Claude, VS Code, and the built-in Backstage CLI instead of `['*']`
- `allowedRedirectUriPatterns` now defaults to loopback addresses (localhost, 127.0.0.1, [::1]) instead of `['*']`
**DCR (`experimentalDynamicClientRegistration`):**
- `allowedRedirectUriPatterns` now defaults to Cursor and loopback addresses instead of `['*']`
If you need to allow additional clients or redirect URIs, you can override these defaults in your `app-config.yaml`:
```yaml
auth:
experimentalClientIdMetadataDocuments:
enabled: true
allowedClientIdPatterns:
- 'https://claude.ai/*'
- 'https://vscode.dev/*'
- 'https://my-custom-client.example.com/*'
allowedRedirectUriPatterns:
- 'http://localhost:*'
- 'http://127.0.0.1:*'
- 'https://my-app.example.com/callback'
experimentalDynamicClientRegistration:
enabled: true
allowedRedirectUriPatterns:
- 'cursor://*'
- 'http://localhost:*'
- 'http://127.0.0.1:*'
- 'myapp://*'
```
@@ -258,6 +258,7 @@ LocalStack
lockdown
lockfile
lockfiles
loopback
lookbehind
lookup
lookups
@@ -593,4 +594,4 @@ Zhou
zod
Zolotusky
zoomable
zsh
zsh
-7
View File
@@ -355,15 +355,8 @@ scaffolder:
auth:
experimentalDynamicClientRegistration:
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.
+22 -12
View File
@@ -200,14 +200,21 @@ This can be enabled in the `auth-backend` plugin by using the `auth.experimental
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/*'
# Optional: override which client_id URLs are allowed.
# Defaults to Claude, VS Code, and the built-in Backstage CLI.
# Note: setting this replaces the defaults entirely. The built-in
# CLI pattern is derived from your auth backend's base URL and
# must be re-added manually if you override this list.
# allowedClientIdPatterns:
# - 'https://claude.ai/*'
# - 'https://vscode.dev/*'
# - 'https://my-custom-client.example.com/*'
# Optional: override which redirect URIs are allowed.
# Defaults to loopback addresses (localhost, 127.0.0.1, [::1]).
# allowedRedirectUriPatterns:
# - 'http://localhost:*'
# - 'http://127.0.0.1:*'
# - 'http://[::1]:*'
```
#### Dynamic Client Registration
@@ -224,10 +231,13 @@ This can be enabled in the `auth-backend` plugin by using the `auth.experimental
auth:
experimentalDynamicClientRegistration:
enabled: true
# Optional: limit valid callback URLs for added security
allowedRedirectUriPatterns:
- cursor://*
# Optional: restrict which redirect URIs are allowed.
# Defaults to Cursor and loopback addresses (localhost, 127.0.0.1, [::1]).
# allowedRedirectUriPatterns:
# - 'cursor://*'
# - 'http://localhost:*'
# - 'http://127.0.0.1:*'
# - 'http://[::1]:*'
```
## Configuring MCP Clients
+5 -3
View File
@@ -163,7 +163,8 @@ export interface Config {
/**
* A list of allowed URI patterns to use for redirect URIs during
* dynamic client registration. Defaults to '[*]' which allows any redirect URI.
* dynamic client registration.
* Defaults to Cursor and loopback addresses (localhost, 127.0.0.1, [::1]).
*/
allowedRedirectUriPatterns?: string[];
};
@@ -183,7 +184,8 @@ export interface Config {
/**
* 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.
* Defaults to `['https://claude.ai/*', 'https://vscode.dev/*', '{baseUrl}/.well-known/oauth-client/cli.json']`
* where `{baseUrl}` is the auth backend's base URL.
*
* @example ['https://example.com/*', 'https://*.trusted-domain.com/*']
*/
@@ -192,7 +194,7 @@ export interface Config {
/**
* 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.
* Defaults to loopback addresses (localhost, 127.0.0.1, [::1]).
*
* @example ['http://localhost:*', 'http://127.0.0.1:*\/callback']
*/
@@ -98,6 +98,7 @@ describe('OidcRouter', () => {
auth: {
experimentalDynamicClientRegistration: {
enabled: true,
allowedRedirectUriPatterns: ['*'],
},
},
},
@@ -182,6 +183,7 @@ describe('OidcRouter', () => {
auth: {
experimentalDynamicClientRegistration: {
enabled: true,
allowedRedirectUriPatterns: ['*'],
},
experimentalRefreshToken: {
enabled: true,
@@ -1349,6 +1351,8 @@ describe('OidcRouter', () => {
auth: {
experimentalClientIdMetadataDocuments: {
enabled: true,
allowedClientIdPatterns: ['*'],
allowedRedirectUriPatterns: ['*'],
},
},
},
@@ -1441,6 +1445,8 @@ describe('OidcRouter', () => {
auth: {
experimentalClientIdMetadataDocuments: {
enabled: true,
allowedClientIdPatterns: ['*'],
allowedRedirectUriPatterns: ['*'],
},
// DCR is NOT enabled
},
@@ -224,7 +224,7 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['https://example.com/callback'],
redirectUris: ['http://localhost:8080/callback'],
responseTypes: ['code'],
grantTypes: ['authorization_code'],
scope: 'openid',
@@ -233,7 +233,7 @@ describe('OidcService', () => {
expect(client).toEqual(
expect.objectContaining({
clientName: 'Test Client',
redirectUris: ['https://example.com/callback'],
redirectUris: ['http://localhost:8080/callback'],
responseTypes: ['code'],
grantTypes: ['authorization_code'],
scope: 'openid',
@@ -287,6 +287,74 @@ describe('OidcService', () => {
);
});
it('should accept IPv6 loopback redirect URI', async () => {
const { service } = await createOidcService({
databaseId,
config: {
auth: {
experimentalDynamicClientRegistration: {
allowedRedirectUriPatterns: [
'http://[::1]:*',
'http://[::1]/*',
],
},
},
},
});
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['http://[::1]:3000/callback'],
});
expect(client).toEqual(
expect.objectContaining({
redirectUris: ['http://[::1]:3000/callback'],
}),
);
});
it('should accept loopback redirect URIs with default patterns', async () => {
const { service } = await createOidcService({ databaseId });
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['http://localhost:3000/callback'],
});
expect(client).toEqual(
expect.objectContaining({
redirectUris: ['http://localhost:3000/callback'],
}),
);
});
it('should accept cursor redirect URIs with default patterns', async () => {
const { service } = await createOidcService({ databaseId });
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['cursor://callback'],
});
expect(client).toEqual(
expect.objectContaining({
redirectUris: ['cursor://callback'],
}),
);
});
it('should reject non-loopback redirect URIs with default patterns', async () => {
const { service } = await createOidcService({ databaseId });
await expect(
service.registerClient({
clientName: 'Test Client',
redirectUris: ['https://example.com/callback'],
}),
).rejects.toThrow('Invalid redirect_uri');
});
it('should reject redirect URIs containing userinfo', async () => {
const { service } = await createOidcService({
databaseId,
@@ -338,12 +406,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['https://example.com/callback'],
redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
scope: 'openid',
state: 'test-state',
@@ -353,7 +421,7 @@ describe('OidcService', () => {
id: expect.any(String),
clientName: 'Test Client',
scope: 'openid',
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
});
});
@@ -363,7 +431,7 @@ describe('OidcService', () => {
await expect(
service.createAuthorizationSession({
clientId: 'invalid-client',
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
}),
).rejects.toThrow('Invalid client_id');
@@ -374,7 +442,7 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['https://example.com/callback'],
redirectUris: ['http://localhost:8080/callback'],
});
await expect(
@@ -391,13 +459,13 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['https://example.com/callback'],
redirectUris: ['http://localhost:8080/callback'],
});
await expect(
service.createAuthorizationSession({
clientId: client.clientId,
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
responseType: 'token',
}),
).rejects.toThrow('Only authorization code flow is supported');
@@ -408,12 +476,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['https://example.com/callback'],
redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
codeChallenge: 'test-challenge',
codeChallengeMethod: 'S256',
@@ -427,13 +495,13 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['https://example.com/callback'],
redirectUris: ['http://localhost:8080/callback'],
});
await expect(
service.createAuthorizationSession({
clientId: client.clientId,
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
codeChallenge: 'test-challenge',
codeChallengeMethod: 'invalid',
@@ -448,12 +516,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['https://example.com/callback'],
redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
state: 'test-state',
});
@@ -464,7 +532,7 @@ describe('OidcService', () => {
});
expect(result.redirectUrl).toMatch(
/^https:\/\/example\.com\/callback\?code=.+&state=test-state$/,
/^http:\/\/localhost:8080\/callback\?code=.+&state=test-state$/,
);
});
@@ -484,12 +552,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['https://example.com/callback'],
redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
});
@@ -511,12 +579,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['https://example.com/callback'],
redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
});
@@ -540,12 +608,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['https://example.com/callback'],
redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
scope: 'openid',
state: 'test-state',
@@ -560,7 +628,7 @@ describe('OidcService', () => {
id: authSession.id,
clientId: client.clientId,
clientName: 'Test Client',
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
scope: 'openid',
state: 'test-state',
responseType: 'code',
@@ -573,12 +641,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['https://example.com/callback'],
redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
});
@@ -599,12 +667,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['https://example.com/callback'],
redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
});
@@ -627,12 +695,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['https://example.com/callback'],
redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
});
@@ -664,12 +732,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['https://example.com/callback'],
redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
});
@@ -691,12 +759,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['https://example.com/callback'],
redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
});
@@ -722,12 +790,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['https://example.com/callback'],
redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
scope: 'openid',
});
@@ -741,7 +809,7 @@ describe('OidcService', () => {
const tokenResult = await service.exchangeCodeForToken({
code,
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
grantType: 'authorization_code',
});
@@ -760,7 +828,7 @@ describe('OidcService', () => {
await expect(
service.exchangeCodeForToken({
code: 'test-code',
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
grantType: 'client_credentials',
}),
).rejects.toThrow('Unsupported grant type');
@@ -773,7 +841,7 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['https://example.com/callback'],
redirectUris: ['http://localhost:8080/callback'],
});
const codeVerifier = 'test-code-verifier';
@@ -784,7 +852,7 @@ describe('OidcService', () => {
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
codeChallenge,
codeChallengeMethod: 'S256',
@@ -799,7 +867,7 @@ describe('OidcService', () => {
const tokenResult = await service.exchangeCodeForToken({
code,
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
grantType: 'authorization_code',
codeVerifier,
});
@@ -812,13 +880,13 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['https://example.com/callback'],
redirectUris: ['http://localhost:8080/callback'],
});
const codeChallenge = 'test-challenge';
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
codeChallenge,
codeChallengeMethod: 'S256',
@@ -834,7 +902,7 @@ describe('OidcService', () => {
await expect(
service.exchangeCodeForToken({
code,
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
grantType: 'authorization_code',
codeVerifier: 'invalid-verifier',
}),
@@ -857,12 +925,12 @@ describe('OidcService', () => {
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['https://example.com/callback'],
redirectUris: ['http://localhost:8080/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
scope: 'openid offline_access',
});
@@ -876,7 +944,7 @@ describe('OidcService', () => {
const tokenResult = await service.exchangeCodeForToken({
code,
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
grantType: 'authorization_code',
});
@@ -921,7 +989,11 @@ describe('OidcService', () => {
databaseId,
config: {
auth: {
experimentalClientIdMetadataDocuments: { enabled: true },
experimentalClientIdMetadataDocuments: {
enabled: true,
allowedClientIdPatterns: ['*'],
allowedRedirectUriPatterns: ['*'],
},
},
},
});
@@ -950,12 +1022,68 @@ describe('OidcService', () => {
});
describe('createAuthorizationSession with CIMD', () => {
it('should accept loopback redirect URIs with default CIMD patterns', async () => {
const { service } = await createOidcService({
databaseId,
config: {
auth: {
experimentalClientIdMetadataDocuments: {
enabled: true,
allowedClientIdPatterns: ['*'],
},
},
},
});
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',
});
});
it('should reject non-loopback redirect URIs with default CIMD patterns', async () => {
const { service } = await createOidcService({
databaseId,
config: {
auth: {
experimentalClientIdMetadataDocuments: {
enabled: true,
allowedClientIdPatterns: ['*'],
},
},
},
});
await expect(
service.createAuthorizationSession({
clientId: cimdClientId,
redirectUri: 'https://example.com/callback',
responseType: 'code',
...pkceParams,
}),
).rejects.toThrow('Invalid redirect_uri');
});
it('should create authorization session for CIMD client', async () => {
const { service } = await createOidcService({
databaseId,
config: {
auth: {
experimentalClientIdMetadataDocuments: { enabled: true },
experimentalClientIdMetadataDocuments: {
enabled: true,
allowedClientIdPatterns: ['*'],
allowedRedirectUriPatterns: ['*'],
},
},
},
});
@@ -1054,7 +1182,11 @@ describe('OidcService', () => {
databaseId,
config: {
auth: {
experimentalClientIdMetadataDocuments: { enabled: true },
experimentalClientIdMetadataDocuments: {
enabled: true,
allowedClientIdPatterns: ['*'],
allowedRedirectUriPatterns: ['*'],
},
},
},
});
@@ -1075,6 +1207,7 @@ describe('OidcService', () => {
auth: {
experimentalClientIdMetadataDocuments: {
enabled: true,
allowedClientIdPatterns: ['*'],
allowedRedirectUriPatterns: ['https://*.example.com/*'],
},
},
@@ -1100,7 +1233,11 @@ describe('OidcService', () => {
databaseId,
config: {
auth: {
experimentalClientIdMetadataDocuments: { enabled: true },
experimentalClientIdMetadataDocuments: {
enabled: true,
allowedClientIdPatterns: ['*'],
allowedRedirectUriPatterns: ['*'],
},
},
},
});
@@ -1131,7 +1268,11 @@ describe('OidcService', () => {
databaseId,
config: {
auth: {
experimentalClientIdMetadataDocuments: { enabled: true },
experimentalClientIdMetadataDocuments: {
enabled: true,
allowedClientIdPatterns: ['*'],
allowedRedirectUriPatterns: ['*'],
},
},
},
});
@@ -1146,6 +1287,82 @@ describe('OidcService', () => {
).rejects.toThrow('Redirect URI not registered');
});
it('should accept IPv6 loopback redirect_uri with a different port per RFC 8252', async () => {
mockFetchCimdMetadata.mockResolvedValue({
...cimdMetadata,
redirectUris: ['http://[::1]/callback'],
});
const { service } = await createOidcService({
databaseId,
config: {
auth: {
experimentalClientIdMetadataDocuments: {
enabled: true,
allowedClientIdPatterns: ['*'],
allowedRedirectUriPatterns: [
'http://[::1]:*',
'http://[::1]/*',
],
},
},
},
});
const authSession = await service.createAuthorizationSession({
clientId: cimdClientId,
redirectUri: 'http://[::1]:54321/callback',
responseType: 'code',
scope: 'openid',
...pkceParams,
});
expect(authSession).toEqual({
id: expect.any(String),
clientName: 'CIMD Test Client',
scope: 'openid',
redirectUri: 'http://[::1]:54321/callback',
});
});
it('should accept 127.0.0.1 loopback redirect_uri with a different port per RFC 8252', async () => {
mockFetchCimdMetadata.mockResolvedValue({
...cimdMetadata,
redirectUris: ['http://127.0.0.1/callback'],
});
const { service } = await createOidcService({
databaseId,
config: {
auth: {
experimentalClientIdMetadataDocuments: {
enabled: true,
allowedClientIdPatterns: ['*'],
allowedRedirectUriPatterns: [
'http://127.0.0.1:*',
'http://127.0.0.1/*',
],
},
},
},
});
const authSession = await service.createAuthorizationSession({
clientId: cimdClientId,
redirectUri: 'http://127.0.0.1:54321/callback',
responseType: 'code',
scope: 'openid',
...pkceParams,
});
expect(authSession).toEqual({
id: expect.any(String),
clientName: 'CIMD Test Client',
scope: 'openid',
redirectUri: 'http://127.0.0.1:54321/callback',
});
});
it('should reject redirect_uri when CIMD metadata uses wildcard patterns', async () => {
mockFetchCimdMetadata.mockResolvedValue({
...cimdMetadata,
@@ -1158,6 +1375,7 @@ describe('OidcService', () => {
auth: {
experimentalClientIdMetadataDocuments: {
enabled: true,
allowedClientIdPatterns: ['*'],
allowedRedirectUriPatterns: ['http://localhost:*'],
},
},
@@ -1181,6 +1399,7 @@ describe('OidcService', () => {
auth: {
experimentalClientIdMetadataDocuments: {
enabled: true,
allowedClientIdPatterns: ['*'],
allowedRedirectUriPatterns: ['http://localhost:*'],
},
},
@@ -1202,7 +1421,11 @@ describe('OidcService', () => {
databaseId,
config: {
auth: {
experimentalClientIdMetadataDocuments: { enabled: true },
experimentalClientIdMetadataDocuments: {
enabled: true,
allowedClientIdPatterns: ['*'],
allowedRedirectUriPatterns: ['*'],
},
},
},
});
@@ -1223,7 +1446,11 @@ describe('OidcService', () => {
databaseId,
config: {
auth: {
experimentalClientIdMetadataDocuments: { enabled: true },
experimentalClientIdMetadataDocuments: {
enabled: true,
allowedClientIdPatterns: ['*'],
allowedRedirectUriPatterns: ['*'],
},
},
},
});
@@ -1260,7 +1487,11 @@ describe('OidcService', () => {
databaseId,
config: {
auth: {
experimentalClientIdMetadataDocuments: { enabled: true },
experimentalClientIdMetadataDocuments: {
enabled: true,
allowedClientIdPatterns: ['*'],
allowedRedirectUriPatterns: ['*'],
},
},
},
});
@@ -1308,7 +1539,11 @@ describe('OidcService', () => {
databaseId,
config: {
auth: {
experimentalClientIdMetadataDocuments: { enabled: true },
experimentalClientIdMetadataDocuments: {
enabled: true,
allowedClientIdPatterns: ['*'],
allowedRedirectUriPatterns: ['*'],
},
},
},
});
@@ -1357,8 +1592,15 @@ describe('OidcService', () => {
databaseId,
config: {
auth: {
experimentalClientIdMetadataDocuments: { enabled: true },
experimentalDynamicClientRegistration: { enabled: true },
experimentalClientIdMetadataDocuments: {
enabled: true,
allowedClientIdPatterns: ['*'],
allowedRedirectUriPatterns: ['*'],
},
experimentalDynamicClientRegistration: {
enabled: true,
allowedRedirectUriPatterns: ['*'],
},
},
},
});
@@ -1366,13 +1608,13 @@ describe('OidcService', () => {
// Register a DCR client
const dcrClient = await service.registerClient({
clientName: 'DCR Client',
redirectUris: ['https://example.com/callback'],
redirectUris: ['http://localhost:8080/callback'],
});
// Create session with DCR client
const authSession = await service.createAuthorizationSession({
clientId: dcrClient.clientId,
redirectUri: 'https://example.com/callback',
redirectUri: 'http://localhost:8080/callback',
responseType: 'code',
});
@@ -1385,8 +1627,15 @@ describe('OidcService', () => {
databaseId,
config: {
auth: {
experimentalClientIdMetadataDocuments: { enabled: true },
experimentalDynamicClientRegistration: { enabled: true },
experimentalClientIdMetadataDocuments: {
enabled: true,
allowedClientIdPatterns: ['*'],
allowedRedirectUriPatterns: ['*'],
},
experimentalDynamicClientRegistration: {
enabled: true,
allowedRedirectUriPatterns: ['*'],
},
},
},
});
@@ -46,6 +46,14 @@ function validateRedirectUri(
}
const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]'];
const LOOPBACK_REDIRECT_PATTERNS = [
'http://localhost:*',
'http://localhost/*',
'http://127.0.0.1:*',
'http://127.0.0.1/*',
'http://[::1]:*',
'http://[::1]/*',
];
/**
* RFC 8252 Section 7.3: For loopback redirect URIs, the authorization server
@@ -213,7 +221,7 @@ export class OidcService {
const allowedRedirectUriPatterns = this.config.getOptionalStringArray(
'auth.experimentalDynamicClientRegistration.allowedRedirectUriPatterns',
) ?? ['*'];
) ?? ['cursor://*', ...LOOPBACK_REDIRECT_PATTERNS];
for (const redirectUri of opts.redirectUris ?? []) {
validateRedirectUri(redirectUri, allowedRedirectUriPatterns);
@@ -297,17 +305,22 @@ export class OidcService {
}
private getCimdConfig() {
const enabled =
this.config.getOptionalBoolean(
'auth.experimentalClientIdMetadataDocuments.enabled',
) ?? false;
const cliClientId = `${this.baseUrl}/.well-known/oauth-client/cli.json`;
return {
enabled:
this.config.getOptionalBoolean(
'auth.experimentalClientIdMetadataDocuments.enabled',
) ?? false,
enabled,
allowedClientIdPatterns: this.config.getOptionalStringArray(
'auth.experimentalClientIdMetadataDocuments.allowedClientIdPatterns',
) ?? ['*'],
allowedRedirectUriPatterns: this.config.getOptionalStringArray(
'auth.experimentalClientIdMetadataDocuments.allowedRedirectUriPatterns',
) ?? ['*'],
) ?? ['https://claude.ai/*', 'https://vscode.dev/*', cliClientId],
allowedRedirectUriPatterns:
this.config.getOptionalStringArray(
'auth.experimentalClientIdMetadataDocuments.allowedRedirectUriPatterns',
) ?? LOOPBACK_REDIRECT_PATTERNS,
};
}