auth-backend: migrate to new backend system for AWS ALB provider.
Signed-off-by: Navdeep Gupta <navdeep.gupta@philips.com> auth-backend: changeset. Signed-off-by: Navdeep Gupta <navdeep.gupta@philips.com> yarn lock file. Signed-off-by: Navdeep Gupta <navdeep.gupta@philips.com> options error. Signed-off-by: Navdeep Gupta <navdeep.gupta@philips.com> add dependency. Signed-off-by: Navdeep Gupta <navdeep.gupta@philips.com> yarn lock file. Signed-off-by: Navdeep Gupta <navdeep.gupta@philips.com> api reports. Signed-off-by: Navdeep Gupta <navdeep.gupta@philips.com> fix: comments. Signed-off-by: Navdeep Gupta <navdeep.gupta@philips.com> fix: catalog info yaml. Signed-off-by: Navdeep Gupta <navdeep.gupta@philips.com> fix: missing dependency. Signed-off-by: Navdeep Gupta <navdeep.gupta@philips.com> fix: yarn lock file. Signed-off-by: Navdeep Gupta <navdeep.gupta@philips.com> fix: package json. Signed-off-by: Navdeep Gupta <navdeep.gupta@philips.com> fix: yarn lock file. Signed-off-by: Navdeep Gupta <navdeep.gupta@philips.com> fix: yarn lock file. Signed-off-by: Navdeep Gupta <navdeep.gupta@philips.com> fix: resolutions for @emotion/serialize errors. Signed-off-by: Navdeep Gupta <navdeep.gupta@philips.com> fix: merged with latest yarn lock file. Signed-off-by: Navdeep Gupta <navdeep.gupta@philips.com> improved tests. Signed-off-by: Navdeep Gupta <navdeep.gupta@philips.com> improved tests. Signed-off-by: Navdeep Gupta <navdeep.gupta@philips.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend-module-aws-alb-provider': minor
|
||||
'@backstage/plugin-auth-backend': patch
|
||||
---
|
||||
|
||||
Migrated the AWS ALB auth provider to new `@backstage/plugin-auth-backend-module-aws-alb-provider` module package.
|
||||
@@ -47,6 +47,7 @@
|
||||
]
|
||||
},
|
||||
"resolutions": {
|
||||
"csstype": ">=3.0.2 <=3.1.2",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"jest-haste-map@^29.7.0": "patch:jest-haste-map@npm%3A29.7.0#./.yarn/patches/jest-haste-map-npm-29.7.0-e3be419eff.patch",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@backstage/plugin-adr-backend": "workspace:^",
|
||||
"@backstage/plugin-app-backend": "workspace:^",
|
||||
"@backstage/plugin-auth-backend": "workspace:^",
|
||||
"@backstage/plugin-auth-backend-module-aws-alb-provider": "workspace:^",
|
||||
"@backstage/plugin-auth-node": "workspace:^",
|
||||
"@backstage/plugin-azure-devops-backend": "workspace:^",
|
||||
"@backstage/plugin-azure-sites-backend": "workspace:^",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,8 @@
|
||||
# Auth Module: AWS ALB Provider
|
||||
|
||||
This module provides an GitHub auth provider implementation for `@backstage/plugin-auth-backend`.
|
||||
|
||||
## Links
|
||||
|
||||
- [Backstage](https://backstage.io)
|
||||
- [Repository](https://github.com/backstage/backstage/tree/master/plugins/auth-backend-module-github-provider)
|
||||
@@ -0,0 +1,47 @@
|
||||
## API Report File for "@backstage/plugin-auth-backend-module-aws-alb-provider"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
/// <reference types="node" />
|
||||
|
||||
import { BackendFeature } from '@backstage/backend-plugin-api';
|
||||
import { JWTHeaderParameters } from 'jose';
|
||||
import { KeyObject } from 'crypto';
|
||||
import passport from 'passport';
|
||||
import { ProxyAuthenticator } from '@backstage/plugin-auth-node';
|
||||
import { SignInResolverFactory } from '@backstage/plugin-auth-node';
|
||||
|
||||
// @public (undocumented)
|
||||
export const authModuleAwsAlbProvider: () => BackendFeature;
|
||||
|
||||
// @public (undocumented)
|
||||
export const awsAlbAuthenticator: ProxyAuthenticator<
|
||||
{
|
||||
issuer: string;
|
||||
getKey: (header: JWTHeaderParameters) => Promise<KeyObject>;
|
||||
},
|
||||
AwsAlbResult
|
||||
>;
|
||||
|
||||
// @public
|
||||
export type AwsAlbResult = {
|
||||
fullProfile: PassportProfile;
|
||||
expiresInSeconds?: number;
|
||||
accessToken: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export namespace awsAlbSignInResolvers {
|
||||
const // (undocumented)
|
||||
emailMatchingUserEntityProfileEmail: SignInResolverFactory<
|
||||
AwsAlbResult,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type PassportProfile = passport.Profile & {
|
||||
avatarUrl?: string;
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: backstage-plugin-auth-backend-module-aws-alb-provider
|
||||
title: '@backstage/plugin-auth-backend-module-aws-alb-provider'
|
||||
description: The aws-alb provider module for the Backstage auth backend.
|
||||
spec:
|
||||
lifecycle: experimental
|
||||
type: backstage-backend-plugin-module
|
||||
owner: maintainers
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@backstage/plugin-auth-backend-module-aws-alb-provider",
|
||||
"description": "The aws-alb provider module for the Backstage auth backend.",
|
||||
"version": "0.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"backstage": {
|
||||
"role": "backend-plugin-module"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "plugins/auth-backend-module-aws-alb-provider"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage"
|
||||
],
|
||||
"scripts": {
|
||||
"start": "backstage-cli package start",
|
||||
"build": "backstage-cli package build",
|
||||
"lint": "backstage-cli package lint",
|
||||
"test": "backstage-cli package test",
|
||||
"clean": "backstage-cli package clean",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "workspace:^",
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/plugin-auth-backend": "workspace:^",
|
||||
"@backstage/plugin-auth-node": "workspace:^",
|
||||
"@types/passport": "^1.0.3",
|
||||
"jose": "^4.6.0",
|
||||
"jwt-decode": "^3.1.0",
|
||||
"node-cache": "^5.1.2",
|
||||
"passport": "^0.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
"express": "^4.18.2"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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 {
|
||||
ALB_ACCESS_TOKEN_HEADER,
|
||||
ALB_JWT_HEADER,
|
||||
awsAlbAuthenticator,
|
||||
} from './authenticator';
|
||||
import { jwtVerify } from 'jose';
|
||||
import express from 'express';
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
import { Config } from '@backstage/config';
|
||||
|
||||
const jwtMock = jwtVerify as jest.Mocked<any>;
|
||||
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');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe('AwsAlbProvider', () => {
|
||||
const mockRequest = {
|
||||
header: jest.fn(name => {
|
||||
if (name === ALB_JWT_HEADER) {
|
||||
return mockJwt;
|
||||
} else if (name === ALB_ACCESS_TOKEN_HEADER) {
|
||||
return mockAccessToken;
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
} as unknown as express.Request;
|
||||
const mockRequestWithoutJwt = {
|
||||
header: jest.fn(name => {
|
||||
if (name === ALB_ACCESS_TOKEN_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;
|
||||
|
||||
describe('should transform to type AwsAlbResponse', () => {
|
||||
it('when JWT is valid and identity is resolved successfully', async () => {
|
||||
jwtMock.mockReturnValueOnce(Promise.resolve({ payload: mockClaims }));
|
||||
|
||||
const response = await awsAlbAuthenticator.authenticate(
|
||||
{ req: mockRequest },
|
||||
{ issuer: 'ISSUER_URL', getKey: jest.fn() },
|
||||
);
|
||||
expect(response).toEqual({
|
||||
result: {
|
||||
fullProfile: {
|
||||
provider: 'unknown',
|
||||
id: mockClaims.sub,
|
||||
displayName: mockClaims.name,
|
||||
username: mockClaims.email.split('@')[0].toLowerCase(),
|
||||
name: {
|
||||
familyName: mockClaims.family_name,
|
||||
givenName: mockClaims.given_name,
|
||||
},
|
||||
emails: [{ value: mockClaims.email.toLowerCase() }],
|
||||
photos: [{ value: mockClaims.picture }],
|
||||
},
|
||||
expiresInSeconds: mockClaims.exp,
|
||||
accessToken: mockAccessToken,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('should fail when', () => {
|
||||
it('Access token is missing', async () => {
|
||||
await expect(
|
||||
awsAlbAuthenticator.authenticate(
|
||||
{ req: mockRequestWithoutAccessToken },
|
||||
{ issuer: 'ISSUER_URL', getKey: jest.fn() },
|
||||
),
|
||||
).rejects.toThrow(AuthenticationError);
|
||||
});
|
||||
|
||||
it('JWT is missing', async () => {
|
||||
await expect(
|
||||
awsAlbAuthenticator.authenticate(
|
||||
{ req: mockRequestWithoutJwt },
|
||||
{ issuer: 'ISSUER_URL', getKey: jest.fn() },
|
||||
),
|
||||
).rejects.toThrow(AuthenticationError);
|
||||
});
|
||||
|
||||
it('JWT is invalid', async () => {
|
||||
jwtMock.mockImplementationOnce(() => {
|
||||
throw new Error('bad JWT');
|
||||
});
|
||||
|
||||
await expect(
|
||||
awsAlbAuthenticator.authenticate(
|
||||
{ req: mockRequest },
|
||||
{ issuer: 'ISSUER_URL', getKey: jest.fn() },
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Exception occurred during JWT processing: Error: bad JWT',
|
||||
);
|
||||
});
|
||||
|
||||
it('issuer is missing', async () => {
|
||||
jwtMock.mockReturnValueOnce({});
|
||||
|
||||
await expect(
|
||||
awsAlbAuthenticator.authenticate(
|
||||
{ req: mockRequest },
|
||||
{ issuer: 'ISSUER_URL', getKey: jest.fn() },
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Exception occurred during JWT processing: AuthenticationError: Issuer mismatch on JWT token',
|
||||
);
|
||||
});
|
||||
|
||||
it('issuer is invalid', async () => {
|
||||
jwtMock.mockReturnValueOnce({
|
||||
iss: 'INVALID_ISSUE_URL',
|
||||
});
|
||||
|
||||
await expect(
|
||||
awsAlbAuthenticator.authenticate(
|
||||
{ req: mockRequest },
|
||||
{ issuer: 'ISSUER_URL', getKey: jest.fn() },
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Exception occurred during JWT processing: AuthenticationError: Issuer mismatch on JWT token',
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('should initialize', () => {
|
||||
it('with default options', async () => {
|
||||
const config = {
|
||||
config: {
|
||||
getString: jest
|
||||
.fn()
|
||||
.mockReturnValueOnce('ISSUER_URL')
|
||||
.mockReturnValueOnce('TEST_REGION'),
|
||||
} as unknown as Config,
|
||||
};
|
||||
|
||||
expect(awsAlbAuthenticator.initialize(config)).toEqual({
|
||||
issuer: 'ISSUER_URL',
|
||||
getKey: expect.any(Function),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 { AuthenticationError } from '@backstage/errors';
|
||||
import { AwsAlbClaims, AwsAlbResult, PassportProfile } from './types';
|
||||
import { jwtVerify } from 'jose';
|
||||
import { createProxyAuthenticator } from '@backstage/plugin-auth-node';
|
||||
import NodeCache from 'node-cache';
|
||||
import { makeProfileInfo, provisionKeyCache } from './helpers';
|
||||
|
||||
export const ALB_JWT_HEADER = 'x-amzn-oidc-data';
|
||||
export const ALB_ACCESS_TOKEN_HEADER = 'x-amzn-oidc-accesstoken';
|
||||
|
||||
/** @public */
|
||||
export const awsAlbAuthenticator = createProxyAuthenticator({
|
||||
defaultProfileTransform: async (result: AwsAlbResult) => {
|
||||
return {
|
||||
profile: makeProfileInfo(result.fullProfile, result.accessToken),
|
||||
};
|
||||
},
|
||||
initialize({ config }) {
|
||||
const issuer = config.getString('issuer');
|
||||
const region = config.getString('region');
|
||||
const keyCache = new NodeCache({ stdTTL: 3600 });
|
||||
const getKey = provisionKeyCache(region, keyCache);
|
||||
return { issuer, getKey };
|
||||
},
|
||||
async authenticate({ req }, { issuer, getKey }) {
|
||||
const jwt = req.header(ALB_JWT_HEADER);
|
||||
const accessToken = req.header(ALB_ACCESS_TOKEN_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_ACCESS_TOKEN_HEADER}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const verifyResult = await jwtVerify(jwt, getKey);
|
||||
const claims = verifyResult.payload as AwsAlbClaims;
|
||||
|
||||
if (issuer && claims?.iss !== 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 {
|
||||
result: {
|
||||
fullProfile,
|
||||
expiresInSeconds: claims.exp,
|
||||
accessToken,
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error(`Exception occurred during JWT processing: ${e}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import NodeCache from 'node-cache';
|
||||
import { makeProfileInfo, provisionKeyCache } from './helpers';
|
||||
import * as crypto from 'crypto';
|
||||
import { JWTHeaderParameters } from 'jose';
|
||||
import { PassportProfile } from '@backstage/plugin-auth-node';
|
||||
import jwtDecoder from 'jwt-decode';
|
||||
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const mockKey = async () => {
|
||||
return `-----BEGIN PUBLIC KEY-----
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnuN4LlaJhaUpx+qZFTzYCrSBLk0I
|
||||
yOlxJ2VW88mLAQGJ7HPAvOdylxZsItMnzCuqNzZvie8m/NJsOjhDncVkrw==
|
||||
-----END PUBLIC KEY-----
|
||||
`;
|
||||
};
|
||||
jest.mock('crypto');
|
||||
const cryptoMock = crypto as jest.Mocked<any>;
|
||||
jest.mock('node-fetch', () => ({
|
||||
__esModule: true,
|
||||
default: async () => {
|
||||
return {
|
||||
text: async () => {
|
||||
return mockKey();
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
const jwtMock = jwtDecoder as jest.Mocked<any>;
|
||||
jest.mock('jwt-decode');
|
||||
|
||||
describe('helpers', () => {
|
||||
const nodeCache = jest.fn() as unknown as NodeCache;
|
||||
nodeCache.set = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('should create a key', () => {
|
||||
const getKey = provisionKeyCache('eu-west-1', nodeCache);
|
||||
expect(getKey).toBeDefined();
|
||||
});
|
||||
it('should return a key from cache', async () => {
|
||||
const getKey = provisionKeyCache('eu-west-1', nodeCache);
|
||||
|
||||
cryptoMock.createPublicKey.mockReturnValueOnce('key');
|
||||
nodeCache.get = jest.fn().mockReturnValue('key');
|
||||
|
||||
const key = await getKey({ kid: 'kid' } as unknown as JWTHeaderParameters);
|
||||
|
||||
expect(key).toBe('key');
|
||||
});
|
||||
it('should update cache if key is not found', async () => {
|
||||
const getKey = provisionKeyCache('eu-west-1', nodeCache);
|
||||
|
||||
nodeCache.get = jest.fn().mockReturnValue(undefined);
|
||||
jest.spyOn(nodeCache, 'set');
|
||||
cryptoMock.createPublicKey.mockReturnValue({
|
||||
export: jest.fn().mockReturnValue('key'),
|
||||
});
|
||||
|
||||
await getKey({ kid: 'kid' } as unknown as JWTHeaderParameters);
|
||||
expect(nodeCache.set).toHaveBeenCalledWith('kid', 'key');
|
||||
});
|
||||
it('should throw error if key is not found', async () => {
|
||||
const getKey = provisionKeyCache('eu-west-1', nodeCache);
|
||||
|
||||
nodeCache.get = jest.fn().mockReturnValue(undefined);
|
||||
cryptoMock.createPublicKey.mockReturnValue(undefined);
|
||||
|
||||
await expect(
|
||||
getKey({ kid: 'kid' } as unknown as JWTHeaderParameters),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
it('should throw if key is not present in request header', async () => {
|
||||
const getKey = provisionKeyCache('eu-west-1', nodeCache);
|
||||
|
||||
nodeCache.get = jest.fn().mockReturnValue(undefined);
|
||||
|
||||
await expect(getKey({} as unknown as JWTHeaderParameters)).rejects.toThrow(
|
||||
'No key id was specified in header',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeProfileInfo', () => {
|
||||
it('should return profile info', () => {
|
||||
const profile = {
|
||||
id: 'id',
|
||||
displayName: 'displayName',
|
||||
username: 'username',
|
||||
name: {
|
||||
familyName: 'familyName',
|
||||
givenName: 'givenName',
|
||||
},
|
||||
emails: [{ value: 'email' }],
|
||||
photos: [{ value: 'picture' }],
|
||||
} as PassportProfile;
|
||||
const accessToken = 'accessToken';
|
||||
const result = {
|
||||
email: 'email',
|
||||
picture: 'picture',
|
||||
displayName: 'displayName',
|
||||
};
|
||||
expect(makeProfileInfo(profile, accessToken)).toEqual(result);
|
||||
});
|
||||
it('should return profile info from id token', () => {
|
||||
jwtMock.mockReturnValueOnce({
|
||||
email: 'email',
|
||||
picture: 'picture',
|
||||
name: 'displayName',
|
||||
});
|
||||
const profile = {
|
||||
name: {
|
||||
familyName: 'familyName',
|
||||
givenName: 'givenName',
|
||||
},
|
||||
} as PassportProfile;
|
||||
const idToken = 'idToken';
|
||||
const result = {
|
||||
email: 'email',
|
||||
picture: 'picture',
|
||||
displayName: 'displayName',
|
||||
};
|
||||
expect(makeProfileInfo(profile, idToken)).toEqual(result);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 { PassportProfile } from './types';
|
||||
import { ProfileInfo } from '@backstage/plugin-auth-node';
|
||||
import { KeyObject } from 'crypto';
|
||||
import jwtDecoder from 'jwt-decode';
|
||||
import NodeCache from 'node-cache';
|
||||
import * as crypto from 'crypto';
|
||||
import { JWTHeaderParameters } from 'jose';
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
|
||||
export const makeProfileInfo = (
|
||||
profile: PassportProfile,
|
||||
idToken?: string,
|
||||
): ProfileInfo => {
|
||||
let email: string | undefined = undefined;
|
||||
if (profile.emails && profile.emails.length > 0) {
|
||||
const [firstEmail] = profile.emails;
|
||||
email = firstEmail.value;
|
||||
}
|
||||
|
||||
let picture: string | undefined = undefined;
|
||||
if (profile.avatarUrl) {
|
||||
picture = profile.avatarUrl;
|
||||
} else if (profile.photos && profile.photos.length > 0) {
|
||||
const [firstPhoto] = profile.photos;
|
||||
picture = firstPhoto.value;
|
||||
}
|
||||
|
||||
let displayName: string | undefined =
|
||||
profile.displayName ?? profile.username ?? profile.id;
|
||||
|
||||
if ((!email || !picture || !displayName) && idToken) {
|
||||
try {
|
||||
const decoded: Record<string, string> = jwtDecoder(idToken);
|
||||
if (!email && decoded.email) {
|
||||
email = decoded.email;
|
||||
}
|
||||
if (!picture && decoded.picture) {
|
||||
picture = decoded.picture;
|
||||
}
|
||||
if (!displayName && decoded.name) {
|
||||
displayName = decoded.name;
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to parse id token and get profile info, ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
email,
|
||||
picture,
|
||||
displayName,
|
||||
};
|
||||
};
|
||||
|
||||
export const provisionKeyCache = (region: string, keyCache: NodeCache) => {
|
||||
return async (header: JWTHeaderParameters): Promise<KeyObject> => {
|
||||
if (!header.kid) {
|
||||
throw new AuthenticationError('No key id was specified in header');
|
||||
}
|
||||
const optionalCacheKey = keyCache.get<KeyObject>(header.kid);
|
||||
if (optionalCacheKey) {
|
||||
return crypto.createPublicKey(optionalCacheKey);
|
||||
}
|
||||
const keyText: string = await fetch(
|
||||
`https://public-keys.auth.elb.${encodeURIComponent(
|
||||
region,
|
||||
)}.amazonaws.com/${encodeURIComponent(header.kid)}`,
|
||||
).then(response => response.text());
|
||||
const keyValue = crypto.createPublicKey(keyText);
|
||||
keyCache.set(header.kid, keyValue.export());
|
||||
return keyValue;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 { type PassportProfile } from './types';
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The auth-backend-module-aws-alb-provider backend module for the auth-backend plugin.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export { awsAlbAuthenticator } from './authenticator';
|
||||
export { authModuleAwsAlbProvider } from './module';
|
||||
export { awsAlbSignInResolvers } from './resolvers';
|
||||
export { type AwsAlbResult } from './types';
|
||||
export { type PassportProfile } from './types';
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 { createBackendModule } from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
authProvidersExtensionPoint,
|
||||
commonSignInResolvers,
|
||||
createProxyAuthProviderFactory,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import { awsAlbAuthenticator } from './authenticator';
|
||||
import { awsAlbSignInResolvers } from './resolvers';
|
||||
|
||||
/** @public */
|
||||
export const authModuleAwsAlbProvider = createBackendModule({
|
||||
pluginId: 'auth',
|
||||
moduleId: 'awsAlbProvider',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: {
|
||||
providers: authProvidersExtensionPoint,
|
||||
},
|
||||
async init({ providers }) {
|
||||
providers.registerProvider({
|
||||
providerId: 'awsAlb',
|
||||
factory: createProxyAuthProviderFactory({
|
||||
authenticator: awsAlbAuthenticator,
|
||||
signInResolverFactories: {
|
||||
...commonSignInResolvers,
|
||||
...awsAlbSignInResolvers,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 {
|
||||
createSignInResolverFactory,
|
||||
SignInInfo,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import { AwsAlbResult } from './types';
|
||||
/**
|
||||
* Available sign-in resolvers for the Google auth provider.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export namespace awsAlbSignInResolvers {
|
||||
export const emailMatchingUserEntityProfileEmail =
|
||||
createSignInResolverFactory({
|
||||
create() {
|
||||
return async (info: SignInInfo<AwsAlbResult>, ctx) => {
|
||||
if (!info.result.fullProfile.emails) {
|
||||
throw new Error(
|
||||
'Login failed, user profile does not contain an email',
|
||||
);
|
||||
}
|
||||
return ctx.signInWithCatalogUser({
|
||||
filter: {
|
||||
kind: ['User'],
|
||||
'spec.profile.email': info.result.fullProfile.emails[0].value,
|
||||
},
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 passport from 'passport';
|
||||
|
||||
/**
|
||||
* JWT header extraction result, containing the raw value and the parsed JWT
|
||||
* payload.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type AwsAlbResult = {
|
||||
fullProfile: PassportProfile;
|
||||
expiresInSeconds?: number;
|
||||
accessToken: string;
|
||||
};
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type PassportProfile = passport.Profile & {
|
||||
avatarUrl?: string;
|
||||
};
|
||||
|
||||
export type AwsAlbClaims = {
|
||||
sub: string;
|
||||
name: string;
|
||||
family_name: string;
|
||||
given_name: string;
|
||||
picture: string;
|
||||
email: string;
|
||||
exp: number;
|
||||
iss: string;
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import { AuthProviderFactory as AuthProviderFactory_2 } from '@backstage/plugin-
|
||||
import { AuthProviderRouteHandlers as AuthProviderRouteHandlers_2 } from '@backstage/plugin-auth-node';
|
||||
import { AuthResolverCatalogUserQuery as AuthResolverCatalogUserQuery_2 } from '@backstage/plugin-auth-node';
|
||||
import { AuthResolverContext as AuthResolverContext_2 } from '@backstage/plugin-auth-node';
|
||||
import { AwsAlbResult as AwsAlbResult_2 } from '@backstage/plugin-auth-backend-module-aws-alb-provider';
|
||||
import { BackendFeature } from '@backstage/backend-plugin-api';
|
||||
import { BackstageSignInResult } from '@backstage/plugin-auth-node';
|
||||
import { CacheService } from '@backstage/backend-plugin-api';
|
||||
@@ -72,12 +73,8 @@ export type AuthResolverContext = AuthResolverContext_2;
|
||||
// @public @deprecated (undocumented)
|
||||
export type AuthResponse<TProviderInfo> = ClientAuthResponse<TProviderInfo>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type AwsAlbResult = {
|
||||
fullProfile: Profile;
|
||||
expiresInSeconds?: number;
|
||||
accessToken: string;
|
||||
};
|
||||
// @public @deprecated
|
||||
export type AwsAlbResult = AwsAlbResult_2;
|
||||
|
||||
// @public (undocumented)
|
||||
export type BitbucketOAuthResult = {
|
||||
@@ -400,9 +397,9 @@ export const providers: Readonly<{
|
||||
create: (
|
||||
options?:
|
||||
| {
|
||||
authHandler?: AuthHandler<AwsAlbResult> | undefined;
|
||||
authHandler?: AuthHandler<AwsAlbResult_2> | undefined;
|
||||
signIn: {
|
||||
resolver: SignInResolver<AwsAlbResult>;
|
||||
resolver: SignInResolver<AwsAlbResult_2>;
|
||||
};
|
||||
}
|
||||
| undefined,
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/plugin-auth-backend-module-atlassian-provider": "workspace:^",
|
||||
"@backstage/plugin-auth-backend-module-aws-alb-provider": "workspace:^",
|
||||
"@backstage/plugin-auth-backend-module-gcp-iap-provider": "workspace:^",
|
||||
"@backstage/plugin-auth-backend-module-github-provider": "workspace:^",
|
||||
"@backstage/plugin-auth-backend-module-gitlab-provider": "workspace:^",
|
||||
|
||||
@@ -15,4 +15,4 @@
|
||||
*/
|
||||
|
||||
export { awsAlb } from './provider';
|
||||
export type { AwsAlbResult } from './provider';
|
||||
export type { AwsAlbResult } from './types';
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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 express from 'express';
|
||||
import { jwtVerify } from 'jose';
|
||||
import {
|
||||
ALB_ACCESS_TOKEN_HEADER,
|
||||
ALB_JWT_HEADER,
|
||||
AwsAlbAuthProvider,
|
||||
} from './provider';
|
||||
import { makeProfileInfo } from '../../lib/passport';
|
||||
import { AuthResolverContext } from '../types';
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
|
||||
const jwtMock = jwtVerify as jest.Mocked<any>;
|
||||
|
||||
const mockKey = async () => {
|
||||
return `-----BEGIN PUBLIC KEY-----
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnuN4LlaJhaUpx+qZFTzYCrSBLk0I
|
||||
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('node-fetch', () => ({
|
||||
__esModule: true,
|
||||
default: async () => {
|
||||
return {
|
||||
text: async () => {
|
||||
return mockKey();
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('AwsAlbAuthProvider', () => {
|
||||
const mockRequest = {
|
||||
header: jest.fn(name => {
|
||||
if (name === ALB_JWT_HEADER) {
|
||||
return mockJwt;
|
||||
} else if (name === ALB_ACCESS_TOKEN_HEADER) {
|
||||
return mockAccessToken;
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
} as unknown as express.Request;
|
||||
const mockRequestWithoutJwt = {
|
||||
header: jest.fn(name => {
|
||||
if (name === ALB_ACCESS_TOKEN_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(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
status: jest.fn(),
|
||||
} as unknown as express.Response;
|
||||
|
||||
describe('should transform to type AwsAlbResponse', () => {
|
||||
it('when JWT is valid and identity is resolved successfully', async () => {
|
||||
const provider = new AwsAlbAuthProvider({
|
||||
region: 'eu-west-1',
|
||||
issuer: 'ISSUER_URL',
|
||||
resolverContext: {} as AuthResolverContext,
|
||||
authHandler: async ({ fullProfile }) => ({
|
||||
profile: makeProfileInfo(fullProfile),
|
||||
}),
|
||||
signInResolver: async () => {
|
||||
return {
|
||||
token:
|
||||
'eyblob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iLCJlbnQiOlsidXNlcjpkZWZhdWx0L2ppbW15bWFya3VtIl19.eyblob',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
jwtMock.mockReturnValueOnce(Promise.resolve({ payload: mockClaims }));
|
||||
|
||||
await provider.refresh(mockRequest, mockResponse);
|
||||
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
backstageIdentity: {
|
||||
token:
|
||||
'eyblob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iLCJlbnQiOlsidXNlcjpkZWZhdWx0L2ppbW15bWFya3VtIl19.eyblob',
|
||||
identity: {
|
||||
ownershipEntityRefs: ['user:default/jimmymarkum'],
|
||||
type: 'user',
|
||||
userEntityRef: 'user:default/jimmymarkum',
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
displayName: 'User Name',
|
||||
email: 'user.name@email.test',
|
||||
picture: 'PICTURE_URL',
|
||||
},
|
||||
providerInfo: {
|
||||
accessToken: mockAccessToken,
|
||||
expiresInSeconds: mockClaims.exp,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('should fail when', () => {
|
||||
it('Access token is missing', async () => {
|
||||
const provider = new AwsAlbAuthProvider({
|
||||
region: 'eu-west-1',
|
||||
issuer: 'ISSUER_URL',
|
||||
resolverContext: {} as AuthResolverContext,
|
||||
authHandler: async ({ fullProfile }) => ({
|
||||
profile: makeProfileInfo(fullProfile),
|
||||
}),
|
||||
signInResolver: async () => {
|
||||
return { id: 'user.name', token: 'TOKEN' };
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.refresh(mockRequestWithoutAccessToken, mockResponse),
|
||||
).rejects.toThrow(AuthenticationError);
|
||||
});
|
||||
|
||||
it('JWT is missing', async () => {
|
||||
const provider = new AwsAlbAuthProvider({
|
||||
region: 'eu-west-1',
|
||||
issuer: 'ISSUER_URL',
|
||||
resolverContext: {} as AuthResolverContext,
|
||||
authHandler: async ({ fullProfile }) => ({
|
||||
profile: makeProfileInfo(fullProfile),
|
||||
}),
|
||||
signInResolver: async () => {
|
||||
return { id: 'user.name', token: 'TOKEN' };
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.refresh(mockRequestWithoutJwt, mockResponse),
|
||||
).rejects.toThrow(AuthenticationError);
|
||||
});
|
||||
|
||||
it('JWT is invalid', async () => {
|
||||
const provider = new AwsAlbAuthProvider({
|
||||
region: 'eu-west-1',
|
||||
issuer: 'ISSUER_URL',
|
||||
resolverContext: {} as AuthResolverContext,
|
||||
authHandler: async ({ fullProfile }) => ({
|
||||
profile: makeProfileInfo(fullProfile),
|
||||
}),
|
||||
signInResolver: async () => {
|
||||
return { id: 'user.name', token: 'TOKEN' };
|
||||
},
|
||||
});
|
||||
|
||||
jwtMock.mockImplementationOnce(() => {
|
||||
throw new Error('bad JWT');
|
||||
});
|
||||
|
||||
await expect(provider.refresh(mockRequest, mockResponse)).rejects.toThrow(
|
||||
AuthenticationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('issuer is missing', async () => {
|
||||
const provider = new AwsAlbAuthProvider({
|
||||
region: 'eu-west-1',
|
||||
issuer: 'ISSUER_URL',
|
||||
resolverContext: {} as AuthResolverContext,
|
||||
authHandler: async ({ fullProfile }) => ({
|
||||
profile: makeProfileInfo(fullProfile),
|
||||
}),
|
||||
signInResolver: async () => {
|
||||
return { id: 'user.name', token: 'TOKEN' };
|
||||
},
|
||||
});
|
||||
|
||||
jwtMock.mockReturnValueOnce({});
|
||||
|
||||
await expect(provider.refresh(mockRequest, mockResponse)).rejects.toThrow(
|
||||
AuthenticationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('issuer is invalid', async () => {
|
||||
const provider = new AwsAlbAuthProvider({
|
||||
region: 'eu-west-1',
|
||||
issuer: 'ISSUER_URL',
|
||||
resolverContext: {} as AuthResolverContext,
|
||||
authHandler: async ({ fullProfile }) => ({
|
||||
profile: makeProfileInfo(fullProfile),
|
||||
}),
|
||||
signInResolver: async () => {
|
||||
return { id: 'user.name', token: 'TOKEN' };
|
||||
},
|
||||
});
|
||||
|
||||
jwtMock.mockReturnValueOnce({
|
||||
iss: 'INVALID_ISSUE_URL',
|
||||
});
|
||||
|
||||
await expect(provider.refresh(mockRequest, mockResponse)).rejects.toThrow(
|
||||
AuthenticationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('SignInResolver rejects', async () => {
|
||||
const provider = new AwsAlbAuthProvider({
|
||||
region: 'eu-west-1',
|
||||
issuer: 'ISSUER_URL',
|
||||
resolverContext: {} as AuthResolverContext,
|
||||
authHandler: async ({ fullProfile }) => ({
|
||||
profile: makeProfileInfo(fullProfile),
|
||||
}),
|
||||
signInResolver: async () => {
|
||||
throw new Error();
|
||||
},
|
||||
});
|
||||
|
||||
jwtMock.mockReturnValueOnce(mockClaims);
|
||||
|
||||
await expect(provider.refresh(mockRequest, mockResponse)).rejects.toThrow(
|
||||
AuthenticationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('AuthHandler rejects', async () => {
|
||||
const provider = new AwsAlbAuthProvider({
|
||||
region: 'eu-west-1',
|
||||
issuer: 'ISSUER_URL',
|
||||
resolverContext: {} as AuthResolverContext,
|
||||
authHandler: async () => {
|
||||
throw new Error();
|
||||
},
|
||||
signInResolver: async () => {
|
||||
return { id: 'user.name', token: 'TOKEN' };
|
||||
},
|
||||
});
|
||||
|
||||
jwtMock.mockReturnValueOnce(mockClaims);
|
||||
|
||||
await expect(provider.refresh(mockRequest, mockResponse)).rejects.toThrow(
|
||||
AuthenticationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,202 +15,13 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
AuthHandler,
|
||||
AuthProviderRouteHandlers,
|
||||
AuthResolverContext,
|
||||
AuthResponse,
|
||||
SignInResolver,
|
||||
} from '../types';
|
||||
import express from 'express';
|
||||
import fetch from 'node-fetch';
|
||||
import * as crypto from 'crypto';
|
||||
import { KeyObject } from 'crypto';
|
||||
import NodeCache from 'node-cache';
|
||||
import { JWTHeaderParameters, jwtVerify } from 'jose';
|
||||
import { Profile as PassportProfile } from 'passport';
|
||||
import { makeProfileInfo } from '../../lib/passport';
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse';
|
||||
AwsAlbResult,
|
||||
awsAlbAuthenticator,
|
||||
} from '@backstage/plugin-auth-backend-module-aws-alb-provider';
|
||||
import { createProxyAuthProviderFactory } from '@backstage/plugin-auth-node';
|
||||
import { AuthHandler, SignInResolver } from '../types';
|
||||
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
|
||||
|
||||
export const ALB_JWT_HEADER = 'x-amzn-oidc-data';
|
||||
export const ALB_ACCESS_TOKEN_HEADER = 'x-amzn-oidc-accesstoken';
|
||||
|
||||
type Options = {
|
||||
region: string;
|
||||
issuer?: string;
|
||||
authHandler: AuthHandler<AwsAlbResult>;
|
||||
signInResolver: SignInResolver<AwsAlbResult>;
|
||||
resolverContext: AuthResolverContext;
|
||||
};
|
||||
|
||||
export type AwsAlbHeaders = {
|
||||
alg: string;
|
||||
kid: string;
|
||||
signer: string;
|
||||
iss: string;
|
||||
client: string;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
export type AwsAlbClaims = {
|
||||
sub: string;
|
||||
name: string;
|
||||
family_name: string;
|
||||
given_name: string;
|
||||
picture: string;
|
||||
email: string;
|
||||
exp: number;
|
||||
iss: string;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
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 resolverContext: AuthResolverContext;
|
||||
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.resolverContext = options.resolverContext;
|
||||
this.keyCache = new NodeCache({ stdTTL: 3600 });
|
||||
}
|
||||
|
||||
frameHandler(): Promise<void> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
async refresh(req: express.Request, res: express.Response): Promise<void> {
|
||||
try {
|
||||
const result = await this.getResult(req);
|
||||
const response = await this.handleResult(result);
|
||||
res.json(response);
|
||||
} catch (e) {
|
||||
throw new AuthenticationError(
|
||||
'Exception occurred during AWS ALB token refresh',
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
start(): Promise<void> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
private async getResult(req: express.Request): Promise<AwsAlbResult> {
|
||||
const jwt = req.header(ALB_JWT_HEADER);
|
||||
const accessToken = req.header(ALB_ACCESS_TOKEN_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_ACCESS_TOKEN_HEADER}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const verifyResult = await jwtVerify(jwt, this.getKey);
|
||||
const claims = verifyResult.payload 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, this.resolverContext);
|
||||
const backstageIdentity = await this.signInResolver(
|
||||
{
|
||||
result,
|
||||
profile,
|
||||
},
|
||||
this.resolverContext,
|
||||
);
|
||||
|
||||
return {
|
||||
providerInfo: {
|
||||
accessToken: result.accessToken,
|
||||
expiresInSeconds: result.expiresInSeconds,
|
||||
},
|
||||
backstageIdentity: prepareBackstageIdentityResponse(backstageIdentity),
|
||||
profile,
|
||||
};
|
||||
}
|
||||
|
||||
getKey = async (header: JWTHeaderParameters): Promise<KeyObject> => {
|
||||
if (!header.kid) {
|
||||
throw new AuthenticationError('No key id was specified in header');
|
||||
}
|
||||
const optionalCacheKey = this.keyCache.get<KeyObject>(header.kid);
|
||||
if (optionalCacheKey) {
|
||||
return crypto.createPublicKey(optionalCacheKey);
|
||||
}
|
||||
const keyText: string = await fetch(
|
||||
`https://public-keys.auth.elb.${encodeURIComponent(
|
||||
this.region,
|
||||
)}.amazonaws.com/${encodeURIComponent(header.kid)}`,
|
||||
).then(response => response.text());
|
||||
const keyValue = crypto.createPublicKey(keyText);
|
||||
this.keyCache.set(
|
||||
header.kid,
|
||||
keyValue.export({ format: 'pem', type: 'spki' }),
|
||||
);
|
||||
return keyValue;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth provider integration for AWS ALB auth
|
||||
*
|
||||
@@ -219,13 +30,14 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers {
|
||||
export const awsAlb = createAuthProviderIntegration({
|
||||
create(options?: {
|
||||
/**
|
||||
* The profile transformation function used to verify and convert the auth response
|
||||
* into the profile that will be presented to the user.
|
||||
* The profile transformation function used to verify and convert the auth
|
||||
* response into the profile that will be presented to the user. The default
|
||||
* implementation just provides the authenticated email that the IAP
|
||||
* presented.
|
||||
*/
|
||||
authHandler?: AuthHandler<AwsAlbResult>;
|
||||
|
||||
/**
|
||||
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
|
||||
* Configures sign-in for this provider.
|
||||
*/
|
||||
signIn: {
|
||||
/**
|
||||
@@ -234,29 +46,10 @@ export const awsAlb = createAuthProviderIntegration({
|
||||
resolver: SignInResolver<AwsAlbResult>;
|
||||
};
|
||||
}) {
|
||||
return ({ config, resolverContext }) => {
|
||||
const region = config.getString('region');
|
||||
const issuer = config.getOptionalString('iss');
|
||||
|
||||
if (options?.signIn.resolver === undefined) {
|
||||
throw new Error(
|
||||
'SignInResolver is required to use this authentication provider',
|
||||
);
|
||||
}
|
||||
|
||||
const authHandler: AuthHandler<AwsAlbResult> = options?.authHandler
|
||||
? options.authHandler
|
||||
: async ({ fullProfile }) => ({
|
||||
profile: makeProfileInfo(fullProfile),
|
||||
});
|
||||
|
||||
return new AwsAlbAuthProvider({
|
||||
region,
|
||||
issuer,
|
||||
signInResolver: options?.signIn.resolver,
|
||||
authHandler,
|
||||
resolverContext,
|
||||
});
|
||||
};
|
||||
return createProxyAuthProviderFactory({
|
||||
authenticator: awsAlbAuthenticator,
|
||||
profileTransform: options?.authHandler,
|
||||
signInResolver: options?.signIn?.resolver,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { AwsAlbResult as _AwsAlbResult } from '@backstage/plugin-auth-backend-module-aws-alb-provider';
|
||||
|
||||
/**
|
||||
* The result of the initial auth challenge. This is the input to the auth
|
||||
* callbacks.
|
||||
*
|
||||
* @public
|
||||
* @deprecated import from `@backstage/plugin-auth-backend-module-aws-alb-provider` instead
|
||||
*/
|
||||
export type AwsAlbResult = _AwsAlbResult;
|
||||
Reference in New Issue
Block a user