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,
});
};