auth-backend: update aws-alb provider

Signed-off-by: Mattias Frinnström <mattias.frinnstrom@husqvarnagroup.com>
This commit is contained in:
Mattias Frinnström
2021-10-05 09:20:07 +02:00
committed by blam
parent 16279cb328
commit 0de80576e3
4 changed files with 399 additions and 145 deletions
@@ -17,8 +17,14 @@ import { getVoidLogger } from '@backstage/backend-common';
import express from 'express';
import { JWT } from 'jose';
import { AwsAlbAuthProvider } from './provider';
import { AuthResponse } from '../types';
import {
ALB_ACCESSTOKEN_HEADER,
ALB_JWT_HEADER,
AwsAlbAuthProvider,
} from './provider';
import { TokenIssuer } from '../../identity/types';
import { CatalogIdentityClient } from '../../lib/catalog';
import { makeProfileInfo } from '../../lib/passport';
const jwtMock = JWT as jest.Mocked<any>;
@@ -29,9 +35,21 @@ yOlxJ2VW88mLAQGJ7HPAvOdylxZsItMnzCuqNzZvie8m/NJsOjhDncVkrw==
-----END PUBLIC KEY-----
`;
};
const mockJwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IktFWV9JRCIsImlzcyI6IklTU1VFUl9VUkwifQ.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlVzZXIgTmFtZSIsImlhdCI6MTUxNjIzOTAyMn0.uMCSBGhij1xn5pnot8XgD-huQuTIBOFGs6kkW_p_X94';
const mockAccessToken = 'ACCESS_TOKEN';
const mockClaims = {
sub: '1234567890',
name: 'User Name',
family_name: 'Name',
given_name: 'User',
picture: 'PICTURE_URL',
email: 'user.name@email.test',
exp: 1632833763,
iss: 'ISSUER_URL',
};
jest.mock('jose');
jest.mock('cross-fetch', () => ({
__esModule: true,
default: async () => {
@@ -43,53 +61,48 @@ jest.mock('cross-fetch', () => ({
},
}));
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 = {
addLocation: jest.fn(),
removeLocationById: jest.fn(),
getEntities: jest.fn(),
getOriginLocationByEntity: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
describe('AwsAlbAuthProvider', () => {
const tokenIssuer: TokenIssuer = {
listPublicKeys: jest.fn(),
async issueToken(params) {
return `token-for-${params.claims.sub}`;
},
};
const catalogIdentityClient: CatalogIdentityClient = {
findUser: jest.fn(),
} as unknown as CatalogIdentityClient;
const mockRequest = {
header: jest.fn(() => {
return 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvbyIsImlzcyI6ImZvbyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.T2BNS4G-6RoiFnXc8Q8TiwdWzTpNitY8jcsGM3N3-Yo';
}),
} as unknown as express.Request;
const mockRequestWithoutJwt = {
header: jest.fn(() => {
header: jest.fn(name => {
if (name === ALB_JWT_HEADER) {
return mockJwt;
} else if (name === ALB_ACCESSTOKEN_HEADER) {
return mockAccessToken;
}
return undefined;
}),
} as unknown as express.Request;
const mockRequestWithoutJwt = {
header: jest.fn(name => {
if (name === ALB_ACCESSTOKEN_HEADER) {
return mockAccessToken;
}
return undefined;
}),
} as unknown as express.Request;
const mockRequestWithoutAccessToken = {
header: jest.fn(name => {
if (name === ALB_JWT_HEADER) {
return mockJwt;
}
return undefined;
}),
} as unknown as express.Request;
const mockResponse = {
end: jest.fn(),
header: () => jest.fn(),
@@ -97,38 +110,78 @@ describe('AwsALBAuthProvider', () => {
status: jest.fn(),
} as unknown as express.Response;
describe('should transform to type OAuthResponse', () => {
describe('should transform to type AwsAlbResponse', () => {
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',
const provider = new AwsAlbAuthProvider({
region: 'eu-west-1',
issuer: 'ISSUER_URL',
logger: getVoidLogger(),
catalogIdentityClient,
tokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
}),
signInResolver: async () => {
return { id: 'user.name', token: 'TOKEN' };
},
});
jwtMock.verify.mockImplementationOnce(() => ({
sub: 'foo',
}));
jwtMock.verify.mockReturnValueOnce(mockClaims);
await provider.refresh(mockRequest, mockResponse);
expect(mockResponse.json).toHaveBeenCalledWith({
backstageIdentity: {
id: 'foo',
idToken: '',
id: 'user.name',
token: 'TOKEN',
},
profile: {
displayName: 'Foo Bar',
displayName: 'User Name',
email: 'user.name@email.test',
picture: 'PICTURE_URL',
},
providerInfo: {
accessToken: mockAccessToken,
expiresInSeconds: mockClaims.exp,
},
providerInfo: {},
});
});
});
describe('should fail when', () => {
it('Access token is missing', async () => {
const provider = new AwsAlbAuthProvider({
region: 'eu-west-1',
issuer: 'ISSUER_URL',
logger: getVoidLogger(),
catalogIdentityClient,
tokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
}),
signInResolver: async () => {
return { id: 'user.name', token: 'TOKEN' };
},
});
await provider.refresh(mockRequestWithoutAccessToken, mockResponse);
expect(mockResponse.status).toHaveBeenCalledWith(401);
});
it('JWT is missing', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foo',
const provider = new AwsAlbAuthProvider({
region: 'eu-west-1',
issuer: 'ISSUER_URL',
logger: getVoidLogger(),
catalogIdentityClient,
tokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
}),
signInResolver: async () => {
return { id: 'user.name', token: 'TOKEN' };
},
});
await provider.refresh(mockRequestWithoutJwt, mockResponse);
@@ -137,10 +190,18 @@ describe('AwsALBAuthProvider', () => {
});
it('JWT is invalid', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foo',
const provider = new AwsAlbAuthProvider({
region: 'eu-west-1',
issuer: 'ISSUER_URL',
logger: getVoidLogger(),
catalogIdentityClient,
tokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
}),
signInResolver: async () => {
return { id: 'user.name', token: 'TOKEN' };
},
});
jwtMock.verify.mockImplementationOnce(() => {
@@ -152,11 +213,19 @@ describe('AwsALBAuthProvider', () => {
expect(mockResponse.status).toHaveBeenCalledWith(401);
});
it('issuer is invalid', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foobar',
it('issuer is missing', async () => {
const provider = new AwsAlbAuthProvider({
region: 'eu-west-1',
issuer: 'ISSUER_URL',
logger: getVoidLogger(),
catalogIdentityClient,
tokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
}),
signInResolver: async () => {
return { id: 'user.name', token: 'TOKEN' };
},
});
jwtMock.verify.mockReturnValueOnce({});
@@ -165,14 +234,68 @@ describe('AwsALBAuthProvider', () => {
expect(mockResponse.status).toHaveBeenCalledWith(401);
});
it('identity resolution callback rejects', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackRejectedMock,
issuer: 'foo',
it('issuer is invalid', async () => {
const provider = new AwsAlbAuthProvider({
region: 'eu-west-1',
issuer: 'ISSUER_URL',
logger: getVoidLogger(),
catalogIdentityClient,
tokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
}),
signInResolver: async () => {
return { id: 'user.name', token: 'TOKEN' };
},
});
jwtMock.verify.mockReturnValueOnce({});
jwtMock.verify.mockReturnValueOnce({
iss: 'INVALID_ISSUE_URL',
});
await provider.refresh(mockRequest, mockResponse);
expect(mockResponse.status).toHaveBeenCalledWith(401);
});
it('SignInResolver rejects', async () => {
const provider = new AwsAlbAuthProvider({
region: 'eu-west-1',
issuer: 'ISSUER_URL',
logger: getVoidLogger(),
catalogIdentityClient,
tokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
}),
signInResolver: async () => {
throw new Error();
},
});
jwtMock.verify.mockReturnValueOnce(mockClaims);
await provider.refresh(mockRequest, mockResponse);
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.end).toHaveBeenCalledTimes(1);
});
it('AuthHandler rejects', async () => {
const provider = new AwsAlbAuthProvider({
region: 'eu-west-1',
issuer: 'ISSUER_URL',
logger: getVoidLogger(),
catalogIdentityClient,
tokenIssuer,
authHandler: async () => {
throw new Error();
},
signInResolver: async () => {
return { id: 'user.name', token: 'TOKEN' };
},
});
jwtMock.verify.mockReturnValueOnce(mockClaims);
await provider.refresh(mockRequest, mockResponse);
@@ -14,9 +14,11 @@
* limitations under the License.
*/
import {
AuthHandler,
AuthProviderFactoryOptions,
AuthProviderRouteHandlers,
ExperimentalIdentityResolver,
AuthResponse,
SignInResolver,
} from '../types';
import express from 'express';
import fetch from 'cross-fetch';
@@ -25,66 +27,101 @@ import { KeyObject } from 'crypto';
import { Logger } from 'winston';
import NodeCache from 'node-cache';
import { JWT } from 'jose';
import { CatalogApi } from '@backstage/catalog-client';
import { TokenIssuer } from '../../identity/types';
import { CatalogIdentityClient } from '../../lib/catalog';
import { Profile as PassportProfile } from 'passport';
import { makeProfileInfo } from '../../lib/passport';
import { AuthenticationError } from '@backstage/errors';
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 = {
export const ALB_JWT_HEADER = 'x-amzn-oidc-data';
export const ALB_ACCESSTOKEN_HEADER = 'x-amzn-oidc-accesstoken';
type Options = {
region: string;
issuer?: string;
identityResolutionCallback: ExperimentalIdentityResolver;
logger: Logger;
authHandler: AuthHandler<AwsAlbResult>;
signInResolver: SignInResolver<AwsAlbResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
};
export const getJWTHeaders = (input: string) => {
export const getJWTHeaders = (input: string): AwsAlbHeaders => {
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;
export type AwsAlbHeaders = {
alg: string;
kid: string;
signer: string;
iss: string;
client: string;
exp: number;
};
constructor(
logger: Logger,
catalogClient: CatalogApi,
options: AwsAlbAuthProviderOptions,
) {
this.logger = logger;
this.catalogClient = catalogClient;
this.options = options;
export type AwsAlbClaims = {
sub: string;
name: string;
family_name: string;
given_name: string;
picture: string;
email: string;
exp: number;
iss: string;
};
export type AwsAlbResult = {
fullProfile: PassportProfile;
expiresInSeconds?: number;
accessToken: string;
};
export type AwsAlbProviderInfo = {
/**
* An access token issued for the signed in user.
*/
accessToken: string;
/**
* Expiry of the access token in seconds.
*/
expiresInSeconds?: number;
};
export type AwsAlbResponse = AuthResponse<AwsAlbProviderInfo>;
export class AwsAlbAuthProvider implements AuthProviderRouteHandlers {
private readonly region: string;
private readonly issuer?: string;
private readonly tokenIssuer: TokenIssuer;
private readonly catalogIdentityClient: CatalogIdentityClient;
private readonly logger: Logger;
private readonly keyCache: NodeCache;
private readonly authHandler: AuthHandler<AwsAlbResult>;
private readonly signInResolver: SignInResolver<AwsAlbResult>;
constructor(options: Options) {
this.region = options.region;
this.issuer = options.issuer;
this.authHandler = options.authHandler;
this.signInResolver = options.signInResolver;
this.tokenIssuer = options.tokenIssuer;
this.catalogIdentityClient = options.catalogIdentityClient;
this.logger = options.logger;
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 payload = JWT.verify(jwt, key);
if (this.options.issuer && headers.iss !== this.options.issuer) {
throw new Error('issuer mismatch on JWT');
}
const resolvedEntity = await this.options.identityResolutionCallback(
payload,
this.catalogClient,
);
res.json(resolvedEntity);
} catch (e) {
this.logger.error('exception occurred during JWT processing', e);
res.status(401);
res.end();
}
} else {
try {
const result = await this.getResult(req);
const response = await this.handleResult(result);
res.json(response);
} catch (e) {
this.logger.error('Exception occurred during AWS ALB token refresh', e);
res.status(401);
res.end();
}
@@ -94,13 +131,85 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers {
return Promise.resolve(undefined);
}
private async getResult(req: express.Request): Promise<AwsAlbResult> {
const jwt = req.header(ALB_JWT_HEADER);
const accessToken = req.header(ALB_ACCESSTOKEN_HEADER);
if (jwt === undefined) {
throw new AuthenticationError(
`Missing ALB OIDC header: ${ALB_JWT_HEADER}`,
);
}
if (accessToken === undefined) {
throw new AuthenticationError(
`Missing ALB OIDC header: ${ALB_ACCESSTOKEN_HEADER}`,
);
}
try {
const headers = getJWTHeaders(jwt);
const key = await this.getKey(headers.kid);
const claims = JWT.verify(jwt, key) as AwsAlbClaims;
if (this.issuer && claims.iss !== this.issuer) {
throw new AuthenticationError('Issuer mismatch on JWT token');
}
const fullProfile: PassportProfile = {
provider: 'unknown',
id: claims.sub,
displayName: claims.name,
username: claims.email.split('@')[0].toLowerCase(),
name: {
familyName: claims.family_name,
givenName: claims.given_name,
},
emails: [{ value: claims.email.toLowerCase() }],
photos: [{ value: claims.picture }],
};
return {
fullProfile,
expiresInSeconds: claims.exp,
accessToken,
};
} catch (e) {
throw new Error(`Exception occurred during JWT processing: ${e}`);
}
}
private async handleResult(result: AwsAlbResult): Promise<AwsAlbResponse> {
const { profile } = await this.authHandler(result);
const backstageIdentity = await this.signInResolver(
{
result,
profile,
},
{
tokenIssuer: this.tokenIssuer,
catalogIdentityClient: this.catalogIdentityClient,
logger: this.logger,
},
);
return {
providerInfo: {
accessToken: result.accessToken,
expiresInSeconds: result.expiresInSeconds,
},
backstageIdentity,
profile,
};
}
async getKey(keyId: string): Promise<KeyObject> {
const optionalCacheKey = this.keyCache.get<KeyObject>(keyId);
if (optionalCacheKey) {
return crypto.createPublicKey(optionalCacheKey);
}
const keyText: string = await fetch(
`https://public-keys.auth.elb.${this.options.region}.amazonaws.com/${keyId}`,
`https://public-keys.auth.elb.${this.region}.amazonaws.com/${keyId}`,
).then(response => response.text());
const keyValue = crypto.createPublicKey(keyText);
this.keyCache.set(keyId, keyValue.export({ format: 'pem', type: 'spki' }));
@@ -108,26 +217,61 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers {
}
}
export type AwsAlbProviderOptions = {};
export type AwsAlbProviderOptions = {
/**
* The profile transformation function used to verify and convert the auth response
* into the profile that will be presented to the user.
*/
authHandler?: AuthHandler<AwsAlbResult>;
export const createAwsAlbProvider = (_options?: AwsAlbProviderOptions) => {
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
signIn: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver: SignInResolver<AwsAlbResult>;
};
};
export const createAwsAlbProvider = (options?: AwsAlbProviderOptions) => {
return ({
logger,
catalogApi,
config,
identityResolver,
tokenIssuer,
catalogApi,
logger,
}: AuthProviderFactoryOptions) => {
const region = config.getString('region');
const issuer = config.getOptionalString('iss');
if (identityResolver !== undefined) {
return new AwsAlbAuthProvider(logger, catalogApi, {
region,
issuer,
identityResolutionCallback: identityResolver,
});
if (options?.signIn.resolver === undefined) {
throw new Error(
'SignInResolver is required to use this authentication provider',
);
}
throw new Error(
'Identity resolver is required to use this authentication provider',
);
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
});
const authHandler: AuthHandler<AwsAlbResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
});
const signInResolver = options?.signIn.resolver;
return new AwsAlbAuthProvider({
region,
issuer,
signInResolver,
authHandler,
tokenIssuer,
catalogIdentityClient,
logger,
});
};
};
@@ -21,6 +21,7 @@ export * from './microsoft';
export * from './oauth2';
export * from './okta';
export * from './bitbucket';
export * from './aws-alb';
export { factories as defaultAuthProviderFactories } from './factories';
@@ -119,19 +119,6 @@ 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;
@@ -140,7 +127,6 @@ export type AuthProviderFactoryOptions = {
tokenIssuer: TokenIssuer;
discovery: PluginEndpointDiscovery;
catalogApi: CatalogApi;
identityResolver?: ExperimentalIdentityResolver;
};
export type AuthProviderFactory = (