Merge pull request #23959 from backstage/mob/user-identity-claims

[Auth] Add identity proof signature to Backstage tokens
This commit is contained in:
Patrik Oldsberg
2024-04-03 17:47:42 +02:00
committed by GitHub
14 changed files with 953 additions and 46 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Added token type header parameter and user identity proof to issued user tokens.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-node': patch
---
Add `tokenTypes` export with constants for various Backstage token types.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-app-api': patch
---
Add support for limited user tokens by using user identity proof provided by the auth backend.
+1
View File
@@ -90,6 +90,7 @@
"@types/node-forge": "^1.3.0",
"@types/stoppable": "^1.1.0",
"http-errors": "^2.0.0",
"msw": "^1.0.0",
"supertest": "^6.1.3"
},
"configSchema": "config.d.ts",
@@ -0,0 +1,384 @@
/*
* Copyright 2024 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 { JsonObject } from '@backstage/types';
import { UserTokenHandler } from './UserTokenHandler';
import {
mockServices,
setupRequestMockHandlers,
} from '@backstage/backend-test-utils';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { AuthenticationError } from '@backstage/errors';
import { SignJWT, GeneralSign, importJWK, base64url } from 'jose';
const mockPublicKey = {
kty: 'EC',
x: 'GHlwg744e8JekzukPTdtix6R868D6fcWy0ooOx-NEZI',
y: 'Lyujcm0M6X9_yQi3l1eH09z0brU8K9cwrLml_fRFKro',
crv: 'P-256',
kid: 'mock',
alg: 'ES256',
};
const mockPrivateKey = {
...mockPublicKey,
d: 'KEn_mDqXYbZdRHb-JnCrW53LDOv5x4NL1FnlKcqBsFI',
};
const server = setupServer();
function encodeData(data: JsonObject) {
return base64url.encode(JSON.stringify(data));
}
async function createToken(options: {
header: JsonObject;
payload: JsonObject;
signature?: string;
}) {
if (options.signature) {
const header = encodeData(options.header);
const payload = encodeData(options.payload);
return `${header}.${payload}.${options.signature}`;
}
return await new SignJWT(options.payload)
.setProtectedHeader({ ...options.header, alg: 'ES256' })
.sign(await importJWK(mockPrivateKey));
}
describe('UserTokenHandler', () => {
let userTokenHandler: UserTokenHandler;
setupRequestMockHandlers(server);
beforeEach(() => {
jest.useRealTimers();
userTokenHandler = new UserTokenHandler({
discovery: mockServices.discovery(),
});
server.use(
rest.get(
'http://localhost:0/api/auth/.well-known/jwks.json',
(_req, res, ctx) =>
res(
ctx.json({
keys: [mockPublicKey],
}),
),
),
);
});
describe('verifyToken', () => {
it('should return undefined if token format or type is unknown', async () => {
await expect(
userTokenHandler.verifyToken('invalid-token'),
).resolves.toBeUndefined();
await expect(
userTokenHandler.verifyToken('a.b.c'),
).resolves.toBeUndefined();
await expect(
userTokenHandler.verifyToken(
await createToken({
header: { typ: 'unknown' },
payload: { sub: 'mock' },
signature: 'sig',
}),
),
).resolves.toBeUndefined();
});
it('should fail to verify tokens with invalid signatures', async () => {
await expect(
userTokenHandler.verifyToken(
await createToken({
header: {
typ: 'vnd.backstage.user',
alg: 'ES256',
kid: mockPublicKey.kid,
},
payload: { sub: 'mock' },
signature: 'sig',
}),
),
).rejects.toThrow('signature verification failed');
await expect(
userTokenHandler.verifyToken(
await createToken({
header: { alg: 'ES256', kid: mockPublicKey.kid },
payload: { aud: 'backstage', sub: 'mock' },
signature: 'sig',
}),
),
).rejects.toThrow('signature verification failed');
});
it('should verify a valid legacy backstage token', async () => {
const expectedIssuedAt = 1712071714;
const expectedExpiresAt = 1712075314;
jest.useFakeTimers({
now: expectedIssuedAt * 1000 + 600_000,
});
const parts = {
header: {
alg: 'ES256',
kid: mockPublicKey.kid,
},
payload: {
iss: 'http://localhost:7007/api/auth',
sub: 'user:development/guest',
ent: ['user:development/guest', 'group:default/team-a'],
aud: 'backstage',
iat: 1712071714,
exp: expectedExpiresAt,
},
};
const token = await createToken(parts);
await expect(userTokenHandler.verifyToken(token)).resolves.toEqual({
userEntityRef: parts.payload.sub,
});
});
it('should fail to verify when the sub claim is missing', async () => {
const expectedIssuedAt = 1712071714;
const expectedExpiresAt = 1712075314;
jest.useFakeTimers({
now: expectedIssuedAt * 1000 + 600_000,
});
const parts = {
header: {
alg: 'ES256',
kid: mockPublicKey.kid,
},
payload: {
iss: 'http://localhost:7007/api/auth',
ent: ['user:development/guest', 'group:default/team-a'],
aud: 'backstage',
iat: 1712071714,
exp: expectedExpiresAt,
},
};
const token = await createToken(parts);
await expect(userTokenHandler.verifyToken(token)).rejects.toThrow(
'No user sub found in token',
);
});
it('should verify a valid user token', async () => {
const expectedIssuedAt = 1712071714;
const expectedExpiresAt = 1712075314;
jest.useFakeTimers({
now: expectedIssuedAt * 1000 + 600_000,
});
const parts = {
header: {
typ: 'vnd.backstage.user',
alg: 'ES256',
kid: mockPublicKey.kid,
},
payload: {
iss: 'http://localhost:7007/api/auth',
sub: 'user:development/guest',
ent: ['user:development/guest', 'group:default/team-a'],
aud: 'backstage',
iat: 1712071714,
exp: expectedExpiresAt,
uip: 'proof',
},
};
const token = await createToken(parts);
await expect(userTokenHandler.verifyToken(token)).resolves.toEqual({
userEntityRef: parts.payload.sub,
});
});
it('should verify a valid limited user token', async () => {
const expectedIssuedAt = 1712071714;
const expectedExpiresAt = 1712075314;
jest.useFakeTimers({
now: expectedIssuedAt * 1000 + 600_000,
});
const parts = {
header: {
typ: 'vnd.backstage.limited-user',
alg: 'ES256',
kid: mockPublicKey.kid,
},
payload: {
sub: 'user:development/guest',
ent: ['user:development/guest', 'group:default/team-a'],
iat: 1712071714,
exp: expectedExpiresAt,
},
};
const token = await createToken(parts);
await expect(userTokenHandler.verifyToken(token)).resolves.toEqual({
userEntityRef: parts.payload.sub,
});
});
});
describe('createLimitedUserToken', () => {
it('should return the original token if it a legacy backstage token', async () => {
const backstageToken = await createToken({
// Without header.typ param
header: { alg: 'ES256' },
payload: {},
});
const result = userTokenHandler.createLimitedUserToken(backstageToken);
expect(result).toEqual({
token: backstageToken,
expiresAt: expect.any(Date),
});
});
it('should return the original token if it is already a limited user token', async () => {
const backstageToken = await createToken({
header: { typ: 'vnd.backstage.user', alg: 'ES256' },
payload: { sub: 'mock', uip: 'proof' },
signature: 'some-signature',
});
const result = userTokenHandler.createLimitedUserToken(backstageToken);
expect(result).toEqual({
token: await createToken({
header: { typ: 'vnd.backstage.limited-user', alg: 'ES256' },
payload: { sub: 'mock' },
signature: 'proof',
}),
expiresAt: expect.any(Date),
});
});
it('should throw an AuthenticationError if the token type is invalid', async () => {
const backstageToken = await createToken({
header: { typ: 'invalid' },
payload: {},
});
expect(() => {
userTokenHandler.createLimitedUserToken(backstageToken);
}).toThrow(
new AuthenticationError(
'Failed to create limited user token, invalid token type',
),
);
});
it('should create a limited user token from a user token', async () => {
const backstageToken = await createToken({
header: { typ: 'vnd.backstage.user', alg: 'ES256' },
payload: {
aud: 'backstage',
sub: 'mock',
ent: ['mock'],
iat: 1,
exp: 2,
uip: 'proof',
},
signature: 'sig',
});
const result = userTokenHandler.createLimitedUserToken(backstageToken);
expect(result).toEqual({
token: await createToken({
header: { typ: 'vnd.backstage.limited-user', alg: 'ES256' },
payload: {
sub: 'mock',
ent: ['mock'],
iat: 1,
exp: 2,
},
signature: 'proof',
}),
expiresAt: expect.any(Date),
});
});
it('should create limited token that can be verified', async () => {
jest.useFakeTimers({
now: 1712071714 * 1000 + 600_000,
});
const parts = {
header: {
typ: 'vnd.backstage.user',
alg: 'ES256',
kid: mockPublicKey.kid,
},
payload: {
iss: 'http://localhost:7007/api/auth',
sub: 'user:development/guest',
ent: ['user:development/guest', 'group:default/team-a'],
aud: 'backstage',
iat: 1712071714,
exp: 1712075314,
uip: '01AQB_IjGMtVsh2Zh3dH55xN_oiIYaCQw82cx6y3PP1yiN38xc31ZLKe4aSCBRSO-tr1sdU3OoD-LIa_-5_QUA',
},
signature: 'sig',
};
const {
signatures: [{ signature: uip }],
} = await new GeneralSign(
new TextEncoder().encode(
JSON.stringify({
sub: parts.payload.sub,
ent: parts.payload.ent,
iat: parts.payload.iat,
exp: parts.payload.exp,
}),
),
)
.addSignature(await importJWK(mockPrivateKey))
.setProtectedHeader({
...parts.header,
typ: 'vnd.backstage.limited-user',
})
.done()
.sign();
parts.payload.uip = uip;
const token = await createToken(parts);
const result = userTokenHandler.createLimitedUserToken(token);
await expect(userTokenHandler.verifyToken(result.token)).resolves.toEqual(
{
userEntityRef: 'user:development/guest',
},
);
});
});
});
@@ -0,0 +1,196 @@
/*
* Copyright 2020 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 { PluginEndpointDiscovery } from '@backstage/backend-common';
import { DiscoveryService } from '@backstage/backend-plugin-api';
import { AuthenticationError } from '@backstage/errors';
import { tokenTypes } from '@backstage/plugin-auth-node';
import {
base64url,
createRemoteJWKSet,
decodeJwt,
decodeProtectedHeader,
FlattenedJWSInput,
JWSHeaderParameters,
jwtVerify,
JWTVerifyOptions,
} from 'jose';
import { GetKeyFunction } from 'jose/dist/types/types';
const CLOCK_MARGIN_S = 10;
/**
* An identity client to interact with auth-backend and authenticate Backstage
* tokens
*
* @internal
*/
export class UserTokenHandler {
readonly #discovery: PluginEndpointDiscovery;
readonly #algorithms?: string[];
#keyStore?: GetKeyFunction<JWSHeaderParameters, FlattenedJWSInput>;
#keyStoreUpdated: number = 0;
constructor(options: { discovery: DiscoveryService }) {
this.#discovery = options.discovery;
this.#algorithms = ['ES256']; // TODO: configurable?
}
async verifyToken(token: string) {
const verifyOpts = this.#getTokenVerificationOptions(token);
if (!verifyOpts) {
return undefined;
}
await this.refreshKeyStore(token);
if (!this.#keyStore) {
throw new AuthenticationError('No keystore exists');
}
// Verify a limited token, ensuring the necessarily claims are present and token type is correct
const { payload } = await jwtVerify(
token,
this.#keyStore,
verifyOpts,
).catch(e => {
throw new AuthenticationError('Invalid token', e);
});
const userEntityRef = payload.sub;
if (!userEntityRef) {
throw new AuthenticationError('No user sub found in token');
}
return { userEntityRef };
}
#getTokenVerificationOptions(token: string): JWTVerifyOptions | undefined {
try {
const { typ } = decodeProtectedHeader(token);
if (typ === tokenTypes.user.typParam) {
return {
algorithms: this.#algorithms,
requiredClaims: ['iat', 'exp', 'sub'],
typ: tokenTypes.user.typParam,
};
}
if (typ === tokenTypes.limitedUser.typParam) {
return {
algorithms: this.#algorithms,
requiredClaims: ['iat', 'exp', 'sub'],
typ: tokenTypes.limitedUser.typParam,
};
}
const { aud } = decodeJwt(token);
if (aud === tokenTypes.user.audClaim) {
return {
algorithms: this.#algorithms,
audience: tokenTypes.user.audClaim,
};
}
} catch {
/* ignore */
}
return undefined;
}
createLimitedUserToken(backstageToken: string) {
const [headerRaw, payloadRaw] = backstageToken.split('.');
const header = JSON.parse(
new TextDecoder().decode(base64url.decode(headerRaw)),
);
const payload = JSON.parse(
new TextDecoder().decode(base64url.decode(payloadRaw)),
);
const tokenType = header.typ;
// Only new user tokens can be used to create a limited user token. If we
// can't create a limited token, or the token is already a limited one, we
// return the original token
if (!tokenType || tokenType === tokenTypes.limitedUser.typParam) {
return { token: backstageToken, expiresAt: new Date(payload.exp * 1000) };
}
if (tokenType !== tokenTypes.user.typParam) {
throw new AuthenticationError(
'Failed to create limited user token, invalid token type',
);
}
// NOTE: The order and properties in both the header and payload must match
// the usage in plugins/auth-backend/src/identity/TokenFactory.ts
const limitedUserToken = [
base64url.encode(
JSON.stringify({
typ: tokenTypes.limitedUser.typParam,
alg: header.alg,
kid: header.kid,
}),
),
base64url.encode(
JSON.stringify({
sub: payload.sub,
ent: payload.ent,
iat: payload.iat,
exp: payload.exp,
}),
),
payload.uip,
].join('.');
return { token: limitedUserToken, expiresAt: new Date(payload.exp * 1000) };
}
/**
* If the last keystore refresh is stale, update the keystore URL to the latest
*/
private async refreshKeyStore(rawJwtToken: string): Promise<void> {
const payload = await decodeJwt(rawJwtToken);
const header = await decodeProtectedHeader(rawJwtToken);
// Refresh public keys if needed
let keyStoreHasKey;
try {
if (this.#keyStore) {
// Check if the key is present in the keystore
const [_, rawPayload, rawSignature] = rawJwtToken.split('.');
keyStoreHasKey = await this.#keyStore(header, {
payload: rawPayload,
signature: rawSignature,
});
}
} catch (error) {
keyStoreHasKey = false;
}
// Refresh public key URL if needed
// Add a small margin in case clocks are out of sync
const issuedAfterLastRefresh =
payload?.iat && payload.iat > this.#keyStoreUpdated - CLOCK_MARGIN_S;
if (!this.#keyStore || (!keyStoreHasKey && issuedAfterLastRefresh)) {
const url = await this.#discovery.getBaseUrl('auth');
const endpoint = new URL(`${url}/.well-known/jwks.json`);
this.#keyStore = createRemoteJWKSet(endpoint);
this.#keyStoreUpdated = Date.now() / 1000;
}
}
}
@@ -17,18 +17,23 @@
import {
ServiceFactoryTester,
mockServices,
setupRequestMockHandlers,
} from '@backstage/backend-test-utils';
import {
InternalBackstageCredentials,
authServiceFactory,
} from './authServiceFactory';
import { decodeJwt } from 'jose';
import { base64url, decodeJwt } from 'jose';
import { discoveryServiceFactory } from '../discovery';
import {
BackstageServicePrincipal,
BackstageUserPrincipal,
} from '@backstage/backend-plugin-api';
import { tokenManagerServiceFactory } from '../tokenManager';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer();
// TODO: Ship discovery mock service in the service factory tester
const mockDeps = [
@@ -45,6 +50,12 @@ const mockDeps = [
];
describe('authServiceFactory', () => {
setupRequestMockHandlers(server);
afterEach(() => {
jest.useRealTimers();
});
it('should authenticate issued tokens', async () => {
const tester = ServiceFactoryTester.from(authServiceFactory, {
dependencies: mockDeps,
@@ -127,4 +138,90 @@ describe('authServiceFactory', () => {
}),
);
});
it('should issue limited user tokens', async () => {
server.use(
rest.get(
'http://localhost:7007/api/auth/.well-known/jwks.json',
(_req, res, ctx) =>
res(
ctx.json({
keys: [
{
kty: 'EC',
x: '78-Ei1H3nKM23ZpGMMzte2mVoYCcnfnSiLTm1P7vZM0',
y: 'Z9-PjG_EU598tLLUc2f8sCqxT7bjs8WpoV-lHm9GJHY',
crv: 'P-256',
kid: '8d01c3db-56f9-45f0-86dd-05b3c835b3d3',
alg: 'ES256',
},
],
}),
),
),
);
const expectedIssuedAt = 1712071714;
const expectedExpiresAt = 1712075314;
jest.useFakeTimers({
now: expectedIssuedAt * 1000 + 600_000,
});
const tester = ServiceFactoryTester.from(authServiceFactory, {
dependencies: mockDeps,
});
const catalogAuth = await tester.get('catalog');
const fullToken =
'eyJ0eXAiOiJ2bmQuYmFja3N0YWdlLnVzZXIiLCJhbGciOiJFUzI1NiIsImtpZCI6IjhkMDFjM2RiLTU2ZjktNDVmMC04NmRkLTA1YjNjODM1YjNkMyJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjcwMDcvYXBpL2F1dGgiLCJzdWIiOiJ1c2VyOmRldmVsb3BtZW50L2d1ZXN0IiwiZW50IjpbInVzZXI6ZGV2ZWxvcG1lbnQvZ3Vlc3QiLCJncm91cDpkZWZhdWx0L3RlYW0tYSJdLCJhdWQiOiJiYWNrc3RhZ2UiLCJpYXQiOjE3MTIwNzE3MTQsImV4cCI6MTcxMjA3NTMxNCwidWlwIjoiMDFBUUJfSWpHTXRWc2gyWmgzZEg1NXhOX29pSVlhQ1F3ODJjeDZ5M1BQMXlpTjM4eGMzMVpMS2U0YVNDQlJTTy10cjFzZFUzT29ELUxJYV8tNV9RVUEifQ.mjIrZGqbZ2t68fS4U3crlGw-bYJZnMlhMHf-YL7q_u1HfaLr4NMTcHkxdnNS2wfJxCmUBxRfUS8b3nSAKsxcHA';
const credentials = await catalogAuth.authenticate(fullToken);
if (!catalogAuth.isPrincipal(credentials, 'user')) {
throw new Error('no a user principal');
}
const { token: limitedToken, expiresAt } =
await catalogAuth.getLimitedUserToken(credentials);
expect(expiresAt).toEqual(new Date(expectedExpiresAt * 1000));
const expectedTokenHeader = base64url.encode(
JSON.stringify({
typ: 'vnd.backstage.limited-user',
alg: 'ES256',
kid: '8d01c3db-56f9-45f0-86dd-05b3c835b3d3',
}),
);
const expectedTokenPayload = base64url.encode(
JSON.stringify({
sub: 'user:development/guest',
ent: ['user:development/guest', 'group:default/team-a'],
iat: expectedIssuedAt,
exp: expectedExpiresAt,
}),
);
const expectedTokenSignature = JSON.parse(
atob(fullToken.split('.')[1]),
).uip;
const expectedToken = `${expectedTokenHeader}.${expectedTokenPayload}.${expectedTokenSignature}`;
expect(limitedToken).toBe(expectedToken);
const limitedCredentials = await catalogAuth.authenticate(limitedToken, {
allowLimitedAccess: true,
});
if (!catalogAuth.isPrincipal(limitedCredentials, 'user')) {
throw new Error('Not user credentials');
}
expect(limitedCredentials.principal.userEntityRef).toBe(
'user:development/guest',
);
expect(limitedCredentials.expiresAt).toEqual(
new Date(expectedExpiresAt * 1000),
);
});
});
@@ -22,13 +22,12 @@ import {
BackstageServicePrincipal,
BackstageNonePrincipal,
BackstageUserPrincipal,
IdentityService,
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import { AuthenticationError } from '@backstage/errors';
import { IdentityApiGetIdentityRequest } from '@backstage/plugin-auth-node';
import { decodeJwt } from 'jose';
import { UserTokenHandler } from './UserTokenHandler';
/** @internal */
export type InternalBackstageCredentials<TPrincipal = unknown> =
@@ -105,7 +104,7 @@ export function toInternalBackstageCredentials(
class DefaultAuthService implements AuthService {
constructor(
private readonly tokenManager: TokenManager,
private readonly identity: IdentityService,
private readonly userTokenHandler: UserTokenHandler,
private readonly pluginId: string,
private readonly disableDefaultAuthPolicy: boolean,
) {}
@@ -120,22 +119,16 @@ class DefaultAuthService implements AuthService {
return createCredentialsWithServicePrincipal('external:backstage-plugin');
}
// User Backstage token
const identity = await this.identity.getIdentity({
request: {
headers: { authorization: `Bearer ${token}` },
},
} as IdentityApiGetIdentityRequest);
if (!identity) {
throw new AuthenticationError('Invalid user token');
const userResult = await this.userTokenHandler.verifyToken(token);
if (userResult) {
return createCredentialsWithUserPrincipal(
userResult.userEntityRef,
token,
this.#getJwtExpiration(token),
);
}
return createCredentialsWithUserPrincipal(
identity.identity.userEntityRef,
token,
this.#getJwtExpiration(token),
);
throw new AuthenticationError('Unknown token');
}
isPrincipal<TType extends keyof BackstagePrincipalTypes>(
@@ -204,17 +197,15 @@ class DefaultAuthService implements AuthService {
async getLimitedUserToken(
credentials: BackstageCredentials<BackstageUserPrincipal>,
): Promise<{ token: string; expiresAt: Date }> {
const internalCredentials = toInternalBackstageCredentials(credentials);
const { token } = internalCredentials;
if (!token) {
const { token: backstageToken } =
toInternalBackstageCredentials(credentials);
if (!backstageToken) {
throw new AuthenticationError(
'User credentials is unexpectedly missing token',
);
}
return { token, expiresAt: this.#getJwtExpiration(token) };
return this.userTokenHandler.createLimitedUserToken(backstageToken);
}
#getJwtExpiration(token: string) {
@@ -232,15 +223,15 @@ export const authServiceFactory = createServiceFactory({
deps: {
config: coreServices.rootConfig,
logger: coreServices.rootLogger,
discovery: coreServices.discovery,
plugin: coreServices.pluginMetadata,
identity: coreServices.identity,
// Re-using the token manager makes sure that we use the same generated keys for
// development as plugins that have not yet been migrated. It's important that this
// keeps working as long as there are plugins that have not been migrated to the
// new auth services in the new backend system.
tokenManager: coreServices.tokenManager,
},
async factory({ config, plugin, identity, tokenManager }) {
async factory({ config, discovery, plugin, tokenManager }) {
const disableDefaultAuthPolicy = Boolean(
config.getOptionalBoolean(
'backend.auth.dangerouslyDisableDefaultAuthPolicy',
@@ -248,7 +239,7 @@ export const authServiceFactory = createServiceFactory({
);
return new DefaultAuthService(
tokenManager,
identity,
new UserTokenHandler({ discovery }),
plugin.getId(),
disableDefaultAuthPolicy,
);
@@ -13,12 +13,18 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getVoidLogger } from '@backstage/backend-common';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { createLocalJWKSet, decodeProtectedHeader, jwtVerify } from 'jose';
import {
base64url,
createLocalJWKSet,
decodeProtectedHeader,
jwtVerify,
} from 'jose';
import { MemoryKeyStore } from './MemoryKeyStore';
import { TokenFactory } from './TokenFactory';
import { tokenTypes } from '@backstage/plugin-auth-node';
const logger = getVoidLogger();
@@ -60,18 +66,54 @@ describe('TokenFactory', () => {
const keyStore = createLocalJWKSet({ keys: keys });
const verifyResult = await jwtVerify(token, keyStore);
expect(verifyResult.protectedHeader.typ).toBe(tokenTypes.user.typParam);
expect(verifyResult.payload).toEqual({
iss: 'my-issuer',
aud: 'backstage',
aud: tokenTypes.user.audClaim,
sub: entityRef,
ent: [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,
);
// Emulate the reconstruction of a limited user token
const limitedUserToken = [
base64url.encode(
JSON.stringify({
typ: tokenTypes.limitedUser.typParam,
alg: verifyResult.protectedHeader.alg,
kid: verifyResult.protectedHeader.kid!,
}),
),
base64url.encode(
JSON.stringify({
sub: verifyResult.payload.sub,
ent: verifyResult.payload.ent,
iat: verifyResult.payload.iat,
exp: verifyResult.payload.exp,
}),
),
verifyResult.payload.uip,
].join('.');
const verifyProofResult = await jwtVerify(limitedUserToken, keyStore);
expect(verifyProofResult.protectedHeader.typ).toBe(
tokenTypes.limitedUser.typParam,
);
expect(verifyProofResult.payload).toEqual({
sub: entityRef,
ent: [entityRef],
iat: expect.any(Number),
exp: expect.any(Number),
});
expect(verifyProofResult.payload.exp).toBe(
verifyProofResult.payload.iat! + keyDurationSeconds,
);
});
it('should generate new signing keys when the current one expires', async () => {
+162 -15
View File
@@ -16,16 +16,99 @@
import { parseEntityRef } from '@backstage/catalog-model';
import { AuthenticationError } from '@backstage/errors';
import { exportJWK, generateKeyPair, importJWK, JWK, SignJWT } from 'jose';
import {
exportJWK,
generateKeyPair,
importJWK,
JWK,
SignJWT,
GeneralSign,
KeyLike,
} from 'jose';
import { DateTime } from 'luxon';
import { v4 as uuid } from 'uuid';
import { LoggerService } from '@backstage/backend-plugin-api';
import { TokenParams } from '@backstage/plugin-auth-node';
import { TokenParams, tokenTypes } from '@backstage/plugin-auth-node';
import { AnyJWK, KeyStore, TokenIssuer } from './types';
import { JsonValue } from '@backstage/types';
const MS_IN_S = 1000;
const MAX_TOKEN_LENGTH = 32768; // At 64 bytes per entity ref this still leaves room for about 500 entities
/**
* The payload contents of a valid Backstage JWT token
*
* @internal
*/
interface BackstageTokenPayload {
/**
* The issuer of the token, currently the discovery URL of the auth backend
*/
iss: string;
/**
* The entity ref of the user
*/
sub: string;
/**
* The entity refs that the user claims ownership througg
*/
ent: string[];
/**
* A hard coded audience string
*/
aud: typeof tokenTypes.user.audClaim;
/**
* Standard expiry in epoch seconds
*/
exp: number;
/**
* Standard issue time in epoch seconds
*/
iat: number;
/**
* A separate user identity proof that the auth service can convert to a limited user token
*/
uip: string;
/**
* Any other custom claims that the adopter may have added
*/
[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;
/**
* The ownership entity refs of the user
*/
ent?: 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 */
@@ -79,13 +162,13 @@ export class TokenFactory implements TokenIssuer {
const key = await this.getKey();
const iss = this.issuer;
const { sub, ent, ...additionalClaims } = params.claims;
const aud = 'backstage';
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;
// Validate that the subject claim is a valid EntityRef
try {
// The subject must be a valid entity ref
parseEntityRef(sub);
} catch (error) {
throw new Error(
@@ -93,21 +176,42 @@ export class TokenFactory implements TokenIssuer {
);
}
this.logger.info(`Issuing token for ${sub}, with entities ${ent ?? []}`);
if (!key.alg) {
throw new AuthenticationError('No algorithm was provided in the key');
}
const claims = { ...additionalClaims, iss, sub, ent, aud, iat, exp };
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, ent, iat, exp },
key: signingKey,
});
const claims: BackstageTokenPayload = {
...additionalClaims,
iss,
sub,
ent,
aud,
iat,
exp,
uip,
};
const token = await new SignJWT(claims)
.setProtectedHeader({ alg: key.alg, kid: key.kid })
.setIssuer(iss)
.setAudience(aud)
.setSubject(sub)
.setIssuedAt(iat)
.setExpirationTime(exp)
.sign(await importJWK(key));
.setProtectedHeader({
typ: tokenTypes.user.typParam,
alg: key.alg,
kid: key.kid,
})
.sign(signingKey);
if (token.length > MAX_TOKEN_LENGTH) {
throw new Error(
@@ -210,4 +314,47 @@ 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,
ent: options.payload.ent,
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;
}
}
+14
View File
@@ -639,6 +639,20 @@ export type TokenParams = {
} & Record<string, JsonValue>;
};
// @public
export const tokenTypes: Readonly<{
user: Readonly<{
typParam: 'vnd.backstage.user';
audClaim: 'backstage';
}>;
limitedUser: Readonly<{
typParam: 'vnd.backstage.limited-user';
}>;
service: Readonly<{
typParam: 'vnd.backstage.service';
}>;
}>;
// @public
export type WebMessageResponse =
| {
+2 -1
View File
@@ -29,8 +29,8 @@ export * from './proxy';
export * from './sign-in';
export type {
AuthProviderConfig,
AuthProviderRouteHandlers,
AuthProviderFactory,
AuthProviderRouteHandlers,
AuthResolverCatalogUserQuery,
AuthResolverContext,
BackstageIdentityResponse,
@@ -44,3 +44,4 @@ export type {
SignInResolver,
TokenParams,
} from './types';
export { tokenTypes } from './types';
+18
View File
@@ -375,3 +375,21 @@ export type CookieConfigurer = (ctx: {
secure: boolean;
sameSite?: 'none' | 'lax' | 'strict';
};
/**
* Core properties of various token types.
*
* @public
*/
export const tokenTypes = Object.freeze({
user: Object.freeze({
typParam: 'vnd.backstage.user',
audClaim: 'backstage',
}),
limitedUser: Object.freeze({
typParam: 'vnd.backstage.limited-user',
}),
service: Object.freeze({
typParam: 'vnd.backstage.service',
}),
});
+1
View File
@@ -3305,6 +3305,7 @@ __metadata:
minimatch: ^9.0.0
minimist: ^1.2.5
morgan: ^1.10.0
msw: ^1.0.0
node-forge: ^1.3.1
path-to-regexp: ^6.2.1
selfsigned: ^2.0.0