auth-backend: initial identity provider implementation

This commit is contained in:
Patrik Oldsberg
2020-06-18 12:56:54 +02:00
parent cddfff19c8
commit 0a305d7f70
8 changed files with 354 additions and 1 deletions
@@ -0,0 +1,49 @@
/*
* 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')
.notNullable()
.defaultTo(knex.fn.now())
.comment('The creation time of the key');
table
.string('key')
.notNullable()
.comment('The serialized public part of the signing key');
});
};
/**
* @param {import('knex')} knex
*/
exports.down = async function down(knex) {
return knex.schema.dropTable('auth_keystore');
};
+3 -1
View File
@@ -32,7 +32,9 @@
"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",
"morgan": "^1.10.0",
"passport": "^0.4.1",
"passport-github2": "^0.1.12",
@@ -46,10 +48,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,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.
*/
import Knex from 'knex';
import path from 'path';
import { Logger } from 'winston';
import { PublicKey } 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 = {
logger: Logger;
database: Knex;
};
export class DatabaseKeyStore {
static async create(options: Options): Promise<DatabaseKeyStore> {
const { logger, database } = options;
await database.migrate.latest({
directory: migrationsDir,
});
return new DatabaseKeyStore({ logger, database });
}
private readonly logger: Logger;
private readonly database: Knex;
private constructor(options: Options) {
const { logger, database } = options;
this.database = database;
this.logger = logger.child({ service: 'key-store' });
}
async addPublicKey(key: PublicKey): Promise<void> {
this.logger.info(`Storing public key ${key.kid}`);
await this.database<Row>(TABLE).insert({
kid: key.kid,
key: JSON.stringify(key),
});
console.log(`DEBUG: stored key`);
}
async listPublicKeys(): Promise<PublicKey[]> {
const rows = await this.database<Row>(TABLE).select();
console.log('DEBUG: rows =', rows);
return rows.map(row => JSON.parse(row.key));
}
}
@@ -0,0 +1,85 @@
/*
* 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 { TokenIssuer, TokenParams, KeyStore, PublicKey } from './types';
import { JSONWebKey, JWK, JWS } from 'jose';
import { Logger } from 'winston';
type Options = {
issuer: string;
logger: Logger;
keyStore: KeyStore;
};
export class TokenFactory implements TokenIssuer {
private readonly issuer: string;
private readonly logger: Logger;
private readonly keyStore: KeyStore;
private privateKey?: Promise<JSONWebKey>;
constructor(options: Options) {
const { issuer, logger, keyStore } = options;
this.issuer = issuer;
this.keyStore = keyStore;
this.logger = logger.child({ service: 'issuer' });
}
async issueToken(claims: TokenParams): Promise<string> {
const key = await this.getKey();
const iss = this.issuer;
const sub = claims.sub;
const aud = 'backstage';
const iat = (Date.now() / 1000) | 0;
const exp = iat + 3600;
this.logger.info(`Issuing token for ${sub}`);
return JWS.sign({ iss, sub, aud, iat, exp }, key, {
alg: key.alg,
kid: key.kid,
});
}
private async getKey(): Promise<JSONWebKey> {
if (this.privateKey) {
return this.privateKey;
}
this.privateKey = (async () => {
const dateStr = new Date().toISOString();
const randStr = Math.random().toString(36).slice(2, 6);
const kid = `key-${dateStr}-${randStr}`;
const key = await JWK.generate('EC', 'P-384', { use: 'sig', kid });
await this.keyStore.addPublicKey(
(key.toJWK(false) as unknown) as PublicKey,
);
return key as JSONWebKey;
})();
try {
await this.privateKey;
} catch (error) {
this.logger.error(`Failed to generate signing key, ${error}`);
}
return this.privateKey;
}
}
@@ -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,73 @@
/*
* 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 { Logger } from 'winston';
import { KeyStore } from './types';
export type Options = {
logger: Logger;
baseUrl: string;
keyStore: KeyStore;
};
export function createOidcRouter(options: Options) {
const {
logger: baseLogger,
keyStore,
baseUrl = 'http://localhost:7000/auth',
} = options;
const logger = baseLogger.child({ router: 'identity' });
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) => {
logger.info('request configuration');
res.json(config);
});
router.get('/v1/token', (_req, res) => {
logger.info('request token');
res.status(501).send('Not Implemented');
});
router.get('/v1/userinfo', (_req, res) => {
logger.info('request userinfo');
res.status(501).send('Not Implemented');
});
router.get('/v1/certs', async (_req, res) => {
logger.info('request certs');
const keys = await keyStore.listPublicKeys();
res.json(keys);
});
return router;
}
@@ -0,0 +1,36 @@
/*
* 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 interface PublicKey extends Record<string, string> {
use: 'sig' | 'enc';
alg: string;
kid: string;
kty: string;
}
export type KeyStore = {
addPublicKey(key: PublicKey): Promise<void>;
listPublicKeys(): Promise<PublicKey[]>;
};
export type TokenParams = {
sub: string;
};
export type TokenIssuer = {
issueToken(claims: TokenParams): Promise<string>;
};
+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"
@@ -11697,6 +11702,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"