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:
committed by
Camila Belo
parent
0d2a05418b
commit
7396a75d62
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
-16
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user