Refactored away from passport into IdentityClient

This commit is contained in:
Erik Larsson
2021-01-09 15:13:28 +01:00
parent 67e60126bb
commit c2926a6c0c
8 changed files with 124 additions and 116 deletions
-1
View File
@@ -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;
}
}
+1 -1
View File
@@ -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';