diff --git a/packages/backend-app-api/package.json b/packages/backend-app-api/package.json index d49cbf3774..2339a1a27b 100644 --- a/packages/backend-app-api/package.json +++ b/packages/backend-app-api/package.json @@ -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", diff --git a/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts b/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts new file mode 100644 index 0000000000..8a7debbe17 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.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; + #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 { + 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; + } + } +} diff --git a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts index f987f3dea5..07193909ab 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts @@ -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), + ); }); }); diff --git a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts index 2f75847608..81172f360b 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -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, ); diff --git a/yarn.lock b/yarn.lock index 94e38f68ed..563f0dda89 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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