feat(auth): allow configuring DCR token expiration

this adds a new config value for exprimental dynamic client registration
feature that allows configuring the token expiration.

added also missing config values to the config schema for this feature.

Signed-off-by: Hellgren Heikki <heikki.hellgren@op.fi>
This commit is contained in:
Hellgren Heikki
2025-09-25 14:21:34 +03:00
parent 9f6f46d234
commit 51ff7d8e46
8 changed files with 208 additions and 31 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-auth-backend': patch
---
Allow configuring dynamic client registration token expiration with config `auth.experimentalDynamicClientRegistration.tokenExpiration`.
Maximum expiration for the DCR token is 24 hours. Default expiration is 1 hour.
+24
View File
@@ -95,6 +95,7 @@ export interface Config {
/**
* The backstage token expiration.
* Defaults to 1 hour (3600s). Maximum allowed is 24 hours.
*/
backstageTokenExpiration?: HumanDuration | string;
@@ -102,5 +103,28 @@ export interface Config {
* Additional app origins to allow for authenticating
*/
experimentalExtraAllowedOrigins?: string[];
/**
* Configuration for dynamic client registration
*/
experimentalDynamicClientRegistration?: {
/**
* Whether to enable dynamic client registration
* Defaults to false
*/
enabled?: boolean;
/**
* A list of allowed URI patterns to use for redirect URIs during
* dynamic client registration. Defaults to '[*]' which allows any redirect URI.
*/
allowedRedirectUriPatterns?: string[];
/**
* The expiration time for the client registration access tokens.
* Defaults to 1 hour (3600s). Maximum allowed is 24 hours.
*/
tokenExpiration?: HumanDuration | string;
};
};
}
@@ -26,6 +26,7 @@ import { TokenIssuer } from '../identity/types';
import { UserInfoDatabase } from '../database/UserInfoDatabase';
import { OidcDatabase } from '../database/OidcDatabase';
import { json } from 'express';
import { readDcrTokenExpiration } from './readTokenExpiration.ts';
export class OidcRouter {
private constructor(
@@ -332,12 +333,15 @@ export class OidcRouter {
});
}
const expiresIn = readDcrTokenExpiration(this.config);
try {
const result = await this.oidc.exchangeCodeForToken({
code,
redirectUri,
codeVerifier,
grantType,
expiresIn,
});
return res.json({
@@ -687,6 +687,7 @@ describe('OidcService', () => {
code,
redirectUri: 'https://example.com/callback',
grantType: 'authorization_code',
expiresIn: 3600,
});
expect(tokenResult).toEqual({
@@ -698,6 +699,46 @@ describe('OidcService', () => {
});
});
it('should exchange valid code for tokens with custom expiration', async () => {
const { service, mocks } = await createOidcService(databaseId);
const mockToken = 'mock-jwt-token';
mocks.tokenIssuer.issueToken.mockResolvedValue({ token: mockToken });
const client = await service.registerClient({
clientName: 'Test Client',
redirectUris: ['https://example.com/callback'],
});
const authSession = await service.createAuthorizationSession({
clientId: client.clientId,
redirectUri: 'https://example.com/callback',
responseType: 'code',
scope: 'openid',
});
const authResult = await service.approveAuthorizationSession({
sessionId: authSession.id,
userEntityRef: 'user:default/test',
});
const code = new URL(authResult.redirectUrl).searchParams.get('code')!;
const tokenResult = await service.exchangeCodeForToken({
code,
redirectUri: 'https://example.com/callback',
grantType: 'authorization_code',
expiresIn: 6000,
});
expect(tokenResult).toEqual({
accessToken: mockToken,
tokenType: 'Bearer',
expiresIn: 6000,
idToken: mockToken,
scope: 'openid',
});
});
it('should throw error for invalid grant type', async () => {
const { service } = await createOidcService(databaseId);
@@ -706,6 +747,7 @@ describe('OidcService', () => {
code: 'test-code',
redirectUri: 'https://example.com/callback',
grantType: 'client_credentials',
expiresIn: 3600,
}),
).rejects.toThrow('Unsupported grant type');
});
@@ -746,6 +788,7 @@ describe('OidcService', () => {
redirectUri: 'https://example.com/callback',
grantType: 'authorization_code',
codeVerifier,
expiresIn: 3600,
});
expect(tokenResult.accessToken).toBe(mockToken);
@@ -781,6 +824,7 @@ describe('OidcService', () => {
redirectUri: 'https://example.com/callback',
grantType: 'authorization_code',
codeVerifier: 'invalid-verifier',
expiresIn: 3600,
}),
).rejects.toThrow('Invalid code verifier');
});
@@ -17,8 +17,8 @@ import { AuthService, RootConfigService } from '@backstage/backend-plugin-api';
import { TokenIssuer } from '../identity/types';
import { UserInfoDatabase } from '../database/UserInfoDatabase';
import {
InputError,
AuthenticationError,
InputError,
NotFoundError,
} from '@backstage/errors';
import { decodeJwt } from 'jose';
@@ -333,8 +333,9 @@ export class OidcService {
redirectUri: string;
codeVerifier?: string;
grantType: string;
expiresIn: number;
}) {
const { code, redirectUri, codeVerifier, grantType } = params;
const { code, redirectUri, codeVerifier, grantType, expiresIn } = params;
if (grantType !== 'authorization_code') {
throw new InputError('Unsupported grant type');
@@ -403,7 +404,7 @@ export class OidcService {
return {
accessToken: token,
tokenType: 'Bearer',
expiresIn: 3600,
expiresIn: expiresIn,
idToken: token,
scope: session.scope || 'openid',
};
@@ -15,7 +15,10 @@
*/
import { ConfigReader } from '@backstage/config';
import { readBackstageTokenExpiration } from './readBackstageTokenExpiration';
import {
readBackstageTokenExpiration,
readTokenExpiration,
} from './readTokenExpiration.ts';
describe('Test for default backstage token expiry time', () => {
it('Will return default backstage session expiration', () => {
@@ -74,4 +77,57 @@ describe('Test for default backstage token expiry time', () => {
});
expect(readBackstageTokenExpiration(config)).toBe(86400);
});
it('will return expiration from custom key', () => {
const config = new ConfigReader({
app: {
baseUrl: 'http://example.com/extra-path',
},
custom: {
tokenExp: { minutes: 20 },
},
});
expect(readTokenExpiration(config, { configKey: 'custom.tokenExp' })).toBe(
1200,
);
});
it('will return custom default expiration', () => {
const config = new ConfigReader({});
expect(
readTokenExpiration(config, {
configKey: 'auth.backstageTokenExpiration',
defaultExpiration: 1234,
}),
).toBe(1234);
});
it('will return custom min/max expiration', () => {
const config = new ConfigReader({
auth: {
backstageTokenExpiration: { minutes: 20 },
},
});
expect(
readTokenExpiration(config, {
configKey: 'auth.backstageTokenExpiration',
minExpiration: 2000,
maxExpiration: 3000,
}),
).toBe(2000);
expect(
readTokenExpiration(config, {
configKey: 'auth.backstageTokenExpiration',
minExpiration: 1000,
maxExpiration: 1100,
}),
).toBe(1100);
expect(
readTokenExpiration(config, {
configKey: 'auth.backstageTokenExpiration',
minExpiration: 1000,
maxExpiration: 2000,
}),
).toBe(1200);
});
});
@@ -23,22 +23,46 @@ const TOKEN_EXP_MIN_S = 600;
const TOKEN_EXP_MAX_S = 86400;
export function readBackstageTokenExpiration(config: RootConfigService) {
const processingIntervalKey = 'auth.backstageTokenExpiration';
return readTokenExpiration(config, {
configKey: 'auth.backstageTokenExpiration',
});
}
if (!config.has(processingIntervalKey)) {
return TOKEN_EXP_DEFAULT_S;
export function readDcrTokenExpiration(config: RootConfigService) {
return readTokenExpiration(config, {
configKey: 'auth.experimentalDynamicClientRegistration.tokenExpiration',
});
}
export function readTokenExpiration(
config: RootConfigService,
options: {
configKey: string;
maxExpiration?: number;
minExpiration?: number;
defaultExpiration?: number;
},
): number {
const {
configKey,
maxExpiration = TOKEN_EXP_MAX_S,
minExpiration = TOKEN_EXP_MIN_S,
defaultExpiration = TOKEN_EXP_DEFAULT_S,
} = options ?? {};
if (!config.has(configKey)) {
return defaultExpiration;
}
const duration = readDurationFromConfig(config, {
key: processingIntervalKey,
key: configKey,
});
const durationS = Math.round(durationToMilliseconds(duration) / 1000);
if (durationS < TOKEN_EXP_MIN_S) {
return TOKEN_EXP_MIN_S;
} else if (durationS > TOKEN_EXP_MAX_S) {
return TOKEN_EXP_MAX_S;
if (durationS < minExpiration) {
return minExpiration;
} else if (durationS > maxExpiration) {
return maxExpiration;
}
return durationS;
}
+36 -19
View File
@@ -35,8 +35,10 @@ import session from 'express-session';
import connectSessionKnex from 'connect-session-knex';
import passport from 'passport';
import { AuthDatabase } from '../database/AuthDatabase';
import { readBackstageTokenExpiration } from './readBackstageTokenExpiration';
import { TokenIssuer } from '../identity/types';
import {
readBackstageTokenExpiration,
readDcrTokenExpiration,
} from './readTokenExpiration.ts';
import { StaticTokenIssuer } from '../identity/StaticTokenIssuer';
import { StaticKeyStore } from '../identity/StaticKeyStore';
import { bindProviderRouters, ProviderFactories } from '../providers/router';
@@ -91,29 +93,37 @@ export async function createRouter(
? ['ent']
: [];
let tokenIssuer: TokenIssuer;
if (keyStore instanceof StaticKeyStore) {
tokenIssuer = new StaticTokenIssuer(
{
logger: logger.child({ component: 'token-factory' }),
issuer: authUrl,
sessionExpirationSeconds: backstageTokenExpiration,
omitClaimsFromToken,
},
keyStore as StaticKeyStore,
);
} else {
tokenIssuer = new TokenFactory({
const createTokenIssuer = (opts: {
logger: LoggerService;
expirationSeconds: number;
}) => {
if (keyStore instanceof StaticKeyStore) {
return new StaticTokenIssuer(
{
logger: opts.logger,
issuer: authUrl,
sessionExpirationSeconds: opts.expirationSeconds,
omitClaimsFromToken,
},
keyStore as StaticKeyStore,
);
}
return new TokenFactory({
issuer: authUrl,
keyStore,
keyDurationSeconds: backstageTokenExpiration,
logger: logger.child({ component: 'token-factory' }),
keyDurationSeconds: opts.expirationSeconds,
logger: opts.logger,
algorithm:
tokenFactoryAlgorithm ??
config.getOptionalString('auth.identityTokenAlgorithm'),
omitClaimsFromToken,
});
}
};
const tokenIssuer = createTokenIssuer({
logger: logger.child({ component: 'token-factory' }),
expirationSeconds: backstageTokenExpiration,
});
const secret = config.getOptionalString('auth.session.secret');
if (secret) {
@@ -151,11 +161,18 @@ export async function createRouter(
userInfo,
});
const dcrTokenExpiration = readDcrTokenExpiration(config);
const oidcTokenIssuer = createTokenIssuer({
logger: logger.child({ component: 'oidc-token-factory' }),
expirationSeconds: dcrTokenExpiration,
});
const oidc = await OidcDatabase.create({ database });
const oidcRouter = OidcRouter.create({
auth: options.auth,
tokenIssuer,
tokenIssuer: oidcTokenIssuer,
baseUrl: authUrl,
appUrl,
userInfo,