diff --git a/plugins/auth-backend/migrations/20200619125845_init.js b/plugins/auth-backend/migrations/20200619125845_init.js new file mode 100644 index 0000000000..295e4eea40 --- /dev/null +++ b/plugins/auth-backend/migrations/20200619125845_init.js @@ -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'); +}; diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index d22a5ef6d6..bee3b3c8b5 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -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": [ diff --git a/plugins/auth-backend/src/identity/DatabaseKeyStore.ts b/plugins/auth-backend/src/identity/DatabaseKeyStore.ts new file mode 100644 index 0000000000..d3ea588c14 --- /dev/null +++ b/plugins/auth-backend/src/identity/DatabaseKeyStore.ts @@ -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 { + 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 { + this.logger.info(`Storing public key ${key.kid}`); + + await this.database(TABLE).insert({ + kid: key.kid, + key: JSON.stringify(key), + }); + console.log(`DEBUG: stored key`); + } + + async listPublicKeys(): Promise { + const rows = await this.database(TABLE).select(); + console.log('DEBUG: rows =', rows); + return rows.map(row => JSON.parse(row.key)); + } +} diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts new file mode 100644 index 0000000000..3df962bf22 --- /dev/null +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -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; + + 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 { + 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 { + 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; + } +} diff --git a/plugins/auth-backend/src/identity/index.ts b/plugins/auth-backend/src/identity/index.ts new file mode 100644 index 0000000000..19eec94556 --- /dev/null +++ b/plugins/auth-backend/src/identity/index.ts @@ -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'; diff --git a/plugins/auth-backend/src/identity/router.ts b/plugins/auth-backend/src/identity/router.ts new file mode 100644 index 0000000000..a52e632ec2 --- /dev/null +++ b/plugins/auth-backend/src/identity/router.ts @@ -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; +} diff --git a/plugins/auth-backend/src/identity/types.ts b/plugins/auth-backend/src/identity/types.ts new file mode 100644 index 0000000000..59221e8d81 --- /dev/null +++ b/plugins/auth-backend/src/identity/types.ts @@ -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 { + use: 'sig' | 'enc'; + alg: string; + kid: string; + kty: string; +} + +export type KeyStore = { + addPublicKey(key: PublicKey): Promise; + + listPublicKeys(): Promise; +}; + +export type TokenParams = { + sub: string; +}; + +export type TokenIssuer = { + issueToken(claims: TokenParams): Promise; +}; diff --git a/yarn.lock b/yarn.lock index c18da809e9..c924d383c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"