feature: add auth backend for AWS ALB
This commit is contained in:
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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==
|
||||
|
||||
Reference in New Issue
Block a user