diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index c467ccefa1..009b4e13af 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -22,6 +22,7 @@ * Happy hacking! */ +import { Request, Response, NextFunction } from 'express'; import Router from 'express-promise-router'; import { createServiceBuilder, @@ -34,7 +35,7 @@ import { useHotMemoize, } from '@backstage/backend-common'; import { Config } from '@backstage/config'; -import { BackstageIdentityStrategy } from '@backstage/plugin-auth-backend'; +import { IdentityClient } from '@backstage/plugin-auth-backend'; import healthcheck from './plugins/healthcheck'; import auth from './plugins/auth'; import catalog from './plugins/catalog'; @@ -46,7 +47,6 @@ 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(); @@ -82,26 +82,34 @@ 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 discovery = SingleHostDiscovery.fromConfig(config); + const identity = new IdentityClient({ + discovery, + issuer: await discovery.getExternalBaseUrl('auth'), }); + const authMiddleware = async ( + req: Request, + res: Response, + next: NextFunction, + ) => { + try { + req.user = await identity.authenticate(req.headers.authorization); + next(); + } catch (error) { + res.status(401).send(`Unauthorized`); + } + }; const apiRouter = Router(); - 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('/catalog', authMiddleware, await catalog(catalogEnv)); + apiRouter.use('/rollbar', authMiddleware, await rollbar(rollbarEnv)); + apiRouter.use('/scaffolder', authMiddleware, await scaffolder(scaffolderEnv)); apiRouter.use('/auth', await auth(authEnv)); - 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()); + apiRouter.use('/techdocs', authMiddleware, await techdocs(techdocsEnv)); + apiRouter.use('/kubernetes', authMiddleware, await kubernetes(kubernetesEnv)); + apiRouter.use('/proxy', authMiddleware, await proxy(proxyEnv)); + apiRouter.use('/graphql', authMiddleware, await graphql(graphqlEnv)); + apiRouter.use(authMiddleware, notFoundHandler()); const service = createServiceBuilder(module) .loadConfig(config) diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index ff9b6db5cb..8f88c69487 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -59,7 +59,6 @@ "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 index 4acaf335a4..7e208fed56 100644 --- a/plugins/auth-backend/src/identity/IdentityClient.test.ts +++ b/plugins/auth-backend/src/identity/IdentityClient.test.ts @@ -39,7 +39,7 @@ describe('IdentityClient', () => { afterEach(() => server.resetHandlers()); beforeEach(() => { - client = new IdentityClient({ discovery }); + client = new IdentityClient({ discovery, issuer: mockBaseUrl }); }); describe('listPublicKeys', () => { diff --git a/plugins/auth-backend/src/identity/IdentityClient.ts b/plugins/auth-backend/src/identity/IdentityClient.ts index 2d1dd40b6c..610d43f7ef 100644 --- a/plugins/auth-backend/src/identity/IdentityClient.ts +++ b/plugins/auth-backend/src/identity/IdentityClient.ts @@ -15,17 +15,97 @@ */ import fetch from 'cross-fetch'; -import { JWKECKey } from 'jose'; +import { JWK, JWT, JWKS, JWKECKey } from 'jose'; +import { BackstageIdentity } from '../providers'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; /** - * A identity client to interact with auth-backend. + * A identity client to interact with auth-backend + * and authenticate backstage identity tokens */ export class IdentityClient { private readonly discovery: PluginEndpointDiscovery; + private readonly issuer: string; + private keyStore: JWKS.KeyStore; + private keyStoreUpdated: number; - constructor(options: { discovery: PluginEndpointDiscovery }) { + constructor(options: { discovery: PluginEndpointDiscovery; issuer: string }) { this.discovery = options.discovery; + this.issuer = options.issuer; + this.keyStore = new JWKS.KeyStore(); + this.keyStoreUpdated = 0; + } + + /** + * Verifies the given backstage identity token + * (provided as a Bearer token in the authorization header). + * Returns a BackstageIdentity (user) matching the token. + * The method throws an error if verification fails. + */ + async authenticate( + authorizationHeader: string | undefined, + ): Promise { + // Extract token from header + const token = IdentityClient.getBearerToken(authorizationHeader); + if (!token) { + throw new Error('No bearer token found in authorization header'); + } + // Get signing key matching token + const key = await this.getKey(token); + if (!key) { + throw new Error('No signing key matching token found'); + } + // Verify token claims and signature + // Note: Claims must match those set by TokenFactory when issuing tokens + // Note: verify throws if verification fails + const decoded = JWT.IdToken.verify(token, key, { + algorithms: ['ES256'], + audience: 'backstage', + issuer: this.issuer, + }) as { sub: string }; + // Verified, return the matching user as BackstageIdentity + // TODO: Settle internal user format/properties + const user: BackstageIdentity = { + id: decoded.sub, + idToken: token, + }; + return user; + } + + /** + * Parses the given authorization header and returns + * the bearer token, or null if no bearer token is given + */ + static getBearerToken( + authorizationHeader: string | undefined, + ): string | null { + if (typeof authorizationHeader !== 'string') { + return null; + } + const matches = authorizationHeader.match(/Bearer\s+(\S+)/i); + return matches && matches[1]; + } + + /** + * Returns the public signing key matching the given jwt token, + * or null if no matching key was found + */ + private async getKey(rawJwtToken: string): Promise { + const { header, payload } = JWT.decode(rawJwtToken, { + complete: true, + }) as { + header: { kid: string }; + payload: { iat: number }; + }; + // Refresh public keys if needed + if ( + !this.keyStore.get({ kid: header.kid }) && + payload?.iat && + payload.iat > this.keyStoreUpdated + ) { + await this.refreshKeyStore(); + } + return this.keyStore.get({ kid: header.kid }); } /** @@ -49,4 +129,16 @@ export class IdentityClient { return publicKeys; } + + /** + * Fetches public keys and caches them locally + */ + private async refreshKeyStore(): Promise { + const now = Date.now() / 1000; + const publicKeys = await this.listPublicKeys(); + this.keyStore = new JWKS.KeyStore( + publicKeys.keys.map(key => JWK.asKey(key)), + ); + this.keyStoreUpdated = now; + } } diff --git a/plugins/auth-backend/src/index.ts b/plugins/auth-backend/src/index.ts index 892842670d..a352362089 100644 --- a/plugins/auth-backend/src/index.ts +++ b/plugins/auth-backend/src/index.ts @@ -15,7 +15,7 @@ */ export * from './service/router'; -export { BackstageIdentityStrategy } from './lib/passport'; +export { IdentityClient } from './identity'; 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 deleted file mode 100644 index 7635e1ec40..0000000000 --- a/plugins/auth-backend/src/lib/passport/BackstageIdentityStrategy.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * 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 { 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'; - } - - // TODO(erilar): Move to its own module (IdentityClient?) and add tests - 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 - // TODO: Settle internal user format/properties - 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 9e8b528ac4..c307e212fa 100644 --- a/plugins/auth-backend/src/lib/passport/index.ts +++ b/plugins/auth-backend/src/lib/passport/index.ts @@ -22,4 +22,3 @@ export { makeProfileInfo, } from './PassportStrategyHelper'; export type { PassportDoneCallback } from './PassportStrategyHelper'; -export { BackstageIdentityStrategy } from './BackstageIdentityStrategy'; diff --git a/yarn.lock b/yarn.lock index 36d5838364..6710c7e8e7 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@^1.0.0: +passport-strategy@*, passport-strategy@1.x.x: version "1.0.0" resolved "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4" integrity sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=