Merge pull request #23959 from backstage/mob/user-identity-claims
[Auth] Add identity proof signature to Backstage tokens
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend': patch
|
||||
---
|
||||
|
||||
Added token type header parameter and user identity proof to issued user tokens.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-node': patch
|
||||
---
|
||||
|
||||
Add `tokenTypes` export with constants for various Backstage token types.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
-1
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
| {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user