auth-backend: refactor to keep token issuers consistent
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user