feature: add auth backend for AWS ALB

This commit is contained in:
Jonah Back
2021-01-12 14:50:04 -08:00
committed by Jonah Back
parent 4211198c30
commit aca5158c7f
5 changed files with 402 additions and 11 deletions
+7 -1
View File
@@ -16,6 +16,11 @@
"url": "https://github.com/backstage/backstage",
"directory": "plugins/auth-backend"
},
"jest": {
"moduleNameMapper": {
"jose/jwt/verify": "<rootDir>/../../../node_modules/jose/lib/jwt/verify"
}
},
"keywords": [
"backstage"
],
@@ -44,7 +49,7 @@
"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",
@@ -59,6 +64,7 @@
"passport-okta-oauth": "^0.0.1",
"passport-onelogin-oauth": "^0.0.1",
"passport-saml": "^2.0.0",
"r2": "^2.0.1",
"uuid": "^8.0.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
@@ -0,0 +1,15 @@
/*
* 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.
*/
@@ -0,0 +1,225 @@
/*
* 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 { UserEntityV1alpha1 } from '@backstage/catalog-model';
const mockKey = async () => {
return `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnuN4LlaJhaUpx+qZFTzYCrSBLk0I
yOlxJ2VW88mLAQGJ7HPAvOdylxZsItMnzCuqNzZvie8m/NJsOjhDncVkrw==
-----END PUBLIC KEY-----
`;
};
jest.mock('r2', () => ({
__esModule: true,
default: () => {
return {
text: mockKey(),
};
},
}));
jest.mock('jose/jwt/verify', () => {
return {
__esModule: true,
default: jest.fn(),
};
});
const identityResolutionCallbackMock = async (): Promise<
UserEntityV1alpha1
> => {
return {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: 'foo',
},
spec: {
memberOf: [],
profile: {
displayName: 'Foo Bar',
},
},
};
};
const identityResolutionCallbackRejectedMock = async (): Promise<
UserEntityV1alpha1
> => {
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 tokenIssuer = {
issueToken: async () => {
return '';
},
listPublicKeys: 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,
tokenIssuer,
{
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foo',
},
);
jwtVerify.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,
tokenIssuer,
{
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,
tokenIssuer,
{
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foo',
},
);
jwtVerify.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,
tokenIssuer,
{
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foobar',
},
);
jwtVerify.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,
tokenIssuer,
{
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackRejectedMock,
issuer: 'foo',
},
);
jwtVerify.default.mockImplementationOnce(async () => {
return {};
});
await provider.refresh(mockRequest, mockResponse);
expect(mockResponseSend.mock.calls[0][0]).toEqual(401);
});
});
});
@@ -0,0 +1,138 @@
/*
* 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,
} from '../types';
import { TokenIssuer } from '../../identity';
import express from 'express';
import r2 from 'r2';
import * as crypto from 'crypto';
import { KeyObject } from 'crypto';
import { Logger } from 'winston';
import jwtVerify from 'jose/jwt/verify';
import { CatalogApi } from '@backstage/catalog-client';
import { UserEntityV1alpha1 } from '@backstage/catalog-model';
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 IdentityResolutionCallback = (
payload: object,
catalogApi: CatalogApi,
) => Promise<UserEntityV1alpha1>;
type AwsAlbAuthProviderOptions = {
region: string;
issuer: string;
identityResolutionCallback: IdentityResolutionCallback;
};
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 tokenIssuer: TokenIssuer;
private options: AwsAlbAuthProviderOptions;
private readonly keyCache: { [key: string]: KeyObject };
constructor(
logger: Logger,
catalogClient: CatalogApi,
tokenIssuer: TokenIssuer,
options: AwsAlbAuthProviderOptions,
) {
this.logger = logger;
this.catalogClient = catalogClient;
this.tokenIssuer = tokenIssuer;
this.options = options;
this.keyCache = {};
}
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({
providerInfo: {},
profile: resolvedEntity?.spec?.profile,
backstageIdentity: {
id: resolvedEntity?.metadata?.name,
idToken: await this.tokenIssuer.issueToken({
claims: { sub: resolvedEntity?.metadata?.name },
}),
},
});
} 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> {
if (this.keyCache[keyId]) {
return this.keyCache[keyId];
}
const keyText: string = await r2(
`https://public-keys.auth.elb.${this.options.region}.amazonaws.com/${keyId}`,
).text;
const keyValue = crypto.createPublicKey(keyText);
this.keyCache[keyId] = keyValue;
return keyValue;
}
}
export const createAwsAlbProvider = (
{ logger, catalogApi, tokenIssuer, config }: AuthProviderFactoryOptions,
identityResolver: IdentityResolutionCallback,
) => {
const region = config.getString('region');
const issuer = config.getString('iss');
return new AwsAlbAuthProvider(logger, catalogApi, tokenIssuer, {
region,
issuer,
identityResolutionCallback: identityResolver,
});
};
+17 -10
View File
@@ -9781,7 +9781,7 @@ case-sensitive-paths-webpack-plugin@^2.2.0:
resolved "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.3.0.tgz#23ac613cc9a856e4f88ff8bb73bbb5e989825cf7"
integrity sha512-/4YgnZS8y1UXXmC02xD5rRrBEu6T5ub+mQHLNRj0fzTRbgdBYhsNo2V5EqwgqrExjxsjtF/OpAKAMkKsxbD5XQ==
caseless@~0.12.0:
caseless@^0.12.0, caseless@~0.12.0:
version "0.12.0"
resolved "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
@@ -16549,13 +16549,6 @@ 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==
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"
@@ -16563,6 +16556,11 @@ jose@^2.0.2:
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"
resolved "https://registry.npmjs.org/joycon/-/joycon-2.2.5.tgz#8d4cf4cbb2544d7b7583c216fcdfec19f6be1615"
@@ -18824,7 +18822,7 @@ node-fetch-npm@^2.0.2:
json-parse-better-errors "^1.0.0"
safe-buffer "^5.1.1"
node-fetch@2.6.1, node-fetch@^2.1.2, node-fetch@^2.2.0, node-fetch@^2.3.0, node-fetch@^2.5.0, node-fetch@^2.6.0, node-fetch@^2.6.1:
node-fetch@2.6.1, node-fetch@^2.0.0-alpha.8, node-fetch@^2.1.2, node-fetch@^2.2.0, node-fetch@^2.3.0, node-fetch@^2.5.0, node-fetch@^2.6.0, node-fetch@^2.6.1:
version "2.6.1"
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
@@ -21206,6 +21204,15 @@ quick-lru@^5.1.1:
resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932"
integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==
r2@^2.0.1:
version "2.0.1"
resolved "https://registry.npmjs.org/r2/-/r2-2.0.1.tgz#94cd802ecfce9a622549c8182032d8e4a2b2e612"
integrity sha512-EEmxoxYCe3LHzAUhRIRxdCKERpeRNmlLj6KLUSORqnK6dWl/K5ShmDGZqM2lRZQeqJgF+wyqk0s1M7SWUveNOQ==
dependencies:
caseless "^0.12.0"
node-fetch "^2.0.0-alpha.8"
typedarray-to-buffer "^3.1.2"
raf-schd@^4.0.2:
version "4.0.2"
resolved "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.2.tgz#bd44c708188f2e84c810bf55fcea9231bcaed8a0"
@@ -25002,7 +25009,7 @@ typed-rest-client@^1.8.0:
tunnel "0.0.6"
underscore "1.8.3"
typedarray-to-buffer@^3.1.5:
typedarray-to-buffer@^3.1.2, typedarray-to-buffer@^3.1.5:
version "3.1.5"
resolved "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080"
integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==