Merge pull request #7380 from mfrinnstrom/aws-alb-auth-backend
Update aws-alb auth provider
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
/// <reference types="node" />
|
||||
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { Config } from '@backstage/config';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
@@ -32,7 +34,6 @@ export type AuthProviderFactoryOptions = {
|
||||
tokenIssuer: TokenIssuer;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
catalogApi: CatalogApi;
|
||||
identityResolver?: ExperimentalIdentityResolver;
|
||||
};
|
||||
|
||||
// Warning: (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag
|
||||
@@ -74,6 +75,16 @@ export type AuthResponse<ProviderInfo> = {
|
||||
backstageIdentity?: BackstageIdentity;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "AwsAlbProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export type AwsAlbProviderOptions = {
|
||||
authHandler?: AuthHandler<AwsAlbResult>;
|
||||
signIn: {
|
||||
resolver: SignInResolver<AwsAlbResult>;
|
||||
};
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "BackstageIdentity" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
@@ -135,6 +146,13 @@ export const bitbucketUserIdSignInResolver: SignInResolver<BitbucketOAuthResult>
|
||||
// @public (undocumented)
|
||||
export const bitbucketUsernameSignInResolver: SignInResolver<BitbucketOAuthResult>;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "createAwsAlbProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const createAwsAlbProvider: (
|
||||
options?: AwsAlbProviderOptions | undefined,
|
||||
) => AuthProviderFactory;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "createBitbucketProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
@@ -526,9 +544,9 @@ export type WebMessageResponse =
|
||||
//
|
||||
// src/identity/types.d.ts:25:5 - (ae-forgotten-export) The symbol "TokenParams" needs to be exported by the entry point index.d.ts
|
||||
// src/identity/types.d.ts:31:9 - (ae-forgotten-export) The symbol "AnyJWK" needs to be exported by the entry point index.d.ts
|
||||
// src/providers/bitbucket/provider.d.ts:61:5 - (ae-forgotten-export) The symbol "AuthHandler" needs to be exported by the entry point index.d.ts
|
||||
// src/providers/bitbucket/provider.d.ts:69:9 - (ae-forgotten-export) The symbol "SignInResolver" needs to be exported by the entry point index.d.ts
|
||||
// src/providers/types.d.ts:109:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts
|
||||
// src/providers/types.d.ts:115:5 - (ae-forgotten-export) The symbol "ExperimentalIdentityResolver" needs to be exported by the entry point index.d.ts
|
||||
// src/providers/types.d.ts:132:8 - (tsdoc-missing-deprecation-message) The @deprecated block must include a deprecation message, e.g. describing the recommended alternative
|
||||
// src/providers/aws-alb/provider.d.ts:77:5 - (ae-forgotten-export) The symbol "AuthHandler" needs to be exported by the entry point index.d.ts
|
||||
// src/providers/aws-alb/provider.d.ts:77:5 - (ae-forgotten-export) The symbol "AwsAlbResult" needs to be exported by the entry point index.d.ts
|
||||
// src/providers/aws-alb/provider.d.ts:85:9 - (ae-forgotten-export) The symbol "SignInResolver" needs to be exported by the entry point index.d.ts
|
||||
// src/providers/types.d.ts:99:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts
|
||||
// src/providers/types.d.ts:121:8 - (tsdoc-missing-deprecation-message) The @deprecated block must include a deprecation message, e.g. describing the recommended alternative
|
||||
```
|
||||
|
||||
@@ -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 {
|
||||
AuthProviderFactoryOptions,
|
||||
AuthHandler,
|
||||
AuthProviderFactory,
|
||||
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,58 @@ 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) => {
|
||||
return ({
|
||||
logger,
|
||||
catalogApi,
|
||||
config,
|
||||
identityResolver,
|
||||
}: AuthProviderFactoryOptions) => {
|
||||
const region = config.getString('region');
|
||||
const issuer = config.getOptionalString('iss');
|
||||
if (identityResolver !== undefined) {
|
||||
return new AwsAlbAuthProvider(logger, catalogApi, {
|
||||
region,
|
||||
issuer,
|
||||
identityResolutionCallback: identityResolver,
|
||||
});
|
||||
}
|
||||
throw new Error(
|
||||
'Identity resolver is required to use this authentication provider',
|
||||
);
|
||||
/**
|
||||
* 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,
|
||||
): AuthProviderFactory => {
|
||||
return ({ config, tokenIssuer, catalogApi, logger }) => {
|
||||
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 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 = (
|
||||
|
||||
Reference in New Issue
Block a user