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:
Ben Lambert
2026-02-10 17:00:51 +01:00
committed by GitHub
parent cfcc0e583c
commit d0786b968e
18 changed files with 2185 additions and 194 deletions
+5
View File
@@ -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.
+31
View File
@@ -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');
};
+3 -1
View File
@@ -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:^",
+19
View File
@@ -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 |
+17
View File
@@ -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);
});
});
});
});
+272 -170
View File
@@ -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'],
});
});
+124 -16
View File
@@ -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());
+15 -4
View File
@@ -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