diff --git a/.changeset/weak-oranges-rhyme.md b/.changeset/weak-oranges-rhyme.md new file mode 100644 index 0000000000..f85baea224 --- /dev/null +++ b/.changeset/weak-oranges-rhyme.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': minor +--- + +Optional identity token authorization of api requests diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 68cc170901..c467ccefa1 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -34,6 +34,7 @@ import { useHotMemoize, } from '@backstage/backend-common'; import { Config } from '@backstage/config'; +import { BackstageIdentityStrategy } from '@backstage/plugin-auth-backend'; import healthcheck from './plugins/healthcheck'; import auth from './plugins/auth'; import catalog from './plugins/catalog'; @@ -45,6 +46,7 @@ import techdocs from './plugins/techdocs'; import graphql from './plugins/graphql'; import app from './plugins/app'; import { PluginEnvironment } from './types'; +import passport from 'passport'; function makeCreateEnv(config: Config) { const root = getRootLogger(); @@ -80,16 +82,26 @@ async function main() { const graphqlEnv = useHotMemoize(module, () => createEnv('graphql')); const appEnv = useHotMemoize(module, () => createEnv('app')); + passport.use( + new BackstageIdentityStrategy({ + discovery: SingleHostDiscovery.fromConfig(config), + }), + ); + const backstageAuth = passport.authenticate('backstage', { + session: false, + }); + const apiRouter = Router(); - apiRouter.use('/catalog', await catalog(catalogEnv)); - apiRouter.use('/rollbar', await rollbar(rollbarEnv)); - apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)); + apiRouter.use(passport.initialize()); + apiRouter.use('/catalog', backstageAuth, await catalog(catalogEnv)); + apiRouter.use('/rollbar', backstageAuth, await rollbar(rollbarEnv)); + apiRouter.use('/scaffolder', backstageAuth, await scaffolder(scaffolderEnv)); apiRouter.use('/auth', await auth(authEnv)); - apiRouter.use('/techdocs', await techdocs(techdocsEnv)); - apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv)); - apiRouter.use('/proxy', await proxy(proxyEnv)); - apiRouter.use('/graphql', await graphql(graphqlEnv)); - apiRouter.use(notFoundHandler()); + apiRouter.use('/techdocs', backstageAuth, await techdocs(techdocsEnv)); + apiRouter.use('/kubernetes', backstageAuth, await kubernetes(kubernetesEnv)); + apiRouter.use('/proxy', backstageAuth, await proxy(proxyEnv)); + apiRouter.use('/graphql', backstageAuth, await graphql(graphqlEnv)); + apiRouter.use(backstageAuth, notFoundHandler()); const service = createServiceBuilder(module) .loadConfig(config) diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 8f88c69487..ff9b6db5cb 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -59,6 +59,7 @@ "passport-okta-oauth": "^0.0.1", "passport-onelogin-oauth": "^0.0.1", "passport-saml": "^2.0.0", + "passport-strategy": "^1.0.0", "uuid": "^8.0.0", "winston": "^3.2.1", "yn": "^4.0.0" diff --git a/plugins/auth-backend/src/identity/IdentityClient.test.ts b/plugins/auth-backend/src/identity/IdentityClient.test.ts new file mode 100644 index 0000000000..d3bb6aa9c5 --- /dev/null +++ b/plugins/auth-backend/src/identity/IdentityClient.test.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { JWKECKey } from 'jose'; +import { IdentityClient } from './IdentityClient'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; + +const server = setupServer(); +const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base'; +const discovery: PluginEndpointDiscovery = { + async getBaseUrl(_pluginId) { + return mockBaseUrl; + }, + async getExternalBaseUrl(_pluginId) { + return mockBaseUrl; + }, +}; + +describe('IdentityClient', () => { + let client: IdentityClient; + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); + afterAll(() => server.close()); + afterEach(() => server.resetHandlers()); + + beforeEach(() => { + client = new IdentityClient({ discovery }); + }); + + describe('listPublicKeys', () => { + const defaultServiceResponse: { + keys: JWKECKey[]; + } = { + keys: [ + { + crv: 'P-256', + x: 'JWy80Goa-8C3oaeDLnk0ANVPPMfI9T3u_T5T7W2b_ls', + y: 'Ge6jAhCDW1PFBfme2RA5ZsXN0cESiCwW29LMRPX5wkw', + kty: 'EC', + kid: 'fecd6d82-224f-43d6-b174-9a48bc42ae44', + alg: 'ES256', + use: 'sig', + }, + { + crv: 'P-256', + x: 'PYGrR5otsNwGdwQC4Ob6sbkVc80jEwsPMUI25y7eUpY', + y: 'GB_mlRnp6METpCW5yUWHpPprZaPJ5sK6RD5nEo1FYac', + kty: 'EC', + kid: 'c5d8c8a8-ca83-43ef-baca-3fccc198901d', + alg: 'ES256', + use: 'sig', + }, + ], + }; + + beforeEach(() => { + server.use( + rest.get(`${mockBaseUrl}/.well-known/jwks.json`, (_, res, ctx) => { + return res(ctx.json(defaultServiceResponse)); + }), + ); + }); + + it('should use the correct endpoint', async () => { + const response = await client.listPublicKeys(); + expect(response).toEqual(defaultServiceResponse); + }); + }); +}); diff --git a/plugins/auth-backend/src/identity/IdentityClient.ts b/plugins/auth-backend/src/identity/IdentityClient.ts new file mode 100644 index 0000000000..2d1dd40b6c --- /dev/null +++ b/plugins/auth-backend/src/identity/IdentityClient.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 fetch from 'cross-fetch'; +import { JWKECKey } from 'jose'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; + +/** + * A identity client to interact with auth-backend. + */ +export class IdentityClient { + private readonly discovery: PluginEndpointDiscovery; + + constructor(options: { discovery: PluginEndpointDiscovery }) { + this.discovery = options.discovery; + } + + /** + * Lists public part of keys used to sign Backstage Identity tokens + */ + async listPublicKeys(): Promise<{ + keys: JWKECKey[]; + }> { + const url = `${await this.discovery.getBaseUrl( + 'auth', + )}/.well-known/jwks.json`; + const response = await fetch(url); + + if (!response.ok) { + const payload = await response.text(); + const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`; + throw new Error(message); + } + + const publicKeys: { keys: JWKECKey[] } = await response.json(); + + return publicKeys; + } +} diff --git a/plugins/auth-backend/src/identity/index.ts b/plugins/auth-backend/src/identity/index.ts index 19eec94556..a76dcea440 100644 --- a/plugins/auth-backend/src/identity/index.ts +++ b/plugins/auth-backend/src/identity/index.ts @@ -15,6 +15,7 @@ */ export { createOidcRouter } from './router'; +export { IdentityClient } from './IdentityClient'; export { TokenFactory } from './TokenFactory'; export { DatabaseKeyStore } from './DatabaseKeyStore'; export type { KeyStore, TokenIssuer, TokenParams } from './types'; diff --git a/plugins/auth-backend/src/index.ts b/plugins/auth-backend/src/index.ts index 3d3f059dcc..892842670d 100644 --- a/plugins/auth-backend/src/index.ts +++ b/plugins/auth-backend/src/index.ts @@ -15,6 +15,7 @@ */ export * from './service/router'; +export { BackstageIdentityStrategy } from './lib/passport'; export * from './providers'; // flow package provides 2 functions diff --git a/plugins/auth-backend/src/lib/passport/BackstageIdentityStrategy.ts b/plugins/auth-backend/src/lib/passport/BackstageIdentityStrategy.ts new file mode 100644 index 0000000000..8f98583e55 --- /dev/null +++ b/plugins/auth-backend/src/lib/passport/BackstageIdentityStrategy.ts @@ -0,0 +1,72 @@ +import { Request } from 'express'; +import { JWK, JWT, JWKS } from 'jose'; +import { BackstageIdentity } from '../../providers'; +import { IdentityClient } from '../../identity'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; + +const Strategy = require('passport-strategy'); +// Using singleton keyStore instead of membership due to Passport wtf +let keyStore: JWKS.KeyStore; +let keyStoreUpdated: number; + +export class BackstageIdentityStrategy extends Strategy { + private readonly client: IdentityClient; + private readonly discovery: PluginEndpointDiscovery; + + constructor(options: { discovery: PluginEndpointDiscovery }) { + super(); + this.client = new IdentityClient({ discovery: options.discovery }); + this.discovery = options.discovery; + this.name = 'backstage'; + } + + private async refreshKeyStore(rawJwtToken: string) { + const { header, payload } = JWT.decode(rawJwtToken, { + complete: true, + }) as { + header: { kid: string }; + payload: { iat: number }; + }; + // Refresh public keys from identity if needed + if ( + !keyStore || + (!keyStore.get({ kid: header.kid }) && + payload?.iat && + payload.iat > keyStoreUpdated) + ) { + const now = Date.now() / 1000; + const publicKeys = await this.client.listPublicKeys(); + keyStore = new JWKS.KeyStore(publicKeys.keys.map(key => JWK.asKey(key))); + keyStoreUpdated = now; + } + } + + async authenticate(req: Request) { + if ( + !req.headers.authorization || + !req.headers.authorization.startsWith('Bearer ') + ) { + this.fail(new Error('No bearer token found in authorization header')); + } + + try { + const token = req!.headers!.authorization!.substring(7); + const issuer = await this.discovery.getExternalBaseUrl('auth'); + await this.refreshKeyStore(token); + const decoded = JWT.IdToken.verify(token, keyStore, { + algorithms: ['ES256'], + audience: 'backstage', + issuer, + }) as { sub: string }; + // Verified, forward BackstageIdentity to req.user + const user: BackstageIdentity = { + id: decoded.sub, + idToken: token, + }; + this.success(user); + } catch (error) { + // JWT verification failed + this.fail(error); + } + } +} diff --git a/plugins/auth-backend/src/lib/passport/index.ts b/plugins/auth-backend/src/lib/passport/index.ts index c307e212fa..9e8b528ac4 100644 --- a/plugins/auth-backend/src/lib/passport/index.ts +++ b/plugins/auth-backend/src/lib/passport/index.ts @@ -22,3 +22,4 @@ export { makeProfileInfo, } from './PassportStrategyHelper'; export type { PassportDoneCallback } from './PassportStrategyHelper'; +export { BackstageIdentityStrategy } from './BackstageIdentityStrategy'; diff --git a/yarn.lock b/yarn.lock index 6710c7e8e7..36d5838364 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19984,7 +19984,7 @@ passport-saml@^2.0.0: xmlbuilder "^11.0.0" xmldom "0.1.x" -passport-strategy@*, passport-strategy@1.x.x: +passport-strategy@*, passport-strategy@1.x.x, passport-strategy@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4" integrity sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=