auth-backend: refactor to keep token issuers consistent

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-04-24 15:32:37 +02:00
parent 768e15d5b0
commit b128ed97c8
6 changed files with 347 additions and 255 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
The `static` key store now issues tokens with the same structure as other key stores. Tokens now include the `typ` field in the header and the `uip` (user identity proof) in the payload.
@@ -18,6 +18,8 @@ import { createLocalJWKSet, jwtVerify } from 'jose';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { StaticKeyStore } from './StaticKeyStore';
import { mockServices } from '@backstage/backend-test-utils';
import { UserInfoDatabaseHandler } from './UserInfoDatabaseHandler';
import { omit } from 'lodash';
const logger = mockServices.logger.mock();
const entityRef = stringifyEntityRef({
@@ -27,60 +29,64 @@ const entityRef = stringifyEntityRef({
});
describe('StaticTokenIssuer', () => {
it('should issue valid tokens signed by the first listed key', async () => {
const staticKeyStore = {
listKeys: () => {
return Promise.resolve({
items: [
{
createdAt: new Date(),
key: {
kty: 'EC',
x: 'vrJVK0vSV4LRwdUXA55pb3rruB92ZoUEY72HTvjIP9w',
y: 'UmyzoVoTERJPOE9Kvdf5t797FquRMxyFsNc0R0IrCmc',
crv: 'P-256',
kid: '1',
alg: 'ES256',
use: 'sig',
},
},
{
createdAt: new Date(),
key: {
kty: 'EC',
x: 'vrJVK0vSV4LRwdUXA55pb3rruB92ZoUEY72HTvjIP9w',
y: 'UmyzoVoTERJPOE9Kvdf5t797FquRMxyFsNc0R0IrCmc',
crv: 'P-256',
kid: '2',
alg: 'ES256',
use: 'sig',
},
},
],
});
},
getPrivateKey: (kid: string) => {
expect(kid).toEqual('1');
return {
kty: 'EC',
x: 'vrJVK0vSV4LRwdUXA55pb3rruB92ZoUEY72HTvjIP9w',
y: 'UmyzoVoTERJPOE9Kvdf5t797FquRMxyFsNc0R0IrCmc',
crv: 'P-256',
d: 'R8Ja2ppMEgOm1KeYKpje00U1luybndt6yC263vcgeKo',
kid: '1',
alg: 'ES256',
};
},
};
const mockUserInfoDatabaseHandler = {
addUserInfo: jest.fn().mockResolvedValue(undefined),
} as unknown as UserInfoDatabaseHandler;
const staticKeyStore = {
listKeys: () => {
return Promise.resolve({
items: [
{
createdAt: new Date(),
key: {
kty: 'EC',
x: 'vrJVK0vSV4LRwdUXA55pb3rruB92ZoUEY72HTvjIP9w',
y: 'UmyzoVoTERJPOE9Kvdf5t797FquRMxyFsNc0R0IrCmc',
crv: 'P-256',
kid: '1',
alg: 'ES256',
use: 'sig',
},
},
{
createdAt: new Date(),
key: {
kty: 'EC',
x: 'vrJVK0vSV4LRwdUXA55pb3rruB92ZoUEY72HTvjIP9w',
y: 'UmyzoVoTERJPOE9Kvdf5t797FquRMxyFsNc0R0IrCmc',
crv: 'P-256',
kid: '2',
alg: 'ES256',
use: 'sig',
},
},
],
});
},
getPrivateKey: (kid: string) => {
expect(kid).toEqual('1');
return {
kty: 'EC',
x: 'vrJVK0vSV4LRwdUXA55pb3rruB92ZoUEY72HTvjIP9w',
y: 'UmyzoVoTERJPOE9Kvdf5t797FquRMxyFsNc0R0IrCmc',
crv: 'P-256',
d: 'R8Ja2ppMEgOm1KeYKpje00U1luybndt6yC263vcgeKo',
kid: '1',
alg: 'ES256',
};
},
};
it('should issue valid tokens signed by the first listed key', async () => {
const keyDurationSeconds = 86400;
const options = {
logger,
issuer: 'my-issuer',
sessionExpirationSeconds: keyDurationSeconds,
};
const issuer = new StaticTokenIssuer(
options,
{
logger,
issuer: 'my-issuer',
sessionExpirationSeconds: keyDurationSeconds,
userInfoDatabaseHandler: mockUserInfoDatabaseHandler,
},
staticKeyStore as unknown as StaticKeyStore,
);
const { token } = await issuer.issueToken({
@@ -98,6 +104,7 @@ describe('StaticTokenIssuer', () => {
expect(verifyResult.protectedHeader).toEqual({
kid: '1',
alg: 'ES256',
typ: 'vnd.backstage.user',
});
expect(verifyResult.payload).toEqual({
iss: 'my-issuer',
@@ -107,9 +114,62 @@ describe('StaticTokenIssuer', () => {
'x-fancy-claim': 'my special claim',
iat: expect.any(Number),
exp: expect.any(Number),
uip: expect.any(String),
});
expect(verifyResult.payload.exp).toBe(
verifyResult.payload.iat! + keyDurationSeconds,
);
expect(mockUserInfoDatabaseHandler.addUserInfo).toHaveBeenCalledWith({
claims: omit(verifyResult.payload, ['aud', 'iat', 'iss', 'uip']),
});
});
it('should issue valid tokens with omitted claims', async () => {
const keyDurationSeconds = 86400;
const issuer = new StaticTokenIssuer(
{
logger,
issuer: 'my-issuer',
sessionExpirationSeconds: keyDurationSeconds,
userInfoDatabaseHandler: mockUserInfoDatabaseHandler,
omitClaimsFromToken: ['ent'],
},
staticKeyStore as unknown as StaticKeyStore,
);
const { token } = await issuer.issueToken({
claims: {
sub: entityRef,
ent: [entityRef],
'x-fancy-claim': 'my special claim',
aud: 'this value will be overridden',
},
});
const { keys } = await issuer.listPublicKeys();
const keyStore = createLocalJWKSet({ keys: keys });
const verifyResult = await jwtVerify(token, keyStore);
expect(verifyResult.protectedHeader).toEqual({
kid: '1',
alg: 'ES256',
typ: 'vnd.backstage.user',
});
expect(verifyResult.payload).toEqual({
iss: 'my-issuer',
aud: 'backstage',
sub: entityRef,
'x-fancy-claim': 'my special claim',
iat: expect.any(Number),
exp: expect.any(Number),
uip: expect.any(String),
});
expect(verifyResult.payload.exp).toBe(
verifyResult.payload.iat! + keyDurationSeconds,
);
expect(mockUserInfoDatabaseHandler.addUserInfo).toHaveBeenCalledWith({
claims: {
...omit(verifyResult.payload, ['aud', 'iat', 'iss', 'uip']),
ent: [entityRef],
},
});
});
});
@@ -15,17 +15,15 @@
*/
import { AnyJWK, TokenIssuer } from './types';
import { SignJWT, importJWK, JWK } from 'jose';
import { parseEntityRef } from '@backstage/catalog-model';
import { AuthenticationError } from '@backstage/errors';
import { JWK } from 'jose';
import { LoggerService } from '@backstage/backend-plugin-api';
import { StaticKeyStore } from './StaticKeyStore';
import {
BackstageSignInResult,
TokenParams,
} from '@backstage/plugin-auth-node';
const MS_IN_S = 1000;
import { UserInfoDatabaseHandler } from './UserInfoDatabaseHandler';
import { issueUserToken } from './issueUserToken';
export type Config = {
publicKeyFile: string;
@@ -40,6 +38,11 @@ export type Options = {
issuer: string;
/** Expiration time of the JWT in seconds */
sessionExpirationSeconds: number;
/**
* A list of claims to omit from issued tokens and only store in the user info database
*/
omitClaimsFromToken?: string[];
userInfoDatabaseHandler: UserInfoDatabaseHandler;
};
/**
@@ -51,56 +54,30 @@ export class StaticTokenIssuer implements TokenIssuer {
private readonly logger: LoggerService;
private readonly keyStore: StaticKeyStore;
private readonly sessionExpirationSeconds: number;
private readonly omitClaimsFromToken?: string[];
private readonly userInfoDatabaseHandler: UserInfoDatabaseHandler;
public constructor(options: Options, keyStore: StaticKeyStore) {
this.issuer = options.issuer;
this.logger = options.logger;
this.sessionExpirationSeconds = options.sessionExpirationSeconds;
this.keyStore = keyStore;
this.omitClaimsFromToken = options.omitClaimsFromToken;
this.userInfoDatabaseHandler = options.userInfoDatabaseHandler;
}
public async issueToken(params: TokenParams): Promise<BackstageSignInResult> {
const key = await this.getSigningKey();
// TODO: code shared with TokenFactory.ts
const iss = this.issuer;
const { sub, ent, ...additionalClaims } = params.claims;
const aud = 'backstage';
const iat = Math.floor(Date.now() / MS_IN_S);
const exp = iat + this.sessionExpirationSeconds;
// Validate that the subject claim is a valid EntityRef
try {
parseEntityRef(sub);
} catch (error) {
throw new Error(
'"sub" claim provided by the auth resolver is not a valid EntityRef.',
);
}
this.logger.info(`Issuing token for ${sub}, with entities ${ent ?? []}`);
if (!key.alg) {
throw new AuthenticationError('No algorithm was provided in the key');
}
const token = await new SignJWT({
...additionalClaims,
iss,
sub,
ent,
aud,
iat,
exp,
})
.setProtectedHeader({ alg: key.alg, kid: key.kid })
.setIssuer(iss)
.setAudience(aud)
.setSubject(sub)
.setIssuedAt(iat)
.setExpirationTime(exp)
.sign(await importJWK(key));
return { token };
return issueUserToken({
issuer: this.issuer,
key,
keyDurationSeconds: this.sessionExpirationSeconds,
logger: this.logger,
omitClaimsFromToken: this.omitClaimsFromToken,
params,
userInfoDatabaseHandler: this.userInfoDatabaseHandler,
});
}
private async getSigningKey(): Promise<JWK> {
+10 -155
View File
@@ -14,18 +14,7 @@
* limitations under the License.
*/
import { parseEntityRef } from '@backstage/catalog-model';
import { AuthenticationError } from '@backstage/errors';
import {
exportJWK,
generateKeyPair,
importJWK,
JWK,
SignJWT,
GeneralSign,
KeyLike,
} from 'jose';
import { omit } from 'lodash';
import { exportJWK, generateKeyPair, JWK } from 'jose';
import { DateTime } from 'luxon';
import { v4 as uuid } from 'uuid';
import { LoggerService } from '@backstage/backend-plugin-api';
@@ -37,9 +26,7 @@ import {
import { AnyJWK, KeyStore, TokenIssuer } from './types';
import { JsonValue } from '@backstage/types';
import { UserInfoDatabaseHandler } from './UserInfoDatabaseHandler';
const MS_IN_S = 1000;
const MAX_TOKEN_LENGTH = 32768; // At 64 bytes per entity ref this still leaves room for about 500 entities
import { issueUserToken } from './issueUserToken';
/**
* The payload contents of a valid Backstage JWT token
@@ -86,28 +73,6 @@ export interface BackstageTokenPayload {
[claim: string]: JsonValue;
}
/**
* The payload contents of a valid Backstage user identity claim token
*
* @internal
*/
interface BackstageUserIdentityProofPayload {
/**
* The entity ref of the user
*/
sub: string;
/**
* Standard expiry in epoch seconds
*/
exp: number;
/**
* Standard issue time in epoch seconds
*/
iat: number;
}
type Options = {
logger: LoggerService;
/** Value of the issuer claim in issued tokens */
@@ -169,83 +134,15 @@ export class TokenFactory implements TokenIssuer {
async issueToken(params: TokenParams): Promise<BackstageSignInResult> {
const key = await this.getKey();
const iss = this.issuer;
const { sub, ent = [sub], ...additionalClaims } = params.claims;
const aud = tokenTypes.user.audClaim;
const iat = Math.floor(Date.now() / MS_IN_S);
const exp = iat + this.keyDurationSeconds;
try {
// The subject must be a valid entity ref
parseEntityRef(sub);
} catch (error) {
throw new Error(
'"sub" claim provided by the auth resolver is not a valid EntityRef.',
);
}
if (!key.alg) {
throw new AuthenticationError('No algorithm was provided in the key');
}
this.logger.info(`Issuing token for ${sub}, with entities ${ent}`);
const signingKey = await importJWK(key);
const uip = await this.createUserIdentityClaim({
header: {
typ: tokenTypes.limitedUser.typParam,
alg: key.alg,
kid: key.kid,
},
payload: { sub, iat, exp },
key: signingKey,
return issueUserToken({
issuer: this.issuer,
key,
keyDurationSeconds: this.keyDurationSeconds,
logger: this.logger,
omitClaimsFromToken: this.omitClaimsFromToken,
params,
userInfoDatabaseHandler: this.userInfoDatabaseHandler,
});
const claims: BackstageTokenPayload = {
...additionalClaims,
iss,
sub,
ent,
aud,
iat,
exp,
uip,
};
const tokenClaims = this.omitClaimsFromToken
? omit(claims, this.omitClaimsFromToken)
: claims;
const token = await new SignJWT(tokenClaims)
.setProtectedHeader({
typ: tokenTypes.user.typParam,
alg: key.alg,
kid: key.kid,
})
.sign(signingKey);
if (token.length > MAX_TOKEN_LENGTH) {
throw new Error(
`Failed to issue a new user token. The resulting token is excessively large, with either too many ownership claims or too large custom claims. You likely have a bug either in the sign-in resolver or catalog data. The following claims were requested: '${JSON.stringify(
tokenClaims,
)}'`,
);
}
// Store the user info in the database upon successful token
// issuance so that it can be retrieved later by limited user tokens
await this.userInfoDatabaseHandler.addUserInfo({
claims: omit(claims, ['aud', 'iat', 'iss', 'uip']),
});
return {
token,
identity: {
type: 'user',
userEntityRef: sub,
ownershipEntityRefs: ent,
},
};
}
// This will be called by other services that want to verify ID tokens.
@@ -338,46 +235,4 @@ export class TokenFactory implements TokenIssuer {
return promise;
}
// Creates a string claim that can be used as part of reconstructing a limited
// user token. The output of this function is only the signature part of a
// JWS.
private async createUserIdentityClaim(options: {
header: {
typ: string;
alg: string;
kid?: string;
};
payload: BackstageUserIdentityProofPayload;
key: KeyLike | Uint8Array;
}): Promise<string> {
// NOTE: We reconstruct the header and payload structures carefully to
// perfectly guarantee ordering. The reason for this is that we store only
// the signature part of these to reduce duplication within the Backstage
// token. Anyone who wants to make an actual JWT based on all this must be
// able to do the EXACT reconstruction of the header and payload parts, to
// then append the signature.
const header = {
typ: options.header.typ,
alg: options.header.alg,
...(options.header.kid ? { kid: options.header.kid } : {}),
};
const payload = {
sub: options.payload.sub,
iat: options.payload.iat,
exp: options.payload.exp,
};
const jws = await new GeneralSign(
new TextEncoder().encode(JSON.stringify(payload)),
)
.addSignature(options.key)
.setProtectedHeader(header)
.done()
.sign();
return jws.signatures[0].signature;
}
}
@@ -0,0 +1,191 @@
/*
* 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 { parseEntityRef } from '@backstage/catalog-model';
import { AuthenticationError } from '@backstage/errors';
import {
BackstageSignInResult,
TokenParams,
tokenTypes,
} from '@backstage/plugin-auth-node';
import { omit } from 'lodash';
import { UserInfoDatabaseHandler } from './UserInfoDatabaseHandler';
import { LoggerService } from '@backstage/backend-plugin-api';
import { GeneralSign, importJWK, JWK, KeyLike, SignJWT } from 'jose';
import { BackstageTokenPayload } from './TokenFactory';
const MS_IN_S = 1000;
const MAX_TOKEN_LENGTH = 32768; // At 64 bytes per entity ref this still leaves room for about 500 entities
export async function issueUserToken({
issuer,
key,
keyDurationSeconds,
logger,
omitClaimsFromToken,
params,
userInfoDatabaseHandler,
}: {
issuer: string;
key: JWK;
keyDurationSeconds: number;
logger: LoggerService;
omitClaimsFromToken?: string[];
params: TokenParams;
userInfoDatabaseHandler: UserInfoDatabaseHandler;
}): Promise<BackstageSignInResult> {
const { sub, ent = [sub], ...additionalClaims } = params.claims;
const aud = tokenTypes.user.audClaim;
const iat = Math.floor(Date.now() / MS_IN_S);
const exp = iat + keyDurationSeconds;
try {
// The subject must be a valid entity ref
parseEntityRef(sub);
} catch (error) {
throw new Error(
'"sub" claim provided by the auth resolver is not a valid EntityRef.',
);
}
if (!key.alg) {
throw new AuthenticationError('No algorithm was provided in the key');
}
logger.info(`Issuing token for ${sub}, with entities ${ent}`);
const signingKey = await importJWK(key);
const uip = await createUserIdentityClaim({
header: {
typ: tokenTypes.limitedUser.typParam,
alg: key.alg,
kid: key.kid,
},
payload: { sub, iat, exp },
key: signingKey,
});
const claims: BackstageTokenPayload = {
...additionalClaims,
iss: issuer,
sub,
ent,
aud,
iat,
exp,
uip,
};
const tokenClaims = omitClaimsFromToken
? omit(claims, omitClaimsFromToken)
: claims;
const token = await new SignJWT(tokenClaims)
.setProtectedHeader({
typ: tokenTypes.user.typParam,
alg: key.alg,
kid: key.kid,
})
.sign(signingKey);
if (token.length > MAX_TOKEN_LENGTH) {
throw new Error(
`Failed to issue a new user token. The resulting token is excessively large, with either too many ownership claims or too large custom claims. You likely have a bug either in the sign-in resolver or catalog data. The following claims were requested: '${JSON.stringify(
tokenClaims,
)}'`,
);
}
// Store the user info in the database upon successful token
// issuance so that it can be retrieved later by limited user tokens
await userInfoDatabaseHandler.addUserInfo({
claims: omit(claims, ['aud', 'iat', 'iss', 'uip']),
});
return {
token,
identity: {
type: 'user',
userEntityRef: sub,
ownershipEntityRefs: ent,
},
};
}
/**
* The payload contents of a valid Backstage user identity claim token
*
* @internal
*/
interface BackstageUserIdentityProofPayload {
/**
* The entity ref of the user
*/
sub: string;
/**
* Standard expiry in epoch seconds
*/
exp: number;
/**
* Standard issue time in epoch seconds
*/
iat: number;
}
/**
* Creates a string claim that can be used as part of reconstructing a limited
* user token. The output of this function is only the signature part of a JWS.
*/
async function createUserIdentityClaim(options: {
header: {
typ: string;
alg: string;
kid?: string;
};
payload: BackstageUserIdentityProofPayload;
key: KeyLike | Uint8Array;
}): Promise<string> {
// NOTE: We reconstruct the header and payload structures carefully to
// perfectly guarantee ordering. The reason for this is that we store only
// the signature part of these to reduce duplication within the Backstage
// token. Anyone who wants to make an actual JWT based on all this must be
// able to do the EXACT reconstruction of the header and payload parts, to
// then append the signature.
const header = {
typ: options.header.typ,
alg: options.header.alg,
...(options.header.kid ? { kid: options.header.kid } : {}),
};
const payload = {
sub: options.payload.sub,
iat: options.payload.iat,
exp: options.payload.exp,
};
const jws = await new GeneralSign(
new TextEncoder().encode(JSON.stringify(payload)),
)
.addSignature(options.key)
.setProtectedHeader(header)
.done()
.sign();
return jws.signatures[0].signature;
}
+9 -5
View File
@@ -81,6 +81,12 @@ export async function createRouter(
await authDb.get(),
);
const omitClaimsFromToken = config.getOptionalBoolean(
'auth.omitIdentityTokenOwnershipClaim',
)
? ['ent']
: [];
let tokenIssuer: TokenIssuer;
if (keyStore instanceof StaticKeyStore) {
tokenIssuer = new StaticTokenIssuer(
@@ -88,6 +94,8 @@ export async function createRouter(
logger: logger.child({ component: 'token-factory' }),
issuer: authUrl,
sessionExpirationSeconds: backstageTokenExpiration,
userInfoDatabaseHandler,
omitClaimsFromToken,
},
keyStore as StaticKeyStore,
);
@@ -101,11 +109,7 @@ export async function createRouter(
tokenFactoryAlgorithm ??
config.getOptionalString('auth.identityTokenAlgorithm'),
userInfoDatabaseHandler,
omitClaimsFromToken: config.getOptionalBoolean(
'auth.omitIdentityTokenOwnershipClaim',
)
? ['ent']
: [],
omitClaimsFromToken,
});
}