From d0786b968e5d9c8accea723b1ee170990a37d07d Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Tue, 10 Feb 2026 17:00:51 +0100 Subject: [PATCH] auth-backend: add experimental refresh token support (#32695) * auth-backend: add experimental refresh token support Signed-off-by: benjdlambert * auth-backend: refresh token review fixes Signed-off-by: benjdlambert * auth-backend: address PR review feedback for refresh tokens Signed-off-by: benjdlambert --------- Signed-off-by: benjdlambert --- .changeset/fuzzy-lights-start.md | 5 + plugins/auth-backend/config.d.ts | 31 ++ .../20251020000000_offline_sessions.js | 78 ++++ plugins/auth-backend/package.json | 4 +- plugins/auth-backend/report.sql.md | 19 + plugins/auth-backend/src/authPlugin.ts | 17 + .../database/OfflineSessionDatabase.test.ts | 424 +++++++++++++++++ .../src/database/OfflineSessionDatabase.ts | 264 +++++++++++ .../auth-backend/src/lib/refreshToken.test.ts | 152 ++++++ plugins/auth-backend/src/lib/refreshToken.ts | 129 +++++ .../src/service/OfflineAccessService.ts | 268 +++++++++++ plugins/auth-backend/src/service/OidcError.ts | 89 ++++ .../src/service/OidcRouter.test.ts | 294 +++++++++++- .../auth-backend/src/service/OidcRouter.ts | 442 +++++++++++------- .../src/service/OidcService.test.ts | 1 - .../auth-backend/src/service/OidcService.ts | 140 +++++- plugins/auth-backend/src/service/router.ts | 3 + yarn.lock | 19 +- 18 files changed, 2185 insertions(+), 194 deletions(-) create mode 100644 .changeset/fuzzy-lights-start.md create mode 100644 plugins/auth-backend/migrations/20251020000000_offline_sessions.js create mode 100644 plugins/auth-backend/src/database/OfflineSessionDatabase.test.ts create mode 100644 plugins/auth-backend/src/database/OfflineSessionDatabase.ts create mode 100644 plugins/auth-backend/src/lib/refreshToken.test.ts create mode 100644 plugins/auth-backend/src/lib/refreshToken.ts create mode 100644 plugins/auth-backend/src/service/OfflineAccessService.ts create mode 100644 plugins/auth-backend/src/service/OidcError.ts diff --git a/.changeset/fuzzy-lights-start.md b/.changeset/fuzzy-lights-start.md new file mode 100644 index 0000000000..b1be85f96d --- /dev/null +++ b/.changeset/fuzzy-lights-start.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': minor +--- + +Added experimental support for refresh tokens via the `auth.experimentalRefreshToken.enabled` configuration option. When enabled, clients can request the `offline_access` scope to receive refresh tokens that can be used to obtain new access tokens without re-authentication. diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index 50ccf2bc4b..0dd531c34e 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -99,6 +99,37 @@ export interface Config { */ backstageTokenExpiration?: HumanDuration | string; + /** + * Configuration for refresh tokens (offline access) + * @visibility backend + */ + experimentalRefreshToken?: { + /** + * Whether to enable refresh tokens + * @default false + * @visibility backend + */ + enabled?: boolean; + /** + * Token lifetime before rotation required + * @default '30 days' + * @visibility backend + */ + tokenLifetime?: HumanDuration | string; + /** + * Maximum session lifetime across all rotations + * @default '1 year' + * @visibility backend + */ + maxRotationLifetime?: HumanDuration | string; + /** + * Maximum number of refresh tokens per user + * @default 20 + * @visibility backend + */ + maxTokensPerUser?: number; + }; + /** * Additional app origins to allow for authenticating */ diff --git a/plugins/auth-backend/migrations/20251020000000_offline_sessions.js b/plugins/auth-backend/migrations/20251020000000_offline_sessions.js new file mode 100644 index 0000000000..1e5cd3cd24 --- /dev/null +++ b/plugins/auth-backend/migrations/20251020000000_offline_sessions.js @@ -0,0 +1,78 @@ +/* + * 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) { + await knex.schema.createTable('offline_sessions', table => { + table.comment( + 'Offline sessions for refresh tokens in dynamic client registration and device auth flows', + ); + + table + .string('id') + .primary() + .notNullable() + .comment('Persistent session ID that remains across token rotations'); + + table + .string('user_entity_ref') + .notNullable() + .comment('Backstage user entity reference'); + + table + .string('oidc_client_id') + .nullable() + .comment('OIDC client identifier (optional, for OIDC flows)'); + + table + .text('token_hash') + .notNullable() + .comment('Current refresh token hash (scrypt)'); + + table + .timestamp('created_at', { useTz: true, precision: 0 }) + .notNullable() + .defaultTo(knex.fn.now()) + .comment('Session creation timestamp'); + + table + .timestamp('last_used_at', { useTz: true, precision: 0 }) + .notNullable() + .defaultTo(knex.fn.now()) + .comment('Last token refresh timestamp'); + + table + .foreign('oidc_client_id') + .references('client_id') + .inTable('oidc_clients') + .onDelete('CASCADE'); + table.index('user_entity_ref', 'offline_sessions_user_idx'); + table.index('oidc_client_id', 'offline_sessions_oidc_client_idx'); + table.index('created_at', 'offline_sessions_created_idx'); + table.index('last_used_at', 'offline_sessions_last_used_idx'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.dropTable('offline_sessions'); +}; diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index ae770b4b08..2e08dbc1e1 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -64,7 +64,9 @@ "matcher": "^4.0.0", "minimatch": "^9.0.0", "passport": "^0.7.0", - "uuid": "^11.0.0" + "uuid": "^11.0.0", + "zod": "^4.3.5", + "zod-validation-error": "^5.0.0" }, "devDependencies": { "@backstage/backend-defaults": "workspace:^", diff --git a/plugins/auth-backend/report.sql.md b/plugins/auth-backend/report.sql.md index c2fa13c743..4dc160a2bf 100644 --- a/plugins/auth-backend/report.sql.md +++ b/plugins/auth-backend/report.sql.md @@ -28,6 +28,25 @@ - `oauth_authorization_sessions_pkey` (`id`) unique primary - `oauth_authorization_sessions_status_expires_at_index` (`status`, `expires_at`) +## Table `offline_sessions` + +| Column | Type | Nullable | Max Length | Default | +| ----------------- | -------------------------- | -------- | ---------- | ------------------- | +| `created_at` | `timestamp with time zone` | false | - | `CURRENT_TIMESTAMP` | +| `id` | `character varying` | false | 255 | - | +| `last_used_at` | `timestamp with time zone` | false | - | `CURRENT_TIMESTAMP` | +| `oidc_client_id` | `character varying` | true | 255 | - | +| `token_hash` | `text` | false | - | - | +| `user_entity_ref` | `character varying` | false | 255 | - | + +### Indices + +- `offline_sessions_created_idx` (`created_at`) +- `offline_sessions_last_used_idx` (`last_used_at`) +- `offline_sessions_oidc_client_idx` (`oidc_client_id`) +- `offline_sessions_pkey` (`id`) unique primary +- `offline_sessions_user_idx` (`user_entity_ref`) + ## Table `oidc_authorization_codes` | Column | Type | Nullable | Max Length | Default | diff --git a/plugins/auth-backend/src/authPlugin.ts b/plugins/auth-backend/src/authPlugin.ts index 025d72fcb7..df42ddce9e 100644 --- a/plugins/auth-backend/src/authPlugin.ts +++ b/plugins/auth-backend/src/authPlugin.ts @@ -26,6 +26,7 @@ import { } from '@backstage/plugin-auth-node'; import { catalogServiceRef } from '@backstage/plugin-catalog-node'; import { createRouter } from './service/router'; +import { OfflineAccessService } from './service/OfflineAccessService'; /** * Auth plugin @@ -67,6 +68,7 @@ export const authPlugin = createBackendPlugin({ discovery: coreServices.discovery, auth: coreServices.auth, httpAuth: coreServices.httpAuth, + lifecycle: coreServices.lifecycle, catalog: catalogServiceRef, }, async init({ @@ -77,8 +79,22 @@ export const authPlugin = createBackendPlugin({ discovery, auth, httpAuth, + lifecycle, catalog, }) { + const refreshTokensEnabled = config.getOptionalBoolean( + 'auth.experimentalRefreshToken.enabled', + ); + + const offlineAccess = refreshTokensEnabled + ? await OfflineAccessService.create({ + config, + database, + logger, + lifecycle, + }) + : undefined; + const router = await createRouter({ logger, config, @@ -89,6 +105,7 @@ export const authPlugin = createBackendPlugin({ providerFactories: Object.fromEntries(providers), ownershipResolver, httpAuth, + offlineAccess, }); httpRouter.addAuthPolicy({ path: '/', diff --git a/plugins/auth-backend/src/database/OfflineSessionDatabase.test.ts b/plugins/auth-backend/src/database/OfflineSessionDatabase.test.ts new file mode 100644 index 0000000000..bd061c3c12 --- /dev/null +++ b/plugins/auth-backend/src/database/OfflineSessionDatabase.test.ts @@ -0,0 +1,424 @@ +/* + * 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 { Knex } from 'knex'; +import { resolvePackagePath } from '@backstage/backend-plugin-api'; +import { OfflineSessionDatabase } from './OfflineSessionDatabase'; +import { DateTime } from 'luxon'; + +jest.setTimeout(60_000); + +describe('OfflineSessionDatabase', () => { + const databases = TestDatabases.create(); + const TOKEN_LIFETIME_SECONDS = 30 * 24 * 60 * 60; // 30 days + const MAX_ROTATION_LIFETIME_SECONDS = 365 * 24 * 60 * 60; // 1 year + + async function createDatabase(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + + await knex.migrate.latest({ + directory: resolvePackagePath( + '@backstage/plugin-auth-backend', + 'migrations', + ), + }); + + // Create an OIDC client for testing foreign key relationships + await knex('oidc_clients').insert({ + client_id: 'test-client', + client_name: 'Test Client', + client_secret: 'test-secret', + response_types: JSON.stringify(['code']), + grant_types: JSON.stringify(['authorization_code']), + redirect_uris: JSON.stringify(['http://localhost']), + }); + + return { + knex, + db: OfflineSessionDatabase.create({ + knex, + tokenLifetimeSeconds: TOKEN_LIFETIME_SECONDS, + maxRotationLifetimeSeconds: MAX_ROTATION_LIFETIME_SECONDS, + maxTokensPerUser: 20, + }), + }; + } + + describe.each(databases.eachSupportedId())('%p', databaseId => { + let knex: Knex; + let db: OfflineSessionDatabase; + + beforeEach(async () => { + ({ knex, db } = await createDatabase(databaseId)); + }); + + describe('createSession', () => { + it('should create a new session', async () => { + const session = await db.createSession({ + id: 'session-1', + userEntityRef: 'user:default/test', + oidcClientId: 'test-client', + tokenHash: 'hash-1', + }); + + expect(session).toMatchObject({ + id: 'session-1', + userEntityRef: 'user:default/test', + oidcClientId: 'test-client', + tokenHash: 'hash-1', + }); + expect(session.createdAt).toBeInstanceOf(Date); + expect(session.lastUsedAt).toBeInstanceOf(Date); + }); + + it('should create session without oidcClientId', async () => { + const session = await db.createSession({ + id: 'session-2', + userEntityRef: 'user:default/test', + tokenHash: 'hash-2', + }); + + expect(session).toMatchObject({ + id: 'session-2', + userEntityRef: 'user:default/test', + oidcClientId: null, + tokenHash: 'hash-2', + }); + }); + + it('should replace existing session for same oidc_client_id', async () => { + await db.createSession({ + id: 'session-1', + userEntityRef: 'user:default/test', + oidcClientId: 'test-client', + tokenHash: 'hash-1', + }); + + await db.createSession({ + id: 'session-2', + userEntityRef: 'user:default/test', + oidcClientId: 'test-client', + tokenHash: 'hash-2', + }); + + const session1 = await db.getSessionById('session-1'); + const session2 = await db.getSessionById('session-2'); + + expect(session1).toBeUndefined(); + expect(session2).toBeDefined(); + }); + + it('should enforce per-user limit of 20 tokens', async () => { + // Create 22 sessions for the same user + for (let i = 0; i < 22; i++) { + await db.createSession({ + id: `session-${i}`, + userEntityRef: 'user:default/test', + tokenHash: `hash-${i}`, + }); + } + + // Check that only 20 sessions exist + const sessions = await knex('offline_sessions') + .where('user_entity_ref', 'user:default/test') + .select('id'); + + expect(sessions).toHaveLength(20); + + // The oldest sessions should be deleted (LRU) + const session0 = await db.getSessionById('session-0'); + const session1 = await db.getSessionById('session-1'); + const session21 = await db.getSessionById('session-21'); + + expect(session0).toBeUndefined(); + expect(session1).toBeUndefined(); + expect(session21).toBeDefined(); + }); + }); + + describe('getSessionById', () => { + it('should retrieve a session by ID', async () => { + await db.createSession({ + id: 'session-1', + userEntityRef: 'user:default/test', + tokenHash: 'hash-1', + }); + + const session = await db.getSessionById('session-1'); + expect(session).toMatchObject({ + id: 'session-1', + userEntityRef: 'user:default/test', + tokenHash: 'hash-1', + }); + }); + + it('should return undefined for non-existent session', async () => { + const session = await db.getSessionById('non-existent'); + expect(session).toBeUndefined(); + }); + }); + + describe('deleteSession', () => { + it('should delete a session by ID', async () => { + await db.createSession({ + id: 'session-1', + userEntityRef: 'user:default/test', + tokenHash: 'hash-1', + }); + + await db.deleteSession('session-1'); + + const session = await db.getSessionById('session-1'); + expect(session).toBeUndefined(); + }); + }); + + describe('deleteSessionsByUserEntityRef', () => { + it('should delete all sessions for a user', async () => { + await db.createSession({ + id: 'session-1', + userEntityRef: 'user:default/test', + tokenHash: 'hash-1', + }); + + await db.createSession({ + id: 'session-2', + userEntityRef: 'user:default/test', + tokenHash: 'hash-2', + }); + + await db.createSession({ + id: 'session-3', + userEntityRef: 'user:default/other', + tokenHash: 'hash-3', + }); + + const deletedCount = await db.deleteSessionsByUserEntityRef( + 'user:default/test', + ); + + expect(deletedCount).toBe(2); + + const session1 = await db.getSessionById('session-1'); + const session2 = await db.getSessionById('session-2'); + const session3 = await db.getSessionById('session-3'); + + expect(session1).toBeUndefined(); + expect(session2).toBeUndefined(); + expect(session3).toBeDefined(); + }); + }); + + describe('cleanupExpiredSessions', () => { + it('should delete sessions expired by token lifetime', async () => { + // Create a session with old last_used_at + await knex('offline_sessions').insert({ + id: 'expired-session', + user_entity_ref: 'user:default/test', + token_hash: 'hash-1', + created_at: DateTime.now().toJSDate(), + last_used_at: DateTime.now() + .minus({ seconds: TOKEN_LIFETIME_SECONDS + 1 }) + .toJSDate(), + }); + + await knex('offline_sessions').insert({ + id: 'valid-session', + user_entity_ref: 'user:default/test', + token_hash: 'hash-2', + created_at: DateTime.now().toJSDate(), + last_used_at: DateTime.now().toJSDate(), + }); + + const deletedCount = await db.cleanupExpiredSessions(); + expect(deletedCount).toBeGreaterThan(0); + + const expiredSession = await db.getSessionById('expired-session'); + const validSession = await db.getSessionById('valid-session'); + + expect(expiredSession).toBeUndefined(); + expect(validSession).toBeDefined(); + }); + + it('should delete sessions expired by max rotation lifetime', async () => { + // Create a session with old created_at + await knex('offline_sessions').insert({ + id: 'expired-session', + user_entity_ref: 'user:default/test', + token_hash: 'hash-1', + created_at: DateTime.now() + .minus({ seconds: MAX_ROTATION_LIFETIME_SECONDS + 1 }) + .toJSDate(), + last_used_at: DateTime.now().toJSDate(), + }); + + await knex('offline_sessions').insert({ + id: 'valid-session', + user_entity_ref: 'user:default/test', + token_hash: 'hash-2', + created_at: DateTime.now().toJSDate(), + last_used_at: DateTime.now().toJSDate(), + }); + + const deletedCount = await db.cleanupExpiredSessions(); + expect(deletedCount).toBeGreaterThan(0); + + const expiredSession = await db.getSessionById('expired-session'); + const validSession = await db.getSessionById('valid-session'); + + expect(expiredSession).toBeUndefined(); + expect(validSession).toBeDefined(); + }); + }); + + describe('getAndRotateToken', () => { + it('should rotate token when hash matches', async () => { + await db.createSession({ + id: 'session-1', + userEntityRef: 'user:default/test', + tokenHash: 'old-hash', + }); + + const result = await db.getAndRotateToken( + 'session-1', + 'old-hash', + 'new-hash', + ); + + expect(result).toBeDefined(); + expect(result!.tokenHash).toBe('old-hash'); + + const updated = await db.getSessionById('session-1'); + expect(updated!.tokenHash).toBe('new-hash'); + }); + + it('should return undefined when hash does not match', async () => { + await db.createSession({ + id: 'session-1', + userEntityRef: 'user:default/test', + tokenHash: 'current-hash', + }); + + const result = await db.getAndRotateToken( + 'session-1', + 'wrong-hash', + 'new-hash', + ); + + expect(result).toBeUndefined(); + + const unchanged = await db.getSessionById('session-1'); + expect(unchanged!.tokenHash).toBe('current-hash'); + }); + + it('should return undefined for non-existent session', async () => { + const result = await db.getAndRotateToken( + 'non-existent', + 'hash', + 'new-hash', + ); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when session expired by token lifetime', async () => { + await knex('offline_sessions').insert({ + id: 'expired-session', + user_entity_ref: 'user:default/test', + token_hash: 'hash-1', + created_at: DateTime.now().toJSDate(), + last_used_at: DateTime.now() + .minus({ seconds: TOKEN_LIFETIME_SECONDS + 1 }) + .toJSDate(), + }); + + const result = await db.getAndRotateToken( + 'expired-session', + 'hash-1', + 'new-hash', + ); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when session expired by max rotation lifetime', async () => { + await knex('offline_sessions').insert({ + id: 'expired-session', + user_entity_ref: 'user:default/test', + token_hash: 'hash-1', + created_at: DateTime.now() + .minus({ seconds: MAX_ROTATION_LIFETIME_SECONDS + 1 }) + .toJSDate(), + last_used_at: DateTime.now().toJSDate(), + }); + + const result = await db.getAndRotateToken( + 'expired-session', + 'hash-1', + 'new-hash', + ); + + expect(result).toBeUndefined(); + }); + }); + + describe('isSessionExpired', () => { + it('should return true for session expired by token lifetime', () => { + const session = { + id: 'session-1', + userEntityRef: 'user:default/test', + oidcClientId: null, + tokenHash: 'hash-1', + createdAt: DateTime.now().toJSDate(), + lastUsedAt: DateTime.now() + .minus({ seconds: TOKEN_LIFETIME_SECONDS + 1 }) + .toJSDate(), + }; + + expect(db.isSessionExpired(session)).toBe(true); + }); + + it('should return true for session expired by max rotation lifetime', () => { + const session = { + id: 'session-1', + userEntityRef: 'user:default/test', + oidcClientId: null, + tokenHash: 'hash-1', + createdAt: DateTime.now() + .minus({ seconds: MAX_ROTATION_LIFETIME_SECONDS + 1 }) + .toJSDate(), + lastUsedAt: DateTime.now().toJSDate(), + }; + + expect(db.isSessionExpired(session)).toBe(true); + }); + + it('should return false for valid session', () => { + const session = { + id: 'session-1', + userEntityRef: 'user:default/test', + oidcClientId: null, + tokenHash: 'hash-1', + createdAt: DateTime.now().toJSDate(), + lastUsedAt: DateTime.now().toJSDate(), + }; + + expect(db.isSessionExpired(session)).toBe(false); + }); + }); + }); +}); diff --git a/plugins/auth-backend/src/database/OfflineSessionDatabase.ts b/plugins/auth-backend/src/database/OfflineSessionDatabase.ts new file mode 100644 index 0000000000..5d2e8ea42b --- /dev/null +++ b/plugins/auth-backend/src/database/OfflineSessionDatabase.ts @@ -0,0 +1,264 @@ +/* + * 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 { DateTime } from 'luxon'; + +const TABLE_NAME = 'offline_sessions'; + +type DbOfflineSessionRow = { + id: string; + user_entity_ref: string; + oidc_client_id: string | null; + token_hash: string; + created_at: Date; + last_used_at: Date; +}; + +/** + * Represents an offline session for refresh tokens + * @internal + */ +export type OfflineSession = { + id: string; + userEntityRef: string; + oidcClientId: string | null; + tokenHash: string; + createdAt: Date; + lastUsedAt: Date; +}; + +/** + * Options for creating a new offline session + * @internal + */ +export type CreateOfflineSessionOptions = { + id: string; + userEntityRef: string; + oidcClientId?: string; + tokenHash: string; +}; + +/** + * Database layer for managing offline sessions (refresh tokens) + * @internal + */ +export class OfflineSessionDatabase { + readonly #knex: Knex; + readonly #tokenLifetimeSeconds: number; + readonly #maxRotationLifetimeSeconds: number; + readonly #maxTokensPerUser: number; + + static create(options: { + knex: Knex; + tokenLifetimeSeconds: number; + maxRotationLifetimeSeconds: number; + maxTokensPerUser: number; + }) { + return new OfflineSessionDatabase( + options.knex, + options.tokenLifetimeSeconds, + options.maxRotationLifetimeSeconds, + options.maxTokensPerUser, + ); + } + + private constructor( + knex: Knex, + tokenLifetimeSeconds: number, + maxRotationLifetimeSeconds: number, + maxTokensPerUser: number, + ) { + this.#knex = knex; + this.#tokenLifetimeSeconds = tokenLifetimeSeconds; + this.#maxRotationLifetimeSeconds = maxRotationLifetimeSeconds; + this.#maxTokensPerUser = maxTokensPerUser; + } + + /** + * Create a new offline session + * Automatically enforces per-user and per-client limits + */ + async createSession( + options: CreateOfflineSessionOptions, + ): Promise { + const { id, userEntityRef, oidcClientId, tokenHash } = options; + + await this.#knex.transaction(async trx => { + // Delete existing session for same user and OIDC client + if (oidcClientId) { + await trx(TABLE_NAME) + .where('oidc_client_id', oidcClientId) + .andWhere('user_entity_ref', userEntityRef) + .delete(); + } + + // Enforce per-user limit + const userSessions = await trx(TABLE_NAME) + .where('user_entity_ref', userEntityRef) + .select('id', 'last_used_at') + .orderBy('last_used_at', 'asc') + .orderBy('id', 'asc'); + + const tokensToDelete = userSessions.length - (this.#maxTokensPerUser - 1); + + if (tokensToDelete > 0) { + const idsToDelete = userSessions + .slice(0, tokensToDelete) + .map(s => s.id); + + await trx(TABLE_NAME).whereIn('id', idsToDelete).delete(); + } + + // Insert new session + await trx(TABLE_NAME).insert({ + id, + user_entity_ref: userEntityRef, + oidc_client_id: oidcClientId ?? null, + token_hash: tokenHash, + created_at: trx.fn.now(), + last_used_at: trx.fn.now(), + }); + }); + + const session = await this.getSessionById(id); + if (!session) { + throw new Error('Failed to create session'); + } + return session; + } + + /** + * Get a session by its ID without expiration filtering + * Used for internal operations that need to check expiration explicitly + */ + async getSessionById(id: string): Promise { + const row = await this.#knex(TABLE_NAME) + .where('id', id) + .first(); + + if (!row) { + return undefined; + } + + return this.#mapRow(row); + } + + /** + * Get a session and rotate its token atomically + * This prevents race conditions where multiple refresh requests use the same token + */ + async getAndRotateToken( + id: string, + expectedTokenHash: string, + newTokenHash: string, + ): Promise { + const now = DateTime.utc(); + const tokenLifetimeThreshold = now + .minus({ seconds: this.#tokenLifetimeSeconds }) + .toJSDate(); + const maxRotationThreshold = now + .minus({ seconds: this.#maxRotationLifetimeSeconds }) + .toJSDate(); + + return await this.#knex.transaction(async trx => { + // Lock the row and verify token hash matches + const row = await trx(TABLE_NAME) + .where('id', id) + .where('token_hash', expectedTokenHash) + .where('last_used_at', '>=', tokenLifetimeThreshold) + .where('created_at', '>=', maxRotationThreshold) + .forUpdate() + .first(); + + if (!row) { + return undefined; + } + + // Update token hash atomically + await trx(TABLE_NAME).where('id', id).update({ + token_hash: newTokenHash, + last_used_at: trx.fn.now(), + }); + + return this.#mapRow(row); + }); + } + + /** + * Delete a session by ID + */ + async deleteSession(id: string): Promise { + return await this.#knex(TABLE_NAME) + .where('id', id) + .delete(); + } + + /** + * Delete all sessions for a user entity ref + */ + async deleteSessionsByUserEntityRef(userEntityRef: string): Promise { + return await this.#knex(TABLE_NAME) + .where('user_entity_ref', userEntityRef) + .delete(); + } + + /** + * Cleanup expired sessions based on both time windows + * - Short window: last_used_at + tokenLifetime + * - Long window: created_at + maxRotationLifetime + */ + async cleanupExpiredSessions(): Promise { + const now = DateTime.utc(); + const tokenLifetimeThreshold = now + .minus({ seconds: this.#tokenLifetimeSeconds }) + .toJSDate(); + const maxRotationThreshold = now + .minus({ seconds: this.#maxRotationLifetimeSeconds }) + .toJSDate(); + + return await this.#knex(TABLE_NAME) + .where('last_used_at', '<', tokenLifetimeThreshold) + .orWhere('created_at', '<', maxRotationThreshold) + .delete(); + } + + /** + * Check if a session is expired based on both time windows + */ + isSessionExpired(session: OfflineSession): boolean { + const now = DateTime.utc(); + const lastUsedExpiry = DateTime.fromJSDate(session.lastUsedAt).plus({ + seconds: this.#tokenLifetimeSeconds, + }); + const createdExpiry = DateTime.fromJSDate(session.createdAt).plus({ + seconds: this.#maxRotationLifetimeSeconds, + }); + + return now > lastUsedExpiry || now > createdExpiry; + } + + #mapRow(row: DbOfflineSessionRow): OfflineSession { + return { + id: row.id, + userEntityRef: row.user_entity_ref, + oidcClientId: row.oidc_client_id, + tokenHash: row.token_hash, + createdAt: new Date(row.created_at), + lastUsedAt: new Date(row.last_used_at), + }; + } +} diff --git a/plugins/auth-backend/src/lib/refreshToken.test.ts b/plugins/auth-backend/src/lib/refreshToken.test.ts new file mode 100644 index 0000000000..9b81c5f0d1 --- /dev/null +++ b/plugins/auth-backend/src/lib/refreshToken.test.ts @@ -0,0 +1,152 @@ +/* + * 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 { + generateRefreshToken, + getRefreshTokenId, + verifyRefreshToken, +} from './refreshToken'; + +describe('refreshToken', () => { + describe('generateRefreshToken', () => { + it('should generate a token with embedded ID', async () => { + const sessionId = '123e4567-e89b-4d3a-a456-426614174000'; + const { token, hash } = await generateRefreshToken(sessionId); + + expect(token).toBeDefined(); + expect(hash).toBeDefined(); + expect(token).toContain('.'); + expect(hash).toContain('.'); + + // Verify ID is embedded in token + const extractedId = getRefreshTokenId(token); + expect(extractedId).toBe(sessionId); + }); + + it('should generate unique tokens for the same ID', async () => { + const sessionId = '123e4567-e89b-4d3a-a456-426614174000'; + const result1 = await generateRefreshToken(sessionId); + const result2 = await generateRefreshToken(sessionId); + + expect(result1.token).not.toBe(result2.token); + expect(result1.hash).not.toBe(result2.hash); + }); + + it('should generate different hashes for different tokens', async () => { + const sessionId1 = '123e4567-e89b-4d3a-a456-426614174000'; + const sessionId2 = '223e4567-e89b-4d3a-a456-426614174001'; + + const result1 = await generateRefreshToken(sessionId1); + const result2 = await generateRefreshToken(sessionId2); + + expect(result1.hash).not.toBe(result2.hash); + }); + }); + + describe('getRefreshTokenId', () => { + it('should extract the session ID from a valid token', async () => { + const sessionId = '123e4567-e89b-4d3a-a456-426614174000'; + const { token } = await generateRefreshToken(sessionId); + + const extractedId = getRefreshTokenId(token); + expect(extractedId).toBe(sessionId); + }); + + it('should handle tokens with any session ID format', async () => { + const sessionId = 'any-session-id-format'; + const { token } = await generateRefreshToken(sessionId); + + const extractedId = getRefreshTokenId(token); + expect(extractedId).toBe(sessionId); + }); + + it('should throw on invalid token format', () => { + expect(() => getRefreshTokenId('invalid-token')).toThrow( + 'Invalid refresh token format', + ); + expect(() => getRefreshTokenId('too.many.parts.here')).toThrow( + 'Invalid refresh token format', + ); + expect(() => getRefreshTokenId('')).toThrow( + 'Invalid refresh token format', + ); + }); + }); + + describe('verifyRefreshToken', () => { + it('should verify a valid token against its hash', async () => { + const sessionId = '123e4567-e89b-4d3a-a456-426614174000'; + const { token, hash } = await generateRefreshToken(sessionId); + + const isValid = await verifyRefreshToken(token, hash); + expect(isValid).toBe(true); + }); + + it('should reject an invalid token', async () => { + const sessionId = '123e4567-e89b-4d3a-a456-426614174000'; + const { hash } = await generateRefreshToken(sessionId); + const { token: wrongToken } = await generateRefreshToken(sessionId); + + const isValid = await verifyRefreshToken(wrongToken, hash); + expect(isValid).toBe(false); + }); + + it('should reject a modified token', async () => { + const sessionId = '123e4567-e89b-4d3a-a456-426614174000'; + const { token, hash } = await generateRefreshToken(sessionId); + + // Modify the token slightly + const modifiedToken = `${token.slice(0, -1)}X`; + + const isValid = await verifyRefreshToken(modifiedToken, hash); + expect(isValid).toBe(false); + }); + + it('should reject with invalid hash format', async () => { + const sessionId = '123e4567-e89b-4d3a-a456-426614174000'; + const { token } = await generateRefreshToken(sessionId); + + const isValid = await verifyRefreshToken(token, 'invalid-hash'); + expect(isValid).toBe(false); + }); + + it('should reject with empty hash', async () => { + const sessionId = '123e4567-e89b-4d3a-a456-426614174000'; + const { token } = await generateRefreshToken(sessionId); + + const isValid = await verifyRefreshToken(token, ''); + expect(isValid).toBe(false); + }); + + it('should handle malformed hash gracefully', async () => { + const sessionId = '123e4567-e89b-4d3a-a456-426614174000'; + const { token } = await generateRefreshToken(sessionId); + + expect(await verifyRefreshToken(token, 'not.a.valid.hash')).toBe(false); + expect(await verifyRefreshToken(token, '.')).toBe(false); + }); + + it('should be timing-safe (multiple verifications should work)', async () => { + const sessionId = '123e4567-e89b-4d3a-a456-426614174000'; + const { token, hash } = await generateRefreshToken(sessionId); + + // Verify multiple times to ensure timing safety + for (let i = 0; i < 10; i++) { + expect(await verifyRefreshToken(token, hash)).toBe(true); + } + }); + }); +}); diff --git a/plugins/auth-backend/src/lib/refreshToken.ts b/plugins/auth-backend/src/lib/refreshToken.ts new file mode 100644 index 0000000000..6e17cf9670 --- /dev/null +++ b/plugins/auth-backend/src/lib/refreshToken.ts @@ -0,0 +1,129 @@ +/* + * 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 { + randomBytes, + scrypt, + timingSafeEqual, + ScryptOptions, +} from 'node:crypto'; + +const SALT_LENGTH = 16; +const KEY_LENGTH = 64; +const SCRYPT_OPTIONS: ScryptOptions = { N: 16384, r: 8, p: 1 }; + +function scryptAsync( + password: string, + salt: Buffer, + keylen: number, + options: ScryptOptions, +): Promise { + return new Promise((resolve, reject) => { + scrypt(password, salt, keylen, options, (err, derivedKey) => { + if (err) reject(err); + else resolve(derivedKey); + }); + }); +} + +/** + * Hash a token using scrypt + * @internal + */ +async function hashToken(token: string): Promise { + const salt = randomBytes(SALT_LENGTH); + const hash = await scryptAsync(token, salt, KEY_LENGTH, SCRYPT_OPTIONS); + + // Store salt + hash together + return `${salt.toString('base64')}.${hash.toString('base64')}`; +} + +/** + * Generate a cryptographically secure refresh token with embedded session ID + * + * @param id - The session ID to embed in the token + * @returns Object containing the token and its hash + * @internal + */ +export async function generateRefreshToken(id: string): Promise<{ + token: string; + hash: string; +}> { + // Generate 32 bytes of random data + const randomPart = randomBytes(32).toString('base64url'); + + // Format: . + const token = `${id}.${randomPart}`; + const hash = await hashToken(token); + + return { token, hash }; +} + +/** + * Extract the session ID from a refresh token + * + * @param token - The refresh token + * @returns The session ID + * @throws Error if token format is invalid + * @internal + */ +export function getRefreshTokenId(token: string): string { + if (!token || typeof token !== 'string') { + throw new Error('Invalid refresh token format'); + } + + const parts = token.split('.'); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error('Invalid refresh token format'); + } + + return parts[0]; +} + +/** + * Verify a refresh token against a stored hash + * + * @param token - The refresh token to verify + * @param storedHash - The stored hash (salt.hash format) + * @returns true if token is valid, false otherwise + * @internal + */ +export async function verifyRefreshToken( + token: string, + storedHash: string, +): Promise { + try { + const [saltBase64, hashBase64] = storedHash.split('.'); + if (!saltBase64 || !hashBase64) { + return false; + } + + const salt = Buffer.from(saltBase64, 'base64'); + const storedHashBuffer = Buffer.from(hashBase64, 'base64'); + + const computedHash = await scryptAsync( + token, + salt, + KEY_LENGTH, + SCRYPT_OPTIONS, + ); + + // Use timing-safe comparison to prevent timing attacks + return timingSafeEqual(storedHashBuffer, computedHash); + } catch { + return false; + } +} diff --git a/plugins/auth-backend/src/service/OfflineAccessService.ts b/plugins/auth-backend/src/service/OfflineAccessService.ts new file mode 100644 index 0000000000..e8a8cae1f1 --- /dev/null +++ b/plugins/auth-backend/src/service/OfflineAccessService.ts @@ -0,0 +1,268 @@ +/* + * 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 { + DatabaseService, + LifecycleService, + LoggerService, + RootConfigService, +} from '@backstage/backend-plugin-api'; +import { AuthenticationError } from '@backstage/errors'; +import { readDurationFromConfig } from '@backstage/config'; +import { durationToMilliseconds } from '@backstage/types'; +import { v4 as uuid } from 'uuid'; +import { OfflineSessionDatabase } from '../database/OfflineSessionDatabase'; +import { + generateRefreshToken, + getRefreshTokenId, + verifyRefreshToken, +} from '../lib/refreshToken'; +import { TokenIssuer } from '../identity/types'; + +/** + * Service for managing offline access (refresh tokens) + * @internal + */ +export class OfflineAccessService { + readonly #offlineSessionDb: OfflineSessionDatabase; + readonly #logger: LoggerService; + + static async create(options: { + config: RootConfigService; + database: DatabaseService; + logger: LoggerService; + lifecycle: LifecycleService; + }): Promise { + const { config, database, logger, lifecycle } = options; + + const tokenLifetime = config.has( + 'auth.experimentalRefreshToken.tokenLifetime', + ) + ? readDurationFromConfig(config, { + key: 'auth.experimentalRefreshToken.tokenLifetime', + }) + : { days: 30 }; + + const maxRotationLifetime = config.has( + 'auth.experimentalRefreshToken.maxRotationLifetime', + ) + ? readDurationFromConfig(config, { + key: 'auth.experimentalRefreshToken.maxRotationLifetime', + }) + : { years: 1 }; + + const tokenLifetimeSeconds = Math.floor( + durationToMilliseconds(tokenLifetime) / 1000, + ); + const maxRotationLifetimeSeconds = Math.floor( + durationToMilliseconds(maxRotationLifetime) / 1000, + ); + + if (tokenLifetimeSeconds <= 0) { + throw new Error( + 'auth.experimentalRefreshToken.tokenLifetime must be a positive duration', + ); + } + if (maxRotationLifetimeSeconds <= 0) { + throw new Error( + 'auth.experimentalRefreshToken.maxRotationLifetime must be a positive duration', + ); + } + if (maxRotationLifetimeSeconds <= tokenLifetimeSeconds) { + throw new Error( + 'auth.experimentalRefreshToken.maxRotationLifetime must be greater than tokenLifetime', + ); + } + + const maxTokensPerUser = + config.getOptionalNumber( + 'auth.experimentalRefreshToken.maxTokensPerUser', + ) ?? 20; + + if (maxTokensPerUser <= 0) { + throw new Error( + 'auth.experimentalRefreshToken.maxTokensPerUser must be a positive number', + ); + } + + const knex = await database.getClient(); + + if ( + knex.client.config.client.includes('sqlite') || + knex.client.config.client.includes('better-sqlite') + ) { + logger.warn( + 'Refresh tokens are enabled with SQLite, which does not support row-level locking. ' + + 'Concurrent token rotation may not be fully protected against race conditions. ' + + 'Use PostgreSQL for production deployments.', + ); + } + + const offlineSessionDb = OfflineSessionDatabase.create({ + knex, + tokenLifetimeSeconds, + maxRotationLifetimeSeconds, + maxTokensPerUser, + }); + + const cleanupIntervalMs = 60 * 60 * 1000; + const cleanupInterval = setInterval(async () => { + try { + const deleted = await offlineSessionDb.cleanupExpiredSessions(); + if (deleted > 0) { + logger.info(`Cleaned up ${deleted} expired offline sessions`); + } + } catch (error) { + logger.error('Failed to cleanup expired offline sessions', error); + } + }, cleanupIntervalMs); + cleanupInterval.unref(); + + lifecycle.addShutdownHook(() => { + clearInterval(cleanupInterval); + }); + + return new OfflineAccessService(offlineSessionDb, logger); + } + + private constructor( + offlineSessionDb: OfflineSessionDatabase, + logger: LoggerService, + ) { + this.#offlineSessionDb = offlineSessionDb; + this.#logger = logger; + } + + /** + * Issue a new refresh token for a user + */ + async issueRefreshToken(options: { + userEntityRef: string; + oidcClientId?: string; + }): Promise { + const { userEntityRef, oidcClientId } = options; + + const sessionId = uuid(); + const { token, hash } = await generateRefreshToken(sessionId); + + await this.#offlineSessionDb.createSession({ + id: sessionId, + userEntityRef, + oidcClientId, + tokenHash: hash, + }); + + this.#logger.debug( + `Issued refresh token for user ${userEntityRef} with session ${sessionId}`, + ); + + return token; + } + + /** + * Refresh an access token using a refresh token + */ + async refreshAccessToken(options: { + refreshToken: string; + tokenIssuer: TokenIssuer; + clientId?: string; + }): Promise<{ accessToken: string; refreshToken: string }> { + const { refreshToken, tokenIssuer, clientId } = options; + + let sessionId: string; + try { + sessionId = getRefreshTokenId(refreshToken); + } catch (error) { + this.#logger.debug('Failed to extract refresh token ID', error); + throw new AuthenticationError('Invalid refresh token format'); + } + + const session = await this.#offlineSessionDb.getSessionById(sessionId); + if (!session) { + throw new AuthenticationError('Invalid refresh token'); + } + + if (this.#offlineSessionDb.isSessionExpired(session)) { + await this.#offlineSessionDb.deleteSession(sessionId); + throw new AuthenticationError('Invalid refresh token'); + } + + if (clientId && session.oidcClientId && clientId !== session.oidcClientId) { + throw new AuthenticationError( + 'Refresh token was not issued to this client', + ); + } + + // Verify the caller actually holds a valid token, not just the session ID + const isValid = await verifyRefreshToken(refreshToken, session.tokenHash); + if (!isValid) { + throw new AuthenticationError('Invalid refresh token'); + } + + const { token: newRefreshToken, hash: newHash } = + await generateRefreshToken(sessionId); + + // Atomically swap the hash so a concurrent request with the same token fails + const rotatedSession = await this.#offlineSessionDb.getAndRotateToken( + sessionId, + session.tokenHash, + newHash, + ); + + if (!rotatedSession) { + throw new AuthenticationError('Invalid refresh token'); + } + + const { token: accessToken } = await tokenIssuer.issueToken({ + claims: { + sub: rotatedSession.userEntityRef, + }, + }); + + this.#logger.debug( + `Refreshed access token for user ${session.userEntityRef} with session ${sessionId}`, + ); + + return { accessToken, refreshToken: newRefreshToken }; + } + + /** + * Revoke a refresh token + */ + async revokeRefreshToken(refreshToken: string): Promise { + try { + const sessionId = getRefreshTokenId(refreshToken); + await this.#offlineSessionDb.deleteSession(sessionId); + this.#logger.debug(`Revoked refresh token with session ${sessionId}`); + } catch (error) { + // Ignore errors when revoking - token may already be invalid + this.#logger.debug('Failed to revoke refresh token', error); + } + } + + /** + * Revoke all refresh tokens for a user + */ + async revokeRefreshTokensByUserEntityRef( + userEntityRef: string, + ): Promise { + const deletedCount = + await this.#offlineSessionDb.deleteSessionsByUserEntityRef(userEntityRef); + this.#logger.debug( + `Revoked ${deletedCount} refresh tokens for user ${userEntityRef}`, + ); + } +} diff --git a/plugins/auth-backend/src/service/OidcError.ts b/plugins/auth-backend/src/service/OidcError.ts new file mode 100644 index 0000000000..44b33db81f --- /dev/null +++ b/plugins/auth-backend/src/service/OidcError.ts @@ -0,0 +1,89 @@ +/* + * 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 { CustomErrorBase, isError } from '@backstage/errors'; +import { Request, Response, NextFunction } from 'express'; +import { LoggerService } from '@backstage/backend-plugin-api'; + +export class OidcError extends CustomErrorBase { + name = 'OidcError'; + + readonly body: { error: string; error_description: string }; + readonly statusCode: number; + + constructor( + errorCode: string, + errorDescription: string, + statusCode: number, + cause?: Error | unknown, + ) { + super(`${errorCode}, ${errorDescription}`, cause); + this.statusCode = statusCode; + this.body = { + error: errorCode, + error_description: errorDescription, + }; + } + + static isOidcError(error: unknown): error is OidcError { + return isError(error) && error.name === 'OidcError'; + } + + static fromError(error: unknown): OidcError { + if (OidcError.isOidcError(error)) { + return error; + } + + if (!isError(error)) { + return new OidcError('server_error', 'Unknown error', 500, error); + } + + const errorMessage = error.message || 'Unknown error'; + + switch (error.name) { + case 'InputError': + return new OidcError('invalid_request', errorMessage, 400, error); + case 'AuthenticationError': + return new OidcError('invalid_client', errorMessage, 401, error); + case 'NotAllowedError': + return new OidcError('access_denied', errorMessage, 403, error); + case 'NotFoundError': + return new OidcError('invalid_request', errorMessage, 400, error); + default: + return new OidcError('server_error', errorMessage, 500, error); + } + } + + static middleware( + logger: LoggerService, + ): (err: unknown, _req: Request, res: Response, next: NextFunction) => void { + return ( + err: unknown, + _req: Request, + res: Response, + next: NextFunction, + ): void => { + if (OidcError.isOidcError(err)) { + logger[err.statusCode >= 500 ? 'error' : 'info']( + `OIDC Request failed with status ${err.statusCode}: ${err.body.error} - ${err.body.error_description}`, + err.cause, + ); + res.status(err.statusCode).json(err.body); + return; + } + next(err); + }; + } +} diff --git a/plugins/auth-backend/src/service/OidcRouter.test.ts b/plugins/auth-backend/src/service/OidcRouter.test.ts index e901ebcab6..0f86b8f413 100644 --- a/plugins/auth-backend/src/service/OidcRouter.test.ts +++ b/plugins/auth-backend/src/service/OidcRouter.test.ts @@ -34,6 +34,7 @@ import { OidcDatabase } from '../database/OidcDatabase'; import { AuthDatabase } from '../database/AuthDatabase'; import { OidcService } from '../service/OidcService'; import { TokenIssuer } from '../identity/types'; +import { OfflineAccessService } from './OfflineAccessService'; jest.setTimeout(60_000); @@ -115,6 +116,93 @@ describe('OidcRouter', () => { }; } + async function createRouterWithOfflineAccess(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + + await knex.migrate.latest({ + directory: resolvePackagePath( + '@backstage/plugin-auth-backend', + 'migrations', + ), + }); + + const authDatabase = AuthDatabase.create({ + getClient: async () => knex, + }); + + const oidcDatabase = await OidcDatabase.create({ + database: authDatabase, + }); + + const userInfoDatabase = await UserInfoDatabase.create({ + database: authDatabase, + }); + + const mockTokenIssuer = { + issueToken: jest.fn(), + listPublicKeys: jest.fn(), + } as unknown as jest.Mocked; + + const mockAuth = mockServices.auth.mock(); + const mockHttpAuth = mockServices.httpAuth.mock(); + const mockConfig = mockServices.rootConfig({ + data: { + auth: { + experimentalDynamicClientRegistration: { + enabled: true, + }, + experimentalRefreshToken: { + enabled: true, + }, + }, + }, + }); + + const mockLifecycle = mockServices.lifecycle.mock(); + + const offlineAccess = await OfflineAccessService.create({ + config: mockConfig, + database: { getClient: async () => knex }, + logger: mockServices.logger.mock(), + lifecycle: mockLifecycle, + }); + + const oidcService = OidcService.create({ + auth: mockAuth, + tokenIssuer: mockTokenIssuer, + baseUrl: 'http://localhost:7000', + userInfo: userInfoDatabase, + oidc: oidcDatabase, + config: mockConfig, + offlineAccess, + }); + + 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, + offlineAccess, + }); + + return { + router: oidcRouter, + mocks: { + httpAuth: mockHttpAuth, + auth: mockAuth, + oidc: oidcDatabase, + userInfo: userInfoDatabase, + service: oidcService, + tokenIssuer: mockTokenIssuer, + }, + }; + } + describe.each(databases.eachSupportedId())('%p', databaseId => { describe('/v1/userinfo', () => { it('should return user info for full tokens', async () => { @@ -702,10 +790,10 @@ describe('OidcRouter', () => { code: 'invalid-code', redirect_uri: 'https://example.com/callback', }) - .expect(401); + .expect(400); expect(tokenResponse.body).toEqual({ - error: 'invalid_client', + error: 'invalid_grant', error_description: 'Invalid authorization code', }); }); @@ -806,5 +894,207 @@ describe('OidcRouter', () => { }); }); }); + + describe('refresh tokens', () => { + async function doAuthFlowWithOfflineAccess(databaseId_: TestDatabaseId) { + const result = await createRouterWithOfflineAccess(databaseId_); + const { + mocks: { auth, service, tokenIssuer, httpAuth }, + router, + } = result; + + tokenIssuer.issueToken.mockResolvedValue({ + token: 'mock-access-token', + }); + + httpAuth.credentials.mockResolvedValueOnce( + mockCredentials.user(MOCK_USER_ENTITY_REF), + ); + auth.isPrincipal.mockReturnValueOnce(true); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code', 'refresh_token'], + scope: 'openid offline_access', + }); + + const authSession = await service.createAuthorizationSession({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + scope: 'openid offline_access', + }); + + 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')!; + + 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); + + return { server, tokenResponse, client, ...result }; + } + + it('should return a refresh token when offline_access scope is requested', async () => { + const { tokenResponse } = await doAuthFlowWithOfflineAccess(databaseId); + + expect(tokenResponse.body).toEqual({ + access_token: 'mock-access-token', + token_type: 'Bearer', + expires_in: 3600, + id_token: 'mock-access-token', + scope: 'openid offline_access', + refresh_token: expect.any(String), + }); + }); + + it('should refresh a token without client credentials', async () => { + const { server, tokenResponse, mocks } = + await doAuthFlowWithOfflineAccess(databaseId); + + mocks.tokenIssuer.issueToken.mockResolvedValue({ + token: 'mock-refreshed-token', + }); + + const refreshResponse = await request(server) + .post('/api/auth/v1/token') + .send({ + grant_type: 'refresh_token', + refresh_token: tokenResponse.body.refresh_token, + }) + .expect(200); + + expect(refreshResponse.body).toEqual({ + access_token: 'mock-refreshed-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: expect.any(String), + }); + + // New refresh token should be different (rotation) + expect(refreshResponse.body.refresh_token).not.toBe( + tokenResponse.body.refresh_token, + ); + }); + + it('should refresh a token with valid client credentials', async () => { + const { server, tokenResponse, client, mocks } = + await doAuthFlowWithOfflineAccess(databaseId); + + mocks.tokenIssuer.issueToken.mockResolvedValue({ + token: 'mock-refreshed-token', + }); + + const basicAuth = Buffer.from( + `${client.clientId}:${client.clientSecret}`, + ).toString('base64'); + + const refreshResponse = await request(server) + .post('/api/auth/v1/token') + .set('Authorization', `Basic ${basicAuth}`) + .send({ + grant_type: 'refresh_token', + refresh_token: tokenResponse.body.refresh_token, + }) + .expect(200); + + expect(refreshResponse.body).toEqual({ + access_token: 'mock-refreshed-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: expect.any(String), + }); + }); + + it('should reject refresh with invalid client credentials', async () => { + const { server, tokenResponse } = await doAuthFlowWithOfflineAccess( + databaseId, + ); + + const badAuth = Buffer.from('bad-client:bad-secret').toString('base64'); + + await request(server) + .post('/api/auth/v1/token') + .set('Authorization', `Basic ${badAuth}`) + .send({ + grant_type: 'refresh_token', + refresh_token: tokenResponse.body.refresh_token, + }) + .expect(401); + }); + + it('should reject refresh with an invalid refresh token', async () => { + const { server } = await doAuthFlowWithOfflineAccess(databaseId); + + await request(server) + .post('/api/auth/v1/token') + .send({ + grant_type: 'refresh_token', + refresh_token: 'invalid-token', + }) + .expect(400); + }); + + it('should reject reuse of a rotated refresh token', async () => { + const { server, tokenResponse, mocks } = + await doAuthFlowWithOfflineAccess(databaseId); + + mocks.tokenIssuer.issueToken.mockResolvedValue({ + token: 'mock-refreshed-token', + }); + + const originalRefreshToken = tokenResponse.body.refresh_token; + + // First refresh should succeed + await request(server) + .post('/api/auth/v1/token') + .send({ + grant_type: 'refresh_token', + refresh_token: originalRefreshToken, + }) + .expect(200); + + // Reusing the same refresh token should fail (it was rotated) + await request(server) + .post('/api/auth/v1/token') + .send({ + grant_type: 'refresh_token', + refresh_token: originalRefreshToken, + }) + .expect(400); + }); + }); }); }); diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts index 16a7b92f29..00861e09e4 100644 --- a/plugins/auth-backend/src/service/OidcRouter.ts +++ b/plugins/auth-backend/src/service/OidcRouter.ts @@ -25,8 +25,114 @@ import { import { TokenIssuer } from '../identity/types'; import { UserInfoDatabase } from '../database/UserInfoDatabase'; import { OidcDatabase } from '../database/OidcDatabase'; +import { OfflineAccessService } from './OfflineAccessService'; import { json } from 'express'; -import { readDcrTokenExpiration } from './readTokenExpiration.ts'; +import { readDcrTokenExpiration } from './readTokenExpiration'; +import { z } from 'zod'; +import { fromZodError } from 'zod-validation-error'; +import { OidcError } from './OidcError'; + +const authorizeQuerySchema = z.object({ + client_id: z.string().min(1), + redirect_uri: z.string().url(), + response_type: z.string().min(1), + scope: z.string().optional(), + state: z.string().optional(), + nonce: z.string().optional(), + code_challenge: z.string().optional(), + code_challenge_method: z.string().optional(), +}); + +const sessionIdParamSchema = z.object({ + sessionId: z.string().min(1), +}); + +const tokenRequestBodySchema = z.object({ + grant_type: z.string().min(1), + code: z.string().optional(), + redirect_uri: z.string().url().optional(), + code_verifier: z.string().optional(), + refresh_token: z.string().optional(), + client_id: z.string().optional(), + client_secret: z.string().optional(), +}); + +const registerRequestBodySchema = z.object({ + client_name: z.string().optional(), + redirect_uris: z.array(z.string().url()).min(1), + response_types: z.array(z.string()).optional(), + grant_types: z.array(z.string()).optional(), + scope: z.string().optional(), +}); + +const revokeRequestBodySchema = z.object({ + token: z.string().min(1), + token_type_hint: z.string().optional(), + client_id: z.string().optional(), + client_secret: z.string().optional(), +}); + +function validateRequest(schema: z.ZodSchema, data: unknown): T { + const parseResult = schema.safeParse(data); + if (!parseResult.success) { + const errorMessage = fromZodError(parseResult.error).message; + throw new OidcError('invalid_request', errorMessage, 400); + } + return parseResult.data; +} + +async function authenticateClient( + req: { headers: { authorization?: string } }, + oidc: OidcService, + bodyClientId?: string, + bodyClientSecret?: string, +): Promise<{ clientId: string; clientSecret: string }> { + let clientId: string | undefined; + let clientSecret: string | undefined; + + const basicAuth = req.headers.authorization?.match(/^Basic[ ]+([^\s]+)$/i); + if (basicAuth) { + try { + const decoded = Buffer.from(basicAuth[1], 'base64').toString('utf8'); + const idx = decoded.indexOf(':'); + if (idx >= 0) { + clientId = decoded.slice(0, idx); + clientSecret = decoded.slice(idx + 1); + } + } catch { + /* ignore */ + } + } + + if (!clientId || !clientSecret) { + if (bodyClientId && bodyClientSecret) { + clientId = bodyClientId; + clientSecret = bodyClientSecret; + } + } + + if (!clientId || !clientSecret) { + throw new OidcError( + 'invalid_client', + 'Client authentication required', + 401, + ); + } + + try { + const ok = await oidc.verifyClientCredentials({ + clientId, + clientSecret, + }); + if (!ok) { + throw new OidcError('invalid_client', 'Invalid client credentials', 401); + } + } catch (e) { + throw OidcError.fromError(e); + } + + return { clientId, clientSecret }; +} export class OidcRouter { private readonly oidc: OidcService; @@ -62,6 +168,7 @@ export class OidcRouter { oidc: OidcDatabase; httpAuth: HttpAuthService; config: RootConfigService; + offlineAccess?: OfflineAccessService; }) { return new OidcRouter( OidcService.create(options), @@ -113,17 +220,16 @@ export class OidcRouter { res.json(userInfo); }); - if ( - this.config.getOptionalBoolean( - 'auth.experimentalDynamicClientRegistration.enabled', - ) - ) { + const dcrEnabled = this.config.getOptionalBoolean( + 'auth.experimentalDynamicClientRegistration.enabled', + ); + + if (dcrEnabled) { // 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, @@ -133,27 +239,18 @@ export class OidcRouter { 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', - }); - } + } = validateRequest(authorizeQuerySchema, req.query); 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, + clientId, + redirectUri, + responseType, + scope, + state, + nonce, + codeChallenge, + codeChallengeMethod, }); // todo(blam): maybe this URL could be overridable by config if @@ -166,36 +263,29 @@ export class OidcRouter { 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); - } + if (OidcError.isOidcError(error)) { + const errorParams = new URLSearchParams(); + errorParams.append('error', error.body.error); + errorParams.append( + 'error_description', + error.body.error_description, + ); + if (state) { + errorParams.append('state', state); + } - const redirectUrl = new URL(redirectUri as string); - redirectUrl.search = errorParams.toString(); - return res.redirect(redirectUrl.toString()); + const redirectUrl = new URL(redirectUri); + redirectUrl.search = errorParams.toString(); + return res.redirect(redirectUrl.toString()); + } + throw error; } }); // 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', - }); - } + const { sessionId } = validateRequest(sessionIdParamSchema, req.params); try { const session = await this.oidc.getAuthorizationSession({ @@ -209,38 +299,24 @@ export class OidcRouter { 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, - }); + throw OidcError.fromError(error); } }); // 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', - }); - } + const { sessionId } = validateRequest(sessionIdParamSchema, req.params); 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', - }); + throw new OidcError( + 'access_denied', + 'Authentication required', + 403, + ); } const { userEntityRef } = httpCredentials.principal; @@ -254,37 +330,19 @@ export class OidcRouter { 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, - }); + throw OidcError.fromError(error); } }); // 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 { sessionId } = validateRequest(sessionIdParamSchema, req.params); const httpCredentials = await this.httpAuth.credentials(req); if (!this.auth.isPrincipal(httpCredentials, 'user')) { - return res.status(401).json({ - error: 'unauthorized', - error_description: 'Authentication required', - }); + throw new OidcError('access_denied', 'Authentication required', 403); } const { userEntityRef } = httpCredentials.principal; @@ -312,139 +370,183 @@ export class OidcRouter { 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, - }); + throw OidcError.fromError(error); } }); // Token endpoint // https://openid.net/specs/openid-connect-core-1_0.html#TokenRequest // Exchanges authorization codes for access tokens and ID tokens + // Also handles refresh token grant type 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', - }); - } + refresh_token: refreshToken, + client_id: bodyClientId, + client_secret: bodyClientSecret, + } = validateRequest(tokenRequestBodySchema, req.body); const expiresIn = readDcrTokenExpiration(this.config); try { - const result = await this.oidc.exchangeCodeForToken({ - code, - redirectUri, - codeVerifier, - grantType, - expiresIn, - }); - - 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, - }); + // Handle authorization_code grant type + if (grantType === 'authorization_code') { + if (!code || !redirectUri) { + throw new OidcError( + 'invalid_request', + 'Missing code or redirect_uri parameters for authorization_code grant', + 400, + ); } + + const result = await this.oidc.exchangeCodeForToken({ + code, + redirectUri, + codeVerifier, + grantType, + expiresIn, + }); + + return res.json({ + access_token: result.accessToken, + token_type: result.tokenType, + expires_in: result.expiresIn, + id_token: result.idToken, + scope: result.scope, + ...(result.refreshToken && { + refresh_token: result.refreshToken, + }), + }); } - return res.status(500).json({ - error: 'server_error', - error_description: description, - }); + // Handle refresh_token grant type + if (grantType === 'refresh_token') { + if (!refreshToken) { + throw new OidcError( + 'invalid_request', + 'Missing refresh_token parameter for refresh_token grant', + 400, + ); + } + + // Authenticate if credentials are provided via Basic auth or body + const hasCredentials = + req.headers.authorization?.match(/^Basic[ ]+([^\s]+)$/i) || + (bodyClientId && bodyClientSecret); + + let authenticatedClientId: string | undefined; + if (hasCredentials) { + const { clientId: authedId } = await authenticateClient( + req, + this.oidc, + bodyClientId, + bodyClientSecret, + ); + authenticatedClientId = authedId; + } + + const result = await this.oidc.refreshAccessToken({ + refreshToken, + clientId: authenticatedClientId, + }); + + return res.json({ + access_token: result.accessToken, + token_type: result.tokenType, + expires_in: result.expiresIn, + refresh_token: result.refreshToken, + }); + } + + // Unsupported grant type + throw new OidcError( + 'unsupported_grant_type', + `Grant type ${grantType} is not supported`, + 400, + ); + } catch (error) { + // Invalid auth codes and refresh tokens should be invalid_grant, not invalid_client. + // Client auth failures are already thrown as OidcError by authenticateClient. + if (isError(error) && error.name === 'AuthenticationError') { + throw new OidcError('invalid_grant', error.message, 400, error); + } + throw OidcError.fromError(error); } }); + } - // Dynamic Client Registration endpoint + // Dynamic Client Registration endpoint - only available when DCR is enabled + if (dcrEnabled) { // 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; - } + } = validateRequest(registerRequestBodySchema, req.body); try { const client = await this.oidc.registerClient({ - clientName, + clientName: clientName ?? 'Backstage CLI', redirectUris, responseTypes, grantTypes, scope, }); - res.status(201).json({ + return 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); + throw OidcError.fromError(e); + } + }); - res.status(500).json({ - error: 'server_error', - error_description: `Failed to register client: ${description}`, - }); + // Token Revocation endpoint (RFC 7009-like) + // Allows clients to revoke refresh tokens + router.post('/v1/revoke', async (req, res) => { + try { + const { + token, + client_id: bodyClientId, + client_secret: bodyClientSecret, + } = validateRequest(revokeRequestBodySchema, req.body ?? {}); + + await authenticateClient( + req, + this.oidc, + bodyClientId, + bodyClientSecret, + ); + + try { + await this.oidc.revokeRefreshToken(token); + } catch (e) { + // RFC 7009 says always respond 200 even for invalid tokens + this.logger.debug('Failed to revoke token', e); + } + + return res.status(200).send(''); + } catch (e) { + throw OidcError.fromError(e); } }); } + router.use(OidcError.middleware(this.logger)); + return router; } } -function ensureTrailingSlash(appUrl: string): string | URL | undefined { +function ensureTrailingSlash(appUrl: string): string { if (appUrl.endsWith('/')) { return appUrl; } diff --git a/plugins/auth-backend/src/service/OidcService.test.ts b/plugins/auth-backend/src/service/OidcService.test.ts index 8dd1c39783..6242795565 100644 --- a/plugins/auth-backend/src/service/OidcService.test.ts +++ b/plugins/auth-backend/src/service/OidcService.test.ts @@ -117,7 +117,6 @@ describe('OidcService', () => { 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'], }); }); diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts index 72ffc361b4..991cc2bcb9 100644 --- a/plugins/auth-backend/src/service/OidcService.ts +++ b/plugins/auth-backend/src/service/OidcService.ts @@ -26,6 +26,8 @@ import crypto from 'node:crypto'; import { OidcDatabase } from '../database/OidcDatabase'; import { DateTime } from 'luxon'; import matcher from 'matcher'; +import { OfflineAccessService } from './OfflineAccessService'; +import { readDcrTokenExpiration } from './readTokenExpiration'; export class OidcService { private readonly auth: AuthService; @@ -34,6 +36,7 @@ export class OidcService { private readonly userInfo: UserInfoDatabase; private readonly oidc: OidcDatabase; private readonly config: RootConfigService; + private readonly offlineAccess?: OfflineAccessService; private constructor( auth: AuthService, @@ -42,6 +45,7 @@ export class OidcService { userInfo: UserInfoDatabase, oidc: OidcDatabase, config: RootConfigService, + offlineAccess?: OfflineAccessService, ) { this.auth = auth; this.tokenIssuer = tokenIssuer; @@ -49,6 +53,7 @@ export class OidcService { this.userInfo = userInfo; this.oidc = oidc; this.config = config; + this.offlineAccess = offlineAccess; } static create(options: { @@ -58,6 +63,7 @@ export class OidcService { userInfo: UserInfoDatabase; oidc: OidcDatabase; config: RootConfigService; + offlineAccess?: OfflineAccessService; }) { return new OidcService( options.auth, @@ -66,10 +72,15 @@ export class OidcService { options.userInfo, options.oidc, options.config, + options.offlineAccess, ); } public getConfiguration() { + const dcrEnabled = this.config.getOptionalBoolean( + 'auth.experimentalDynamicClientRegistration.enabled', + ); + return { issuer: this.baseUrl, token_endpoint: `${this.baseUrl}/v1/token`, @@ -89,16 +100,25 @@ export class OidcService { 'PS512', 'EdDSA', ], - scopes_supported: ['openid'], + scopes_supported: [ + 'openid', + ...(this.offlineAccess ? ['offline_access'] : []), + ], token_endpoint_auth_methods_supported: [ 'client_secret_basic', 'client_secret_post', ], claims_supported: ['sub', 'ent'], - grant_types_supported: ['authorization_code'], + grant_types_supported: [ + 'authorization_code', + ...(this.offlineAccess ? ['refresh_token'] : []), + ], authorization_endpoint: `${this.baseUrl}/v1/authorize`, - registration_endpoint: `${this.baseUrl}/v1/register`, code_challenge_methods_supported: ['S256', 'plain'], + ...(dcrEnabled && { + registration_endpoint: `${this.baseUrl}/v1/register`, + revocation_endpoint: `${this.baseUrl}/v1/revoke`, + }), }; } @@ -184,14 +204,7 @@ export class OidcService { 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'); - } + const client = await this.resolveClient(clientId, redirectUri); if (codeChallenge) { if ( @@ -226,6 +239,33 @@ export class OidcService { }; } + private async getClientName(clientId: string): Promise { + const client = await this.oidc.getClient({ clientId }); + if (!client) { + throw new InputError('Invalid client_id'); + } + return client.clientName; + } + + private async resolveClient( + clientId: string, + redirectUri: string, + ): Promise<{ clientName: string; redirectUris: string[] }> { + 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'); + } + + return { + clientName: client.clientName, + redirectUris: client.redirectUris, + }; + } + public async approveAuthorizationSession(opts: { sessionId: string; userEntityRef: string; @@ -292,15 +332,12 @@ export class OidcService { 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'); - } + const clientName = await this.getClientName(session.clientId); return { id: session.id, clientId: session.clientId, - clientName: client.clientName, + clientName, redirectUri: session.redirectUri, scope: session.scope, state: session.state, @@ -415,15 +452,86 @@ export class OidcService { }, }); + // Check if offline_access scope is requested + let refreshToken: string | undefined; + const scopes = session.scope?.split(' ') ?? []; + if (scopes.includes('offline_access') && this.offlineAccess) { + refreshToken = await this.offlineAccess.issueRefreshToken({ + userEntityRef: session.userEntityRef, + oidcClientId: session.clientId, + }); + } + return { accessToken: token, tokenType: 'Bearer', expiresIn: expiresIn, idToken: token, scope: session.scope || 'openid', + refreshToken, }; } + public async refreshAccessToken(params: { + refreshToken: string; + clientId?: string; + }): Promise<{ + accessToken: string; + tokenType: string; + expiresIn: number; + refreshToken: string; + }> { + if (!this.offlineAccess) { + throw new InputError('Refresh tokens are not enabled'); + } + + const { accessToken, refreshToken } = + await this.offlineAccess.refreshAccessToken({ + refreshToken: params.refreshToken, + tokenIssuer: this.tokenIssuer, + clientId: params.clientId, + }); + + const expiresIn = readDcrTokenExpiration(this.config); + + return { + accessToken, + tokenType: 'Bearer', + expiresIn, + refreshToken, + }; + } + + /** + * Verifies client credentials against the registered OIDC clients + */ + public async verifyClientCredentials(options: { + clientId: string; + clientSecret: string; + }): Promise { + const { clientId, clientSecret } = options; + const client = await this.oidc.getClient({ clientId }); + if (!client?.clientSecret) { + return false; + } + const expected = Buffer.from(client.clientSecret, 'utf8'); + const provided = Buffer.from(clientSecret, 'utf8'); + if (expected.length !== provided.length) { + return false; + } + return crypto.timingSafeEqual(expected, provided); + } + + /** + * Revoke a refresh token if offline access is enabled + */ + public async revokeRefreshToken(token: string): Promise { + if (!this.offlineAccess) { + return; + } + await this.offlineAccess.revokeRefreshToken(token); + } + private verifyPkce( codeChallenge: string, codeVerifier: string, diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 4fb87546c8..ec5b303710 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -44,6 +44,7 @@ import { StaticKeyStore } from '../identity/StaticKeyStore'; import { bindProviderRouters, ProviderFactories } from '../providers/router'; import { OidcRouter } from './OidcRouter'; import { OidcDatabase } from '../database/OidcDatabase'; +import { OfflineAccessService } from './OfflineAccessService'; interface RouterOptions { logger: LoggerService; @@ -56,6 +57,7 @@ interface RouterOptions { catalog: CatalogService; ownershipResolver?: AuthOwnershipResolver; httpAuth: HttpAuthService; + offlineAccess?: OfflineAccessService; } export async function createRouter( @@ -180,6 +182,7 @@ export async function createRouter( logger, httpAuth, config, + offlineAccess: options.offlineAccess, }); router.use(oidcRouter.getRouter()); diff --git a/yarn.lock b/yarn.lock index 93be60a454..2ec0fb8512 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4737,6 +4737,8 @@ __metadata: passport: "npm:^0.7.0" supertest: "npm:^7.0.0" uuid: "npm:^11.0.0" + zod: "npm:^4.3.5" + zod-validation-error: "npm:^5.0.0" languageName: unknown linkType: soft @@ -51535,6 +51537,15 @@ __metadata: languageName: node linkType: hard +"zod-validation-error@npm:^5.0.0": + version: 5.0.0 + resolution: "zod-validation-error@npm:5.0.0" + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + checksum: 10/d5713f6608f9b8fcb0d74c591cb5c5839fcab679adde5c668f8f95539a1b54fb6910ba577ec66ccda77645e9d2c1ae3fafca771a291f4648aaae8bdfed9b27a7 + languageName: node + linkType: hard + "zod@npm:^3.24.2, zod@npm:^3.25.76": version: 3.25.76 resolution: "zod@npm:3.25.76" @@ -51542,10 +51553,10 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.25 || ^4.0, zod@npm:^4.1.11": - version: 4.1.13 - resolution: "zod@npm:4.1.13" - checksum: 10/0679190318928f69fcb07751063719de232c663b13955fcdb55db59839569d39f3f29b955cb0cba7af0b724233f88c06b3e84c550397ad4e68f8088fa6799d88 +"zod@npm:^3.25 || ^4.0, zod@npm:^4.1.11, zod@npm:^4.3.5": + version: 4.3.6 + resolution: "zod@npm:4.3.6" + checksum: 10/25fc0f62e01b557b4644bf0b393bbaf47542ab30877c37837ea8caf314a8713d220c7d7fe51f68ffa72f0e1018ddfa34d96f1973d23033f5a2a5a9b6b9d9da01 languageName: node linkType: hard