From 64dc5463ba2671faaae38d8f495bf772981b0cb2 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Wed, 2 Jul 2025 17:45:45 +0200 Subject: [PATCH] feat: started to add some tests for the oidc database Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- ...20250701120000_oidc_client_registration.js | 5 + .../src/database/OidcDatabase.test.ts | 199 +++++++++++++++ .../auth-backend/src/database/OidcDatabase.ts | 229 ++++++++++++++++++ 3 files changed, 433 insertions(+) create mode 100644 plugins/auth-backend/src/database/OidcDatabase.test.ts create mode 100644 plugins/auth-backend/src/database/OidcDatabase.ts diff --git a/plugins/auth-backend/migrations/20250701120000_oidc_client_registration.js b/plugins/auth-backend/migrations/20250701120000_oidc_client_registration.js index 9f40831ae4..d4863965de 100644 --- a/plugins/auth-backend/migrations/20250701120000_oidc_client_registration.js +++ b/plugins/auth-backend/migrations/20250701120000_oidc_client_registration.js @@ -41,6 +41,11 @@ exports.up = async function up(knex) { .notNullable() .comment('The name of the client, should be human readable'); + table + .text('redirect_uris', 'longtext') + .notNullable() + .comment('JSON array of valid redirect URIs'); + table .timestamp('created_at', { useTz: false, precision: 0 }) .notNullable() diff --git a/plugins/auth-backend/src/database/OidcDatabase.test.ts b/plugins/auth-backend/src/database/OidcDatabase.test.ts new file mode 100644 index 0000000000..bf00159b49 --- /dev/null +++ b/plugins/auth-backend/src/database/OidcDatabase.test.ts @@ -0,0 +1,199 @@ +/* + * 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'; + +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('Client', () => { + 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, + createdAt: expect.any(String), + }); + }); + + 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 Code', () => { + it('should create and return an authorization code', async () => { + const { oidc } = await createOidcDatabase(databaseId); + + const mockClient = await oidc.createClient({ + clientId: 'test-client', + clientName: 'Test Client', + clientSecret: 'test-secret', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + }); + + const authorizationCode = await oidc.createAuthorizationCode({ + code: 'test-code', + clientId: mockClient.clientId, + userEntityRef: 'user:default/blam', + redirectUri: 'https://example.com/callback', + scope: undefined, + codeChallenge: 'test-challenge', + codeChallengeMethod: 'S256', + nonce: 'test-nonce', + expiresAt: '2025-01-01', + }); + + await expect( + oidc.getAuthorizationCode({ code: 'test-code' }), + ).resolves.toEqual(authorizationCode); + }); + + it('should return null if the authorization code does not exist', async () => { + const { oidc } = await createOidcDatabase(databaseId); + + await expect( + oidc.getAuthorizationCode({ code: 'test-code' }), + ).resolves.toBeNull(); + }); + + it('should return the authorization code when created', async () => { + const { oidc } = await createOidcDatabase(databaseId); + + const mockClient = await oidc.createClient({ + clientId: 'test-client', + clientName: 'Test Client', + clientSecret: 'test-secret', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + }); + + const authorizationCode = await oidc.createAuthorizationCode({ + code: 'test-code', + clientId: mockClient.clientId, + userEntityRef: 'user:default/blam', + redirectUri: 'https://example.com/callback', + scope: undefined, + codeChallenge: 'test-challenge', + codeChallengeMethod: 'S256', + nonce: 'test-nonce', + expiresAt: '2025-01-01', + }); + + await expect( + oidc.getAuthorizationCode({ code: 'test-code' }), + ).resolves.toEqual(authorizationCode); + }); + + it('should allow updating the authorization code', async () => { + const { oidc } = await createOidcDatabase(databaseId); + + const mockClient = await oidc.createClient({ + clientId: 'test-client', + clientName: 'Test Client', + clientSecret: 'test-secret', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + }); + + const authorizationCode = await oidc.createAuthorizationCode({ + code: 'test-code', + clientId: mockClient.clientId, + userEntityRef: 'user:default/blam', + redirectUri: 'https://example.com/callback', + codeChallenge: 'test-challenge', + codeChallengeMethod: 'S256', + nonce: 'test-nonce', + expiresAt: '2025-01-01', + }); + + await expect( + oidc.updateAuthorizationCode({ + code: 'test-code', + used: true, + }), + ).resolves.toEqual({ + ...authorizationCode, + used: true, + }); + }); + }); + }); +}); diff --git a/plugins/auth-backend/src/database/OidcDatabase.ts b/plugins/auth-backend/src/database/OidcDatabase.ts new file mode 100644 index 0000000000..ae3cfc42a6 --- /dev/null +++ b/plugins/auth-backend/src/database/OidcDatabase.ts @@ -0,0 +1,229 @@ +/* + * 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'; + +import { DateTime } from 'luxon'; + +type OidcClientRow = { + client_id: string; + client_secret: string; + client_name: string; + created_at: string; + expires_at: string | null; + response_types: string; + grant_types: string; + redirect_uris: string; + scope: string | null; + metadata: string | null; +}; + +type OidcAuthorizationCodeRow = { + code: string; + client_id: string; + user_entity_ref: string; + redirect_uri: string; + scope: string | null; + code_challenge: string | null; + code_challenge_method: string | null; + nonce: string | null; + created_at: string; + expires_at: string; + used?: boolean; +}; + +type Client = { + clientId: string; + clientName: string; + clientSecret: string; + redirectUris: string[]; + responseTypes: string[]; + grantTypes: string[]; + scope?: string; + expiresAt?: string; + metadata?: Record; + createdAt: string; +}; + +type AuthorizationCode = { + code: string; + clientId: string; + userEntityRef: string; + redirectUri: string; + scope?: string; + codeChallenge?: string; + codeChallengeMethod?: string; + nonce?: string; + createdAt: string; + expiresAt: string; + used: boolean; +}; + +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: Omit) { + const now = DateTime.now().toString(); + + await this.db('oidc_clients').insert({ + client_id: client.clientId, + client_secret: client.clientSecret, + client_name: client.clientName, + created_at: now, + expires_at: client.expiresAt, + 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, + createdAt: now, + }; + } + + async getClient({ clientId }: { clientId: string }) { + const client = await this.db('oidc_clients') + .where('client_id', clientId) + .first(); + + if (!client) { + return null; + } + + return this.rowToClient(client) as Client; + } + + async createAuthorizationCode( + authorizationCode: Omit, + ) { + const now = DateTime.now().toString(); + + await this.db('oidc_authorization_codes').insert({ + code: authorizationCode.code, + client_id: authorizationCode.clientId, + user_entity_ref: authorizationCode.userEntityRef, + redirect_uri: authorizationCode.redirectUri, + scope: authorizationCode.scope, + code_challenge: authorizationCode.codeChallenge, + code_challenge_method: authorizationCode.codeChallengeMethod, + nonce: authorizationCode.nonce, + expires_at: authorizationCode.expiresAt, + created_at: now, + used: false, + }); + + return { + ...authorizationCode, + createdAt: now, + used: false, + }; + } + + async getAuthorizationCode({ code }: { code: string }) { + const authorizationCode = await this.db( + 'oidc_authorization_codes', + ) + .where('code', code) + .first(); + + if (!authorizationCode) { + return null; + } + + return this.rowToAuthorizationCode(authorizationCode) as AuthorizationCode; + } + + async updateAuthorizationCode( + authorizationCode: Partial & { code: string }, + ) { + const row = this.authorizationCodeToRow(authorizationCode); + const updatedFields = Object.fromEntries( + Object.entries(row).filter(([_, value]) => value !== undefined), + ); + console.log(updatedFields); + const updated = await this.db( + 'oidc_authorization_codes', + ) + .where('code', authorizationCode.code) + .update(updatedFields) + .returning('*'); + + return this.rowToAuthorizationCode(updated[0]) as AuthorizationCode; + } + + private rowToClient(row: Partial): Partial { + 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, + expiresAt: row.expires_at ?? undefined, + metadata: row.metadata ? JSON.parse(row.metadata) : undefined, + createdAt: row.created_at, + }; + } + + private authorizationCodeToRow( + authorizationCode: Partial, + ): Partial { + return { + code: authorizationCode.code, + client_id: authorizationCode.clientId, + user_entity_ref: authorizationCode.userEntityRef, + redirect_uri: authorizationCode.redirectUri, + scope: authorizationCode.scope, + code_challenge: authorizationCode.codeChallenge, + code_challenge_method: authorizationCode.codeChallengeMethod, + nonce: authorizationCode.nonce, + created_at: authorizationCode.createdAt, + expires_at: authorizationCode.expiresAt, + used: authorizationCode.used, + }; + } + + private rowToAuthorizationCode( + row: Partial, + ): Partial { + return { + code: row.code, + clientId: row.client_id, + userEntityRef: row.user_entity_ref, + redirectUri: row.redirect_uri, + scope: row.scope ?? undefined, + codeChallenge: row.code_challenge ?? undefined, + codeChallengeMethod: row.code_challenge_method ?? undefined, + nonce: row.nonce ?? undefined, + createdAt: row.created_at, + expiresAt: row.expires_at, + used: Boolean(row.used), + }; + } +}