Optional identity token authorization of api requests

This commit is contained in:
Erik Larsson
2021-01-05 11:10:18 +01:00
parent 54dd7b3003
commit a2291d7cc5
10 changed files with 238 additions and 9 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': minor
---
Optional identity token authorization of api requests
+20 -8
View File
@@ -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)
+1
View File
@@ -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"
@@ -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);
});
});
});
@@ -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;
}
}
@@ -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';
+1
View File
@@ -15,6 +15,7 @@
*/
export * from './service/router';
export { BackstageIdentityStrategy } from './lib/passport';
export * from './providers';
// flow package provides 2 functions
@@ -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);
}
}
}
@@ -22,3 +22,4 @@ export {
makeProfileInfo,
} from './PassportStrategyHelper';
export type { PassportDoneCallback } from './PassportStrategyHelper';
export { BackstageIdentityStrategy } from './BackstageIdentityStrategy';
+1 -1
View File
@@ -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=