auth-backend: add experimental refresh token support (#32695)
* auth-backend: add experimental refresh token support Signed-off-by: benjdlambert <ben@blam.sh> * auth-backend: refresh token review fixes Signed-off-by: benjdlambert <ben@blam.sh> * auth-backend: address PR review feedback for refresh tokens Signed-off-by: benjdlambert <ben@blam.sh> --------- Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
@@ -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.
|
||||
Vendored
+31
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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: '/',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<OfflineSession> {
|
||||
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<DbOfflineSessionRow>(TABLE_NAME)
|
||||
.where('oidc_client_id', oidcClientId)
|
||||
.andWhere('user_entity_ref', userEntityRef)
|
||||
.delete();
|
||||
}
|
||||
|
||||
// Enforce per-user limit
|
||||
const userSessions = await trx<DbOfflineSessionRow>(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<DbOfflineSessionRow>(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<OfflineSession | undefined> {
|
||||
const row = await this.#knex<DbOfflineSessionRow>(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<OfflineSession | undefined> {
|
||||
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<DbOfflineSessionRow>(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<DbOfflineSessionRow>(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<number> {
|
||||
return await this.#knex<DbOfflineSessionRow>(TABLE_NAME)
|
||||
.where('id', id)
|
||||
.delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all sessions for a user entity ref
|
||||
*/
|
||||
async deleteSessionsByUserEntityRef(userEntityRef: string): Promise<number> {
|
||||
return await this.#knex<DbOfflineSessionRow>(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<number> {
|
||||
const now = DateTime.utc();
|
||||
const tokenLifetimeThreshold = now
|
||||
.minus({ seconds: this.#tokenLifetimeSeconds })
|
||||
.toJSDate();
|
||||
const maxRotationThreshold = now
|
||||
.minus({ seconds: this.#maxRotationLifetimeSeconds })
|
||||
.toJSDate();
|
||||
|
||||
return await this.#knex<DbOfflineSessionRow>(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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<Buffer> {
|
||||
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<string> {
|
||||
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: <id>.<random_bytes>
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<OfflineAccessService> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const deletedCount =
|
||||
await this.#offlineSessionDb.deleteSessionsByUserEntityRef(userEntityRef);
|
||||
this.#logger.debug(
|
||||
`Revoked ${deletedCount} refresh tokens for user ${userEntityRef}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<TokenIssuer>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<T>(schema: z.ZodSchema<T>, 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;
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
if (!this.offlineAccess) {
|
||||
return;
|
||||
}
|
||||
await this.offlineAccess.revokeRefreshToken(token);
|
||||
}
|
||||
|
||||
private verifyPkce(
|
||||
codeChallenge: string,
|
||||
codeVerifier: string,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user