diff --git a/.changeset/yummy-socks-brake.md b/.changeset/yummy-socks-brake.md new file mode 100644 index 0000000000..4f3dcfb598 --- /dev/null +++ b/.changeset/yummy-socks-brake.md @@ -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. diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index 4e099e75be..50ccf2bc4b 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -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; + }; }; } diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts index 9a3308b4eb..291b525e5a 100644 --- a/plugins/auth-backend/src/service/OidcRouter.ts +++ b/plugins/auth-backend/src/service/OidcRouter.ts @@ -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({ diff --git a/plugins/auth-backend/src/service/OidcService.test.ts b/plugins/auth-backend/src/service/OidcService.test.ts index e4e1673397..6673bb282c 100644 --- a/plugins/auth-backend/src/service/OidcService.test.ts +++ b/plugins/auth-backend/src/service/OidcService.test.ts @@ -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'); }); diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts index b4c6bb122b..273d3f5de3 100644 --- a/plugins/auth-backend/src/service/OidcService.ts +++ b/plugins/auth-backend/src/service/OidcService.ts @@ -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', }; diff --git a/plugins/auth-backend/src/service/readBackstageTokenExpiration.test.ts b/plugins/auth-backend/src/service/readTokenExpiration.test.ts similarity index 62% rename from plugins/auth-backend/src/service/readBackstageTokenExpiration.test.ts rename to plugins/auth-backend/src/service/readTokenExpiration.test.ts index e1f863678a..69b6bbf848 100644 --- a/plugins/auth-backend/src/service/readBackstageTokenExpiration.test.ts +++ b/plugins/auth-backend/src/service/readTokenExpiration.test.ts @@ -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); + }); }); diff --git a/plugins/auth-backend/src/service/readBackstageTokenExpiration.ts b/plugins/auth-backend/src/service/readTokenExpiration.ts similarity index 56% rename from plugins/auth-backend/src/service/readBackstageTokenExpiration.ts rename to plugins/auth-backend/src/service/readTokenExpiration.ts index c687ecdfc5..3094d2c054 100644 --- a/plugins/auth-backend/src/service/readBackstageTokenExpiration.ts +++ b/plugins/auth-backend/src/service/readTokenExpiration.ts @@ -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; } diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 0f0d5b2830..4fb87546c8 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -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,