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:
@@ -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.
|
||||
Vendored
+24
@@ -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',
|
||||
};
|
||||
|
||||
+57
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
+32
-8
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user