Merge pull request #4047 from backjo/feature/AwsAlbReverseProxyAuthentication

Add auth-backend provider for AWS ALB OIDC reverse proxy
This commit is contained in:
Patrik Oldsberg
2021-01-20 11:03:31 +01:00
committed by GitHub
11 changed files with 442 additions and 45 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Add AWS ALB OIDC reverse proxy authentication provider
+7 -1
View File
@@ -16,6 +16,11 @@
"url": "https://github.com/backstage/backstage",
"directory": "plugins/auth-backend"
},
"jest": {
"moduleNameMapper": {
"^jose/(.*)$": "<rootDir>/../../../node_modules/jose/dist/node/cjs/$1"
}
},
"keywords": [
"backstage"
],
@@ -44,11 +49,12 @@
"fs-extra": "^9.0.0",
"got": "^11.5.2",
"helmet": "^4.0.0",
"jose": "^1.27.1",
"jose": "^3.5.1",
"jwt-decode": "^3.1.0",
"knex": "^0.21.6",
"moment": "^2.26.0",
"morgan": "^1.10.0",
"node-cache": "^5.1.2",
"openid-client": "^4.2.1",
"passport": "^0.4.1",
"passport-github2": "^0.1.12",
@@ -13,12 +13,23 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TextDecoder, TextEncoder } from 'util';
// These two statements are structured like this because Jest doesn't include these in the default
// test environment, even though they exist in Node.
global.TextEncoder = TextEncoder;
// @ts-ignore
global.TextDecoder = TextDecoder;
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';
import { AnyJWK, KeyStore, StoredKey } from './types';
import jwtVerify from 'jose/jwt/verify';
import parseJwk from 'jose/jwk/parse';
import decodeProtectedHeader, {
ProtectedHeaderParameters,
} from 'jose/util/decode_protected_header';
const logger = getVoidLogger();
@@ -52,10 +63,8 @@ class MemoryKeyStore implements KeyStore {
}
function jwtKid(jwt: string): string {
const { header } = JWT.decode(jwt, { complete: true }) as {
header: { kid: string };
};
return header.kid;
const header = decodeProtectedHeader(jwt) as ProtectedHeaderParameters;
return header.kid as string;
}
describe('TokenFactory', () => {
@@ -72,14 +81,14 @@ describe('TokenFactory', () => {
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 keyMap = Object.fromEntries(keys.map(key => [key.kid, key]));
const payload = JWT.verify(token, keyStore) as object & {
iat: number;
exp: number;
};
const payload = (
await jwtVerify(token, async header => {
const kid = header.kid as string;
return await parseJwk(keyMap[kid]);
})
).payload;
expect(payload).toEqual({
iss: 'my-issuer',
aud: 'backstage',
@@ -87,7 +96,7 @@ describe('TokenFactory', () => {
iat: expect.any(Number),
exp: expect.any(Number),
});
expect(payload.exp).toBe(payload.iat + keyDurationSeconds);
expect(payload.exp).toBe(Number(payload.iat) + keyDurationSeconds);
});
it('should generate new signing keys when the current one expires', async () => {
@@ -16,7 +16,10 @@
import moment from 'moment';
import { TokenIssuer, TokenParams, KeyStore, AnyJWK } from './types';
import { JSONWebKey, JWK, JWS } from 'jose';
import parseJwk, { JWK } from 'jose/jwk/parse';
import SignJWT from 'jose/jwt/sign';
import generateKeyPair from 'jose/util/generate_key_pair';
import fromKeyLike from 'jose/jwk/from_key_like';
import { Logger } from 'winston';
import { v4 as uuid } from 'uuid';
@@ -53,7 +56,7 @@ export class TokenFactory implements TokenIssuer {
private readonly keyDurationSeconds: number;
private keyExpiry?: moment.Moment;
private privateKeyPromise?: Promise<JSONWebKey>;
private privateKeyPromise?: Promise<JWK>;
constructor(options: Options) {
this.issuer = options.issuer;
@@ -64,7 +67,7 @@ export class TokenFactory implements TokenIssuer {
async issueToken(params: TokenParams): Promise<string> {
const key = await this.getKey();
const keyLike = await parseJwk(key);
const iss = this.issuer;
const sub = params.claims.sub;
const aud = 'backstage';
@@ -72,11 +75,9 @@ export class TokenFactory implements TokenIssuer {
const exp = iat + this.keyDurationSeconds;
this.logger.info(`Issuing token for ${sub}`);
return JWS.sign({ iss, sub, aud, iat, exp }, key, {
alg: key.alg,
kid: key.kid,
});
return new SignJWT({ iss, sub, aud, iat, exp })
.setProtectedHeader({ alg: key.alg, typ: 'JWT', kid: key.kid })
.sign(keyLike);
}
// This will be called by other services that want to verify ID tokens.
@@ -114,7 +115,7 @@ export class TokenFactory implements TokenIssuer {
return { keys: validKeys.map(({ key }) => key) };
}
private async getKey(): Promise<JSONWebKey> {
private async getKey(): Promise<JWK> {
// Make sure that we only generate one key at a time
if (this.privateKeyPromise) {
if (this.keyExpiry?.isAfter()) {
@@ -127,23 +128,30 @@ export class TokenFactory implements TokenIssuer {
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',
});
const key = await generateKeyPair('ES256');
const kid = uuid();
const jwk = await fromKeyLike(key.privateKey);
// JOSE Library provides optional for most fields - and TS does not distinguish between missing/undefined.
// Because AnyJWK requires keys to have type "string", this throws a TypeError - though in practice, if the field
// is undefined, JOSE will not send it back as key.
const storedJwk: AnyJWK = {
...jwk,
alg: 'ES256',
kid: kid,
use: 'sig',
} as AnyJWK;
// 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);
this.logger.info(`Created new signing key ${jwk.kid}`);
await this.keyStore.addKey(storedJwk);
// At this point we are allowed to start using the new key
return key as JSONWebKey;
return storedJwk;
})();
this.privateKeyPromise = promise;
@@ -0,0 +1,16 @@
/*
* Copyright 2021 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 { createAwsAlbProvider } from './provider';
@@ -0,0 +1,192 @@
/*
* 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 { getVoidLogger } from '@backstage/backend-common';
import express from 'express';
import * as jwtVerify from 'jose/jwt/verify';
import { AwsAlbAuthProvider } from './provider';
import { AuthResponse } from '../types';
const mockedJwtVerify = jwtVerify as jest.Mocked<any>;
const mockKey = async () => {
return `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnuN4LlaJhaUpx+qZFTzYCrSBLk0I
yOlxJ2VW88mLAQGJ7HPAvOdylxZsItMnzCuqNzZvie8m/NJsOjhDncVkrw==
-----END PUBLIC KEY-----
`;
};
jest.mock('cross-fetch', () => ({
__esModule: true,
default: async () => {
return {
json: async () => {
return mockKey();
},
};
},
}));
jest.mock('jose/jwt/verify', () => {
return {
__esModule: true,
default: jest.fn(),
};
});
const identityResolutionCallbackMock = async (): Promise<AuthResponse<any>> => {
return {
backstageIdentity: {
id: 'foo',
idToken: '',
},
profile: {
displayName: 'Foo Bar',
},
providerInfo: {},
};
};
const identityResolutionCallbackRejectedMock = async (): Promise<
AuthResponse<any>
> => {
throw new Error('failed');
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('AwsALBAuthProvider', () => {
const catalogApi = {
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
addLocation: jest.fn(),
getEntities: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
};
const mockResponseSend = jest.fn();
const mockRequest = ({
header: jest.fn(() => {
return 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvbyIsImlzc3VlciI6ImZvbyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.zUkMYAuMwC1T0tyHMpxXrkbFDa4aGhB8d9um_tI2hsI';
}),
} as unknown) as express.Request;
const mockRequestWithoutJwt = ({
header: jest.fn(() => {
return undefined;
}),
} as unknown) as express.Request;
const mockResponse = ({
header: () => jest.fn(),
send: mockResponseSend,
} as unknown) as express.Response;
describe('should transform to type OAuthResponse', () => {
it('when JWT is valid and identity is resolved successfully', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foo',
});
mockedJwtVerify.default.mockImplementationOnce(async () => {
return {
payload: {
sub: 'foo',
},
};
});
await provider.refresh(mockRequest, mockResponse);
expect(mockResponseSend.mock.calls[0][0]).toEqual({
backstageIdentity: {
id: 'foo',
idToken: '',
},
profile: {
displayName: 'Foo Bar',
},
providerInfo: {},
});
});
});
describe('should fail when', () => {
it('JWT is missing', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foo',
});
await provider.refresh(mockRequestWithoutJwt, mockResponse);
expect(mockResponseSend.mock.calls[0][0]).toEqual(401);
});
it('JWT is invalid', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foo',
});
mockedJwtVerify.default.mockImplementationOnce(async () => {
throw new Error('bad JWT');
});
await provider.refresh(mockRequest, mockResponse);
expect(mockResponseSend.mock.calls[0][0]).toEqual(401);
});
it('issuer is invalid', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foobar',
});
mockedJwtVerify.default.mockImplementationOnce(async () => {
return {};
});
await provider.refresh(mockRequest, mockResponse);
expect(mockResponseSend.mock.calls[0][0]).toEqual(401);
});
it('identity resolution callback rejects', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackRejectedMock,
issuer: 'foo',
});
mockedJwtVerify.default.mockImplementationOnce(async () => {
return {};
});
await provider.refresh(mockRequest, mockResponse);
expect(mockResponseSend.mock.calls[0][0]).toEqual(401);
});
});
});
@@ -0,0 +1,130 @@
/*
* Copyright 2021 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 {
AuthProviderFactoryOptions,
AuthProviderRouteHandlers,
ExperimentalIdentityResolver,
} from '../types';
import express from 'express';
import fetch from 'cross-fetch';
import * as crypto from 'crypto';
import { KeyObject } from 'crypto';
import { Logger } from 'winston';
import NodeCache from 'node-cache';
import jwtVerify from 'jose/jwt/verify';
import { CatalogApi } from '@backstage/catalog-client';
const ALB_JWT_HEADER = 'x-amzn-oidc-data';
/**
* A callback function that receives a verified JWT and returns a UserEntity
* @param {payload} The verified JWT payload
*/
type AwsAlbAuthProviderOptions = {
region: string;
issuer: string;
identityResolutionCallback: ExperimentalIdentityResolver;
};
export const getJWTHeaders = (input: string) => {
const encoded = input.split('.')[0];
return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8'));
};
export class AwsAlbAuthProvider implements AuthProviderRouteHandlers {
private logger: Logger;
private readonly catalogClient: CatalogApi;
private options: AwsAlbAuthProviderOptions;
private readonly keyCache: NodeCache;
constructor(
logger: Logger,
catalogClient: CatalogApi,
options: AwsAlbAuthProviderOptions,
) {
this.logger = logger;
this.catalogClient = catalogClient;
this.options = options;
this.keyCache = new NodeCache({ stdTTL: 3600 });
}
frameHandler(): Promise<void> {
return Promise.resolve(undefined);
}
async refresh(req: express.Request, res: express.Response): Promise<void> {
const jwt = req.header(ALB_JWT_HEADER);
if (jwt !== undefined) {
try {
const headers = getJWTHeaders(jwt);
const key = await this.getKey(headers.kid);
const verifiedToken = await jwtVerify(jwt, key, {});
if (
this.options.issuer !== '' &&
headers.issuer !== this.options.issuer
) {
throw new Error('issuer mismatch on JWT');
}
const resolvedEntity = await this.options.identityResolutionCallback(
verifiedToken.payload,
this.catalogClient,
);
res.send(resolvedEntity);
} catch (e) {
this.logger.error('exception occurred during JWT processing', e);
res.send(401);
}
} else {
res.send(401);
}
}
start(): Promise<void> {
return Promise.resolve(undefined);
}
async getKey(keyId: string): Promise<KeyObject> {
const optionalCacheKey = this.keyCache.get<KeyObject>(keyId);
if (optionalCacheKey) {
return optionalCacheKey;
}
const keyText: string = await fetch(
`https://public-keys.auth.elb.${this.options.region}.amazonaws.com/${keyId}`,
).then(response => response.json());
const keyValue = crypto.createPublicKey(keyText);
this.keyCache.set(keyId, keyValue);
return keyValue;
}
}
export const createAwsAlbProvider = ({
logger,
catalogApi,
config,
identityResolver,
}: AuthProviderFactoryOptions) => {
const region = config.getString('region');
const issuer = config.getString('iss');
if (identityResolver !== undefined) {
return new AwsAlbAuthProvider(logger, catalogApi, {
region,
issuer,
identityResolutionCallback: identityResolver,
});
}
throw new Error(
'Identity resolver is required to use this authentication provider',
);
};
@@ -25,6 +25,7 @@ import { createAuth0Provider } from './auth0';
import { createMicrosoftProvider } from './microsoft';
import { createOneLoginProvider } from './onelogin';
import { AuthProviderFactory } from './types';
import { createAwsAlbProvider } from './aws-alb';
export const factories: { [providerId: string]: AuthProviderFactory } = {
google: createGoogleProvider,
@@ -37,4 +38,5 @@ export const factories: { [providerId: string]: AuthProviderFactory } = {
oauth2: createOAuth2Provider,
oidc: createOidcProvider,
onelogin: createOneLoginProvider,
awsalb: createAwsAlbProvider,
};
@@ -13,13 +13,17 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TextDecoder, TextEncoder } from 'util';
// @ts-ignore
global.TextDecoder = TextDecoder;
global.TextEncoder = TextEncoder;
import express from 'express';
import { Session } from 'express-session';
import nock from 'nock';
import { ClientMetadata, IssuerMetadata } from 'openid-client';
import { createOidcProvider, OidcAuthProvider } from './provider';
import { JWT, JWK } from 'jose';
import UnsecuredJWT from 'jose/jwt/unsecured';
import { AuthProviderFactoryOptions } from '../types';
import { Config } from '@backstage/config';
import { OAuthAdapter } from '../../lib/oauth';
@@ -71,6 +75,7 @@ describe('OidcAuthProvider', () => {
const jwt = {
sub: 'alice',
iss: 'https://oidc.test',
iat: Date.now(),
aud: clientMetadata.clientId,
exp: Date.now() + 10000,
};
@@ -79,7 +84,7 @@ describe('OidcAuthProvider', () => {
.reply(200, issuerMetadata)
.post('/as/token.oauth2')
.reply(200, {
id_token: JWT.sign(jwt, JWK.None),
id_token: new UnsecuredJWT(jwt).encode(),
access_token: 'test',
authorization_signed_response_alg: 'HS256',
})
@@ -112,6 +112,19 @@ export interface AuthProviderRouteHandlers {
logout?(req: express.Request, res: express.Response): Promise<void>;
}
/**
* EXPERIMENTAL - this will almost certainly break in a future release.
*
* Used to resolve an identity from auth information in some auth providers.
*/
export type ExperimentalIdentityResolver = (
/**
* An object containing information specific to the auth provider.
*/
payload: object,
catalogApi: CatalogApi,
) => Promise<AuthResponse<any>>;
export type AuthProviderFactoryOptions = {
providerId: string;
globalConfig: AuthProviderConfig;
@@ -120,6 +133,7 @@ export type AuthProviderFactoryOptions = {
tokenIssuer: TokenIssuer;
discovery: PluginEndpointDiscovery;
catalogApi: CatalogApi;
identityResolver?: ExperimentalIdentityResolver;
};
export type AuthProviderFactory = (
+20 -10
View File
@@ -10126,6 +10126,11 @@ clone-response@^1.0.2:
dependencies:
mimic-response "^1.0.0"
clone@2.x:
version "2.1.2"
resolved "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=
clone@^1.0.2:
version "1.0.4"
resolved "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
@@ -16549,19 +16554,17 @@ jest@^26.0.1:
import-local "^3.0.2"
jest-cli "^26.6.3"
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==
jose@^2.0.2:
version "2.0.3"
resolved "https://registry.npmjs.org/jose/-/jose-2.0.3.tgz#9c931ab3e13e2d16a5b9e6183e60b2fc40a8e1b8"
integrity sha512-L+RlDgjO0Tk+Ki6/5IXCSEnmJCV8iMFZoBuEgu2vPQJJ4zfG/k3CAqZUMKDYNRHIDyy0QidJpOvX0NgpsAqFlw==
dependencies:
"@panva/asn1.js" "^1.0.0"
jose@^2.0.2:
version "2.0.2"
resolved "https://registry.npmjs.org/jose/-/jose-2.0.2.tgz#fb22385b80c658cc7a0cae05b7086c04c6be49f4"
integrity sha512-yD93lsiMA1go/qxSY/vXWBodmIZJIxeB7QhFi8z1yQ3KUwKENqI9UA8VCHlQ5h3x1zWuWZjoY87ByQzkQbIrQg==
dependencies:
"@panva/asn1.js" "^1.0.0"
jose@^3.5.1:
version "3.5.1"
resolved "https://registry.npmjs.org/jose/-/jose-3.5.1.tgz#adc0d5000dbf64a7f4db171d449dbcf6b556b6f0"
integrity sha512-BQQJafCDvsmtc/LaK57cjfhU/1AKBhJSbJhKPq+uhrjMoV/sh3/dbk9cm08nAeSGS6j+sjhWoY+LZPUXiKzYzw==
joycon@^2.2.5:
version "2.2.5"
@@ -18808,6 +18811,13 @@ node-addon-api@2.0.0:
resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.0.tgz#f9afb8d777a91525244b01775ea0ddbe1125483b"
integrity sha512-ASCL5U13as7HhOExbT6OlWJJUV/lLzL2voOSP1UVehpRD8FbSrSDjfScK/KwAvVTI5AS6r4VwbOMlIqtvRidnA==
node-cache@^5.1.2:
version "5.1.2"
resolved "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz#f264dc2ccad0a780e76253a694e9fd0ed19c398d"
integrity sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==
dependencies:
clone "2.x"
node-dir@^0.1.10:
version "0.1.17"
resolved "https://registry.npmjs.org/node-dir/-/node-dir-0.1.17.tgz#5f5665d93351335caabef8f1c554516cf5f1e4e5"