Merge pull request #30606 from backstage/blam/oidc-auth/3

`auth-backend`: Implementing Dynamic Client Registration
This commit is contained in:
Ben Lambert
2025-09-09 16:25:40 +02:00
committed by GitHub
17 changed files with 3364 additions and 126 deletions
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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');
};
+1
View File
@@ -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"
+53
View File
@@ -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 |
+3
View File
@@ -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),
};
}
}
+118
View File
@@ -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',
},
});
});
});
});
});
+372 -11
View File
@@ -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');
});
});
});
});
+342 -5
View File
@@ -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());
+34 -2
View File
@@ -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());
},
);
}
},
});
},
+10
View File
@@ -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"