Merge pull request #1380 from spotify/rugvip/oidc

auth-backend: Add lightweight OpenID Provider discovery and token issuing
This commit is contained in:
Patrik Oldsberg
2020-06-22 11:26:20 +02:00
committed by GitHub
21 changed files with 797 additions and 79 deletions
+2 -1
View File
@@ -19,7 +19,8 @@ import { PluginEnvironment } from '../types';
export default async function createPlugin({
logger,
database,
config,
}: PluginEnvironment) {
return await createRouter({ logger, config });
return await createRouter({ logger, config, database });
}
@@ -0,0 +1,46 @@
/*
* 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.
*/
// @ts-check
/**
* @param {import('knex')} knex
*/
exports.up = async function up(knex) {
return knex.schema.createTable('signing_keys', table => {
table.comment(
'Signing keys that are currently in use or have recently been used to issue tokens',
);
table
.string('kid')
.primary()
.notNullable()
.comment('ID of the signing key');
table
.timestamp('created_at', { useTz: false, precision: 0 })
.notNullable()
.defaultTo(knex.fn.now())
.comment('The creation time of the key');
table.string('key').notNullable().comment('The serialized signing key');
});
};
/**
* @param {import('knex')} knex
*/
exports.down = async function down(knex) {
return knex.schema.dropTable('auth_keystore');
};
+5 -1
View File
@@ -32,12 +32,16 @@
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.0",
"helmet": "^3.22.0",
"jose": "^1.27.1",
"jwt-decode": "2.2.0",
"knex": "^0.21.1",
"moment": "^2.26.0",
"morgan": "^1.10.0",
"passport": "^0.4.1",
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0",
"passport-saml": "^1.3.3",
"uuid": "^8.0.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
@@ -46,10 +50,10 @@
"@types/body-parser": "^1.19.0",
"@types/cookie-parser": "^1.4.2",
"@types/jwt-decode": "2.2.1",
"@types/passport": "^1.0.3",
"@types/passport-github2": "^1.2.4",
"@types/passport-google-oauth20": "^2.0.3",
"@types/passport-saml": "^1.1.2",
"@types/passport": "^1.0.3",
"jest-fetch-mock": "^3.0.3"
},
"files": [
@@ -0,0 +1,122 @@
/*
* 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 Knex from 'knex';
import moment from 'moment';
import { DatabaseKeyStore } from './DatabaseKeyStore';
function createDB() {
const knex = Knex({
client: 'sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
return knex;
}
const keyBase = {
use: 'sig',
kty: 'plain',
alg: 'Base64',
} as const;
describe('DatabaseKeyStore', () => {
it('should store a key', async () => {
const database = createDB();
const store = await DatabaseKeyStore.create({ database });
const key = {
kid: '123',
...keyBase,
};
await expect(store.listKeys()).resolves.toEqual({ items: [] });
await store.addKey(key);
const { items } = await store.listKeys();
expect(items).toEqual([{ createdAt: expect.anything(), key }]);
expect(Math.abs(items[0].createdAt.diff(moment(), 's'))).toBeLessThan(10);
});
it('should remove stored keys', async () => {
const database = createDB();
const store = await DatabaseKeyStore.create({ database });
const key1 = { kid: '1', ...keyBase };
const key2 = { kid: '2', ...keyBase };
const key3 = { kid: '3', ...keyBase };
await store.addKey(key1);
await store.addKey(key2);
await store.addKey(key3);
await expect(store.listKeys()).resolves.toEqual({
items: [
{ key: key1, createdAt: expect.anything() },
{ key: key2, createdAt: expect.anything() },
{ key: key3, createdAt: expect.anything() },
],
});
store.removeKeys(['1']);
await expect(store.listKeys()).resolves.toEqual({
items: [
{ key: key2, createdAt: expect.anything() },
{ key: key3, createdAt: expect.anything() },
],
});
store.removeKeys(['1', '2']);
await expect(store.listKeys()).resolves.toEqual({
items: [{ key: key3, createdAt: expect.anything() }],
});
store.removeKeys([]);
await expect(store.listKeys()).resolves.toEqual({
items: [{ key: key3, createdAt: expect.anything() }],
});
store.removeKeys(['3', '4']);
await expect(store.listKeys()).resolves.toEqual({
items: [],
});
await store.addKey(key1);
await store.addKey(key2);
await store.addKey(key3);
await expect(store.listKeys()).resolves.toEqual({
items: [
{ key: key1, createdAt: expect.anything() },
{ key: key2, createdAt: expect.anything() },
{ key: key3, createdAt: expect.anything() },
],
});
store.removeKeys(['1', '2', '3']);
await expect(store.listKeys()).resolves.toEqual({
items: [],
});
});
});
@@ -0,0 +1,77 @@
/*
* 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 Knex from 'knex';
import path from 'path';
import { utc } from 'moment';
import { AnyJWK, KeyStore, StoredKey } from './types';
const migrationsDir = path.resolve(
require.resolve('@backstage/plugin-auth-backend/package.json'),
'../migrations',
);
const TABLE = 'signing_keys';
type Row = {
created_at: Date;
kid: string;
key: string;
};
type Options = {
database: Knex;
};
export class DatabaseKeyStore implements KeyStore {
static async create(options: Options): Promise<DatabaseKeyStore> {
const { database } = options;
await database.migrate.latest({
directory: migrationsDir,
});
return new DatabaseKeyStore(options);
}
private readonly database: Knex;
private constructor(options: Options) {
this.database = options.database;
}
async addKey(key: AnyJWK): Promise<void> {
await this.database<Row>(TABLE).insert({
kid: key.kid,
key: JSON.stringify(key),
});
}
async listKeys(): Promise<{ items: StoredKey[] }> {
const rows = await this.database<Row>(TABLE).select();
return {
items: rows.map(row => ({
key: JSON.parse(row.key),
createdAt: utc(row.created_at),
})),
};
}
async removeKeys(kids: string[]): Promise<void> {
await this.database(TABLE).delete().whereIn('kid', kids);
}
}
@@ -0,0 +1,133 @@
/*
* 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 { utc } from 'moment';
import { TokenFactory } from './TokenFactory';
import { getVoidLogger } from '@backstage/backend-common';
import { KeyStore, AnyJWK, StoredKey } from './types';
import { JWKS, JSONWebKey, JWT } from 'jose';
const logger = getVoidLogger();
class MemoryKeyStore implements KeyStore {
private readonly keys = new Map<
string,
{ createdAt: moment.Moment; key: string }
>();
async addKey(key: AnyJWK): Promise<void> {
this.keys.set(key.kid, {
createdAt: utc(),
key: JSON.stringify(key),
});
}
async removeKeys(kids: string[]): Promise<void> {
for (const kid of kids) {
this.keys.delete(kid);
}
}
async listKeys(): Promise<{ items: StoredKey[] }> {
return {
items: Array.from(this.keys).map(([, { createdAt, key: keyStr }]) => ({
createdAt,
key: JSON.parse(keyStr),
})),
};
}
}
function jwtKid(jwt: string): string {
const { header } = JWT.decode(jwt, { complete: true }) as {
header: { kid: string };
};
return header.kid;
}
describe('TokenFactory', () => {
it('should issue valid tokens signed by a listed key', async () => {
const keyDurationSeconds = 5;
const factory = new TokenFactory({
issuer: 'my-issuer',
keyStore: new MemoryKeyStore(),
keyDurationSeconds,
logger,
});
await expect(factory.listPublicKeys()).resolves.toEqual({ keys: [] });
const token = await factory.issueToken({ claims: { sub: 'foo' } });
const { keys } = await factory.listPublicKeys();
const keyStore = JWKS.asKeyStore({
keys: keys.map(key => key as JSONWebKey),
});
const payload = JWT.verify(token, keyStore) as object & {
iat: number;
exp: number;
};
expect(payload).toEqual({
iss: 'my-issuer',
aud: 'backstage',
sub: 'foo',
iat: expect.any(Number),
exp: expect.any(Number),
});
expect(payload.exp).toBe(payload.iat + keyDurationSeconds * 1000);
});
it('should generate new signing keys when the current one expires', async () => {
const fixedTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => fixedTime);
const factory = new TokenFactory({
issuer: 'my-issuer',
keyStore: new MemoryKeyStore(),
keyDurationSeconds: 5,
logger,
});
const token1 = await factory.issueToken({ claims: { sub: 'foo' } });
const token2 = await factory.issueToken({ claims: { sub: 'foo' } });
expect(jwtKid(token1)).toBe(jwtKid(token2));
await expect(factory.listPublicKeys()).resolves.toEqual({
keys: [
expect.objectContaining({
kid: jwtKid(token1),
}),
],
});
jest.spyOn(Date, 'now').mockImplementation(() => fixedTime + 60000);
await expect(factory.listPublicKeys()).resolves.toEqual({
keys: [],
});
const token3 = await factory.issueToken({ claims: { sub: 'foo' } });
expect(jwtKid(token3)).not.toBe(jwtKid(token2));
await expect(factory.listPublicKeys()).resolves.toEqual({
keys: [
expect.objectContaining({
kid: jwtKid(token3),
}),
],
});
});
});
@@ -0,0 +1,163 @@
/*
* 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 moment from 'moment';
import { TokenIssuer, TokenParams, KeyStore, AnyJWK } from './types';
import { JSONWebKey, JWK, JWS } from 'jose';
import { Logger } from 'winston';
import { v4 as uuid } from 'uuid';
const MS_IN_S = 1000;
type Options = {
logger: Logger;
/** Value of the issuer claim in issued tokens */
issuer: string;
/** Key store used for storing signing keys */
keyStore: KeyStore;
/** Expiration time of signing keys in seconds */
keyDurationSeconds: number;
};
/**
* A token issuer that is able to issue tokens in a distributed system
* backed by a single database. Tokens are issued using lazily generated
* signing keys, where each running instance of the auth service uses its own
* signing key.
*
* The public parts of the keys are all stored in the shared key storage,
* and any of the instances of the auth service will return the full list
* of public keys that are currently in storage.
*
* Signing keys are automatically rotated at the same interval as the token
* duration. Expired keys are kept in storage until there are no valid tokens
* in circulation that could have been signed by that key.
*/
export class TokenFactory implements TokenIssuer {
private readonly issuer: string;
private readonly logger: Logger;
private readonly keyStore: KeyStore;
private readonly keyDurationSeconds: number;
private keyExpiry?: moment.Moment;
private privateKeyPromise?: Promise<JSONWebKey>;
constructor(options: Options) {
this.issuer = options.issuer;
this.logger = options.logger;
this.keyStore = options.keyStore;
this.keyDurationSeconds = options.keyDurationSeconds;
}
async issueToken(params: TokenParams): Promise<string> {
const key = await this.getKey();
const iss = this.issuer;
const sub = params.claims.sub;
const aud = 'backstage';
const iat = Math.floor(Date.now() / MS_IN_S);
const exp = iat + this.keyDurationSeconds * MS_IN_S;
this.logger.info(`Issuing token for ${sub}`);
return JWS.sign({ iss, sub, aud, iat, exp }, key, {
alg: key.alg,
kid: key.kid,
});
}
// This will be called by other services that want to verify ID tokens.
// It is important that it returns a list of all public keys that could
// have been used to sign tokens that have not yet expired.
async listPublicKeys(): Promise<{ keys: AnyJWK[] }> {
const { items: keys } = await this.keyStore.listKeys();
const validKeys = [];
const expiredKeys = [];
for (const key of keys) {
// Allow for a grace period of another full key duration before we remove the keys from the database
const expireAt = key.createdAt.add(3 * this.keyDurationSeconds, 's');
if (expireAt.isBefore()) {
expiredKeys.push(key);
} else {
validKeys.push(key);
}
}
// Lazily prune expired keys. This may cause duplicate removals if we have concurrent callers, but w/e
if (expiredKeys.length > 0) {
const kids = expiredKeys.map(({ key }) => key.kid);
this.logger.info(`Removing expired signing keys, '${kids.join("', '")}'`);
// We don't await this, just let it run in the background
this.keyStore.removeKeys(kids).catch(error => {
this.logger.error(`Failed to remove expired keys, ${error}`);
});
}
// NOTE: we're currently only storing public keys, but if we start storing private keys we'd have to convert here
return { keys: validKeys.map(({ key }) => key) };
}
private async getKey(): Promise<JSONWebKey> {
// Make sure that we only generate one key at a time
if (this.privateKeyPromise) {
if (this.keyExpiry?.isAfter()) {
return this.privateKeyPromise;
}
this.logger.info(`Signing key has expired, generating new key`);
delete this.privateKeyPromise;
}
this.keyExpiry = moment().add(this.keyDurationSeconds, 'seconds');
const promise = (async () => {
// This generates a new signing key to be used to sign tokens until the next key rotation
const key = await JWK.generate('EC', 'P-256', {
use: 'sig',
kid: uuid(),
alg: 'ES256',
});
// We're not allowed to use the key until it has been successfully stored
// TODO: some token verification implementations aggressively cache the list of keys, and
// don't attempt to fetch new ones even if they encounter an unknown kid. Therefore we
// may want to keep using the existing key for some period of time until we switch to
// the new one. This also needs to be implemented cross-service though, meaning new services
// that boot up need to be able to grab an existing key to use for signing.
this.logger.info(`Created new signing key ${key.kid}`);
await this.keyStore.addKey((key.toJWK(false) as unknown) as AnyJWK);
// At this point we are allowed to start using the new key
return key as JSONWebKey;
})();
this.privateKeyPromise = promise;
try {
// If we fail to generate a new key, we need to clear the state so that
// the next caller will try to generate another key.
await promise;
} catch (error) {
this.logger.error(`Failed to generate new signing key, ${error}`);
delete this.keyExpiry;
delete this.privateKeyPromise;
}
return promise;
}
}
@@ -0,0 +1,20 @@
/*
* 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.
*/
export { createOidcRouter } from './router';
export { TokenFactory } from './TokenFactory';
export { DatabaseKeyStore } from './DatabaseKeyStore';
export type { KeyStore, TokenIssuer, TokenParams } from './types';
@@ -0,0 +1,62 @@
/*
* 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 Router from 'express-promise-router';
import { TokenIssuer } from './types';
export type Options = {
baseUrl: string;
tokenIssuer: TokenIssuer;
};
export function createOidcRouter(options: Options) {
const { baseUrl, tokenIssuer } = options;
const router = Router();
const config = {
issuer: baseUrl,
token_endpoint: `${baseUrl}/v1/token`,
userinfo_endpoint: `${baseUrl}/v1/userinfo`,
jwks_uri: `${baseUrl}/v1/certs`,
response_types_supported: ['id_token'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
scopes_supported: ['openid'],
token_endpoint_auth_methods_supported: [],
claims_supported: ['sub'],
grant_types_supported: [],
};
router.get('/.well-known/openid-configuration', (_req, res) => {
res.json(config);
});
router.get('/.well-known/jwks.json', async (_req, res) => {
const { keys } = await tokenIssuer.listPublicKeys();
res.json({ keys });
});
router.get('/v1/token', (_req, res) => {
res.status(501).send('Not Implemented');
});
router.get('/v1/userinfo', (_req, res) => {
res.status(501).send('Not Implemented');
});
return router;
}
@@ -0,0 +1,76 @@
/*
* 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.
*/
/** Represents any form of serializable JWK */
export interface AnyJWK extends Record<string, string> {
use: 'sig';
alg: string;
kid: string;
kty: string;
}
/** Parameters used to issue new ID Tokens */
export type TokenParams = {
/** The claims that will be embedded within the token */
claims: {
/** The token subject, i.e. User ID */
sub: string;
};
};
/**
* A TokenIssuer is able to issue verifiable ID Tokens on demand.
*/
export type TokenIssuer = {
/**
* Issues a new ID Token
*/
issueToken(params: TokenParams): Promise<string>;
/**
* List all public keys that are currently being used to sign tokens, or have been used
* in the past within the token expiration time, including a grace period.
*/
listPublicKeys(): Promise<{ keys: AnyJWK[] }>;
};
/**
* A JWK stored by a KeyStore
*/
export type StoredKey = {
key: AnyJWK;
createdAt: moment.Moment;
};
/**
* A KeyStore stores JWKs for later and shared use.
*/
export type KeyStore = {
/**
* Store a new key to be used for signing.
*/
addKey(key: AnyJWK): Promise<void>;
/**
* Remove all keys with the provided kids.
*/
removeKeys(kids: string[]): Promise<void>;
/**
* List all stored keys.
*/
listKeys(): Promise<{ items: StoredKey[] }>;
};
@@ -180,6 +180,10 @@ describe('OAuthProvider', () => {
disableRefresh: true,
baseUrl: 'http://localhost:7000/auth',
appOrigin: 'http://localhost:3000',
tokenIssuer: {
issueToken: async () => 'my-id-token',
listPublicKeys: async () => ({ keys: [] }),
},
};
it('sets the correct headers in start', async () => {
@@ -23,6 +23,7 @@ import {
OAuthProviderHandlers,
} from '../providers/types';
import { InputError } from '@backstage/backend-common';
import { TokenIssuer } from '../identity';
export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
export const TEN_MINUTES_MS = 600 * 1000;
@@ -33,6 +34,7 @@ export type Options = {
disableRefresh?: boolean;
baseUrl: string;
appOrigin: string;
tokenIssuer: TokenIssuer;
};
export const verifyNonce = (req: express.Request, providerId: string) => {
@@ -142,6 +144,10 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
this.setRefreshTokenCookie(res, refreshToken);
}
user.userIdToken = await this.options.tokenIssuer.issueToken({
claims: { sub: user.profile.email },
});
// post message back to popup if successful
return postMessageResponse(res, this.options.appOrigin, {
type: 'auth-result',
@@ -198,6 +204,11 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
refreshToken,
scope,
);
refreshInfo.userIdToken = await this.options.tokenIssuer.issueToken({
claims: { sub: refreshInfo.profile?.email },
});
return res.send(refreshInfo);
} catch (error) {
return res.status(401).send(`${error.message}`);
@@ -20,6 +20,7 @@ import { createGoogleProvider } from './google';
import { createSamlProvider } from './saml';
import { AuthProviderFactory, AuthProviderConfig } from './types';
import { Logger } from 'winston';
import { TokenIssuer } from '../identity';
const factories: { [providerId: string]: AuthProviderFactory } = {
google: createGoogleProvider,
@@ -32,13 +33,14 @@ export const createAuthProviderRouter = (
globalConfig: AuthProviderConfig,
providerConfig: any, // TODO: make this a config reader object of sorts
logger: Logger,
issuer: TokenIssuer,
) => {
const factory = factories[providerId];
if (!factory) {
throw Error(`No auth provider available for '${providerId}'`);
}
const provider = factory(globalConfig, providerConfig, logger);
const provider = factory(globalConfig, providerConfig, logger, issuer);
const router = Router();
router.get('/start', provider.start.bind(provider));
@@ -36,6 +36,7 @@ import {
EnvironmentHandler,
} from '../../lib/EnvironmentHandler';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
export class GithubAuthProvider implements OAuthProviderHandlers {
private readonly _strategy: GithubStrategy;
@@ -69,6 +70,7 @@ export function createGithubProvider(
{ baseUrl }: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
tokenIssuer: TokenIssuer,
) {
const envProviders: EnvironmentHandlers = {};
@@ -101,6 +103,7 @@ export function createGithubProvider(
secure,
baseUrl,
appOrigin,
tokenIssuer,
});
}
return new EnvironmentHandler(envProviders);
@@ -41,6 +41,7 @@ import {
EnvironmentHandlers,
} from '../../lib/EnvironmentHandler';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
export class GoogleAuthProvider implements OAuthProviderHandlers {
private readonly _strategy: GoogleStrategy;
@@ -116,6 +117,7 @@ export function createGoogleProvider(
{ baseUrl }: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
tokenIssuer: TokenIssuer,
) {
const envProviders: EnvironmentHandlers = {};
@@ -148,6 +150,7 @@ export function createGoogleProvider(
secure,
baseUrl,
appOrigin,
tokenIssuer,
});
}
return new EnvironmentHandler(envProviders);
@@ -16,6 +16,7 @@
import express from 'express';
import { Logger } from 'winston';
import { TokenIssuer } from '../identity';
export type OAuthProviderOptions = {
/**
@@ -180,6 +181,7 @@ export type AuthProviderFactory = (
globalConfig: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
issuer: TokenIssuer,
) => AuthProviderRouteHandlers;
export type AuthInfoBase = {
+3 -4
View File
@@ -14,15 +14,12 @@
* limitations under the License.
*/
import yn from 'yn';
import { getRootLogger } from '@backstage/backend-common';
import { startStandaloneServer } from './service/standaloneServer';
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3003;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
startStandaloneServer({ port, enableCors, logger }).catch(err => {
startStandaloneServer({ logger }).catch(err => {
logger.error(err);
process.exit(1);
});
@@ -31,3 +28,5 @@ process.on('SIGINT', () => {
logger.info('CTRL+C pressed; exiting.');
process.exit(0);
});
module.hot?.accept();
+25 -1
View File
@@ -18,12 +18,15 @@ import express from 'express';
import Router from 'express-promise-router';
import cookieParser from 'cookie-parser';
import bodyParser from 'body-parser';
import Knex from 'knex';
import { Logger } from 'winston';
import { createAuthProviderRouter } from '../providers';
import { Config } from '@backstage/config';
import { DatabaseKeyStore, TokenFactory, createOidcRouter } from '../identity';
export interface RouterOptions {
logger: Logger;
database: Knex;
config: Config;
}
@@ -33,6 +36,19 @@ export async function createRouter(
const router = Router();
const logger = options.logger.child({ plugin: 'auth' });
const baseUrl = `${options.config.getString('backend.baseUrl')}/auth`;
const keyDurationSeconds = 3600;
const keyStore = await DatabaseKeyStore.create({
database: options.database,
});
const tokenIssuer = new TokenFactory({
issuer: baseUrl,
keyStore,
keyDurationSeconds,
logger: logger.child({ component: 'token-factory' }),
});
router.use(cookieParser());
router.use(bodyParser.urlencoded({ extended: false }));
router.use(bodyParser.json());
@@ -79,7 +95,6 @@ export async function createRouter(
const providerConfigs = config.auth.providers;
for (const [providerId, providerConfig] of Object.entries(providerConfigs)) {
const baseUrl = `${options.config.getString('backend.baseUrl')}/auth`;
logger.info(`Configuring provider, ${providerId}`);
try {
const providerRouter = createAuthProviderRouter(
@@ -87,11 +102,20 @@ export async function createRouter(
{ baseUrl },
providerConfig,
logger,
tokenIssuer,
);
router.use(`/${providerId}`, providerRouter);
} catch (e) {
logger.error(e.message);
}
}
router.use(
createOidcRouter({
tokenIssuer,
baseUrl,
}),
);
return router;
}
@@ -1,54 +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 {
errorHandler,
notFoundHandler,
requestLoggingHandler,
} from '@backstage/backend-common';
import { Config } from '@backstage/config';
import compression from 'compression';
import cors from 'cors';
import express from 'express';
import helmet from 'helmet';
import { Logger } from 'winston';
import { createRouter } from './router';
export interface ApplicationOptions {
enableCors: boolean;
logger: Logger;
config: Config;
}
export async function createStandaloneApplication(
options: ApplicationOptions,
): Promise<express.Application> {
const { enableCors, logger, config } = options;
const app = express();
app.use(helmet());
if (enableCors) {
app.use(cors());
}
app.use(compression());
app.use(express.json());
app.use(requestLoggingHandler());
app.use('/', await createRouter({ logger, config }));
app.use(notFoundHandler());
app.use(errorHandler());
return app;
}
@@ -14,15 +14,15 @@
* limitations under the License.
*/
import Knex from 'knex';
import { Server } from 'http';
import { Logger } from 'winston';
import { createStandaloneApplication } from './standaloneApplication';
import { ConfigReader } from '@backstage/config';
import { loadConfig } from '@backstage/config-loader';
import { createRouter } from './router';
import { createServiceBuilder, useHotMemoize } from '@backstage/backend-common';
export interface ServerOptions {
port: number;
enableCors: boolean;
logger: Logger;
}
@@ -32,23 +32,31 @@ export async function startStandaloneServer(
const logger = options.logger.child({ service: 'auth-backend' });
const config = ConfigReader.fromConfigs(await loadConfig());
logger.debug('Creating application...');
const app = await createStandaloneApplication({
enableCors: options.enableCors,
logger,
config,
const database = useHotMemoize(module, () => {
const knex = Knex({
client: 'sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
return knex;
});
logger.debug('Starting application server...');
return await new Promise((resolve, reject) => {
const server = app.listen(options.port, (err?: Error) => {
if (err) {
reject(err);
return;
}
const router = await createRouter({
logger,
config,
database,
});
logger.info(`Listening on port ${options.port}`);
resolve(server);
});
const service = createServiceBuilder(module)
.enableCors({ origin: 'http://localhost:3000', credentials: true })
.addRouter('/auth', router);
return await service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}
+12
View File
@@ -2411,6 +2411,11 @@
resolved "https://registry.npmjs.org/@open-draft/until/-/until-1.0.3.tgz#db9cc719191a62e7d9200f6e7bab21c5b848adca"
integrity sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q==
"@panva/asn1.js@^1.0.0":
version "1.0.0"
resolved "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz#dd55ae7b8129e02049f009408b97c61ccf9032f6"
integrity sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==
"@reach/router@^1.2.1":
version "1.3.3"
resolved "https://registry.npmjs.org/@reach/router/-/router-1.3.3.tgz#58162860dce6c9449d49be86b0561b5ef46d80db"
@@ -11692,6 +11697,13 @@ jest@^26.0.1:
import-local "^3.0.2"
jest-cli "^26.0.1"
jose@^1.27.1:
version "1.27.1"
resolved "https://registry.npmjs.org/jose/-/jose-1.27.1.tgz#a1de2ecb5b3ae1ae28f0d9d0cc536349ada27ec8"
integrity sha512-VyHM6IJPw0TTGqHVNlPWg16/ASDPAmcChcLqSb3WNBvwWFoWPeFqlmAUCm8/oIG1GjZwAlUDuRKFfycowarcVA==
dependencies:
"@panva/asn1.js" "^1.0.0"
js-cookie@^2.2.1:
version "2.2.1"
resolved "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8"