feat(auth-backend): add experimental CIMD support (#32307)

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
Ben Lambert
2026-02-17 17:00:49 +01:00
committed by GitHub
parent 29fc1f8f23
commit 31de2c9b3a
17 changed files with 1967 additions and 157 deletions
+34
View File
@@ -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"
}
```
@@ -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.
+7
View File
@@ -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.
+31
View File
@@ -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[];
};
};
}
@@ -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');
});
};
+2
View File
@@ -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"
@@ -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();
},
);
});
@@ -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<typeof dns.lookup>;
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<string, string | undefined>).NODE_ENV =
'development';
});
afterEach(() => {
(process.env as Record<string, string | undefined>).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<string, string | undefined>).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<string, string | undefined>).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');
});
});
});
});
@@ -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<void> {
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<CimdClientInfo> {
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,
};
}
@@ -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<TokenIssuer>;
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);
});
});
});
});
+21 -19
View File
@@ -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<T>(schema: z.ZodSchema<T>, 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}/`;
}
@@ -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<UserInfoDatabase>;
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<BackstageUserPrincipal> = {
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<BackstageServicePrincipal> =
{
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),
});
});
});
});
});
});
+126 -69
View File
@@ -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<string> {
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;
+3 -1
View File
@@ -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"
}
}
@@ -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$/);
});
});
});
+26 -5
View File
@@ -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],
});
},
);
}
},
});
+8 -4
View File
@@ -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