feat(auth-backend): add experimental CIMD support (#32307)
Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user