backend-app-api: complete limited token implementation and verification + test

Co-authored-by: Camila Belo <camilaibs@gmail.com>
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-04-02 17:40:45 +02:00
committed by Camila Belo
parent 0d2a05418b
commit 7396a75d62
5 changed files with 218 additions and 41 deletions
+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,137 @@
/*
* 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 {
createRemoteJWKSet,
decodeJwt,
decodeProtectedHeader,
FlattenedJWSInput,
JWSHeaderParameters,
jwtVerify,
} 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 verifyFullUserToken(token: string): Promise<{ userEntityRef: string }> {
// Check if the keystore needs to be updated
await this.refreshKeyStore(token);
if (!this.#keyStore) {
throw new AuthenticationError('No keystore exists');
}
// Verify token claims and signature
// Note: Claims must match those set by TokenFactory when issuing tokens
// Note: verify throws if verification fails
const { payload } = await jwtVerify(token, this.#keyStore, {
algorithms: this.#algorithms,
audience: 'backstage',
}).catch(e => {
console.log(`DEBUG: e=`, e);
throw new AuthenticationError('invalid token', e);
});
const userEntityRef = payload.sub;
if (!userEntityRef) {
throw new AuthenticationError('No user sub found in token');
}
return { userEntityRef };
}
async verifyLimitedUserToken(
token: string,
): Promise<{ userEntityRef: string }> {
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, {
algorithms: this.#algorithms,
requiredClaims: ['iat', 'exp', 'sub'],
typ: TokenTypes.limitedUser.typParam,
}).catch(e => {
console.log(`DEBUG: e=`, e);
throw new AuthenticationError('invalid token', e);
});
const userEntityRef = payload.sub;
if (!userEntityRef) {
throw new AuthenticationError('No user sub found in token');
}
return { userEntityRef };
}
/**
* 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,6 +17,7 @@
import {
ServiceFactoryTester,
mockServices,
setupRequestMockHandlers,
} from '@backstage/backend-test-utils';
import {
InternalBackstageCredentials,
@@ -29,6 +30,10 @@ import {
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,
@@ -129,16 +140,33 @@ describe('authServiceFactory', () => {
});
it('should issue limited user tokens', async () => {
const publicKey = [
{
kty: 'EC',
x: 'Xd7ATJLz0085GTqYTKdl3oSZqHwcs-l1bMxrG7iFMOw',
y: 'EvFsODRaJsNWKLgknbHeCE1KxAPZL2WiSNkXB5gO1WM',
crv: 'P-256',
kid: 'b49bc495-e926-4ff9-b44f-4100e2dc069d',
alg: 'ES256',
},
];
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,
@@ -147,7 +175,7 @@ describe('authServiceFactory', () => {
const catalogAuth = await tester.get('catalog');
const fullToken =
'eyJhbGciOiJFUzI1NiIsImtpZCI6ImI0OWJjNDk1LWU5MjYtNGZmOS1iNDRmLTQxMDBlMmRjMDY5ZCJ9.eyJ0eXAiOiJ2bmQuYmFja3N0YWdlLnVzZXIiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjcwMDcvYXBpL2F1dGgiLCJzdWIiOiJ1c2VyOmRldmVsb3BtZW50L2d1ZXN0IiwiZW50IjpbInVzZXI6ZGV2ZWxvcG1lbnQvZ3Vlc3QiLCJncm91cDpkZWZhdWx0L3RlYW0tYSJdLCJhdWQiOiJiYWNrc3RhZ2UiLCJpYXQiOjE3MTIwNjQ0NzksImV4cCI6MTcxMjA2ODA3OSwidWlwIjoiMVQxR1JIcGpsdF9oRl8zM2trUFZ2QjdqM1dkWlJqcFowVHVnLXJTaXQwRHNQclJLY1V4eGU3VGVpZDhCbDhCTDE2QnRtTTRWTzJzQ0ExcjVkWUdLS2ZnIn0.5fFibx-RJVPHOvJNSCLGbUg3_sJVUMnyfN6QAq5abyKi8wtbDCCUAI9_x0Rb22KYCmBolV_cdjut-V6wQ3YmBg';
'eyJ0eXAiOiJ2bmQuYmFja3N0YWdlLnVzZXIiLCJhbGciOiJFUzI1NiIsImtpZCI6IjhkMDFjM2RiLTU2ZjktNDVmMC04NmRkLTA1YjNjODM1YjNkMyJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjcwMDcvYXBpL2F1dGgiLCJzdWIiOiJ1c2VyOmRldmVsb3BtZW50L2d1ZXN0IiwiZW50IjpbInVzZXI6ZGV2ZWxvcG1lbnQvZ3Vlc3QiLCJncm91cDpkZWZhdWx0L3RlYW0tYSJdLCJhdWQiOiJiYWNrc3RhZ2UiLCJpYXQiOjE3MTIwNzE3MTQsImV4cCI6MTcxMjA3NTMxNCwidWlwIjoiMDFBUUJfSWpHTXRWc2gyWmgzZEg1NXhOX29pSVlhQ1F3ODJjeDZ5M1BQMXlpTjM4eGMzMVpMS2U0YVNDQlJTTy10cjFzZFUzT29ELUxJYV8tNV9RVUEifQ.mjIrZGqbZ2t68fS4U3crlGw-bYJZnMlhMHf-YL7q_u1HfaLr4NMTcHkxdnNS2wfJxCmUBxRfUS8b3nSAKsxcHA';
const credentials = await catalogAuth.authenticate(fullToken);
if (!catalogAuth.isPrincipal(credentials, 'user')) {
@@ -157,21 +185,21 @@ describe('authServiceFactory', () => {
const { token: limitedToken, expiresAt } =
await catalogAuth.getLimitedUserToken(credentials);
expect(expiresAt).toEqual(new Date(1712068079 * 1000));
expect(expiresAt).toEqual(new Date(expectedExpiresAt * 1000));
const expectedTokenHeader = base64url.encode(
JSON.stringify({
typ: 'vnd.backstage.limited-user',
alg: 'ES256',
kid: 'b49bc495-e926-4ff9-b44f-4100e2dc069d',
kid: '8d01c3db-56f9-45f0-86dd-05b3c835b3d3',
}),
);
const expectedTokenPayload = base64url.encode(
JSON.stringify({
typ: 'vnd.backstage.limited-user',
sub: 'user:development/guest',
ent: ['user:development/guest', 'group:default/team-a'],
iat: 1712064479,
exp: 1712068079,
iat: expectedIssuedAt,
exp: expectedExpiresAt,
}),
);
const expectedTokenSignature = JSON.parse(
@@ -185,5 +213,15 @@ describe('authServiceFactory', () => {
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),
);
});
});
@@ -105,7 +105,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,
) {}
@@ -117,13 +117,24 @@ class DefaultAuthService implements AuthService {
if (typ) {
switch (typ) {
case TokenTypes.user.typClaim:
return this.#authenticateFullUser(token);
case TokenTypes.limitedUser.typClaim:
throw new AuthenticationError(
'Limited user tokens are not supported',
case TokenTypes.user.typParam: {
const { userEntityRef } =
await this.userTokenHandler.verifyFullUserToken(token);
return createCredentialsWithUserPrincipal(
userEntityRef,
token,
this.#getJwtExpiration(token),
);
}
case TokenTypes.limitedUser.typParam: {
const { userEntityRef } =
await this.userTokenHandler.verifyLimitedUserToken(token);
return createCredentialsWithUserPrincipal(
userEntityRef,
token,
this.#getJwtExpiration(token),
);
}
default:
throw new AuthenticationError("Invalid token 'typ' claim");
}
@@ -136,22 +147,11 @@ class DefaultAuthService implements AuthService {
}
// User Backstage token
return this.#authenticateFullUser(token);
}
async #authenticateFullUser(token: string) {
const identity = await this.identity.getIdentity({
request: {
headers: { authorization: `Bearer ${token}` },
},
} as IdentityApiGetIdentityRequest);
if (!identity) {
throw new AuthenticationError('Invalid user token');
}
const { userEntityRef } = await this.userTokenHandler.verifyFullUserToken(
token,
);
return createCredentialsWithUserPrincipal(
identity.identity.userEntityRef,
userEntityRef,
token,
this.#getJwtExpiration(token),
);
@@ -276,15 +276,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',
@@ -292,7 +292,7 @@ export const authServiceFactory = createServiceFactory({
);
return new DefaultAuthService(
tokenManager,
identity,
new UserTokenHandler({ discovery }),
plugin.getId(),
disableDefaultAuthPolicy,
);
+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