Merge pull request #30606 from backstage/blam/oidc-auth/3
`auth-backend`: Implementing Dynamic Client Registration
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-mcp-actions-backend': patch
|
||||
---
|
||||
|
||||
Proxy `/.well-known/oauth-authorization-server` to `/.well-known/openid-configuration` on `auth-backend` when `auth.experimentalDynamicClientRegistration.enabled` is enabled.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend': patch
|
||||
---
|
||||
|
||||
Implementing Dynamic Client Registration with the OIDC server. You can enable this by setting `auth.experimentalDynamicClientRegistration.enabled` in `app-config.yaml`. This is highly experimental, but feedback welcome.
|
||||
@@ -209,6 +209,11 @@ scaffolder:
|
||||
defaultCommitMessage: 'Initial commit'
|
||||
|
||||
auth:
|
||||
experimentalDynamicClientRegistration:
|
||||
enabled: true
|
||||
allowedRedirectUriPatterns:
|
||||
- cursor://*
|
||||
|
||||
### Add auth.keyStore.provider to more granularly control how to store JWK data when running
|
||||
# the auth-backend.
|
||||
#
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* @param {import('knex').Knex} knex
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
// These tables make up the OIDC client registration flow.
|
||||
// Clients are the top of the tree, that are created by the client registration flow.
|
||||
await knex.schema.createTable('oidc_clients', table => {
|
||||
table.comment(
|
||||
'OIDC clients that are registered via dynamic client registration',
|
||||
);
|
||||
|
||||
table
|
||||
.string('client_id')
|
||||
.primary()
|
||||
.notNullable()
|
||||
.comment('The unique client ID of the client');
|
||||
|
||||
table
|
||||
.string('client_secret')
|
||||
.notNullable()
|
||||
.comment('The client secret of the client');
|
||||
|
||||
table
|
||||
.string('client_name')
|
||||
.notNullable()
|
||||
.comment('The name of the client, should be human readable');
|
||||
|
||||
table
|
||||
.text('response_types', 'longtext')
|
||||
.notNullable()
|
||||
.comment('JSON array of supported response types');
|
||||
|
||||
table
|
||||
.text('grant_types', 'longtext')
|
||||
.notNullable()
|
||||
.comment('JSON array of supported grant types');
|
||||
|
||||
table
|
||||
.text('redirect_uris', 'longtext')
|
||||
.notNullable()
|
||||
.comment('Allowed redirect URIs as JSON array');
|
||||
|
||||
table.text('scope').nullable().comment('Default scopes for the client');
|
||||
|
||||
table
|
||||
.text('metadata', 'longtext')
|
||||
.nullable()
|
||||
.comment('Additional client metadata as JSON');
|
||||
});
|
||||
|
||||
await knex.schema.createTable('oauth_authorization_sessions', table => {
|
||||
table.comment('Core OAuth authorization sessions with shared context');
|
||||
|
||||
table
|
||||
.string('id')
|
||||
.primary()
|
||||
.notNullable()
|
||||
.comment('Unique session identifier');
|
||||
|
||||
table.string('client_id').notNullable().comment('OIDC client identifier');
|
||||
|
||||
table
|
||||
.string('user_entity_ref')
|
||||
.nullable()
|
||||
.comment('Backstage user entity reference');
|
||||
|
||||
table
|
||||
.text('redirect_uri', 'longtext')
|
||||
.notNullable()
|
||||
.comment('Client redirect URI');
|
||||
|
||||
table.text('scope').nullable().comment('Requested scopes space-separated');
|
||||
|
||||
table.string('state').nullable().comment('Client state parameter');
|
||||
|
||||
table.string('response_type').notNullable().comment('OAuth2 response type');
|
||||
|
||||
table.string('code_challenge').nullable().comment('PKCE code challenge');
|
||||
|
||||
table
|
||||
.string('code_challenge_method')
|
||||
.nullable()
|
||||
.comment('PKCE code challenge method');
|
||||
|
||||
table.string('nonce').nullable().comment('OIDC nonce parameter');
|
||||
|
||||
table
|
||||
.enum('status', ['pending', 'approved', 'rejected', 'expired'])
|
||||
.defaultTo('pending')
|
||||
.comment('Authorization session status');
|
||||
|
||||
table
|
||||
.timestamp('expires_at', { useTz: true, precision: 0 })
|
||||
.notNullable()
|
||||
.comment('Session expiration timestamp');
|
||||
|
||||
table.foreign('client_id').references('client_id').inTable('oidc_clients');
|
||||
table.index(['client_id', 'user_entity_ref']);
|
||||
table.index(['status', 'expires_at']);
|
||||
});
|
||||
|
||||
await knex.schema.createTable('oidc_authorization_codes', table => {
|
||||
table.comment('OAuth authorization codes for code exchange flow');
|
||||
|
||||
table
|
||||
.string('code')
|
||||
.primary()
|
||||
.notNullable()
|
||||
.comment('Unique authorization code');
|
||||
|
||||
table
|
||||
.string('session_id')
|
||||
.notNullable()
|
||||
.comment('Authorization session identifier');
|
||||
|
||||
table
|
||||
.timestamp('expires_at', { useTz: true, precision: 0 })
|
||||
.notNullable()
|
||||
.comment('Authorization code expiration timestamp');
|
||||
|
||||
table
|
||||
.boolean('used')
|
||||
.defaultTo(false)
|
||||
.comment('Whether the authorization code has been used');
|
||||
|
||||
table
|
||||
.foreign('session_id')
|
||||
.references('id')
|
||||
.inTable('oauth_authorization_sessions')
|
||||
.onDelete('CASCADE');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {import('knex').Knex} knex
|
||||
*/
|
||||
exports.down = async function down(knex) {
|
||||
await knex.schema.dropTable('oidc_authorization_codes');
|
||||
await knex.schema.dropTable('oauth_authorization_sessions');
|
||||
await knex.schema.dropTable('oidc_clients');
|
||||
};
|
||||
@@ -60,6 +60,7 @@
|
||||
"knex": "^3.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.0.0",
|
||||
"matcher": "^4.0.0",
|
||||
"minimatch": "^9.0.0",
|
||||
"passport": "^0.7.0",
|
||||
"uuid": "^11.0.0"
|
||||
|
||||
@@ -5,6 +5,59 @@
|
||||
> [!WARNING]
|
||||
> Failed to migrate down from '20220321100910_timestamptz_again.js'
|
||||
|
||||
## Table `oauth_authorization_sessions`
|
||||
|
||||
| Column | Type | Nullable | Max Length | Default |
|
||||
| ----------------------- | -------------------------- | -------- | ---------- | ----------------- |
|
||||
| `client_id` | `character varying` | false | 255 | - |
|
||||
| `code_challenge` | `character varying` | true | 255 | - |
|
||||
| `code_challenge_method` | `character varying` | true | 255 | - |
|
||||
| `expires_at` | `timestamp with time zone` | false | - | - |
|
||||
| `id` | `character varying` | false | 255 | - |
|
||||
| `nonce` | `character varying` | true | 255 | - |
|
||||
| `redirect_uri` | `text` | false | - | - |
|
||||
| `response_type` | `character varying` | false | 255 | - |
|
||||
| `scope` | `text` | true | - | - |
|
||||
| `state` | `character varying` | true | 255 | - |
|
||||
| `status` | `text` | true | - | `'pending'::text` |
|
||||
| `user_entity_ref` | `character varying` | true | 255 | - |
|
||||
|
||||
### Indices
|
||||
|
||||
- `oauth_authorization_sessions_client_id_user_entity_ref_index` (`client_id`, `user_entity_ref`)
|
||||
- `oauth_authorization_sessions_pkey` (`id`) unique primary
|
||||
- `oauth_authorization_sessions_status_expires_at_index` (`status`, `expires_at`)
|
||||
|
||||
## Table `oidc_authorization_codes`
|
||||
|
||||
| Column | Type | Nullable | Max Length | Default |
|
||||
| ------------ | -------------------------- | -------- | ---------- | ------- |
|
||||
| `code` | `character varying` | false | 255 | - |
|
||||
| `expires_at` | `timestamp with time zone` | false | - | - |
|
||||
| `session_id` | `character varying` | false | 255 | - |
|
||||
| `used` | `boolean` | true | - | `false` |
|
||||
|
||||
### Indices
|
||||
|
||||
- `oidc_authorization_codes_pkey` (`code`) unique primary
|
||||
|
||||
## Table `oidc_clients`
|
||||
|
||||
| Column | Type | Nullable | Max Length | Default |
|
||||
| ---------------- | ------------------- | -------- | ---------- | ------- |
|
||||
| `client_id` | `character varying` | false | 255 | - |
|
||||
| `client_name` | `character varying` | false | 255 | - |
|
||||
| `client_secret` | `character varying` | false | 255 | - |
|
||||
| `grant_types` | `text` | false | - | - |
|
||||
| `metadata` | `text` | true | - | - |
|
||||
| `redirect_uris` | `text` | false | - | - |
|
||||
| `response_types` | `text` | false | - | - |
|
||||
| `scope` | `text` | true | - | - |
|
||||
|
||||
### Indices
|
||||
|
||||
- `oidc_clients_pkey` (`client_id`) unique primary
|
||||
|
||||
## Table `sessions`
|
||||
|
||||
| Column | Type | Nullable | Max Length | Default |
|
||||
|
||||
@@ -66,6 +66,7 @@ export const authPlugin = createBackendPlugin({
|
||||
database: coreServices.database,
|
||||
discovery: coreServices.discovery,
|
||||
auth: coreServices.auth,
|
||||
httpAuth: coreServices.httpAuth,
|
||||
catalog: catalogServiceRef,
|
||||
},
|
||||
async init({
|
||||
@@ -75,6 +76,7 @@ export const authPlugin = createBackendPlugin({
|
||||
database,
|
||||
discovery,
|
||||
auth,
|
||||
httpAuth,
|
||||
catalog,
|
||||
}) {
|
||||
const router = await createRouter({
|
||||
@@ -86,6 +88,7 @@ export const authPlugin = createBackendPlugin({
|
||||
catalog,
|
||||
providerFactories: Object.fromEntries(providers),
|
||||
ownershipResolver,
|
||||
httpAuth,
|
||||
});
|
||||
httpRouter.addAuthPolicy({
|
||||
path: '/',
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
/*
|
||||
* 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 { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
|
||||
import { AuthDatabase } from './AuthDatabase';
|
||||
import { OidcDatabase } from './OidcDatabase';
|
||||
import { resolvePackagePath } from '@backstage/backend-plugin-api';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('Oidc Database', () => {
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
async function createOidcDatabase(databaseId: TestDatabaseId) {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
await knex.migrate.latest({
|
||||
directory: resolvePackagePath(
|
||||
'@backstage/plugin-auth-backend',
|
||||
'migrations',
|
||||
),
|
||||
});
|
||||
|
||||
return {
|
||||
oidc: await OidcDatabase.create({
|
||||
database: AuthDatabase.create({
|
||||
getClient: async () => knex,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe.each(databases.eachSupportedId())('%p', databaseId => {
|
||||
describe('Clients', () => {
|
||||
it('should create and return a client', async () => {
|
||||
const { oidc } = await createOidcDatabase(databaseId);
|
||||
|
||||
await expect(
|
||||
oidc.createClient({
|
||||
clientId: 'test-client',
|
||||
clientName: 'Test Client',
|
||||
clientSecret: 'test-secret',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
responseTypes: ['code'],
|
||||
grantTypes: ['authorization_code'],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
clientId: 'test-client',
|
||||
clientName: 'Test Client',
|
||||
clientSecret: 'test-secret',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
responseTypes: ['code'],
|
||||
grantTypes: ['authorization_code'],
|
||||
scope: undefined,
|
||||
expiresAt: undefined,
|
||||
metadata: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the client thats created in a list', async () => {
|
||||
const { oidc } = await createOidcDatabase(databaseId);
|
||||
|
||||
const client = await oidc.createClient({
|
||||
clientId: 'test-client',
|
||||
clientName: 'Test Client',
|
||||
clientSecret: 'test-secret',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
responseTypes: ['code'],
|
||||
grantTypes: ['authorization_code'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
oidc.getClient({ clientId: 'test-client' }),
|
||||
).resolves.toEqual(client);
|
||||
});
|
||||
|
||||
it('should return null if the client does not exist', async () => {
|
||||
const { oidc } = await createOidcDatabase(databaseId);
|
||||
|
||||
await expect(
|
||||
oidc.getClient({ clientId: 'test-client' }),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authorization Sessions', () => {
|
||||
it('should create and return an authorization session', async () => {
|
||||
const { oidc } = await createOidcDatabase(databaseId);
|
||||
|
||||
const client = await oidc.createClient({
|
||||
clientId: 'test-client',
|
||||
clientName: 'Test Client',
|
||||
clientSecret: 'test-secret',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
responseTypes: ['code'],
|
||||
grantTypes: ['authorization_code'],
|
||||
});
|
||||
|
||||
const session = await oidc.createAuthorizationSession({
|
||||
id: 'test-session',
|
||||
clientId: client.clientId,
|
||||
userEntityRef: 'user:default/blam',
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
scope: 'openid',
|
||||
state: 'test-state',
|
||||
codeChallenge: 'test-challenge',
|
||||
codeChallengeMethod: 'S256',
|
||||
nonce: 'test-nonce',
|
||||
expiresAt: new Date('2025-01-01T00:00:00Z'),
|
||||
});
|
||||
|
||||
expect(session).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'test-session',
|
||||
clientId: client.clientId,
|
||||
userEntityRef: 'user:default/blam',
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
scope: 'openid',
|
||||
state: 'test-state',
|
||||
codeChallenge: 'test-challenge',
|
||||
codeChallengeMethod: 'S256',
|
||||
nonce: 'test-nonce',
|
||||
expiresAt: new Date('2025-01-01T00:00:00Z'),
|
||||
status: 'pending',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow updating the authorization session', async () => {
|
||||
const { oidc } = await createOidcDatabase(databaseId);
|
||||
|
||||
const client = await oidc.createClient({
|
||||
clientId: 'test-client',
|
||||
clientName: 'Test Client',
|
||||
clientSecret: 'test-secret',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
responseTypes: ['code'],
|
||||
grantTypes: ['authorization_code'],
|
||||
});
|
||||
|
||||
const session = await oidc.createAuthorizationSession({
|
||||
id: 'test-session',
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
expiresAt: new Date('2025-01-01T00:00:00Z'),
|
||||
});
|
||||
|
||||
await expect(
|
||||
oidc.updateAuthorizationSession({
|
||||
id: 'test-session',
|
||||
userEntityRef: 'user:default/blam',
|
||||
status: 'approved',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
...session,
|
||||
userEntityRef: 'user:default/blam',
|
||||
status: 'approved',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authorization Codes', () => {
|
||||
it('should create and return an authorization code', async () => {
|
||||
const { oidc } = await createOidcDatabase(databaseId);
|
||||
|
||||
const client = await oidc.createClient({
|
||||
clientId: 'test-client',
|
||||
clientName: 'Test Client',
|
||||
clientSecret: 'test-secret',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
responseTypes: ['code'],
|
||||
grantTypes: ['authorization_code'],
|
||||
});
|
||||
|
||||
const session = await oidc.createAuthorizationSession({
|
||||
id: 'test-session',
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
expiresAt: new Date('2025-01-01T00:00:00Z'),
|
||||
});
|
||||
|
||||
const authCode = await oidc.createAuthorizationCode({
|
||||
code: 'test-code',
|
||||
sessionId: session.id,
|
||||
expiresAt: new Date('2025-01-01T00:00:00Z'),
|
||||
});
|
||||
|
||||
expect(authCode).toEqual(
|
||||
expect.objectContaining({
|
||||
code: 'test-code',
|
||||
sessionId: session.id,
|
||||
expiresAt: new Date('2025-01-01T00:00:00Z'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return authorization code with session data', async () => {
|
||||
const { oidc } = await createOidcDatabase(databaseId);
|
||||
|
||||
const client = await oidc.createClient({
|
||||
clientId: 'test-client',
|
||||
clientName: 'Test Client',
|
||||
clientSecret: 'test-secret',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
responseTypes: ['code'],
|
||||
grantTypes: ['authorization_code'],
|
||||
});
|
||||
|
||||
const session = await oidc.createAuthorizationSession({
|
||||
id: 'test-session',
|
||||
clientId: client.clientId,
|
||||
userEntityRef: 'user:default/blam',
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
scope: 'openid',
|
||||
codeChallenge: 'test-challenge',
|
||||
codeChallengeMethod: 'S256',
|
||||
nonce: 'test-nonce',
|
||||
expiresAt: new Date('2025-01-01T00:00:00Z'),
|
||||
});
|
||||
|
||||
const authCode = await oidc.createAuthorizationCode({
|
||||
code: 'test-code',
|
||||
sessionId: session.id,
|
||||
expiresAt: new Date('2025-01-01T00:00:00Z'),
|
||||
});
|
||||
|
||||
const authCodeFromDb = await oidc.getAuthorizationCode({
|
||||
code: 'test-code',
|
||||
});
|
||||
const sessionFromDb = await oidc.getAuthorizationSession({
|
||||
id: authCodeFromDb!.sessionId,
|
||||
});
|
||||
|
||||
expect(authCodeFromDb).toEqual(authCode);
|
||||
expect(sessionFromDb).toEqual(session);
|
||||
});
|
||||
|
||||
it('should allow updating the authorization code', async () => {
|
||||
const { oidc } = await createOidcDatabase(databaseId);
|
||||
|
||||
const client = await oidc.createClient({
|
||||
clientId: 'test-client',
|
||||
clientName: 'Test Client',
|
||||
clientSecret: 'test-secret',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
responseTypes: ['code'],
|
||||
grantTypes: ['authorization_code'],
|
||||
});
|
||||
|
||||
const session = await oidc.createAuthorizationSession({
|
||||
id: 'test-session',
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
expiresAt: new Date('2025-01-01T00:00:00Z'),
|
||||
});
|
||||
|
||||
const authCode = await oidc.createAuthorizationCode({
|
||||
code: 'test-code',
|
||||
sessionId: session.id,
|
||||
expiresAt: new Date('2025-01-01T00:00:00Z'),
|
||||
});
|
||||
|
||||
const updatedAuthCode = await oidc.updateAuthorizationCode({
|
||||
code: 'test-code',
|
||||
used: true,
|
||||
});
|
||||
|
||||
expect(updatedAuthCode).toEqual({
|
||||
...authCode,
|
||||
used: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
/*
|
||||
* 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 { Knex } from 'knex';
|
||||
import { AuthDatabase } from './AuthDatabase';
|
||||
|
||||
function toDate(value?: Date | string | number): Date | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return typeof value === 'string' || typeof value === 'number'
|
||||
? new Date(value)
|
||||
: value;
|
||||
}
|
||||
type OidcClientRow = {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
client_name: string;
|
||||
response_types: string;
|
||||
grant_types: string;
|
||||
redirect_uris: string;
|
||||
scope: string | null;
|
||||
metadata: string | null;
|
||||
};
|
||||
|
||||
type OAuthAuthorizationSessionRow = {
|
||||
id: string;
|
||||
client_id: string;
|
||||
user_entity_ref: string | null;
|
||||
redirect_uri: string;
|
||||
scope: string | null;
|
||||
state: string | null;
|
||||
response_type: string;
|
||||
code_challenge: string | null;
|
||||
code_challenge_method: string | null;
|
||||
nonce: string | null;
|
||||
status: 'pending' | 'approved' | 'rejected' | 'expired';
|
||||
expires_at: Date | string;
|
||||
};
|
||||
|
||||
type OidcAuthorizationCodeRow = {
|
||||
code: string;
|
||||
session_id: string;
|
||||
expires_at: Date | string;
|
||||
used: boolean;
|
||||
};
|
||||
|
||||
export type Client = {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
clientSecret: string;
|
||||
redirectUris: string[];
|
||||
responseTypes: string[];
|
||||
grantTypes: string[];
|
||||
scope?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AuthorizationSession = {
|
||||
id: string;
|
||||
clientId: string;
|
||||
userEntityRef?: string;
|
||||
redirectUri: string;
|
||||
scope?: string;
|
||||
state?: string;
|
||||
responseType: string;
|
||||
codeChallenge?: string;
|
||||
codeChallengeMethod?: string;
|
||||
nonce?: string;
|
||||
status: 'pending' | 'approved' | 'rejected' | 'expired';
|
||||
expiresAt: Date;
|
||||
};
|
||||
|
||||
export type ConsentRequest = {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
expiresAt: Date;
|
||||
};
|
||||
|
||||
export type AuthorizationCode = {
|
||||
code: string;
|
||||
sessionId: string;
|
||||
expiresAt: Date;
|
||||
used: boolean;
|
||||
};
|
||||
|
||||
export type AccessToken = {
|
||||
tokenId: string;
|
||||
sessionId: string;
|
||||
expiresAt: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* This class provides database operations for OpenID Connect (OIDC) authentication flows.
|
||||
* It manages OIDC clients, authorization codes, and access tokens in the database.
|
||||
*/
|
||||
export class OidcDatabase {
|
||||
private constructor(private readonly db: Knex) {}
|
||||
|
||||
static async create(options: { database: AuthDatabase }) {
|
||||
const client = await options.database.get();
|
||||
return new OidcDatabase(client);
|
||||
}
|
||||
|
||||
async createClient(client: Client) {
|
||||
await this.db<OidcClientRow>('oidc_clients').insert({
|
||||
client_id: client.clientId,
|
||||
client_secret: client.clientSecret,
|
||||
client_name: client.clientName,
|
||||
response_types: JSON.stringify(client.responseTypes),
|
||||
grant_types: JSON.stringify(client.grantTypes),
|
||||
redirect_uris: JSON.stringify(client.redirectUris),
|
||||
scope: client.scope,
|
||||
metadata: JSON.stringify(client.metadata),
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
async getClient({ clientId }: { clientId: string }) {
|
||||
const client = await this.db<OidcClientRow>('oidc_clients')
|
||||
.where('client_id', clientId)
|
||||
.first();
|
||||
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.rowToClient(client) as Client;
|
||||
}
|
||||
|
||||
async createAuthorizationSession(
|
||||
session: Omit<AuthorizationSession, 'status'>,
|
||||
) {
|
||||
await this.db<OAuthAuthorizationSessionRow>(
|
||||
'oauth_authorization_sessions',
|
||||
).insert({
|
||||
id: session.id,
|
||||
client_id: session.clientId,
|
||||
user_entity_ref: session.userEntityRef,
|
||||
redirect_uri: session.redirectUri,
|
||||
scope: session.scope,
|
||||
state: session.state,
|
||||
response_type: session.responseType,
|
||||
code_challenge: session.codeChallenge,
|
||||
code_challenge_method: session.codeChallengeMethod,
|
||||
nonce: session.nonce,
|
||||
status: 'pending',
|
||||
expires_at: session.expiresAt,
|
||||
});
|
||||
|
||||
return {
|
||||
...session,
|
||||
status: 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
async updateAuthorizationSession(
|
||||
session: Partial<AuthorizationSession> & { id: string },
|
||||
) {
|
||||
const row = this.authorizationSessionToRow(session);
|
||||
const updatedFields = Object.fromEntries(
|
||||
Object.entries(row).filter(([_, value]) => value !== undefined),
|
||||
);
|
||||
|
||||
// MySQL and SQLite3 don't support RETURNING
|
||||
if (
|
||||
this.db.client.config.client.includes('sqlite3') ||
|
||||
this.db.client.config.client.includes('mysql')
|
||||
) {
|
||||
return await this.db.transaction(async trx => {
|
||||
await trx<OAuthAuthorizationSessionRow>('oauth_authorization_sessions')
|
||||
.where('id', session.id)
|
||||
.update(updatedFields);
|
||||
|
||||
const updated = await trx<OAuthAuthorizationSessionRow>(
|
||||
'oauth_authorization_sessions',
|
||||
)
|
||||
.where('id', session.id)
|
||||
.first();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(
|
||||
`Failed to retrieve updated authorization session with id ${session.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.rowToAuthorizationSession(updated) as AuthorizationSession;
|
||||
});
|
||||
}
|
||||
|
||||
const returnedRows = await this.db<OAuthAuthorizationSessionRow>(
|
||||
'oauth_authorization_sessions',
|
||||
)
|
||||
.where('id', session.id)
|
||||
.update(updatedFields)
|
||||
.returning('*');
|
||||
|
||||
if (returnedRows.length !== 1) {
|
||||
throw new Error(
|
||||
`Failed to retrieve updated authorization session with id ${session.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
const [returnedSession] = returnedRows;
|
||||
|
||||
return this.rowToAuthorizationSession(
|
||||
returnedSession,
|
||||
) as AuthorizationSession;
|
||||
}
|
||||
|
||||
async getAuthorizationSession({ id }: { id: string }) {
|
||||
const session = await this.db<OAuthAuthorizationSessionRow>(
|
||||
'oauth_authorization_sessions',
|
||||
)
|
||||
.where('id', id)
|
||||
.first();
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.rowToAuthorizationSession(session) as AuthorizationSession;
|
||||
}
|
||||
|
||||
async createAuthorizationCode(
|
||||
authorizationCode: Omit<AuthorizationCode, 'used'>,
|
||||
) {
|
||||
await this.db<OidcAuthorizationCodeRow>('oidc_authorization_codes').insert({
|
||||
code: authorizationCode.code,
|
||||
session_id: authorizationCode.sessionId,
|
||||
expires_at: authorizationCode.expiresAt,
|
||||
used: false,
|
||||
});
|
||||
|
||||
return {
|
||||
...authorizationCode,
|
||||
used: false,
|
||||
};
|
||||
}
|
||||
|
||||
async getAuthorizationCode({ code }: { code: string }) {
|
||||
const authCode = await this.db<OidcAuthorizationCodeRow>(
|
||||
'oidc_authorization_codes',
|
||||
)
|
||||
.where('code', code)
|
||||
.first();
|
||||
|
||||
if (!authCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.rowToAuthorizationCode(authCode) as AuthorizationCode;
|
||||
}
|
||||
|
||||
async updateAuthorizationCode(
|
||||
authorizationCode: Partial<AuthorizationCode> & { code: string },
|
||||
) {
|
||||
const row = this.authorizationCodeToRow(authorizationCode);
|
||||
const updatedFields = Object.fromEntries(
|
||||
Object.entries(row).filter(([_, value]) => value !== undefined),
|
||||
);
|
||||
|
||||
// MySQL and SQLite3 don't support RETURNING
|
||||
if (
|
||||
this.db.client.config.client.includes('sqlite3') ||
|
||||
this.db.client.config.client.includes('mysql')
|
||||
) {
|
||||
return await this.db.transaction(async trx => {
|
||||
await trx<OidcAuthorizationCodeRow>('oidc_authorization_codes')
|
||||
.where('code', authorizationCode.code)
|
||||
.update(updatedFields);
|
||||
|
||||
const updated = await trx<OidcAuthorizationCodeRow>(
|
||||
'oidc_authorization_codes',
|
||||
)
|
||||
.where('code', authorizationCode.code)
|
||||
.first();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(
|
||||
`Failed to retrieve updated authorization code with code ${authorizationCode.code}`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.rowToAuthorizationCode(updated) as AuthorizationCode;
|
||||
});
|
||||
}
|
||||
|
||||
const returnedRows = await this.db<OidcAuthorizationCodeRow>(
|
||||
'oidc_authorization_codes',
|
||||
)
|
||||
.where('code', authorizationCode.code)
|
||||
.update(updatedFields)
|
||||
.returning('*');
|
||||
|
||||
if (returnedRows.length !== 1) {
|
||||
throw new Error(
|
||||
`Failed to retrieve updated authorization code with code ${authorizationCode.code}`,
|
||||
);
|
||||
}
|
||||
|
||||
const [returnedCode] = returnedRows;
|
||||
|
||||
return this.rowToAuthorizationCode(returnedCode) as AuthorizationCode;
|
||||
}
|
||||
|
||||
private rowToClient(row: OidcClientRow): Client {
|
||||
return {
|
||||
clientId: row.client_id,
|
||||
clientName: row.client_name,
|
||||
clientSecret: row.client_secret,
|
||||
redirectUris: row.redirect_uris
|
||||
? JSON.parse(row.redirect_uris)
|
||||
: undefined,
|
||||
responseTypes: row.response_types
|
||||
? JSON.parse(row.response_types)
|
||||
: undefined,
|
||||
grantTypes: row.grant_types ? JSON.parse(row.grant_types) : undefined,
|
||||
scope: row.scope ?? undefined,
|
||||
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private authorizationSessionToRow(
|
||||
session: Partial<AuthorizationSession>,
|
||||
): Partial<OAuthAuthorizationSessionRow> {
|
||||
return {
|
||||
id: session.id,
|
||||
client_id: session.clientId,
|
||||
user_entity_ref: session.userEntityRef,
|
||||
redirect_uri: session.redirectUri,
|
||||
scope: session.scope,
|
||||
state: session.state,
|
||||
response_type: session.responseType,
|
||||
code_challenge: session.codeChallenge,
|
||||
code_challenge_method: session.codeChallengeMethod,
|
||||
nonce: session.nonce,
|
||||
status: session.status,
|
||||
expires_at: toDate(session.expiresAt),
|
||||
};
|
||||
}
|
||||
|
||||
private rowToAuthorizationSession(
|
||||
row: OAuthAuthorizationSessionRow,
|
||||
): Partial<AuthorizationSession> {
|
||||
return {
|
||||
id: row.id,
|
||||
clientId: row.client_id,
|
||||
userEntityRef: row.user_entity_ref ?? undefined,
|
||||
redirectUri: row.redirect_uri,
|
||||
scope: row.scope ?? undefined,
|
||||
state: row.state ?? undefined,
|
||||
responseType: row.response_type,
|
||||
codeChallenge: row.code_challenge ?? undefined,
|
||||
codeChallengeMethod: row.code_challenge_method ?? undefined,
|
||||
nonce: row.nonce ?? undefined,
|
||||
status: row.status,
|
||||
expiresAt: toDate(row.expires_at),
|
||||
};
|
||||
}
|
||||
|
||||
private authorizationCodeToRow(
|
||||
authorizationCode: Partial<AuthorizationCode>,
|
||||
): Partial<OidcAuthorizationCodeRow> {
|
||||
return {
|
||||
code: authorizationCode.code,
|
||||
session_id: authorizationCode.sessionId,
|
||||
expires_at: toDate(authorizationCode.expiresAt),
|
||||
used: authorizationCode.used,
|
||||
};
|
||||
}
|
||||
|
||||
private rowToAuthorizationCode(
|
||||
row: OidcAuthorizationCodeRow,
|
||||
): Partial<AuthorizationCode> {
|
||||
return {
|
||||
code: row.code,
|
||||
sessionId: row.session_id,
|
||||
expiresAt: toDate(row.expires_at),
|
||||
used: Boolean(row.used),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -186,4 +186,122 @@ describe('migrations', () => {
|
||||
await knex.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'20250909120000_oidc_client_registration.js, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
await migrateUntilBefore(
|
||||
knex,
|
||||
'20250909120000_oidc_client_registration.js',
|
||||
);
|
||||
await migrateUpOnce(knex);
|
||||
|
||||
await knex
|
||||
.insert({
|
||||
client_id: 'test-client-id',
|
||||
client_secret: 'test-client-secret',
|
||||
client_name: 'Test Client',
|
||||
response_types: JSON.stringify(['code']),
|
||||
grant_types: JSON.stringify(['authorization_code']),
|
||||
redirect_uris: JSON.stringify(['https://example.com/callback']),
|
||||
scope: 'openid profile',
|
||||
metadata: JSON.stringify({ description: 'Test client' }),
|
||||
})
|
||||
.into('oidc_clients');
|
||||
|
||||
await expect(
|
||||
knex('oidc_clients').where('client_id', 'test-client-id').first(),
|
||||
).resolves.toEqual({
|
||||
client_id: 'test-client-id',
|
||||
client_secret: 'test-client-secret',
|
||||
client_name: 'Test Client',
|
||||
response_types: JSON.stringify(['code']),
|
||||
grant_types: JSON.stringify(['authorization_code']),
|
||||
redirect_uris: JSON.stringify(['https://example.com/callback']),
|
||||
scope: 'openid profile',
|
||||
metadata: JSON.stringify({ description: 'Test client' }),
|
||||
});
|
||||
|
||||
await knex
|
||||
.insert({
|
||||
id: 'test-session-id',
|
||||
client_id: 'test-client-id',
|
||||
user_entity_ref: 'user:default/test-user',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
scope: 'openid',
|
||||
state: 'test-state',
|
||||
response_type: 'code',
|
||||
code_challenge: 'test-challenge',
|
||||
code_challenge_method: 'S256',
|
||||
nonce: 'test-nonce',
|
||||
status: 'pending',
|
||||
expires_at: new Date(Date.now() + 3600000),
|
||||
})
|
||||
.into('oauth_authorization_sessions');
|
||||
|
||||
await expect(
|
||||
knex('oauth_authorization_sessions')
|
||||
.where('id', 'test-session-id')
|
||||
.first(),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'test-session-id',
|
||||
client_id: 'test-client-id',
|
||||
user_entity_ref: 'user:default/test-user',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
scope: 'openid',
|
||||
state: 'test-state',
|
||||
response_type: 'code',
|
||||
code_challenge: 'test-challenge',
|
||||
code_challenge_method: 'S256',
|
||||
nonce: 'test-nonce',
|
||||
status: 'pending',
|
||||
}),
|
||||
);
|
||||
|
||||
await knex
|
||||
.insert({
|
||||
code: 'test-auth-code',
|
||||
session_id: 'test-session-id',
|
||||
expires_at: new Date(Date.now() + 600000),
|
||||
used: false,
|
||||
})
|
||||
.into('oidc_authorization_codes');
|
||||
|
||||
await expect(
|
||||
knex('oidc_authorization_codes')
|
||||
.where('code', 'test-auth-code')
|
||||
.first(),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
code: 'test-auth-code',
|
||||
session_id: 'test-session-id',
|
||||
}),
|
||||
);
|
||||
|
||||
await knex('oauth_authorization_sessions')
|
||||
.where('id', 'test-session-id')
|
||||
.del();
|
||||
|
||||
await expect(
|
||||
knex('oidc_authorization_codes').where('session_id', 'test-session-id'),
|
||||
).resolves.toHaveLength(0);
|
||||
|
||||
await migrateDownOnce(knex);
|
||||
|
||||
const tables = [
|
||||
'oidc_clients',
|
||||
'oauth_authorization_sessions',
|
||||
'oidc_authorization_codes',
|
||||
];
|
||||
|
||||
for (const table of tables) {
|
||||
await expect(knex.schema.hasTable(table)).resolves.toBe(false);
|
||||
}
|
||||
|
||||
await knex.destroy();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -17,135 +17,794 @@
|
||||
import {
|
||||
coreServices,
|
||||
createBackendPlugin,
|
||||
resolvePackagePath,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { mockServices, startTestBackend } from '@backstage/backend-test-utils';
|
||||
import Router from 'express-promise-router';
|
||||
import {
|
||||
mockServices,
|
||||
startTestBackend,
|
||||
TestDatabases,
|
||||
TestDatabaseId,
|
||||
mockCredentials,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import request from 'supertest';
|
||||
import crypto from 'crypto';
|
||||
import { OidcRouter } from './OidcRouter';
|
||||
import { UserInfoDatabase } from '../database/UserInfoDatabase';
|
||||
import { OidcDatabase } from '../database/OidcDatabase';
|
||||
import { AuthDatabase } from '../database/AuthDatabase';
|
||||
import { OidcService } from '../service/OidcService';
|
||||
import { TokenIssuer } from '../identity/types';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('OidcRouter', () => {
|
||||
describe('/v1/userinfo', () => {
|
||||
it('should return user info for full tokens', async () => {
|
||||
const auth = mockServices.auth.mock();
|
||||
const mockUserInfo = {
|
||||
getUserInfo: jest.fn().mockResolvedValue({
|
||||
claims: {
|
||||
sub: 'k/ns:n',
|
||||
ent: ['k/ns:a', 'k/ns:b'],
|
||||
},
|
||||
}),
|
||||
} as unknown as UserInfoDatabase;
|
||||
const MOCK_USER_TOKEN = 'mock-user-token';
|
||||
const MOCK_USER_ENTITY_REF = 'user:default/test-user';
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
const { server } = await startTestBackend({
|
||||
features: [
|
||||
createBackendPlugin({
|
||||
pluginId: 'auth',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: { httpRouter: coreServices.httpRouter },
|
||||
async init({ httpRouter }) {
|
||||
const router = Router();
|
||||
async function createRouter(databaseId: TestDatabaseId) {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
router.use(
|
||||
OidcRouter.create({
|
||||
auth,
|
||||
tokenIssuer: {} as any,
|
||||
baseUrl: 'http://localhost:7000',
|
||||
userInfo: mockUserInfo,
|
||||
}).getRouter(),
|
||||
);
|
||||
httpRouter.use(router);
|
||||
httpRouter.addAuthPolicy({
|
||||
path: '/',
|
||||
allow: 'unauthenticated',
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
auth.authenticate.mockResolvedValueOnce({} as any);
|
||||
auth.isPrincipal.mockReturnValueOnce(true);
|
||||
|
||||
await request(server)
|
||||
.get('/api/auth/v1/userinfo')
|
||||
.set(
|
||||
'Authorization',
|
||||
`Bearer h.${btoa(
|
||||
JSON.stringify({ sub: 'k/ns:n', ent: ['k/ns:a', 'k/ns:b'] }),
|
||||
)}.s`,
|
||||
)
|
||||
.expect(200, {
|
||||
claims: {
|
||||
sub: 'k/ns:n',
|
||||
ent: ['k/ns:a', 'k/ns:b'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockUserInfo.getUserInfo).toHaveBeenCalledWith('k/ns:n');
|
||||
await knex.migrate.latest({
|
||||
directory: resolvePackagePath(
|
||||
'@backstage/plugin-auth-backend',
|
||||
'migrations',
|
||||
),
|
||||
});
|
||||
|
||||
it('should return user info for limited tokens', async () => {
|
||||
const auth = mockServices.auth.mock();
|
||||
const mockUserInfo = {
|
||||
getUserInfo: jest.fn().mockResolvedValue({
|
||||
claims: {
|
||||
sub: 'k/ns:n',
|
||||
ent: ['k/ns:a', 'k/ns:b'],
|
||||
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();
|
||||
const mockConfig = mockServices.rootConfig({
|
||||
data: {
|
||||
auth: {
|
||||
experimentalDynamicClientRegistration: {
|
||||
enabled: true,
|
||||
},
|
||||
}),
|
||||
} as unknown as UserInfoDatabase;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server } = await startTestBackend({
|
||||
features: [
|
||||
createBackendPlugin({
|
||||
pluginId: 'auth',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: { httpRouter: coreServices.httpRouter },
|
||||
async init({ httpRouter }) {
|
||||
const router = Router();
|
||||
const oidcService = OidcService.create({
|
||||
auth: mockAuth,
|
||||
tokenIssuer: mockTokenIssuer,
|
||||
baseUrl: 'http://localhost:7000',
|
||||
userInfo: userInfoDatabase,
|
||||
oidc: oidcDatabase,
|
||||
config: mockConfig,
|
||||
});
|
||||
|
||||
router.use(
|
||||
OidcRouter.create({
|
||||
auth,
|
||||
tokenIssuer: {} as any,
|
||||
baseUrl: 'http://localhost:7000',
|
||||
userInfo: mockUserInfo,
|
||||
}).getRouter(),
|
||||
);
|
||||
httpRouter.use(router);
|
||||
httpRouter.addAuthPolicy({
|
||||
path: '/',
|
||||
allow: 'unauthenticated',
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
const oidcRouter = OidcRouter.create({
|
||||
auth: mockAuth,
|
||||
tokenIssuer: mockTokenIssuer,
|
||||
baseUrl: 'http://localhost:7000',
|
||||
appUrl: 'http://localhost:3000',
|
||||
logger: mockServices.logger.mock(),
|
||||
userInfo: userInfoDatabase,
|
||||
oidc: oidcDatabase,
|
||||
httpAuth: mockHttpAuth,
|
||||
config: mockConfig,
|
||||
});
|
||||
|
||||
auth.authenticate.mockResolvedValueOnce({} as any);
|
||||
auth.isPrincipal.mockReturnValueOnce(true);
|
||||
return {
|
||||
router: oidcRouter,
|
||||
mocks: {
|
||||
httpAuth: mockHttpAuth,
|
||||
auth: mockAuth,
|
||||
oidc: oidcDatabase,
|
||||
userInfo: userInfoDatabase,
|
||||
service: oidcService,
|
||||
tokenIssuer: mockTokenIssuer,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await request(server)
|
||||
.get('/api/auth/v1/userinfo')
|
||||
.set(
|
||||
'Authorization',
|
||||
`Bearer h.${btoa(JSON.stringify({ sub: 'k/ns:n' }))}.s`,
|
||||
)
|
||||
.expect(200, {
|
||||
describe.each(databases.eachSupportedId())('%p', databaseId => {
|
||||
describe('/v1/userinfo', () => {
|
||||
it('should return user info for full tokens', async () => {
|
||||
const {
|
||||
mocks: { auth, userInfo },
|
||||
router,
|
||||
} = await createRouter(databaseId);
|
||||
|
||||
await userInfo.addUserInfo({
|
||||
claims: {
|
||||
sub: 'k/ns:n',
|
||||
ent: ['k/ns:a', 'k/ns:b'],
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockUserInfo.getUserInfo).toHaveBeenCalledWith('k/ns:n');
|
||||
const { server } = await startTestBackend({
|
||||
features: [
|
||||
createBackendPlugin({
|
||||
pluginId: 'auth',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: { httpRouter: coreServices.httpRouter },
|
||||
async init({ httpRouter }) {
|
||||
httpRouter.use(router.getRouter());
|
||||
httpRouter.addAuthPolicy({
|
||||
path: '/',
|
||||
allow: 'unauthenticated',
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
auth.isPrincipal.mockReturnValueOnce(true);
|
||||
|
||||
const response = await request(server)
|
||||
.get('/api/auth/v1/userinfo')
|
||||
.set(
|
||||
'Authorization',
|
||||
`Bearer h.${btoa(
|
||||
JSON.stringify({ sub: 'k/ns:n', ent: ['k/ns:a', 'k/ns:b'] }),
|
||||
)}.s`,
|
||||
)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
claims: {
|
||||
sub: 'k/ns:n',
|
||||
ent: ['k/ns:a', 'k/ns:b'],
|
||||
exp: expect.any(Number),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return user info for limited tokens', async () => {
|
||||
const {
|
||||
mocks: { auth, userInfo },
|
||||
router,
|
||||
} = await createRouter(databaseId);
|
||||
|
||||
await userInfo.addUserInfo({
|
||||
claims: {
|
||||
sub: 'k/ns:n',
|
||||
ent: ['k/ns:a', 'k/ns:b'],
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
},
|
||||
});
|
||||
|
||||
const { server } = await startTestBackend({
|
||||
features: [
|
||||
createBackendPlugin({
|
||||
pluginId: 'auth',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: { httpRouter: coreServices.httpRouter },
|
||||
async init({ httpRouter }) {
|
||||
httpRouter.use(router.getRouter());
|
||||
httpRouter.addAuthPolicy({
|
||||
path: '/',
|
||||
allow: 'unauthenticated',
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
auth.isPrincipal.mockReturnValueOnce(true);
|
||||
|
||||
const response = await request(server)
|
||||
.get('/api/auth/v1/userinfo')
|
||||
.set(
|
||||
'Authorization',
|
||||
`Bearer h.${btoa(JSON.stringify({ sub: 'k/ns:n' }))}.s`,
|
||||
)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
claims: {
|
||||
sub: 'k/ns:n',
|
||||
ent: ['k/ns:a', 'k/ns:b'],
|
||||
exp: expect.any(Number),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth flow', () => {
|
||||
it('should register a client', async () => {
|
||||
const { router } = await createRouter(databaseId);
|
||||
|
||||
const { server } = await startTestBackend({
|
||||
features: [
|
||||
createBackendPlugin({
|
||||
pluginId: 'auth',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: { httpRouter: coreServices.httpRouter },
|
||||
async init({ httpRouter }) {
|
||||
httpRouter.use(router.getRouter());
|
||||
httpRouter.addAuthPolicy({
|
||||
path: '/',
|
||||
allow: 'unauthenticated',
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await request(server)
|
||||
.post('/api/auth/v1/register')
|
||||
.send({
|
||||
client_name: 'Test Client',
|
||||
redirect_uris: ['https://example.com/callback'],
|
||||
response_types: ['code'],
|
||||
grant_types: ['authorization_code'],
|
||||
scope: 'openid',
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
client_id: expect.any(String),
|
||||
client_secret: expect.any(String),
|
||||
redirect_uris: ['https://example.com/callback'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should create an authorization session via authorization endpoint', async () => {
|
||||
const {
|
||||
mocks: { service },
|
||||
router,
|
||||
} = await createRouter(databaseId);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
responseTypes: ['code'],
|
||||
grantTypes: ['authorization_code'],
|
||||
scope: 'openid',
|
||||
});
|
||||
|
||||
const { server } = await startTestBackend({
|
||||
features: [
|
||||
createBackendPlugin({
|
||||
pluginId: 'auth',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: { httpRouter: coreServices.httpRouter },
|
||||
async init({ httpRouter }) {
|
||||
httpRouter.use(router.getRouter());
|
||||
httpRouter.addAuthPolicy({
|
||||
path: '/',
|
||||
allow: 'unauthenticated',
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await request(server)
|
||||
.get('/api/auth/v1/authorize')
|
||||
.query({
|
||||
client_id: client.clientId,
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
response_type: 'code',
|
||||
scope: 'openid',
|
||||
state: 'test-state',
|
||||
})
|
||||
.expect(302);
|
||||
|
||||
expect(response.header.location).toMatch(
|
||||
/^http:\/\/localhost:3000\/oauth2\/authorize\/[a-f0-9-]+$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should get auth session details', async () => {
|
||||
const {
|
||||
mocks: { service },
|
||||
router,
|
||||
} = await createRouter(databaseId);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
responseTypes: ['code'],
|
||||
grantTypes: ['authorization_code'],
|
||||
scope: 'openid',
|
||||
});
|
||||
|
||||
const authSession = await service.createAuthorizationSession({
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
scope: 'openid',
|
||||
state: 'test-state',
|
||||
});
|
||||
|
||||
const { server } = await startTestBackend({
|
||||
features: [
|
||||
createBackendPlugin({
|
||||
pluginId: 'auth',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: { httpRouter: coreServices.httpRouter },
|
||||
async init({ httpRouter }) {
|
||||
httpRouter.use(router.getRouter());
|
||||
httpRouter.addAuthPolicy({
|
||||
path: '/',
|
||||
allow: 'unauthenticated',
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await request(server)
|
||||
.get(`/api/auth/v1/sessions/${authSession.id}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
id: authSession.id,
|
||||
clientName: 'Test Client',
|
||||
scope: 'openid',
|
||||
redirectUri: 'https://example.com/callback',
|
||||
});
|
||||
});
|
||||
|
||||
it('should approve authorization session', async () => {
|
||||
const {
|
||||
mocks: { auth, service, httpAuth },
|
||||
router,
|
||||
} = await createRouter(databaseId);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
responseTypes: ['code'],
|
||||
grantTypes: ['authorization_code'],
|
||||
scope: 'openid',
|
||||
});
|
||||
|
||||
const authSession = await service.createAuthorizationSession({
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
scope: 'openid',
|
||||
state: 'test-state',
|
||||
});
|
||||
|
||||
const { server } = await startTestBackend({
|
||||
features: [
|
||||
createBackendPlugin({
|
||||
pluginId: 'auth',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: { httpRouter: coreServices.httpRouter },
|
||||
async init({ httpRouter }) {
|
||||
httpRouter.use(router.getRouter());
|
||||
httpRouter.addAuthPolicy({
|
||||
path: '/',
|
||||
allow: 'unauthenticated',
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
httpAuth.credentials.mockResolvedValueOnce(
|
||||
mockCredentials.user('user:default/test-user'),
|
||||
);
|
||||
|
||||
auth.isPrincipal.mockReturnValueOnce(true);
|
||||
|
||||
const response = await request(server)
|
||||
.post(`/api/auth/v1/sessions/${authSession.id}/approve`)
|
||||
.set('Authorization', `Bearer ${MOCK_USER_TOKEN}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
redirectUrl: expect.stringMatching(
|
||||
/^https:\/\/example\.com\/callback\?code=[\w-]+&state=test-state$/,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject auth session', async () => {
|
||||
const {
|
||||
mocks: { service, httpAuth, auth },
|
||||
router,
|
||||
} = await createRouter(databaseId);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
responseTypes: ['code'],
|
||||
grantTypes: ['authorization_code'],
|
||||
scope: 'openid',
|
||||
});
|
||||
|
||||
const authSession = await service.createAuthorizationSession({
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
scope: 'openid',
|
||||
state: 'test-state',
|
||||
});
|
||||
|
||||
httpAuth.credentials.mockResolvedValueOnce(
|
||||
mockCredentials.user('user:default/test-user'),
|
||||
);
|
||||
|
||||
auth.isPrincipal.mockReturnValueOnce(true);
|
||||
|
||||
const { server } = await startTestBackend({
|
||||
features: [
|
||||
createBackendPlugin({
|
||||
pluginId: 'auth',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: { httpRouter: coreServices.httpRouter },
|
||||
async init({ httpRouter }) {
|
||||
httpRouter.use(router.getRouter());
|
||||
httpRouter.addAuthPolicy({
|
||||
path: '/',
|
||||
allow: 'unauthenticated',
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await request(server)
|
||||
.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$/,
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('token exchange', () => {
|
||||
it('should exchange authorization code for tokens', async () => {
|
||||
const {
|
||||
mocks: { auth, service, tokenIssuer, httpAuth },
|
||||
router,
|
||||
} = await createRouter(databaseId);
|
||||
|
||||
httpAuth.credentials.mockResolvedValueOnce(
|
||||
mockCredentials.user('user:default/test-user'),
|
||||
);
|
||||
|
||||
auth.isPrincipal.mockReturnValueOnce(true);
|
||||
|
||||
tokenIssuer.issueToken.mockResolvedValue({
|
||||
token: 'mock-access-token',
|
||||
});
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
responseTypes: ['code'],
|
||||
grantTypes: ['authorization_code'],
|
||||
scope: 'openid',
|
||||
});
|
||||
|
||||
const authSession = await service.createAuthorizationSession({
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
scope: 'openid',
|
||||
state: 'test-state',
|
||||
});
|
||||
|
||||
const { server } = await startTestBackend({
|
||||
features: [
|
||||
createBackendPlugin({
|
||||
pluginId: 'auth',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: { httpRouter: coreServices.httpRouter },
|
||||
async init({ httpRouter }) {
|
||||
httpRouter.use(router.getRouter());
|
||||
httpRouter.addAuthPolicy({
|
||||
path: '/',
|
||||
allow: 'unauthenticated',
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const approvalResponse = await request(server)
|
||||
.post(`/api/auth/v1/sessions/${authSession.id}/approve`)
|
||||
.set('Authorization', `Bearer ${MOCK_USER_TOKEN}`)
|
||||
.expect(200);
|
||||
|
||||
const redirectUrl = new URL(approvalResponse.body.redirectUrl);
|
||||
const authorizationCode = redirectUrl.searchParams.get('code');
|
||||
|
||||
expect(authorizationCode).toBeDefined();
|
||||
|
||||
const tokenResponse = await request(server)
|
||||
.post('/api/auth/v1/token')
|
||||
.send({
|
||||
grant_type: 'authorization_code',
|
||||
code: authorizationCode,
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(tokenResponse.body).toEqual({
|
||||
access_token: 'mock-access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
id_token: 'mock-access-token',
|
||||
scope: 'openid',
|
||||
});
|
||||
|
||||
expect(tokenIssuer.issueToken).toHaveBeenCalledWith({
|
||||
claims: {
|
||||
sub: MOCK_USER_ENTITY_REF,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should exchange authorization code for tokens with PKCE', async () => {
|
||||
const {
|
||||
mocks: { auth, service, tokenIssuer, httpAuth },
|
||||
router,
|
||||
} = await createRouter(databaseId);
|
||||
|
||||
tokenIssuer.issueToken.mockResolvedValue({
|
||||
token: 'mock-access-token-pkce',
|
||||
});
|
||||
|
||||
httpAuth.credentials.mockResolvedValueOnce(
|
||||
mockCredentials.user('user:default/test-user-pkce'),
|
||||
);
|
||||
|
||||
auth.isPrincipal.mockReturnValueOnce(true);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
responseTypes: ['code'],
|
||||
grantTypes: ['authorization_code'],
|
||||
scope: 'openid',
|
||||
});
|
||||
|
||||
const codeVerifier =
|
||||
'test-code-verifier-123456789012345678901234567890123456789012345';
|
||||
const codeChallenge = codeVerifier;
|
||||
|
||||
const authSession = await service.createAuthorizationSession({
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
scope: 'openid',
|
||||
state: 'test-state',
|
||||
codeChallenge,
|
||||
codeChallengeMethod: 'plain',
|
||||
});
|
||||
|
||||
const { server } = await startTestBackend({
|
||||
features: [
|
||||
createBackendPlugin({
|
||||
pluginId: 'auth',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: { httpRouter: coreServices.httpRouter },
|
||||
async init({ httpRouter }) {
|
||||
httpRouter.use(router.getRouter());
|
||||
httpRouter.addAuthPolicy({
|
||||
path: '/',
|
||||
allow: 'unauthenticated',
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const approvalResponse = await request(server)
|
||||
.post(`/api/auth/v1/sessions/${authSession.id}/approve`)
|
||||
.set('Authorization', `Bearer ${MOCK_USER_TOKEN}`)
|
||||
.expect(200);
|
||||
|
||||
const redirectUrl = new URL(approvalResponse.body.redirectUrl);
|
||||
const authorizationCode = redirectUrl.searchParams.get('code');
|
||||
|
||||
expect(authorizationCode).toBeDefined();
|
||||
|
||||
const tokenResponse = await request(server)
|
||||
.post('/api/auth/v1/token')
|
||||
.send({
|
||||
grant_type: 'authorization_code',
|
||||
code: authorizationCode,
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
code_verifier: codeVerifier,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(tokenResponse.body).toEqual({
|
||||
access_token: 'mock-access-token-pkce',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
id_token: 'mock-access-token-pkce',
|
||||
scope: 'openid',
|
||||
});
|
||||
|
||||
expect(tokenIssuer.issueToken).toHaveBeenCalledWith({
|
||||
claims: {
|
||||
sub: 'user:default/test-user-pkce',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject token exchange with invalid authorization code', async () => {
|
||||
const { router } = await createRouter(databaseId);
|
||||
|
||||
const { server } = await startTestBackend({
|
||||
features: [
|
||||
createBackendPlugin({
|
||||
pluginId: 'auth',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: { httpRouter: coreServices.httpRouter },
|
||||
async init({ httpRouter }) {
|
||||
httpRouter.use(router.getRouter());
|
||||
httpRouter.addAuthPolicy({
|
||||
path: '/',
|
||||
allow: 'unauthenticated',
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const tokenResponse = await request(server)
|
||||
.post('/api/auth/v1/token')
|
||||
.send({
|
||||
grant_type: 'authorization_code',
|
||||
code: 'invalid-code',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
})
|
||||
.expect(401);
|
||||
|
||||
expect(tokenResponse.body).toEqual({
|
||||
error: 'invalid_client',
|
||||
error_description: 'Invalid authorization code',
|
||||
});
|
||||
});
|
||||
|
||||
it('should exchange authorization code for tokens with PKCE S256', async () => {
|
||||
const {
|
||||
mocks: { auth, service, tokenIssuer, httpAuth },
|
||||
router,
|
||||
} = await createRouter(databaseId);
|
||||
|
||||
tokenIssuer.issueToken.mockResolvedValue({
|
||||
token: 'mock-access-token-s256',
|
||||
});
|
||||
|
||||
httpAuth.credentials.mockResolvedValueOnce(
|
||||
mockCredentials.user('user:default/test-user-s256'),
|
||||
);
|
||||
|
||||
auth.isPrincipal.mockReturnValueOnce(true);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
responseTypes: ['code'],
|
||||
grantTypes: ['authorization_code'],
|
||||
scope: 'openid',
|
||||
});
|
||||
|
||||
const codeVerifier =
|
||||
'test-code-verifier-s256-123456789012345678901234567890123456789';
|
||||
const codeChallenge = crypto
|
||||
.createHash('sha256')
|
||||
.update(codeVerifier)
|
||||
.digest('base64url');
|
||||
|
||||
const authSession = await service.createAuthorizationSession({
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
scope: 'openid',
|
||||
state: 'test-state',
|
||||
codeChallenge,
|
||||
codeChallengeMethod: 'S256',
|
||||
});
|
||||
|
||||
const { server } = await startTestBackend({
|
||||
features: [
|
||||
createBackendPlugin({
|
||||
pluginId: 'auth',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: { httpRouter: coreServices.httpRouter },
|
||||
async init({ httpRouter }) {
|
||||
httpRouter.use(router.getRouter());
|
||||
httpRouter.addAuthPolicy({
|
||||
path: '/',
|
||||
allow: 'unauthenticated',
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const approvalResponse = await request(server)
|
||||
.post(`/api/auth/v1/sessions/${authSession.id}/approve`)
|
||||
.set('Authorization', `Bearer ${MOCK_USER_TOKEN}`)
|
||||
.expect(200);
|
||||
|
||||
const redirectUrl = new URL(approvalResponse.body.redirectUrl);
|
||||
const authorizationCode = redirectUrl.searchParams.get('code');
|
||||
|
||||
expect(authorizationCode).toBeDefined();
|
||||
|
||||
const tokenResponse = await request(server)
|
||||
.post('/api/auth/v1/token')
|
||||
.send({
|
||||
grant_type: 'authorization_code',
|
||||
code: authorizationCode,
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
code_verifier: codeVerifier,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(tokenResponse.body).toEqual({
|
||||
access_token: 'mock-access-token-s256',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
id_token: 'mock-access-token-s256',
|
||||
scope: 'openid',
|
||||
});
|
||||
|
||||
expect(tokenIssuer.issueToken).toHaveBeenCalledWith({
|
||||
claims: {
|
||||
sub: 'user:default/test-user-s256',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,42 +15,72 @@
|
||||
*/
|
||||
import Router from 'express-promise-router';
|
||||
import { OidcService } from './OidcService';
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
import { AuthService } from '@backstage/backend-plugin-api';
|
||||
import { AuthenticationError, isError } from '@backstage/errors';
|
||||
import {
|
||||
AuthService,
|
||||
HttpAuthService,
|
||||
LoggerService,
|
||||
RootConfigService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { TokenIssuer } from '../identity/types';
|
||||
import { UserInfoDatabase } from '../database/UserInfoDatabase';
|
||||
import { OidcDatabase } from '../database/OidcDatabase';
|
||||
import { json } from 'express';
|
||||
|
||||
export class OidcRouter {
|
||||
private constructor(private readonly oidc: OidcService) {}
|
||||
private constructor(
|
||||
private readonly oidc: OidcService,
|
||||
private readonly logger: LoggerService,
|
||||
private readonly auth: AuthService,
|
||||
private readonly appUrl: string,
|
||||
private readonly httpAuth: HttpAuthService,
|
||||
private readonly config: RootConfigService,
|
||||
) {}
|
||||
|
||||
static create(options: {
|
||||
auth: AuthService;
|
||||
tokenIssuer: TokenIssuer;
|
||||
baseUrl: string;
|
||||
appUrl: string;
|
||||
logger: LoggerService;
|
||||
userInfo: UserInfoDatabase;
|
||||
oidc: OidcDatabase;
|
||||
httpAuth: HttpAuthService;
|
||||
config: RootConfigService;
|
||||
}) {
|
||||
return new OidcRouter(OidcService.create(options));
|
||||
return new OidcRouter(
|
||||
OidcService.create(options),
|
||||
options.logger,
|
||||
options.auth,
|
||||
options.appUrl,
|
||||
options.httpAuth,
|
||||
options.config,
|
||||
);
|
||||
}
|
||||
|
||||
public getRouter() {
|
||||
const router = Router();
|
||||
|
||||
router.use(json());
|
||||
|
||||
// OpenID Provider Configuration endpoint
|
||||
// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig
|
||||
// Returns the OpenID Provider Configuration document containing metadata about the provider
|
||||
router.get('/.well-known/openid-configuration', (_req, res) => {
|
||||
res.json(this.oidc.getConfiguration());
|
||||
});
|
||||
|
||||
// JSON Web Key Set endpoint
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.10.1.1
|
||||
// Returns the public keys used to verify JWTs issued by this provider
|
||||
router.get('/.well-known/jwks.json', async (_req, res) => {
|
||||
const { keys } = await this.oidc.listPublicKeys();
|
||||
res.json({ keys });
|
||||
});
|
||||
|
||||
router.get('/v1/token', (_req, res) => {
|
||||
res.status(501).send('Not Implemented');
|
||||
});
|
||||
|
||||
// This endpoint doesn't use the regular HttpAuthoidc, since the contract
|
||||
// is specifically for the header to be communicated in the Authorization
|
||||
// header, regardless of token type
|
||||
// UserInfo endpoint
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
|
||||
// Returns claims about the authenticated user using an access token
|
||||
router.get('/v1/userinfo', async (req, res) => {
|
||||
const matches = req.headers.authorization?.match(/^Bearer[ ]+(\S+)$/i);
|
||||
const token = matches?.[1];
|
||||
@@ -68,6 +98,337 @@ export class OidcRouter {
|
||||
res.json(userInfo);
|
||||
});
|
||||
|
||||
if (
|
||||
this.config.getOptionalBoolean(
|
||||
'auth.experimentalDynamicClientRegistration.enabled',
|
||||
)
|
||||
) {
|
||||
// Authorization endpoint
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
|
||||
// Handles the initial authorization request from the client, validates parameters,
|
||||
// and redirects to the Authorization Session page for user approval
|
||||
router.get('/v1/authorize', async (req, res) => {
|
||||
// todo(blam): maybe add zod types for validating input
|
||||
const {
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: responseType,
|
||||
scope,
|
||||
state,
|
||||
nonce,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: codeChallengeMethod,
|
||||
} = req.query;
|
||||
|
||||
if (!clientId || !redirectUri || !responseType) {
|
||||
this.logger.error(`Failed to authorize: Missing required parameters`);
|
||||
return res.status(400).json({
|
||||
error: 'invalid_request',
|
||||
error_description:
|
||||
'Missing required parameters: client_id, redirect_uri, response_type',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.oidc.createAuthorizationSession({
|
||||
clientId: clientId as string,
|
||||
redirectUri: redirectUri as string,
|
||||
responseType: responseType as string,
|
||||
scope: scope as string | undefined,
|
||||
state: state as string | undefined,
|
||||
nonce: nonce as string | undefined,
|
||||
codeChallenge: codeChallenge as string | undefined,
|
||||
codeChallengeMethod: codeChallengeMethod as string | undefined,
|
||||
});
|
||||
|
||||
// todo(blam): maybe this URL could be overridable by config if
|
||||
// the plugin is mounted somewhere else?
|
||||
// support slashes in baseUrl?
|
||||
const authSessionRedirectUrl = new URL(
|
||||
`./oauth2/authorize/${result.id}`,
|
||||
ensureTrailingSlash(this.appUrl),
|
||||
);
|
||||
|
||||
return res.redirect(authSessionRedirectUrl.toString());
|
||||
} catch (error) {
|
||||
const errorParams = new URLSearchParams();
|
||||
errorParams.append(
|
||||
'error',
|
||||
isError(error) ? error.name : 'server_error',
|
||||
);
|
||||
errorParams.append(
|
||||
'error_description',
|
||||
isError(error) ? error.message : 'Unknown error',
|
||||
);
|
||||
if (state) {
|
||||
errorParams.append('state', state as string);
|
||||
}
|
||||
|
||||
const redirectUrl = new URL(redirectUri as string);
|
||||
redirectUrl.search = errorParams.toString();
|
||||
return res.redirect(redirectUrl.toString());
|
||||
}
|
||||
});
|
||||
|
||||
// Authorization Session request details endpoint
|
||||
// Returns Authorization Session request details for the frontend
|
||||
router.get('/v1/sessions/:sessionId', async (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({
|
||||
error: 'invalid_request',
|
||||
error_description: 'Missing Authorization Session ID',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await this.oidc.getAuthorizationSession({
|
||||
sessionId,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
id: session.id,
|
||||
clientName: session.clientName,
|
||||
scope: session.scope,
|
||||
redirectUri: session.redirectUri,
|
||||
});
|
||||
} catch (error) {
|
||||
const description = isError(error) ? error.message : 'Unknown error';
|
||||
this.logger.error(
|
||||
`Failed to get authorization session: ${description}`,
|
||||
error,
|
||||
);
|
||||
return res.status(404).json({
|
||||
error: 'not_found',
|
||||
error_description: description,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Authorization Session approval endpoint
|
||||
// Handles user approval of Authorization Session requests and generates authorization codes
|
||||
router.post('/v1/sessions/:sessionId/approve', async (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({
|
||||
error: 'invalid_request',
|
||||
error_description: 'Missing authorization session ID',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const httpCredentials = await this.httpAuth.credentials(req);
|
||||
|
||||
if (!this.auth.isPrincipal(httpCredentials, 'user')) {
|
||||
return res.status(401).json({
|
||||
error: 'unauthorized',
|
||||
error_description: 'Authentication required',
|
||||
});
|
||||
}
|
||||
|
||||
const { userEntityRef } = httpCredentials.principal;
|
||||
|
||||
const result = await this.oidc.approveAuthorizationSession({
|
||||
sessionId,
|
||||
userEntityRef,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
redirectUrl: result.redirectUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
const description = isError(error) ? error.message : 'Unknown error';
|
||||
this.logger.error(
|
||||
`Failed to approve authorization session: ${description}`,
|
||||
error,
|
||||
);
|
||||
return res.status(400).json({
|
||||
error: 'invalid_request',
|
||||
error_description: description,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Authorization Session rejection endpoint
|
||||
// Handles user rejection of Authorization Session requests and redirects with error
|
||||
router.post('/v1/sessions/:sessionId/reject', async (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({
|
||||
error: 'invalid_request',
|
||||
error_description: 'Missing authorization session ID',
|
||||
});
|
||||
}
|
||||
|
||||
const httpCredentials = await this.httpAuth.credentials(req);
|
||||
|
||||
if (!this.auth.isPrincipal(httpCredentials, 'user')) {
|
||||
return res.status(401).json({
|
||||
error: 'unauthorized',
|
||||
error_description: 'Authentication required',
|
||||
});
|
||||
}
|
||||
|
||||
const { userEntityRef } = httpCredentials.principal;
|
||||
try {
|
||||
const session = await this.oidc.getAuthorizationSession({
|
||||
sessionId,
|
||||
});
|
||||
|
||||
await this.oidc.rejectAuthorizationSession({
|
||||
sessionId,
|
||||
userEntityRef,
|
||||
});
|
||||
|
||||
const errorParams = new URLSearchParams();
|
||||
errorParams.append('error', 'access_denied');
|
||||
errorParams.append('error_description', 'User denied the request');
|
||||
if (session.state) {
|
||||
errorParams.append('state', session.state);
|
||||
}
|
||||
|
||||
const redirectUrl = new URL(session.redirectUri);
|
||||
redirectUrl.search = errorParams.toString();
|
||||
|
||||
return res.json({
|
||||
redirectUrl: redirectUrl.toString(),
|
||||
});
|
||||
} catch (error) {
|
||||
const description = isError(error) ? error.message : 'Unknown error';
|
||||
this.logger.error(
|
||||
`Failed to reject authorization session: ${description}`,
|
||||
error,
|
||||
);
|
||||
|
||||
return res.status(400).json({
|
||||
error: 'invalid_request',
|
||||
error_description: description,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Token endpoint
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#TokenRequest
|
||||
// Exchanges authorization codes for access tokens and ID tokens
|
||||
router.post('/v1/token', async (req, res) => {
|
||||
// todo(blam): maybe add zod types for validating input
|
||||
const {
|
||||
grant_type: grantType,
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
} = req.body;
|
||||
|
||||
if (!grantType || !code || !redirectUri) {
|
||||
this.logger.error(
|
||||
`Failed to exchange code for token: Missing required parameters`,
|
||||
);
|
||||
return res.status(400).json({
|
||||
error: 'invalid_request',
|
||||
error_description: 'Missing required parameters',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.oidc.exchangeCodeForToken({
|
||||
code,
|
||||
redirectUri,
|
||||
codeVerifier,
|
||||
grantType,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
access_token: result.accessToken,
|
||||
token_type: result.tokenType,
|
||||
expires_in: result.expiresIn,
|
||||
id_token: result.idToken,
|
||||
scope: result.scope,
|
||||
});
|
||||
} catch (error) {
|
||||
const description = isError(error) ? error.message : 'Unknown error';
|
||||
this.logger.error(
|
||||
`Failed to exchange code for token: ${description}`,
|
||||
error,
|
||||
);
|
||||
|
||||
if (isError(error)) {
|
||||
if (error.name === 'AuthenticationError') {
|
||||
return res.status(401).json({
|
||||
error: 'invalid_client',
|
||||
error_description: error.message,
|
||||
});
|
||||
}
|
||||
if (error.name === 'InputError') {
|
||||
return res.status(400).json({
|
||||
error: 'invalid_request',
|
||||
error_description: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'server_error',
|
||||
error_description: description,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Dynamic Client Registration endpoint
|
||||
// https://openid.net/specs/openid-connect-registration-1_0.html#ClientRegistration
|
||||
// Allows clients to register themselves dynamically with the provider
|
||||
router.post('/v1/register', async (req, res) => {
|
||||
// todo(blam): maybe add zod types for validating input
|
||||
const {
|
||||
client_name: clientName,
|
||||
redirect_uris: redirectUris,
|
||||
response_types: responseTypes,
|
||||
grant_types: grantTypes,
|
||||
scope,
|
||||
} = req.body;
|
||||
|
||||
if (!redirectUris?.length) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_request',
|
||||
error_description: 'redirect_uris is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await this.oidc.registerClient({
|
||||
clientName,
|
||||
redirectUris,
|
||||
responseTypes,
|
||||
grantTypes,
|
||||
scope,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
client_id: client.clientId,
|
||||
redirect_uris: client.redirectUris,
|
||||
client_secret: client.clientSecret,
|
||||
});
|
||||
} catch (e) {
|
||||
const description = isError(e) ? e.message : 'Unknown error';
|
||||
this.logger.error(`Failed to register client: ${description}`, e);
|
||||
|
||||
res.status(500).json({
|
||||
error: 'server_error',
|
||||
error_description: `Failed to register client: ${description}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return router;
|
||||
}
|
||||
}
|
||||
function ensureTrailingSlash(appUrl: string): string | URL | undefined {
|
||||
if (appUrl.endsWith('/')) {
|
||||
return appUrl;
|
||||
}
|
||||
return `${appUrl}/`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,789 @@
|
||||
/*
|
||||
* 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 {
|
||||
mockServices,
|
||||
TestDatabaseId,
|
||||
TestDatabases,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import { OidcService } from './OidcService';
|
||||
import {
|
||||
BackstageCredentials,
|
||||
BackstageServicePrincipal,
|
||||
BackstageUserPrincipal,
|
||||
resolvePackagePath,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { AuthDatabase } from '../database/AuthDatabase';
|
||||
import { OidcDatabase } from '../database/OidcDatabase';
|
||||
import { UserInfoDatabase } from '../database/UserInfoDatabase';
|
||||
import crypto from 'crypto';
|
||||
import { AnyJWK, TokenIssuer } from '../identity/types';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('OidcService', () => {
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
async function createOidcService(databaseId: TestDatabaseId) {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
await knex.migrate.latest({
|
||||
directory: resolvePackagePath(
|
||||
'@backstage/plugin-auth-backend',
|
||||
'migrations',
|
||||
),
|
||||
});
|
||||
|
||||
const oidcDatabase = await OidcDatabase.create({
|
||||
database: AuthDatabase.create({
|
||||
getClient: async () => knex,
|
||||
}),
|
||||
});
|
||||
|
||||
const mockAuth = mockServices.auth.mock();
|
||||
const mockTokenIssuer = {
|
||||
issueToken: jest.fn(),
|
||||
listPublicKeys: jest.fn(),
|
||||
} as jest.Mocked<TokenIssuer>;
|
||||
|
||||
const mockUserInfo = {
|
||||
addUserInfo: jest.fn(),
|
||||
getUserInfo: jest.fn(),
|
||||
} as unknown as jest.Mocked<UserInfoDatabase>;
|
||||
|
||||
const mockConfig = mockServices.rootConfig.mock();
|
||||
|
||||
return {
|
||||
service: OidcService.create({
|
||||
auth: mockAuth,
|
||||
tokenIssuer: mockTokenIssuer,
|
||||
baseUrl: 'http://mock-base-url',
|
||||
userInfo: mockUserInfo,
|
||||
oidc: oidcDatabase,
|
||||
config: mockConfig,
|
||||
}),
|
||||
mocks: {
|
||||
auth: mockAuth,
|
||||
tokenIssuer: mockTokenIssuer,
|
||||
userInfo: mockUserInfo,
|
||||
config: mockConfig,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe.each(databases.eachSupportedId())('%p', databaseId => {
|
||||
describe('getConfiguration', () => {
|
||||
it('should return OIDC configuration', async () => {
|
||||
const { service } = await createOidcService(databaseId);
|
||||
|
||||
const config = service.getConfiguration();
|
||||
|
||||
expect(config).toEqual({
|
||||
issuer: 'http://mock-base-url',
|
||||
token_endpoint: 'http://mock-base-url/v1/token',
|
||||
userinfo_endpoint: 'http://mock-base-url/v1/userinfo',
|
||||
jwks_uri: 'http://mock-base-url/.well-known/jwks.json',
|
||||
response_types_supported: ['code', 'id_token'],
|
||||
subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: [
|
||||
'RS256',
|
||||
'RS384',
|
||||
'RS512',
|
||||
'ES256',
|
||||
'ES384',
|
||||
'ES512',
|
||||
'PS256',
|
||||
'PS384',
|
||||
'PS512',
|
||||
'EdDSA',
|
||||
],
|
||||
scopes_supported: ['openid'],
|
||||
token_endpoint_auth_methods_supported: [
|
||||
'client_secret_basic',
|
||||
'client_secret_post',
|
||||
],
|
||||
claims_supported: ['sub', 'ent'],
|
||||
grant_types_supported: ['authorization_code'],
|
||||
authorization_endpoint: 'http://mock-base-url/v1/authorize',
|
||||
registration_endpoint: 'http://mock-base-url/v1/register',
|
||||
code_challenge_methods_supported: ['S256', 'plain'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('listPublicKeys', () => {
|
||||
it('should return public keys from token issuer', async () => {
|
||||
const { service, mocks } = await createOidcService(databaseId);
|
||||
const mockKeys = [{ kid: 'key-1', use: 'sig' }] as AnyJWK[];
|
||||
mocks.tokenIssuer.listPublicKeys.mockResolvedValue({ keys: mockKeys });
|
||||
|
||||
const { keys } = await service.listPublicKeys();
|
||||
|
||||
expect(keys).toEqual(mockKeys);
|
||||
expect(mocks.tokenIssuer.listPublicKeys).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserInfo', () => {
|
||||
it('should return user info for valid token', async () => {
|
||||
const { service, mocks } = await createOidcService(databaseId);
|
||||
const mockCredentials: BackstageCredentials<BackstageUserPrincipal> = {
|
||||
principal: {
|
||||
type: 'user',
|
||||
userEntityRef: 'user:default/test',
|
||||
},
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
};
|
||||
const mockUserInfo = { sub: 'user:default/test', name: 'Test User' };
|
||||
|
||||
mocks.auth.authenticate.mockResolvedValue(mockCredentials);
|
||||
mocks.auth.isPrincipal.mockReturnValue(true);
|
||||
mocks.userInfo.getUserInfo.mockResolvedValue({
|
||||
claims: mockUserInfo,
|
||||
});
|
||||
|
||||
const mockToken =
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvdGVzdCJ9.signature';
|
||||
|
||||
const userInfo = await service.getUserInfo({ token: mockToken });
|
||||
|
||||
expect(userInfo).toEqual({
|
||||
claims: mockUserInfo,
|
||||
});
|
||||
|
||||
expect(mocks.auth.authenticate).toHaveBeenCalledWith(mockToken, {
|
||||
allowLimitedAccess: true,
|
||||
});
|
||||
|
||||
expect(mocks.userInfo.getUserInfo).toHaveBeenCalledWith(
|
||||
'user:default/test',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for non-user principal', async () => {
|
||||
const { service, mocks } = await createOidcService(databaseId);
|
||||
const mockCredentials: BackstageCredentials<BackstageServicePrincipal> =
|
||||
{
|
||||
principal: {
|
||||
type: 'service',
|
||||
subject: 'test-service',
|
||||
},
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
};
|
||||
|
||||
mocks.auth.authenticate.mockResolvedValue(mockCredentials);
|
||||
mocks.auth.isPrincipal.mockReturnValue(false);
|
||||
|
||||
const mockToken =
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvdGVzdCJ9.signature';
|
||||
|
||||
await expect(service.getUserInfo({ token: mockToken })).rejects.toThrow(
|
||||
'Userinfo endpoint must be called with a token that represents a user principal',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerClient', () => {
|
||||
it('should create a new client with generated credentials', async () => {
|
||||
const { service } = await createOidcService(databaseId);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
responseTypes: ['code'],
|
||||
grantTypes: ['authorization_code'],
|
||||
scope: 'openid',
|
||||
});
|
||||
|
||||
expect(client).toEqual(
|
||||
expect.objectContaining({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
responseTypes: ['code'],
|
||||
grantTypes: ['authorization_code'],
|
||||
scope: 'openid',
|
||||
}),
|
||||
);
|
||||
expect(client.clientId).toBeDefined();
|
||||
expect(client.clientSecret).toBeDefined();
|
||||
});
|
||||
|
||||
it('should throw an error for invalid redirect URI', async () => {
|
||||
const {
|
||||
service,
|
||||
mocks: { config },
|
||||
} = await createOidcService(databaseId);
|
||||
|
||||
config.getOptionalStringArray.mockReturnValue([
|
||||
'https://example.com/*',
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://invalid.com/callback'],
|
||||
}),
|
||||
).rejects.toThrow('Invalid redirect_uri');
|
||||
});
|
||||
|
||||
it('should create a new client with valid redirect URI', async () => {
|
||||
const {
|
||||
service,
|
||||
mocks: { config },
|
||||
} = await createOidcService(databaseId);
|
||||
|
||||
config.getOptionalStringArray.mockReturnValue(['cursor:*']);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['cursor://callback/asd?asd=asd'],
|
||||
});
|
||||
|
||||
expect(client).toEqual(
|
||||
expect.objectContaining({
|
||||
redirectUris: ['cursor://callback/asd?asd=asd'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a client with default values', async () => {
|
||||
const { service } = await createOidcService(databaseId);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
});
|
||||
|
||||
expect(client).toEqual(
|
||||
expect.objectContaining({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: [],
|
||||
responseTypes: ['code'],
|
||||
grantTypes: ['authorization_code'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAuthorizationSession', () => {
|
||||
it('should create a authorization session for valid client', async () => {
|
||||
const { service } = await createOidcService(databaseId);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
});
|
||||
|
||||
const authSession = await service.createAuthorizationSession({
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
scope: 'openid',
|
||||
state: 'test-state',
|
||||
});
|
||||
|
||||
expect(authSession).toEqual({
|
||||
id: expect.any(String),
|
||||
clientName: 'Test Client',
|
||||
scope: 'openid',
|
||||
redirectUri: 'https://example.com/callback',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for invalid client', async () => {
|
||||
const { service } = await createOidcService(databaseId);
|
||||
|
||||
await expect(
|
||||
service.createAuthorizationSession({
|
||||
clientId: 'invalid-client',
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
}),
|
||||
).rejects.toThrow('Invalid client_id');
|
||||
});
|
||||
|
||||
it('should throw error for invalid redirect URI', async () => {
|
||||
const { service } = await createOidcService(databaseId);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createAuthorizationSession({
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://invalid.com/callback',
|
||||
responseType: 'code',
|
||||
}),
|
||||
).rejects.toThrow('Invalid redirect_uri');
|
||||
});
|
||||
|
||||
it('should throw error for unsupported response type', async () => {
|
||||
const { service } = await createOidcService(databaseId);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createAuthorizationSession({
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'token',
|
||||
}),
|
||||
).rejects.toThrow('Only authorization code flow is supported');
|
||||
});
|
||||
|
||||
it('should handle PKCE parameters', async () => {
|
||||
const { service } = await createOidcService(databaseId);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
});
|
||||
|
||||
const authSession = await service.createAuthorizationSession({
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
codeChallenge: 'test-challenge',
|
||||
codeChallengeMethod: 'S256',
|
||||
});
|
||||
|
||||
expect(authSession.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('should throw error for invalid PKCE method', async () => {
|
||||
const { service } = await createOidcService(databaseId);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createAuthorizationSession({
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
codeChallenge: 'test-challenge',
|
||||
codeChallengeMethod: 'invalid',
|
||||
}),
|
||||
).rejects.toThrow('Invalid code_challenge_method');
|
||||
});
|
||||
});
|
||||
|
||||
describe('approveAuthorizationSession', () => {
|
||||
it('should approve a valid authorization session', async () => {
|
||||
const { service } = await createOidcService(databaseId);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
});
|
||||
|
||||
const authSession = await service.createAuthorizationSession({
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
state: 'test-state',
|
||||
});
|
||||
|
||||
const result = await service.approveAuthorizationSession({
|
||||
sessionId: authSession.id,
|
||||
userEntityRef: 'user:default/test',
|
||||
});
|
||||
|
||||
expect(result.redirectUrl).toMatch(
|
||||
/^https:\/\/example\.com\/callback\?code=.+&state=test-state$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid authorization session', async () => {
|
||||
const { service } = await createOidcService(databaseId);
|
||||
|
||||
await expect(
|
||||
service.approveAuthorizationSession({
|
||||
sessionId: 'invalid-session',
|
||||
userEntityRef: 'user:default/test',
|
||||
}),
|
||||
).rejects.toThrow('Invalid authorization session');
|
||||
});
|
||||
|
||||
it('should throw error when trying to approve an already approved session', async () => {
|
||||
const { service } = await createOidcService(databaseId);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
});
|
||||
|
||||
const authSession = await service.createAuthorizationSession({
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
});
|
||||
|
||||
await service.approveAuthorizationSession({
|
||||
sessionId: authSession.id,
|
||||
userEntityRef: 'user:default/test',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.approveAuthorizationSession({
|
||||
sessionId: authSession.id,
|
||||
userEntityRef: 'user:default/test',
|
||||
}),
|
||||
).rejects.toThrow('Authorization session not found or expired');
|
||||
});
|
||||
|
||||
it('should throw error when trying to approve an already rejected session', async () => {
|
||||
const { service } = await createOidcService(databaseId);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
});
|
||||
|
||||
const authSession = await service.createAuthorizationSession({
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
});
|
||||
|
||||
await service.rejectAuthorizationSession({
|
||||
sessionId: authSession.id,
|
||||
userEntityRef: 'user:default/test',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.approveAuthorizationSession({
|
||||
sessionId: authSession.id,
|
||||
userEntityRef: 'user:default/test',
|
||||
}),
|
||||
).rejects.toThrow('Authorization session not found or expired');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAuthorizationSession', () => {
|
||||
it('should return authorization session details', async () => {
|
||||
const { service } = await createOidcService(databaseId);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
});
|
||||
|
||||
const authSession = await service.createAuthorizationSession({
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
scope: 'openid',
|
||||
state: 'test-state',
|
||||
});
|
||||
|
||||
const details = await service.getAuthorizationSession({
|
||||
sessionId: authSession.id,
|
||||
});
|
||||
|
||||
expect(details).toEqual(
|
||||
expect.objectContaining({
|
||||
id: authSession.id,
|
||||
clientId: client.clientId,
|
||||
clientName: 'Test Client',
|
||||
redirectUri: 'https://example.com/callback',
|
||||
scope: 'openid',
|
||||
state: 'test-state',
|
||||
responseType: 'code',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when trying to get an already approved session', async () => {
|
||||
const { service } = await createOidcService(databaseId);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
});
|
||||
|
||||
const authSession = await service.createAuthorizationSession({
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
});
|
||||
|
||||
await service.approveAuthorizationSession({
|
||||
sessionId: authSession.id,
|
||||
userEntityRef: 'user:default/test',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getAuthorizationSession({
|
||||
sessionId: authSession.id,
|
||||
}),
|
||||
).rejects.toThrow('Authorization session not found or expired');
|
||||
});
|
||||
|
||||
it('should throw error when trying to get an already rejected session', async () => {
|
||||
const { service } = await createOidcService(databaseId);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
});
|
||||
|
||||
const authSession = await service.createAuthorizationSession({
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
});
|
||||
|
||||
await service.rejectAuthorizationSession({
|
||||
sessionId: authSession.id,
|
||||
userEntityRef: 'user:default/test',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getAuthorizationSession({
|
||||
sessionId: authSession.id,
|
||||
}),
|
||||
).rejects.toThrow('Authorization session not found or expired');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rejectAuthorizationSession', () => {
|
||||
it('should reject a authorization session', async () => {
|
||||
const { service } = await createOidcService(databaseId);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
});
|
||||
|
||||
const authSession = await service.createAuthorizationSession({
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
});
|
||||
|
||||
await service.rejectAuthorizationSession({
|
||||
sessionId: authSession.id,
|
||||
userEntityRef: 'user:default/test',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getAuthorizationSession({
|
||||
sessionId: authSession.id,
|
||||
}),
|
||||
).rejects.toThrow('Authorization session not found or expired');
|
||||
});
|
||||
|
||||
it('should throw error for invalid authorization session', async () => {
|
||||
const { service } = await createOidcService(databaseId);
|
||||
|
||||
await expect(
|
||||
service.rejectAuthorizationSession({
|
||||
sessionId: 'invalid-session',
|
||||
userEntityRef: 'user:default/test',
|
||||
}),
|
||||
).rejects.toThrow('Invalid authorization session');
|
||||
});
|
||||
|
||||
it('should throw error when trying to reject an already approved session', async () => {
|
||||
const { service } = await createOidcService(databaseId);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
});
|
||||
|
||||
const authSession = await service.createAuthorizationSession({
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
});
|
||||
|
||||
await service.approveAuthorizationSession({
|
||||
sessionId: authSession.id,
|
||||
userEntityRef: 'user:default/test',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.rejectAuthorizationSession({
|
||||
sessionId: authSession.id,
|
||||
userEntityRef: 'user:default/test',
|
||||
}),
|
||||
).rejects.toThrow('Authorization session not found or expired');
|
||||
});
|
||||
|
||||
it('should throw error when trying to reject an already rejected session', async () => {
|
||||
const { service } = await createOidcService(databaseId);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
});
|
||||
|
||||
const authSession = await service.createAuthorizationSession({
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
});
|
||||
|
||||
await service.rejectAuthorizationSession({
|
||||
sessionId: authSession.id,
|
||||
userEntityRef: 'user:default/test',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.rejectAuthorizationSession({
|
||||
sessionId: authSession.id,
|
||||
userEntityRef: 'user:default/test',
|
||||
}),
|
||||
).rejects.toThrow('Authorization session not found or expired');
|
||||
});
|
||||
});
|
||||
|
||||
describe('exchangeCodeForToken', () => {
|
||||
it('should exchange valid code for tokens', async () => {
|
||||
const { service, mocks } = await createOidcService(databaseId);
|
||||
const mockToken = 'mock-jwt-token';
|
||||
mocks.tokenIssuer.issueToken.mockResolvedValue({ token: mockToken });
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
});
|
||||
|
||||
const authSession = await service.createAuthorizationSession({
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
scope: 'openid',
|
||||
});
|
||||
|
||||
const authResult = await service.approveAuthorizationSession({
|
||||
sessionId: authSession.id,
|
||||
userEntityRef: 'user:default/test',
|
||||
});
|
||||
|
||||
const code = new URL(authResult.redirectUrl).searchParams.get('code')!;
|
||||
|
||||
const tokenResult = await service.exchangeCodeForToken({
|
||||
code,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
grantType: 'authorization_code',
|
||||
});
|
||||
|
||||
expect(tokenResult).toEqual({
|
||||
accessToken: mockToken,
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: 3600,
|
||||
idToken: mockToken,
|
||||
scope: 'openid',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for invalid grant type', async () => {
|
||||
const { service } = await createOidcService(databaseId);
|
||||
|
||||
await expect(
|
||||
service.exchangeCodeForToken({
|
||||
code: 'test-code',
|
||||
redirectUri: 'https://example.com/callback',
|
||||
grantType: 'client_credentials',
|
||||
}),
|
||||
).rejects.toThrow('Unsupported grant type');
|
||||
});
|
||||
|
||||
it('should handle PKCE verification', async () => {
|
||||
const { service, mocks } = await createOidcService(databaseId);
|
||||
const mockToken = 'mock-jwt-token';
|
||||
mocks.tokenIssuer.issueToken.mockResolvedValue({ token: mockToken });
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
});
|
||||
|
||||
const codeVerifier = 'test-code-verifier';
|
||||
const codeChallenge = crypto
|
||||
.createHash('sha256')
|
||||
.update(codeVerifier)
|
||||
.digest('base64url');
|
||||
|
||||
const authSession = await service.createAuthorizationSession({
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
codeChallenge,
|
||||
codeChallengeMethod: 'S256',
|
||||
});
|
||||
|
||||
const authResult = await service.approveAuthorizationSession({
|
||||
sessionId: authSession.id,
|
||||
userEntityRef: 'user:default/test',
|
||||
});
|
||||
|
||||
const code = new URL(authResult.redirectUrl).searchParams.get('code')!;
|
||||
|
||||
const tokenResult = await service.exchangeCodeForToken({
|
||||
code,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
grantType: 'authorization_code',
|
||||
codeVerifier,
|
||||
});
|
||||
|
||||
expect(tokenResult.accessToken).toBe(mockToken);
|
||||
});
|
||||
|
||||
it('should throw error for invalid PKCE verifier', async () => {
|
||||
const { service } = await createOidcService(databaseId);
|
||||
|
||||
const client = await service.registerClient({
|
||||
clientName: 'Test Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
});
|
||||
|
||||
const codeChallenge = 'test-challenge';
|
||||
const authSession = await service.createAuthorizationSession({
|
||||
clientId: client.clientId,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
responseType: 'code',
|
||||
codeChallenge,
|
||||
codeChallengeMethod: 'S256',
|
||||
});
|
||||
|
||||
const authResult = await service.approveAuthorizationSession({
|
||||
sessionId: authSession.id,
|
||||
userEntityRef: 'user:default/test',
|
||||
});
|
||||
|
||||
const code = new URL(authResult.redirectUrl).searchParams.get('code')!;
|
||||
|
||||
await expect(
|
||||
service.exchangeCodeForToken({
|
||||
code,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
grantType: 'authorization_code',
|
||||
codeVerifier: 'invalid-verifier',
|
||||
}),
|
||||
).rejects.toThrow('Invalid code verifier');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,11 +13,19 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { AuthService } from '@backstage/backend-plugin-api';
|
||||
import { AuthService, RootConfigService } from '@backstage/backend-plugin-api';
|
||||
import { TokenIssuer } from '../identity/types';
|
||||
import { UserInfoDatabase } from '../database/UserInfoDatabase';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import {
|
||||
InputError,
|
||||
AuthenticationError,
|
||||
NotFoundError,
|
||||
} from '@backstage/errors';
|
||||
import { decodeJwt } from 'jose';
|
||||
import crypto from 'crypto';
|
||||
import { OidcDatabase } from '../database/OidcDatabase';
|
||||
import { DateTime } from 'luxon';
|
||||
import matcher from 'matcher';
|
||||
|
||||
export class OidcService {
|
||||
private constructor(
|
||||
@@ -25,6 +33,8 @@ export class OidcService {
|
||||
private readonly tokenIssuer: TokenIssuer,
|
||||
private readonly baseUrl: string,
|
||||
private readonly userInfo: UserInfoDatabase,
|
||||
private readonly oidc: OidcDatabase,
|
||||
private readonly config: RootConfigService,
|
||||
) {}
|
||||
|
||||
static create(options: {
|
||||
@@ -32,12 +42,16 @@ export class OidcService {
|
||||
tokenIssuer: TokenIssuer;
|
||||
baseUrl: string;
|
||||
userInfo: UserInfoDatabase;
|
||||
oidc: OidcDatabase;
|
||||
config: RootConfigService;
|
||||
}) {
|
||||
return new OidcService(
|
||||
options.auth,
|
||||
options.tokenIssuer,
|
||||
options.baseUrl,
|
||||
options.userInfo,
|
||||
options.oidc,
|
||||
options.config,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,7 +61,7 @@ export class OidcService {
|
||||
token_endpoint: `${this.baseUrl}/v1/token`,
|
||||
userinfo_endpoint: `${this.baseUrl}/v1/userinfo`,
|
||||
jwks_uri: `${this.baseUrl}/.well-known/jwks.json`,
|
||||
response_types_supported: ['id_token'],
|
||||
response_types_supported: ['code', 'id_token'],
|
||||
subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: [
|
||||
'RS256',
|
||||
@@ -62,9 +76,15 @@ export class OidcService {
|
||||
'EdDSA',
|
||||
],
|
||||
scopes_supported: ['openid'],
|
||||
token_endpoint_auth_methods_supported: [],
|
||||
token_endpoint_auth_methods_supported: [
|
||||
'client_secret_basic',
|
||||
'client_secret_post',
|
||||
],
|
||||
claims_supported: ['sub', 'ent'],
|
||||
grant_types_supported: [],
|
||||
grant_types_supported: ['authorization_code'],
|
||||
authorization_endpoint: `${this.baseUrl}/v1/authorize`,
|
||||
registration_endpoint: `${this.baseUrl}/v1/register`,
|
||||
code_challenge_methods_supported: ['S256', 'plain'],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,4 +109,321 @@ export class OidcService {
|
||||
}
|
||||
return await this.userInfo.getUserInfo(userEntityRef);
|
||||
}
|
||||
|
||||
public async registerClient(opts: {
|
||||
responseTypes?: string[];
|
||||
grantTypes?: string[];
|
||||
clientName: string;
|
||||
redirectUris?: string[];
|
||||
scope?: string;
|
||||
}) {
|
||||
const generatedClientId = crypto.randomUUID();
|
||||
const generatedClientSecret = crypto.randomUUID();
|
||||
|
||||
const allowedRedirectUriPatterns = this.config.getOptionalStringArray(
|
||||
'auth.experimentalDynamicClientRegistration.allowedRedirectUriPatterns',
|
||||
) ?? ['*'];
|
||||
|
||||
for (const redirectUri of opts.redirectUris ?? []) {
|
||||
if (
|
||||
!allowedRedirectUriPatterns.some(pattern =>
|
||||
matcher.isMatch(redirectUri, pattern),
|
||||
)
|
||||
) {
|
||||
throw new InputError('Invalid redirect_uri');
|
||||
}
|
||||
}
|
||||
|
||||
return await this.oidc.createClient({
|
||||
clientId: generatedClientId,
|
||||
clientName: opts.clientName,
|
||||
clientSecret: generatedClientSecret,
|
||||
redirectUris: opts.redirectUris ?? [],
|
||||
responseTypes: opts.responseTypes ?? ['code'],
|
||||
grantTypes: opts.grantTypes ?? ['authorization_code'],
|
||||
scope: opts.scope,
|
||||
});
|
||||
}
|
||||
|
||||
public async createAuthorizationSession(opts: {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
responseType: string;
|
||||
scope?: string;
|
||||
state?: string;
|
||||
nonce?: string;
|
||||
codeChallenge?: string;
|
||||
codeChallengeMethod?: string;
|
||||
}) {
|
||||
const {
|
||||
clientId,
|
||||
redirectUri,
|
||||
responseType,
|
||||
scope,
|
||||
state,
|
||||
nonce,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
} = opts;
|
||||
|
||||
if (responseType !== 'code') {
|
||||
throw new InputError('Only authorization code flow is supported');
|
||||
}
|
||||
|
||||
const client = await this.oidc.getClient({ clientId });
|
||||
if (!client) {
|
||||
throw new InputError('Invalid client_id');
|
||||
}
|
||||
|
||||
if (!client.redirectUris.includes(redirectUri)) {
|
||||
throw new InputError('Invalid redirect_uri');
|
||||
}
|
||||
|
||||
if (codeChallenge) {
|
||||
if (
|
||||
!codeChallengeMethod ||
|
||||
!['S256', 'plain'].includes(codeChallengeMethod)
|
||||
) {
|
||||
throw new InputError('Invalid code_challenge_method');
|
||||
}
|
||||
}
|
||||
|
||||
const sessionId = crypto.randomUUID();
|
||||
const sessionExpiresAt = DateTime.now().plus({ hours: 1 }).toJSDate();
|
||||
|
||||
await this.oidc.createAuthorizationSession({
|
||||
id: sessionId,
|
||||
clientId,
|
||||
redirectUri,
|
||||
responseType,
|
||||
scope,
|
||||
state,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
nonce,
|
||||
expiresAt: sessionExpiresAt,
|
||||
});
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
clientName: client.clientName,
|
||||
scope,
|
||||
redirectUri,
|
||||
};
|
||||
}
|
||||
|
||||
public async approveAuthorizationSession(opts: {
|
||||
sessionId: string;
|
||||
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');
|
||||
}
|
||||
|
||||
await this.oidc.updateAuthorizationSession({
|
||||
id: session.id,
|
||||
userEntityRef,
|
||||
status: 'approved',
|
||||
});
|
||||
|
||||
const authorizationCode = crypto.randomBytes(32).toString('base64url');
|
||||
const codeExpiresAt = DateTime.now().plus({ minutes: 10 }).toJSDate();
|
||||
|
||||
await this.oidc.createAuthorizationCode({
|
||||
code: authorizationCode,
|
||||
sessionId: session.id,
|
||||
expiresAt: codeExpiresAt,
|
||||
});
|
||||
|
||||
const redirectUrl = new URL(session.redirectUri);
|
||||
|
||||
redirectUrl.searchParams.append('code', authorizationCode);
|
||||
if (session.state) {
|
||||
redirectUrl.searchParams.append('state', session.state);
|
||||
}
|
||||
|
||||
return {
|
||||
redirectUrl: redirectUrl.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
public async getAuthorizationSession(opts: { sessionId: string }) {
|
||||
const session = await this.oidc.getAuthorizationSession({
|
||||
id: opts.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 client = await this.oidc.getClient({ clientId: session.clientId });
|
||||
if (!client) {
|
||||
throw new InputError('Invalid client_id');
|
||||
}
|
||||
|
||||
return {
|
||||
id: session.id,
|
||||
clientId: session.clientId,
|
||||
clientName: client.clientName,
|
||||
redirectUri: session.redirectUri,
|
||||
scope: session.scope,
|
||||
state: session.state,
|
||||
responseType: session.responseType,
|
||||
codeChallenge: session.codeChallenge,
|
||||
codeChallengeMethod: session.codeChallengeMethod,
|
||||
nonce: session.nonce,
|
||||
expiresAt: session.expiresAt,
|
||||
status: session.status,
|
||||
};
|
||||
}
|
||||
|
||||
public async rejectAuthorizationSession(opts: {
|
||||
sessionId: string;
|
||||
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');
|
||||
}
|
||||
|
||||
await this.oidc.updateAuthorizationSession({
|
||||
id: session.id,
|
||||
status: 'rejected',
|
||||
userEntityRef,
|
||||
});
|
||||
}
|
||||
|
||||
public async exchangeCodeForToken(params: {
|
||||
code: string;
|
||||
redirectUri: string;
|
||||
codeVerifier?: string;
|
||||
grantType: string;
|
||||
}) {
|
||||
const { code, redirectUri, codeVerifier, grantType } = params;
|
||||
|
||||
if (grantType !== 'authorization_code') {
|
||||
throw new InputError('Unsupported grant type');
|
||||
}
|
||||
|
||||
const authCode = await this.oidc.getAuthorizationCode({ code });
|
||||
if (!authCode) {
|
||||
throw new AuthenticationError('Invalid authorization code');
|
||||
}
|
||||
|
||||
if (DateTime.fromJSDate(authCode.expiresAt) < DateTime.now()) {
|
||||
throw new AuthenticationError('Authorization code expired');
|
||||
}
|
||||
|
||||
if (authCode.used) {
|
||||
throw new AuthenticationError('Authorization code already used');
|
||||
}
|
||||
|
||||
const session = await this.oidc.getAuthorizationSession({
|
||||
id: authCode.sessionId,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
throw new NotFoundError('Invalid authorization session');
|
||||
}
|
||||
|
||||
if (session.redirectUri !== redirectUri) {
|
||||
throw new AuthenticationError('Redirect URI mismatch');
|
||||
}
|
||||
|
||||
if (session.status !== 'approved') {
|
||||
throw new AuthenticationError('Authorization not approved');
|
||||
}
|
||||
|
||||
if (!session.userEntityRef) {
|
||||
throw new AuthenticationError('No user associated with authorization');
|
||||
}
|
||||
|
||||
if (session.codeChallenge) {
|
||||
if (!codeVerifier) {
|
||||
throw new AuthenticationError('Code verifier required for PKCE');
|
||||
}
|
||||
|
||||
if (
|
||||
!this.verifyPkce(
|
||||
session.codeChallenge,
|
||||
codeVerifier,
|
||||
session.codeChallengeMethod,
|
||||
)
|
||||
) {
|
||||
throw new AuthenticationError('Invalid code verifier');
|
||||
}
|
||||
}
|
||||
|
||||
await this.oidc.updateAuthorizationCode({
|
||||
code,
|
||||
used: true,
|
||||
});
|
||||
|
||||
const { token } = await this.tokenIssuer.issueToken({
|
||||
claims: {
|
||||
sub: session.userEntityRef,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: token,
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: 3600,
|
||||
idToken: token,
|
||||
scope: session.scope || 'openid',
|
||||
};
|
||||
}
|
||||
|
||||
private verifyPkce(
|
||||
codeChallenge: string,
|
||||
codeVerifier: string,
|
||||
method?: string,
|
||||
): boolean {
|
||||
if (!method || method === 'plain') {
|
||||
return codeChallenge === codeVerifier;
|
||||
}
|
||||
|
||||
if (method === 'S256') {
|
||||
const hash = crypto.createHash('sha256').update(codeVerifier).digest();
|
||||
const base64urlHash = hash.toString('base64url');
|
||||
return codeChallenge === base64urlHash;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
AuthService,
|
||||
DatabaseService,
|
||||
DiscoveryService,
|
||||
HttpAuthService,
|
||||
LoggerService,
|
||||
RootConfigService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
@@ -40,6 +41,7 @@ import { StaticTokenIssuer } from '../identity/StaticTokenIssuer';
|
||||
import { StaticKeyStore } from '../identity/StaticKeyStore';
|
||||
import { bindProviderRouters, ProviderFactories } from '../providers/router';
|
||||
import { OidcRouter } from './OidcRouter';
|
||||
import { OidcDatabase } from '../database/OidcDatabase';
|
||||
|
||||
interface RouterOptions {
|
||||
logger: LoggerService;
|
||||
@@ -51,6 +53,7 @@ interface RouterOptions {
|
||||
providerFactories?: ProviderFactories;
|
||||
catalog: CatalogService;
|
||||
ownershipResolver?: AuthOwnershipResolver;
|
||||
httpAuth: HttpAuthService;
|
||||
}
|
||||
|
||||
export async function createRouter(
|
||||
@@ -63,6 +66,7 @@ export async function createRouter(
|
||||
database: db,
|
||||
tokenFactoryAlgorithm,
|
||||
providerFactories = {},
|
||||
httpAuth,
|
||||
} = options;
|
||||
|
||||
const router = Router();
|
||||
@@ -147,11 +151,18 @@ export async function createRouter(
|
||||
userInfo,
|
||||
});
|
||||
|
||||
const oidc = await OidcDatabase.create({ database });
|
||||
|
||||
const oidcRouter = OidcRouter.create({
|
||||
auth: options.auth,
|
||||
tokenIssuer,
|
||||
baseUrl: authUrl,
|
||||
appUrl,
|
||||
userInfo,
|
||||
oidc,
|
||||
logger,
|
||||
httpAuth,
|
||||
config,
|
||||
});
|
||||
|
||||
router.use(oidcRouter.getRouter());
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
coreServices,
|
||||
createBackendPlugin,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { json, Router } from 'express';
|
||||
import { json } from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { McpService } from './services/McpService';
|
||||
import { createStreamableRouter } from './routers/createStreamableRouter';
|
||||
import { createSseRouter } from './routers/createSseRouter';
|
||||
@@ -42,8 +43,19 @@ export const mcpPlugin = createBackendPlugin({
|
||||
httpRouter: coreServices.httpRouter,
|
||||
actions: actionsServiceRef,
|
||||
registry: actionsRegistryServiceRef,
|
||||
rootRouter: coreServices.rootHttpRouter,
|
||||
discovery: coreServices.discovery,
|
||||
config: coreServices.rootConfig,
|
||||
},
|
||||
async init({ actions, logger, httpRouter, httpAuth }) {
|
||||
async init({
|
||||
actions,
|
||||
logger,
|
||||
httpRouter,
|
||||
httpAuth,
|
||||
rootRouter,
|
||||
discovery,
|
||||
config,
|
||||
}) {
|
||||
const mcpService = await McpService.create({
|
||||
actions,
|
||||
});
|
||||
@@ -66,6 +78,26 @@ export const mcpPlugin = createBackendPlugin({
|
||||
router.use('/v1', streamableRouter);
|
||||
|
||||
httpRouter.use(router);
|
||||
|
||||
if (
|
||||
config.getOptionalBoolean(
|
||||
'auth.experimentalDynamicClientRegistration.enabled',
|
||||
)
|
||||
) {
|
||||
// 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.
|
||||
rootRouter.use(
|
||||
'/.well-known/oauth-authorization-server',
|
||||
async (_, res) => {
|
||||
const authBaseUrl = await discovery.getBaseUrl('auth');
|
||||
const oidcResponse = await fetch(
|
||||
`${authBaseUrl}/.well-known/openid-configuration`,
|
||||
);
|
||||
|
||||
res.json(await oidcResponse.json());
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -4196,6 +4196,7 @@ __metadata:
|
||||
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"
|
||||
passport: "npm:^0.7.0"
|
||||
supertest: "npm:^7.0.0"
|
||||
@@ -37214,6 +37215,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"matcher@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "matcher@npm:4.0.0"
|
||||
dependencies:
|
||||
escape-string-regexp: "npm:^4.0.0"
|
||||
checksum: 10/d338aff31d8dfd3626873e43777f46b123579734d53bb8d18d64b08a822ba5e8d39f5fe2e23403258e6143aa0cbe20a15662720d825cd0d3af961d5a44230328
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"material-ui-confirm@npm:^3.0.12":
|
||||
version: 3.0.18
|
||||
resolution: "material-ui-confirm@npm:3.0.18"
|
||||
|
||||
Reference in New Issue
Block a user