Refactored away from passport into IdentityClient
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('IdentityClient', () => {
|
||||
afterEach(() => server.resetHandlers());
|
||||
|
||||
beforeEach(() => {
|
||||
client = new IdentityClient({ discovery });
|
||||
client = new IdentityClient({ discovery, issuer: mockBaseUrl });
|
||||
});
|
||||
|
||||
describe('listPublicKeys', () => {
|
||||
|
||||
@@ -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<BackstageIdentity> {
|
||||
// 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<JWK.Key | null> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,4 +22,3 @@ export {
|
||||
makeProfileInfo,
|
||||
} from './PassportStrategyHelper';
|
||||
export type { PassportDoneCallback } from './PassportStrategyHelper';
|
||||
export { BackstageIdentityStrategy } from './BackstageIdentityStrategy';
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user