Merge pull request #22103 from GuptaNavdeep1983/nav/aws-alb

auth-backend: migrated AWS ALB provider to new backend.
This commit is contained in:
Patrik Oldsberg
2024-01-25 12:56:17 +01:00
committed by GitHub
21 changed files with 853 additions and 518 deletions
+5 -8
View File
@@ -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';
@@ -71,12 +72,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 = {
@@ -396,9 +393,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,
+1
View File
@@ -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;