diff --git a/.changeset/dry-tips-build.md b/.changeset/dry-tips-build.md new file mode 100644 index 0000000000..ef3ec700d1 --- /dev/null +++ b/.changeset/dry-tips-build.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-node': minor +--- + +IdentityClient is now deprecated. Please migrate to IdentityApi and DefaultIdentityClient instead. The authenticate function on DefaultIdentityClient is also deprecated. Please use getIdentity instead. diff --git a/.changeset/thin-cows-watch.md b/.changeset/thin-cows-watch.md new file mode 100644 index 0000000000..12c96df268 --- /dev/null +++ b/.changeset/thin-cows-watch.md @@ -0,0 +1,7 @@ +--- +'@internal/plugin-todo-list-backend': patch +'@backstage/plugin-permission-backend': patch +'@backstage/plugin-scaffolder-backend': patch +--- + +Uptake the IdentityApi change to use getIdentiy instead of authenticate for retrieving the logged in users identity. diff --git a/docs/permissions/plugin-authors/01-setup.md b/docs/permissions/plugin-authors/01-setup.md index d08d1ee79c..18de410612 100644 --- a/docs/permissions/plugin-authors/01-setup.md +++ b/docs/permissions/plugin-authors/01-setup.md @@ -46,7 +46,7 @@ The source code is available here: Create a new `packages/backend/src/plugins/todolist.ts` with the following content: ```javascript - import { IdentityClient } from '@backstage/plugin-auth-node'; + import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; import { createRouter } from '@internal/plugin-todo-list-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; @@ -57,7 +57,7 @@ The source code is available here: }: PluginEnvironment): Promise { return await createRouter({ logger, - identity: IdentityClient.create({ + identity: DefaultIdentityClient.create({ discovery, issuer: await discovery.getExternalBaseUrl('auth'), }), diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 1942c36ad1..00fc2d529e 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -59,6 +59,7 @@ import jenkins from './plugins/jenkins'; import permission from './plugins/permission'; import { PluginEnvironment } from './types'; import { ServerPermissionClient } from '@backstage/plugin-permission-node'; +import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; function makeCreateEnv(config: Config) { const root = getRootLogger(); @@ -72,6 +73,11 @@ function makeCreateEnv(config: Config) { const databaseManager = DatabaseManager.fromConfig(config); const cacheManager = CacheManager.fromConfig(config); const taskScheduler = TaskScheduler.fromConfig(config); + const identity = DefaultIdentityClient.create({ + discovery, + algorithms: undefined, + issuer: undefined, + }); root.info(`Created UrlReader ${reader}`); @@ -80,6 +86,7 @@ function makeCreateEnv(config: Config) { const database = databaseManager.forPlugin(plugin); const cache = cacheManager.forPlugin(plugin); const scheduler = taskScheduler.forPlugin(plugin); + return { logger, cache, @@ -90,6 +97,7 @@ function makeCreateEnv(config: Config) { tokenManager, permissions, scheduler, + identity, }; }; } diff --git a/packages/backend/src/plugins/permission.ts b/packages/backend/src/plugins/permission.ts index e4c2e1d435..ab647b7f95 100644 --- a/packages/backend/src/plugins/permission.ts +++ b/packages/backend/src/plugins/permission.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { IdentityClient } from '@backstage/plugin-auth-node'; +import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; import { createRouter } from '@backstage/plugin-permission-backend'; import { AuthorizeResult, @@ -40,7 +40,7 @@ export default async function createPlugin( logger: env.logger, discovery: env.discovery, policy: new AllowAllPermissionPolicy(), - identity: IdentityClient.create({ + identity: DefaultIdentityClient.create({ discovery: env.discovery, issuer: await env.discovery.getExternalBaseUrl('auth'), }), diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index 465616f433..19da3b3ae0 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -32,5 +32,6 @@ export default async function createPlugin( database: env.database, catalogClient: catalogClient, reader: env.reader, + identity: env.identity, }); } diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 3e47b1a523..831eaebb66 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -28,6 +28,7 @@ import { PermissionAuthorizer, PermissionEvaluator, } from '@backstage/plugin-permission-common'; +import { IdentityApi } from '@backstage/plugin-auth-node'; export type PluginEnvironment = { logger: Logger; @@ -39,4 +40,5 @@ export type PluginEnvironment = { tokenManager: TokenManager; permissions: PermissionEvaluator | PermissionAuthorizer; scheduler: PluginTaskScheduler; + identity: IdentityApi; }; diff --git a/plugins/auth-node/api-report.md b/plugins/auth-node/api-report.md index 80c00b43fc..0f1c5bb934 100644 --- a/plugins/auth-node/api-report.md +++ b/plugins/auth-node/api-report.md @@ -4,6 +4,7 @@ ```ts import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { Request as Request_2 } from 'express'; // @public export interface BackstageIdentityResponse extends BackstageSignInResult { @@ -22,21 +23,39 @@ export type BackstageUserIdentity = { ownershipEntityRefs: string[]; }; +// @public +export class DefaultIdentityClient implements IdentityApi { + // @deprecated + authenticate(token: string | undefined): Promise; + static create(options: IdentityClientOptions): DefaultIdentityClient; + // (undocumented) + getIdentity(req: Request_2): Promise; +} + // @public export function getBearerTokenFromAuthorizationHeader( authorizationHeader: unknown, ): string | undefined; // @public +export interface IdentityApi { + getIdentity( + req: Request_2, + ): Promise; +} + +// @public @deprecated export class IdentityClient { + // @deprecated authenticate(token: string | undefined): Promise; + // (undocumented) static create(options: IdentityClientOptions): IdentityClient; } // @public export type IdentityClientOptions = { discovery: PluginEndpointDiscovery; - issuer: string; + issuer?: string; algorithms?: string[]; }; ``` diff --git a/plugins/auth-node/package.json b/plugins/auth-node/package.json index 011aade178..03fec84859 100644 --- a/plugins/auth-node/package.json +++ b/plugins/auth-node/package.json @@ -26,6 +26,7 @@ "@backstage/backend-common": "^0.14.1-next.1", "@backstage/config": "^1.0.1", "@backstage/errors": "^1.1.0-next.0", + "express": "^4.18.1", "jose": "^4.6.0", "node-fetch": "^2.6.7", "winston": "^3.2.1" diff --git a/plugins/auth-node/src/DefaultIdentityClient.test.ts b/plugins/auth-node/src/DefaultIdentityClient.test.ts new file mode 100644 index 0000000000..27aceaa780 --- /dev/null +++ b/plugins/auth-node/src/DefaultIdentityClient.test.ts @@ -0,0 +1,369 @@ +/* + * 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 { + decodeProtectedHeader, + exportJWK, + generateKeyPair, + SignJWT, +} from 'jose'; +import { cloneDeep } from 'lodash'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { v4 as uuid } from 'uuid'; + +import { DefaultIdentityClient } from './DefaultIdentityClient'; + +interface AnyJWK extends Record { + use: 'sig'; + alg: string; + kid: string; + kty: string; +} + +// Simplified copy of TokenFactory in @backstage/plugin-auth-backend +class FakeTokenFactory { + private readonly keys = new Array(); + + constructor( + private readonly options: { + issuer: string; + keyDurationSeconds: number; + }, + ) {} + + async issueToken(params: { + claims: { + sub: string; + ent?: string[]; + }; + }): Promise { + const pair = await generateKeyPair('ES256'); + const publicKey = await exportJWK(pair.publicKey); + const kid = uuid(); + publicKey.kid = kid; + this.keys.push(publicKey as AnyJWK); + + const iss = this.options.issuer; + const sub = params.claims.sub; + const ent = params.claims.ent; + const aud = 'backstage'; + const iat = Math.floor(Date.now() / 1000); + const exp = iat + this.options.keyDurationSeconds; + + return new SignJWT({ iss, sub, aud, iat, exp, ent, kid }) + .setProtectedHeader({ alg: 'ES256', ent: ent, kid: kid }) + .setIssuer(iss) + .setAudience(aud) + .setSubject(sub) + .setIssuedAt(iat) + .setExpirationTime(exp) + .sign(pair.privateKey); + } + + async listPublicKeys(): Promise<{ keys: AnyJWK[] }> { + return { keys: this.keys }; + } +} + +function jwtKid(jwt: string): string { + const header = decodeProtectedHeader(jwt); + return header.kid ?? ''; +} + +const server = setupServer(); +const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base'; +const discovery: PluginEndpointDiscovery = { + async getBaseUrl() { + return mockBaseUrl; + }, + async getExternalBaseUrl() { + return mockBaseUrl; + }, +}; + +describe('DefaultIdentityClient', () => { + let client: DefaultIdentityClient; + let factory: FakeTokenFactory; + const keyDurationSeconds = 5; + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); + afterAll(() => server.close()); + afterEach(() => server.resetHandlers()); + + beforeEach(() => { + client = DefaultIdentityClient.create({ discovery, issuer: mockBaseUrl }); + factory = new FakeTokenFactory({ + issuer: mockBaseUrl, + keyDurationSeconds, + }); + }); + + describe('identity client configuration', () => { + beforeEach(() => { + server.use( + rest.get( + `${mockBaseUrl}/.well-known/jwks.json`, + async (_, res, ctx) => { + const keys = await factory.listPublicKeys(); + return res(ctx.json(keys)); + }, + ), + ); + }); + + it('should defaults to ES256 when no algorithm is supplied', async () => { + const identityClient = DefaultIdentityClient.create({ + discovery, + issuer: mockBaseUrl, + }); + + const token = await factory.issueToken({ claims: { sub: 'foo' } }); + const response = await identityClient.authenticate(token); + + // expect that the authenticate is able to validate a token with ES256, which is the one set to FakeTokenFactory. + // This means that IdentityClient set ES256 by default. + expect(response).toEqual({ + token: token, + identity: { + type: 'user', + userEntityRef: 'foo', + ownershipEntityRefs: [], + }, + }); + }); + + it('should throw error on empty algorithms array', async () => { + const identityClient = DefaultIdentityClient.create({ + discovery, + issuer: mockBaseUrl, + algorithms: [''], + }); + + const token = await factory.issueToken({ claims: { sub: 'foo' } }); + return expect( + async () => await identityClient.authenticate(token), + ).rejects.toThrow(); + }); + + it('should throw error on empty algorithm string', async () => { + const identityClient = DefaultIdentityClient.create({ + discovery, + issuer: mockBaseUrl, + algorithms: [], + }); + + const token = await factory.issueToken({ claims: { sub: 'foo' } }); + return expect( + async () => await identityClient.authenticate(token), + ).rejects.toThrow(); + }); + }); + + describe('authenticate', () => { + beforeEach(() => { + server.use( + rest.get( + `${mockBaseUrl}/.well-known/jwks.json`, + async (_, res, ctx) => { + const keys = await factory.listPublicKeys(); + return res(ctx.json(keys)); + }, + ), + ); + }); + + it('should throw on undefined header', async () => { + return expect(async () => { + await client.authenticate(undefined); + }).rejects.toThrow(); + }); + + it('should accept fresh token', async () => { + const token = await factory.issueToken({ claims: { sub: 'foo' } }); + const response = await client.authenticate(token); + expect(response).toEqual({ + token: token, + identity: { + type: 'user', + userEntityRef: 'foo', + ownershipEntityRefs: [], + }, + }); + }); + + it('should decode claims correctly', async () => { + const token = await factory.issueToken({ + claims: { sub: 'foo', ent: ['entity1', 'entity2'] }, + }); + const response = await client.authenticate(token); + expect(response).toEqual({ + token: token, + identity: { + type: 'user', + userEntityRef: 'foo', + ownershipEntityRefs: ['entity1', 'entity2'], + }, + }); + }); + + it('should throw on incorrect issuer', async () => { + const hackerFactory = new FakeTokenFactory({ + issuer: 'hacker', + keyDurationSeconds, + }); + return expect(async () => { + const token = await hackerFactory.issueToken({ + claims: { sub: 'foo' }, + }); + await client.authenticate(token); + }).rejects.toThrow(); + }); + + it('should throw on expired token', async () => { + return expect(async () => { + const fixedTime = Date.now(); + jest + .spyOn(Date, 'now') + .mockImplementation(() => fixedTime - keyDurationSeconds * 1000 * 2); + const token = await factory.issueToken({ + claims: { sub: 'foo' }, + }); + jest.spyOn(Date, 'now').mockImplementation(() => fixedTime); + await client.authenticate(token); + }).rejects.toThrow(); + }); + + it('should throw on incorrect signing key', async () => { + const hackerFactory = new FakeTokenFactory({ + issuer: mockBaseUrl, + keyDurationSeconds, + }); + return expect(async () => { + const token = await hackerFactory.issueToken({ + claims: { sub: 'foo' }, + }); + await client.authenticate(token); + }).rejects.toThrow(); + }); + + it('should accept token from new key', async () => { + const fixedTime = Date.now(); + jest + .spyOn(Date, 'now') + .mockImplementation(() => fixedTime - keyDurationSeconds * 1000 * 2); + const token1 = await factory.issueToken({ claims: { sub: 'foo1' } }); + try { + // This throws as token has already expired + await client.authenticate(token1); + } catch (_err) { + // Ignore thrown error + } + // Move forward in time where the signing key has been rotated and the + // cooldown period to look up a new public key has elapsed. + jest + .spyOn(Date, 'now') + .mockImplementation( + () => fixedTime + 30 * keyDurationSeconds * 1000 + 2, + ); + const token = await factory.issueToken({ claims: { sub: 'foo' } }); + const response = await client.authenticate(token); + expect(response).toEqual({ + token: token, + identity: { + type: 'user', + userEntityRef: 'foo', + ownershipEntityRefs: [], + }, + }); + }); + + it('should not be fooled by the none algorithm', async () => { + return expect(async () => { + const token = await factory.issueToken({ claims: { sub: 'foo' } }); + const header = btoa( + JSON.stringify({ alg: 'none', kid: jwtKid(token) }), + ); + const payload = btoa( + JSON.stringify({ + iss: mockBaseUrl, + sub: 'foo', + aud: 'backstage', + iat: Date.now() / 1000, + exp: Date.now() / 1000 + 60000, + }), + ); + const fakeToken = `${header}.${payload}.`; + return await client.authenticate(fakeToken); + }).rejects.toThrow(); + }); + + it('should use an updated endpoint when the key is not found', async () => { + const updatedURL = 'http://backstage:9191/an-updated-base'; + const getBaseUrl = discovery.getBaseUrl; + const getExternalBaseUrl = discovery.getExternalBaseUrl; + // Generate a key and sign a token with it + await factory.issueToken({ claims: { sub: 'foo' } }); + // Only return the key from a single token + const singleKey = cloneDeep(await factory.listPublicKeys()); + server.use( + rest.get( + `${mockBaseUrl}/.well-known/jwks.json`, + async (_, res, ctx) => { + return res(ctx.json(singleKey)); + }, + ), + ); + // Update the discovery endpoint to point to a new URL + discovery.getBaseUrl = async () => { + return updatedURL; + }; + discovery.getExternalBaseUrl = async () => { + return updatedURL; + }; + let calledUpdatedEndpoint = false; + server.use( + rest.get(`${updatedURL}/.well-known/jwks.json`, async (_, res, ctx) => { + const keys = await factory.listPublicKeys(); + calledUpdatedEndpoint = true; + return res(ctx.json(keys)); + }), + ); + // Advance time + const future_11s = Date.now() + 11 * 1000; + const dateSpy = jest + .spyOn(Date, 'now') + .mockImplementation(() => future_11s); + // Issue a new token + const token = await factory.issueToken({ claims: { sub: 'foo2' } }); + const response = await client.authenticate(token); + // Verify that the endpoint was updated. + expect(calledUpdatedEndpoint).toBeTruthy(); + expect(response).toEqual({ + token: token, + identity: { + type: 'user', + userEntityRef: 'foo2', + ownershipEntityRefs: [], + }, + }); + // Restore the discovery endpoint and time + discovery.getBaseUrl = getBaseUrl; + discovery.getExternalBaseUrl = getExternalBaseUrl; + dateSpy.mockClear(); + }); + }); +}); diff --git a/plugins/auth-node/src/DefaultIdentityClient.ts b/plugins/auth-node/src/DefaultIdentityClient.ts new file mode 100644 index 0000000000..93fdb5542b --- /dev/null +++ b/plugins/auth-node/src/DefaultIdentityClient.ts @@ -0,0 +1,168 @@ +/* + * 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 { AuthenticationError } from '@backstage/errors'; +import { + createRemoteJWKSet, + decodeJwt, + decodeProtectedHeader, + FlattenedJWSInput, + JWSHeaderParameters, + jwtVerify, +} from 'jose'; +import { GetKeyFunction } from 'jose/dist/types/types'; + +import { BackstageIdentityResponse } from './types'; +import { getBearerTokenFromAuthorizationHeader, IdentityApi } from '.'; +import { Request } from 'express'; + +const CLOCK_MARGIN_S = 10; + +/** + * An identity client options object which allows extra configurations + * + * @experimental This is not a stable API yet + * @public + */ +export type IdentityClientOptions = { + discovery: PluginEndpointDiscovery; + issuer?: string; + + /** JWS "alg" (Algorithm) Header Parameter values. Defaults to an array containing just ES256. + * More info on supported algorithms: https://github.com/panva/jose */ + algorithms?: string[]; +}; + +/** + * An identity client to interact with auth-backend and authenticate Backstage + * tokens + * + * @experimental This is not a stable API yet + * @public + */ +export class DefaultIdentityClient implements IdentityApi { + private readonly discovery: PluginEndpointDiscovery; + private readonly issuer?: string; + private readonly algorithms?: string[]; + private keyStore?: GetKeyFunction; + private keyStoreUpdated: number = 0; + + /** + * Create a new {@link DefaultIdentityClient} instance. + */ + static create(options: IdentityClientOptions): DefaultIdentityClient { + return new DefaultIdentityClient(options); + } + + private constructor(options: IdentityClientOptions) { + this.discovery = options.discovery; + this.issuer = options.issuer; + this.algorithms = options.hasOwnProperty('algorithms') + ? options.algorithms + : ['ES256']; + } + + async getIdentity(req: Request) { + try { + return await this.authenticate( + getBearerTokenFromAuthorizationHeader(req.headers.authorization), + ); + } catch (e) { + return undefined; + } + } + + /** + * Verifies the given backstage identity token + * Returns a BackstageIdentity (user) matching the token. + * The method throws an error if verification fails. + * + * @deprecated You should start to use getIdentity instead of authenticate to retrieve the user + * identity. + */ + async authenticate( + token: string | undefined, + ): Promise { + // Extract token from header + if (!token) { + throw new AuthenticationError('No token specified'); + } + + // Verify token claims and signature + // Note: Claims must match those set by TokenFactory when issuing tokens + // Note: verify throws if verification fails + // Check if the keystore needs to be updated + await this.refreshKeyStore(token); + if (!this.keyStore) { + throw new AuthenticationError('No keystore exists'); + } + const decoded = await jwtVerify(token, this.keyStore, { + algorithms: this.algorithms, + audience: 'backstage', + issuer: this.issuer, + }); + // Verified, return the matching user as BackstageIdentity + // TODO: Settle internal user format/properties + if (!decoded.payload.sub) { + throw new AuthenticationError('No user sub found in token'); + } + + const user: BackstageIdentityResponse = { + token, + identity: { + type: 'user', + userEntityRef: decoded.payload.sub, + ownershipEntityRefs: decoded.payload.ent + ? (decoded.payload.ent as string[]) + : [], + }, + }; + return user; + } + + /** + * 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/plugins/auth-node/src/IdentityApi.ts b/plugins/auth-node/src/IdentityApi.ts new file mode 100644 index 0000000000..2387d01f65 --- /dev/null +++ b/plugins/auth-node/src/IdentityApi.ts @@ -0,0 +1,35 @@ +/* + * 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 { Request } from 'express'; +import { BackstageIdentityResponse } from './types'; + +/** + * An identity client api to authenticate Backstage + * tokens + * + * @experimental This is not a stable API yet + * @public + */ +export interface IdentityApi { + /** + * Verifies the given backstage identity token + * Returns a BackstageIdentity (user) matching the token. + * The method throws an error if verification fails. + */ + getIdentity( + req: Request, + ): Promise; +} diff --git a/plugins/auth-node/src/IdentityClient.ts b/plugins/auth-node/src/IdentityClient.ts index 0773d22c26..8252a61f81 100644 --- a/plugins/auth-node/src/IdentityClient.ts +++ b/plugins/auth-node/src/IdentityClient.ts @@ -13,139 +13,42 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { PluginEndpointDiscovery } from '@backstage/backend-common'; -import { AuthenticationError } from '@backstage/errors'; + import { - createRemoteJWKSet, - decodeJwt, - decodeProtectedHeader, - FlattenedJWSInput, - JWSHeaderParameters, - jwtVerify, -} from 'jose'; -import { GetKeyFunction } from 'jose/dist/types/types'; - + DefaultIdentityClient, + IdentityClientOptions, +} from './DefaultIdentityClient'; import { BackstageIdentityResponse } from './types'; -const CLOCK_MARGIN_S = 10; - -/** - * An identity client options object which allows extra configurations - * - * @experimental This is not a stable API yet - * @public - */ -export type IdentityClientOptions = { - discovery: PluginEndpointDiscovery; - issuer: string; - - /** JWS "alg" (Algorithm) Header Parameter values. Defaults to an array containing just ES256. - * More info on supported algorithms: https://github.com/panva/jose */ - algorithms?: string[]; -}; - /** * An identity client to interact with auth-backend and authenticate Backstage * tokens * - * @experimental This is not a stable API yet * @public + * @experimental This is not a stable API yet + * @deprecated Please migrate to the DefaultIdentityClient. */ export class IdentityClient { - private readonly discovery: PluginEndpointDiscovery; - private readonly issuer: string; - private readonly algorithms: string[]; - private keyStore?: GetKeyFunction; - private keyStoreUpdated: number = 0; - - /** - * Create a new {@link IdentityClient} instance. - */ + private readonly defaultIdentityClient: DefaultIdentityClient; static create(options: IdentityClientOptions): IdentityClient { - return new IdentityClient(options); + return new IdentityClient(DefaultIdentityClient.create(options)); } - private constructor(options: IdentityClientOptions) { - this.discovery = options.discovery; - this.issuer = options.issuer; - this.algorithms = options.algorithms ?? ['ES256']; + private constructor(defaultIdentityClient: DefaultIdentityClient) { + this.defaultIdentityClient = defaultIdentityClient; } /** * Verifies the given backstage identity token * Returns a BackstageIdentity (user) matching the token. * The method throws an error if verification fails. + * + * @deprecated You should start to use IdentityApi#getIdentity instead of authenticate + * to retrieve the user identity. */ async authenticate( token: string | undefined, ): Promise { - // Extract token from header - if (!token) { - throw new AuthenticationError('No token specified'); - } - - // Verify token claims and signature - // Note: Claims must match those set by TokenFactory when issuing tokens - // Note: verify throws if verification fails - // Check if the keystore needs to be updated - await this.refreshKeyStore(token); - if (!this.keyStore) { - throw new AuthenticationError('No keystore exists'); - } - const decoded = await jwtVerify(token, this.keyStore, { - algorithms: this.algorithms, - audience: 'backstage', - issuer: this.issuer, - }); - // Verified, return the matching user as BackstageIdentity - // TODO: Settle internal user format/properties - if (!decoded.payload.sub) { - throw new AuthenticationError('No user sub found in token'); - } - - const user: BackstageIdentityResponse = { - token, - identity: { - type: 'user', - userEntityRef: decoded.payload.sub, - ownershipEntityRefs: decoded.payload.ent - ? (decoded.payload.ent as string[]) - : [], - }, - }; - return user; - } - - /** - * 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; - } + return await this.defaultIdentityClient.authenticate(token); } } diff --git a/plugins/auth-node/src/index.ts b/plugins/auth-node/src/index.ts index cfb7275abd..a17678a038 100644 --- a/plugins/auth-node/src/index.ts +++ b/plugins/auth-node/src/index.ts @@ -21,8 +21,10 @@ */ export { getBearerTokenFromAuthorizationHeader } from './getBearerTokenFromAuthorizationHeader'; +export { DefaultIdentityClient } from './DefaultIdentityClient'; export { IdentityClient } from './IdentityClient'; -export type { IdentityClientOptions } from './IdentityClient'; +export type { IdentityApi } from './IdentityApi'; +export type { IdentityClientOptions } from './DefaultIdentityClient'; export type { BackstageIdentityResponse, BackstageSignInResult, diff --git a/plugins/example-todo-list-backend/api-report.md b/plugins/example-todo-list-backend/api-report.md index fa62ada8e7..c4f8665a70 100644 --- a/plugins/example-todo-list-backend/api-report.md +++ b/plugins/example-todo-list-backend/api-report.md @@ -4,7 +4,7 @@ ```ts import express from 'express'; -import { IdentityClient } from '@backstage/plugin-auth-node'; +import { IdentityApi } from '@backstage/plugin-auth-node'; import { Logger } from 'winston'; // @public @@ -13,7 +13,7 @@ export function createRouter(options: RouterOptions): Promise; // @public export interface RouterOptions { // (undocumented) - identity: IdentityClient; + identity: IdentityApi; // (undocumented) logger: Logger; } diff --git a/plugins/example-todo-list-backend/src/service/router.test.ts b/plugins/example-todo-list-backend/src/service/router.test.ts index e381aad143..3a1ce458f2 100644 --- a/plugins/example-todo-list-backend/src/service/router.test.ts +++ b/plugins/example-todo-list-backend/src/service/router.test.ts @@ -15,7 +15,7 @@ */ import { getVoidLogger } from '@backstage/backend-common'; -import { IdentityClient } from '@backstage/plugin-auth-node'; +import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; import express from 'express'; import request from 'supertest'; @@ -27,7 +27,7 @@ describe('createRouter', () => { beforeAll(async () => { const router = await createRouter({ logger: getVoidLogger(), - identity: {} as IdentityClient, + identity: {} as DefaultIdentityClient, }); app = express().use(router); }); diff --git a/plugins/example-todo-list-backend/src/service/router.ts b/plugins/example-todo-list-backend/src/service/router.ts index cdaf1f82b4..58ce730d24 100644 --- a/plugins/example-todo-list-backend/src/service/router.ts +++ b/plugins/example-todo-list-backend/src/service/router.ts @@ -18,12 +18,9 @@ import { errorHandler } from '@backstage/backend-common'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; -import { - IdentityClient, - getBearerTokenFromAuthorizationHeader, -} from '@backstage/plugin-auth-node'; import { add, getAll, update } from './todos'; import { InputError } from '@backstage/errors'; +import { IdentityApi } from '@backstage/plugin-auth-node'; /** * Dependencies of the todo-list router @@ -32,7 +29,7 @@ import { InputError } from '@backstage/errors'; */ export interface RouterOptions { logger: Logger; - identity: IdentityClient; + identity: IdentityApi; } /** @@ -62,12 +59,9 @@ export async function createRouter( }); router.post('/todos', async (req, res) => { - const token = getBearerTokenFromAuthorizationHeader( - req.header('authorization'), - ); let author: string | undefined = undefined; - const user = token ? await identity.authenticate(token) : undefined; + const user = await identity.getIdentity(req); author = user?.identity.userEntityRef; if (!isTodoCreateRequest(req.body)) { diff --git a/plugins/example-todo-list-backend/src/service/standaloneServer.ts b/plugins/example-todo-list-backend/src/service/standaloneServer.ts index b0332e0931..eedf1230ca 100644 --- a/plugins/example-todo-list-backend/src/service/standaloneServer.ts +++ b/plugins/example-todo-list-backend/src/service/standaloneServer.ts @@ -19,7 +19,7 @@ import { loadBackendConfig, SingleHostDiscovery, } from '@backstage/backend-common'; -import { IdentityClient } from '@backstage/plugin-auth-node'; +import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; import { Server } from 'http'; import { Logger } from 'winston'; import { createRouter } from './router'; @@ -39,7 +39,7 @@ export async function startStandaloneServer( const discovery = SingleHostDiscovery.fromConfig(config); const router = await createRouter({ logger, - identity: IdentityClient.create({ + identity: DefaultIdentityClient.create({ discovery, issuer: await discovery.getExternalBaseUrl('auth'), }), diff --git a/plugins/permission-backend/api-report.md b/plugins/permission-backend/api-report.md index 3c606e3cb9..e9cc4e6272 100644 --- a/plugins/permission-backend/api-report.md +++ b/plugins/permission-backend/api-report.md @@ -5,7 +5,7 @@ ```ts import { Config } from '@backstage/config'; import express from 'express'; -import { IdentityClient } from '@backstage/plugin-auth-node'; +import { IdentityApi } from '@backstage/plugin-auth-node'; import { Logger } from 'winston'; import { PermissionPolicy } from '@backstage/plugin-permission-node'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; @@ -20,7 +20,7 @@ export interface RouterOptions { // (undocumented) discovery: PluginEndpointDiscovery; // (undocumented) - identity: IdentityClient; + identity: IdentityApi; // (undocumented) logger: Logger; // (undocumented) diff --git a/plugins/permission-backend/package.json b/plugins/permission-backend/package.json index 4df852d255..cfd200407d 100644 --- a/plugins/permission-backend/package.json +++ b/plugins/permission-backend/package.json @@ -28,6 +28,7 @@ "@backstage/plugin-auth-node": "^0.2.3-next.1", "@backstage/plugin-permission-common": "^0.6.3-next.0", "@backstage/plugin-permission-node": "^0.6.3-next.1", + "@backstage/plugin-auth-node": "^0.0.1", "@types/express": "*", "dataloader": "^2.0.0", "express": "^4.17.1", diff --git a/plugins/permission-backend/src/service/router.test.ts b/plugins/permission-backend/src/service/router.test.ts index 6c6ad14af4..7668245679 100644 --- a/plugins/permission-backend/src/service/router.test.ts +++ b/plugins/permission-backend/src/service/router.test.ts @@ -17,7 +17,6 @@ import express from 'express'; import request from 'supertest'; import { getVoidLogger } from '@backstage/backend-common'; -import { IdentityClient } from '@backstage/plugin-auth-node'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { ApplyConditionsRequestEntry, @@ -71,17 +70,23 @@ describe('createRouter', () => { getExternalBaseUrl: jest.fn(), }, identity: { - authenticate: jest.fn(token => { + getIdentity: jest.fn(req => { + const token = req.headers.authorization?.replace(/^Bearer[ ]+/, ''); + if (!token) { - throw new Error('No token supplied!'); + return Promise.resolve(undefined); } return Promise.resolve({ - id: 'test-user', + identity: { + type: 'user', + userEntityRef: 'test-user', + ownershipEntityRefs: ['blah'], + }, token, }); }), - } as unknown as IdentityClient, + }, policy, }); @@ -184,7 +189,14 @@ describe('createRouter', () => { attributes: {}, }, }, - { id: 'test-user', token: 'test-token' }, + { + token: 'test-token', + identity: { + type: 'user', + userEntityRef: 'test-user', + ownershipEntityRefs: ['blah'], + }, + }, ); expect(response.body).toEqual({ items: [{ id: '123', result: AuthorizeResult.ALLOW }], diff --git a/plugins/permission-backend/src/service/router.ts b/plugins/permission-backend/src/service/router.ts index ea7fbcca24..217c135ac3 100644 --- a/plugins/permission-backend/src/service/router.ts +++ b/plugins/permission-backend/src/service/router.ts @@ -24,9 +24,8 @@ import { } from '@backstage/backend-common'; import { InputError } from '@backstage/errors'; import { - getBearerTokenFromAuthorizationHeader, BackstageIdentityResponse, - IdentityClient, + IdentityApi, } from '@backstage/plugin-auth-node'; import { AuthorizeResult, @@ -96,7 +95,7 @@ export interface RouterOptions { logger: Logger; discovery: PluginEndpointDiscovery; policy: PermissionPolicy; - identity: IdentityClient; + identity: IdentityApi; config: Config; } @@ -189,10 +188,7 @@ export async function createRouter( req: Request, res: Response, ) => { - const token = getBearerTokenFromAuthorizationHeader( - req.header('authorization'), - ); - const user = token ? await identity.authenticate(token) : undefined; + const user = await identity.getIdentity(req); const parseResult = evaluatePermissionRequestBatchSchema.safeParse( req.body, diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index 2053f01e39..12d4746f42 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -13,6 +13,7 @@ import { createPullRequest } from 'octokit-plugin-create-pull-request'; import { Entity } from '@backstage/catalog-model'; import express from 'express'; import { GithubCredentialsProvider } from '@backstage/integration'; +import { IdentityApi } from '@backstage/plugin-auth-node'; import { JsonObject } from '@backstage/types'; import { JsonValue } from '@backstage/types'; import { Knex } from 'knex'; @@ -526,6 +527,8 @@ export interface RouterOptions { // (undocumented) database: PluginDatabaseManager; // (undocumented) + identity: IdentityApi; + // (undocumented) logger: Logger; // (undocumented) reader: UrlReader; diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 6f3aea1844..dfd264514e 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -42,6 +42,7 @@ "@backstage/integration": "^1.2.2-next.2", "@backstage/plugin-catalog-backend": "^1.2.1-next.2", "@backstage/plugin-scaffolder-common": "^1.1.2-next.0", + "@backstage/plugin-auth-node": "^0.0.1", "@backstage/types": "^1.0.0", "@gitbeaker/core": "^35.6.0", "@gitbeaker/node": "^35.1.0", diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index aea33f1158..72d494824b 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; + const mockAccess = jest.fn(); jest.doMock('fs-extra', () => ({ access: mockAccess, @@ -75,6 +77,16 @@ const mockUrlReader = UrlReaders.default({ describe('createRouter', () => { let app: express.Express; let taskBroker: TaskBroker; + const getIdentity = jest.fn(); + const rawPayload = Buffer.from( + JSON.stringify({ + sub: 'user:default/guest', + ent: ['group:default/guests'], + }), + 'utf8', + ).toString('base64'); + const mockToken = ['blob', rawPayload, 'blob'].join('.'); + const catalogClient = { getEntityByRef: jest.fn() } as unknown as CatalogApi; const mockTemplate: TemplateEntityV1beta3 = { @@ -123,6 +135,7 @@ describe('createRouter', () => { }; beforeEach(async () => { + getIdentity.mockReset(); const logger = getVoidLogger(); const databaseTaskStore = await DatabaseTaskStore.create({ database: await createDatabase().getClient(), @@ -134,6 +147,18 @@ describe('createRouter', () => { jest.spyOn(taskBroker, 'list'); jest.spyOn(taskBroker, 'event$'); + getIdentity.mockImplementation( + async (_req): Promise => { + return { + token: mockToken, + identity: { + type: 'user', + userEntityRef: 'user:default/guest', + ownershipEntityRefs: ['group:default/guests'], + }, + }; + }, + ); const router = await createRouter({ logger: getVoidLogger(), config: new ConfigReader({}), @@ -141,6 +166,9 @@ describe('createRouter', () => { catalogClient, reader: mockUrlReader, taskBroker, + identity: { + getIdentity, + }, }); app = express().use(router); @@ -214,8 +242,6 @@ describe('createRouter', () => { it('should call the broker with a correct spec', async () => { const broker = taskBroker.dispatch as jest.Mocked['dispatch']; - const mockToken = - 'blob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvZ3Vlc3QiLCJuYW1lIjoiSm9obiBEb2UifQ.blob'; await request(app) .post('/v2/tasks') @@ -264,29 +290,51 @@ describe('createRouter', () => { ); }); - it('should not decorate a user when no backstage auth is passed', async () => { - const broker = taskBroker.dispatch as jest.Mocked['dispatch']; - - await request(app) - .post('/v2/tasks') - .send({ - templateRef: stringifyEntityRef({ - kind: 'template', - name: 'create-react-app-template', - }), - values: { - required: 'required-value', + describe('no auth is passed', () => { + beforeEach(async () => { + getIdentity.mockImplementation( + async (_req): Promise => { + return undefined; + }, + ); + const router = await createRouter({ + logger: getVoidLogger(), + config: new ConfigReader({}), + database: createDatabase(), + catalogClient, + reader: mockUrlReader, + taskBroker, + identity: { + getIdentity, }, }); + app = express().use(router); + }); + it('should not decorate a user when no backstage auth is passed', async () => { + const broker = + taskBroker.dispatch as jest.Mocked['dispatch']; - expect(broker).toHaveBeenCalledWith( - expect.objectContaining({ - createdBy: undefined, - spec: expect.objectContaining({ - user: { entity: undefined, ref: undefined }, + await request(app) + .post('/v2/tasks') + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + required: 'required-value', + }, + }); + + expect(broker).toHaveBeenCalledWith( + expect.objectContaining({ + createdBy: undefined, + spec: expect.objectContaining({ + user: { entity: undefined, ref: undefined }, + }), }), - }), - ); + ); + }); }); }); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 2abb730250..74431b47f5 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -23,14 +23,13 @@ import { } from '@backstage/catalog-model'; import { Entity } from '@backstage/catalog-model'; import { Config, JsonObject } from '@backstage/config'; -import { InputError, NotFoundError, stringifyError } from '@backstage/errors'; +import { InputError, NotFoundError } from '@backstage/errors'; import { ScmIntegrations } from '@backstage/integration'; import { TemplateEntityV1beta3, TaskSpec, templateEntityV1beta3Validator, } from '@backstage/plugin-scaffolder-common'; -import { JsonValue } from '@backstage/types'; import express from 'express'; import Router from 'express-promise-router'; import { validate } from 'jsonschema'; @@ -48,6 +47,7 @@ import { import { createDryRunner } from '../scaffolder/dryrun'; import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker'; import { getEntityBaseUrl, getWorkingDirectory, findTemplate } from './helpers'; +import { IdentityApi } from '@backstage/plugin-auth-node'; /** * RouterOptions @@ -64,6 +64,7 @@ export interface RouterOptions { taskWorkers?: number; taskBroker?: TaskBroker; additionalTemplateFilters?: Record; + identity: IdentityApi; } function isSupportedTemplate(entity: TemplateEntityV1beta3) { @@ -89,6 +90,7 @@ export async function createRouter( actions, taskWorkers, additionalTemplateFilters, + identity, } = options; const logger = parentLogger.child({ plugin: 'scaffolder' }); @@ -146,7 +148,8 @@ export async function createRouter( '/v2/templates/:namespace/:kind/:name/parameter-schema', async (req, res) => { const { namespace, kind, name } = req.params; - const { token } = parseBearerToken(req.headers.authorization); + const userIdentity = await identity.getIdentity(req); + const token = userIdentity?.token; const template = await findTemplate({ catalogApi: catalogClient, entityRef: { kind, namespace, name }, @@ -185,9 +188,9 @@ export async function createRouter( const { kind, namespace, name } = parseEntityRef(templateRef, { defaultKind: 'template', }); - const { token, entityRef: userEntityRef } = parseBearerToken( - req.headers.authorization, - ); + const callerIdentity = await identity.getIdentity(req); + const token = callerIdentity?.token; + const userEntityRef = callerIdentity?.identity.userEntityRef; const userEntity = userEntityRef ? await catalogClient.getEntityByRef(userEntityRef, { token }) @@ -377,7 +380,7 @@ export async function createRouter( throw new InputError('Input template is not a template'); } - const { token } = parseBearerToken(req.headers.authorization); + const token = (await identity.getIdentity(req))?.token; for (const parameters of [template.spec.parameters ?? []].flat()) { const result = validate(body.values, parameters); @@ -427,41 +430,3 @@ export async function createRouter( return app; } - -function parseBearerToken(header?: string): { - token?: string; - entityRef?: string; -} { - if (!header) { - return {}; - } - - try { - const token = header.match(/^Bearer\s(\S+\.\S+\.\S+)$/i)?.[1]; - if (!token) { - throw new TypeError('Expected Bearer with JWT'); - } - - const [_header, rawPayload, _signature] = token.split('.'); - const payload: JsonValue = JSON.parse( - Buffer.from(rawPayload, 'base64').toString(), - ); - - if ( - typeof payload !== 'object' || - payload === null || - Array.isArray(payload) - ) { - throw new TypeError('Malformed JWT payload'); - } - - const sub = payload.sub; - if (typeof sub !== 'string') { - throw new TypeError('Expected string sub claim'); - } - - return { entityRef: sub, token }; - } catch (e) { - throw new InputError(`Invalid authorization header: ${stringifyError(e)}`); - } -}