From 9fa77c4b91a55dd8c63041e0b161d036159bbddc Mon Sep 17 00:00:00 2001 From: Fabian Hippmann Date: Sat, 12 Jun 2021 02:42:53 +0200 Subject: [PATCH] feat: add gcp-iap first draft Signed-off-by: Fabian Hippmann --- contrib/docs/tutorials/gcp-iap-auth.md | 139 +++++++++++++ .../src/providers/gcp-iap/index.ts | 17 ++ .../src/providers/gcp-iap/provider.test.ts | 182 ++++++++++++++++++ .../src/providers/gcp-iap/provider.ts | 123 ++++++++++++ 4 files changed, 461 insertions(+) create mode 100644 contrib/docs/tutorials/gcp-iap-auth.md create mode 100644 plugins/auth-backend/src/providers/gcp-iap/index.ts create mode 100644 plugins/auth-backend/src/providers/gcp-iap/provider.test.ts create mode 100644 plugins/auth-backend/src/providers/gcp-iap/provider.ts diff --git a/contrib/docs/tutorials/gcp-iap-auth.md b/contrib/docs/tutorials/gcp-iap-auth.md new file mode 100644 index 0000000000..c2ec031467 --- /dev/null +++ b/contrib/docs/tutorials/gcp-iap-auth.md @@ -0,0 +1,139 @@ +# Using AWS Application Load Balancer with Azure Active Directory to authenticate requests + +Backstage allows offloading the responsibility of authenticating users to an AWS Application Load Balancer (**ALB**), leveraging the authentication support on ALB. +This tutorial shows how to use authentication on an ALB sitting in front of Backstage. +Azure Active Directory (**AAD**) is used as identity provider but any identity provider supporting OpenID Connect (OIDC) can be used. + +It is assumed an ALB is already serving traffic in front of a Backstage instance configured to serve the frontend app from the backend. + +## Infrastructure setup + +## Backstage changes + +### Frontend + +The Backstage App needs a SignInPage when authentication is required. +When using ALB authentication Backstage will only be loaded once the user has successfully authenticated; we won't need to display a SignIn page, however we will need to create a dummy SignIn component that can refresh the token. + +- edit `packages/app/src/App.tsx` +- import the following two additional definitions from `@backstage/core`: `useApi`, `configApiRef`; these will be used to check whether Backstage is running locally or behind an ALB +- add the following definition just before the app is created (`const app = createApp`): + +```ts +const refreshToken = async ({ props, discoveryApiConfig, config }) => { + const baseUrl = await discoveryApiConfig.getBaseUrl("auth"); + const shouldAuth = !!config.getOptionalConfig('auth.providers.gcp-iap'); + + if (!shouldAuth) { + props.onResult({ + userId: 'guest', + profile: { + email: 'guest@example.com', + displayName: 'Guest', + picture: '', + }, + }); + return; + } + try { + + const request = await fetch(`${baseUrl}/gcp-iap/refresh`, { + headers: { + "x-requested-with": "XMLHttpRequest" + }, + credentials: "include" + }); + const data = await request.json() + + props.onResult({ + userId: data.backstageIdentity.id ?? "nouser@ms.at", + profile: data.profile ?? "nouser@ms.at", + }); + } catch (e) { + props.onResult({ + userId: 'guest', + profile: { + email: 'guest@example.com', + displayName: 'Guest', + picture: '', + }, + }); + } +}; +const DummySignInComponent: any = (props: any) => { + try { + const config = useApi(configApiRef); + const discoveryApiConfig = useApi(discoveryApiRef); + refreshToken({ props, discoveryApiConfig, config }) + return
; + } catch (err) { + return
{err.message}
; + } +}; +``` + +### Backend + +When using ALB auth it is not possible to leverage the built-in auth config discovery mechanism implemented in the app created by default; bespoke logic needs to be implemented. + +- replace the content of `packages/backend/plugin/auth.ts` with the below + +```ts +// imports are relative - as this was tested out in repo directly +import { createGcpIAPProvider } from './../providers/gcp-iap/provider'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; +import { + createRouter, + AuthResponse, + AuthProviderFactoryOptions, +} from '@backstage/plugin-auth-backend'; + +export default async function createPlugin({ + logger, + database, + config, + discovery, +}: PluginEnvironment): Promise { + const identityResolver = (payload: any): Promise> => { + return Promise.resolve({ + providerInfo: {}, + profile: { + email: payload.email, + displayName: payload.name, + picture: payload.picture, + }, + backstageIdentity: { + id: payload.email, + }, + }); + }; + return await createRouter({ + logger, config, database, discovery, providerFactories: { + "gcp-iap": (options: AuthProviderFactoryOptions) => { + return createGcpIAPProvider({ ...options, identityResolver })({ ...options, identityResolver }) + } + }, + }); +} +``` + +### Configuration + +Use the following `auth` configuration when running Backstage on AWS: + +```yaml +auth: + providers: + gcp-iap: + audience: "/projects/0123456/global/backendServices/1242345678765434567" +``` + + +## Conclusion + +Once it's deployed, after going through the AAD authentication flow, Backstage should display the AAD user details. + + + +[monorepo-app-setup-with-auth-ms]: https://backstage.io/docs/auth/microsoft/provider diff --git a/plugins/auth-backend/src/providers/gcp-iap/index.ts b/plugins/auth-backend/src/providers/gcp-iap/index.ts new file mode 100644 index 0000000000..1c81aa919d --- /dev/null +++ b/plugins/auth-backend/src/providers/gcp-iap/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { createGcpIAPProvider } from './provider'; +export type { GcpIAPProviderOptions } from './provider'; diff --git a/plugins/auth-backend/src/providers/gcp-iap/provider.test.ts b/plugins/auth-backend/src/providers/gcp-iap/provider.test.ts new file mode 100644 index 0000000000..3fa168665a --- /dev/null +++ b/plugins/auth-backend/src/providers/gcp-iap/provider.test.ts @@ -0,0 +1,182 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { getVoidLogger } from '@backstage/backend-common'; +import express from 'express'; +import { JWT } from 'jose'; + +import { AwsAlbAuthProvider } from './provider'; +import { AuthResponse } from '../types'; + +const jwtMock = JWT as jest.Mocked; + +const mockKey = async () => { + return `-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnuN4LlaJhaUpx+qZFTzYCrSBLk0I +yOlxJ2VW88mLAQGJ7HPAvOdylxZsItMnzCuqNzZvie8m/NJsOjhDncVkrw== +-----END PUBLIC KEY----- +`; +}; + +jest.mock('jose'); + +jest.mock('cross-fetch', () => ({ + __esModule: true, + default: async () => { + return { + text: async () => { + return mockKey(); + }, + }; + }, +})); + +const identityResolutionCallbackMock = async (): Promise> => { + return { + backstageIdentity: { + id: 'foo', + idToken: '', + }, + profile: { + displayName: 'Foo Bar', + }, + providerInfo: {}, + }; +}; + +const identityResolutionCallbackRejectedMock = async (): Promise< + AuthResponse +> => { + throw new Error('failed'); +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('AwsALBAuthProvider', () => { + const catalogApi = { + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + addLocation: jest.fn(), + removeLocationById: jest.fn(), + getEntities: jest.fn(), + getOriginLocationByEntity: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + getEntityByName: jest.fn(), + }; + + const mockRequest = ({ + header: jest.fn(() => { + return 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvbyIsImlzcyI6ImZvbyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.T2BNS4G-6RoiFnXc8Q8TiwdWzTpNitY8jcsGM3N3-Yo'; + }), + } as unknown) as express.Request; + const mockRequestWithoutJwt = ({ + header: jest.fn(() => { + 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 OAuthResponse', () => { + 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', + }); + + jwtMock.verify.mockImplementationOnce(() => ({ + sub: 'foo', + })); + + await provider.refresh(mockRequest, mockResponse); + + expect(mockResponse.json).toHaveBeenCalledWith({ + backstageIdentity: { + id: 'foo', + idToken: '', + }, + profile: { + displayName: 'Foo Bar', + }, + providerInfo: {}, + }); + }); + }); + describe('should fail when', () => { + it('JWT is missing', async () => { + const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { + region: 'us-west-2', + identityResolutionCallback: identityResolutionCallbackMock, + issuer: 'foo', + }); + + await provider.refresh(mockRequestWithoutJwt, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + }); + + it('JWT is invalid', async () => { + const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { + region: 'us-west-2', + identityResolutionCallback: identityResolutionCallbackMock, + issuer: 'foo', + }); + + jwtMock.verify.mockImplementationOnce(() => { + throw new Error('bad JWT'); + }); + + await provider.refresh(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + }); + + it('issuer is invalid', async () => { + const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { + region: 'us-west-2', + identityResolutionCallback: identityResolutionCallbackMock, + issuer: 'foobar', + }); + + jwtMock.verify.mockReturnValueOnce({}); + + await provider.refresh(mockRequest, mockResponse); + 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', + }); + + jwtMock.verify.mockReturnValueOnce({}); + + await provider.refresh(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.end).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/plugins/auth-backend/src/providers/gcp-iap/provider.ts b/plugins/auth-backend/src/providers/gcp-iap/provider.ts new file mode 100644 index 0000000000..1d6f5c3d5e --- /dev/null +++ b/plugins/auth-backend/src/providers/gcp-iap/provider.ts @@ -0,0 +1,123 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + AuthProviderFactoryOptions, + AuthProviderRouteHandlers, + AuthResponse +} from '@backstage/plugin-auth-backend'; +import express from 'express'; +import { Logger } from 'winston'; +import { CatalogApi } from '@backstage/catalog-client'; + +const { OAuth2Client } = require('google-auth-library'); + +const IAP_JWT_HEADER = 'x-goog-iap-jwt-assertion'; + +export type ExperimentalIdentityResolver = ( + /** + * An object containing information specific to the auth provider. + */ + payload: object, + catalogApi: CatalogApi, +) => Promise>; +export type GcpIAPProviderOptions = { + audience: string; + identityResolutionCallback: ExperimentalIdentityResolver; +}; +export class GcpIAPProvider implements AuthProviderRouteHandlers { + private logger: Logger; + private options: GcpIAPProviderOptions; + private readonly catalogClient: CatalogApi; + + constructor( + logger: Logger, + catalogClient: CatalogApi, + options: GcpIAPProviderOptions, + ) { + this.logger = logger; + this.catalogClient = catalogClient; + this.options = options; + } + frameHandler(): Promise { + return Promise.resolve(undefined); + } + + async refresh(req: express.Request, res: express.Response): Promise { + const expectedAudience = this.options.audience; + + const jwtToken = req.header(IAP_JWT_HEADER); + + const oAuth2Client = new OAuth2Client(); + const verify = async () => { + const response = await oAuth2Client.getIapPublicKeys(); + const ticket = await oAuth2Client.verifySignedJwtWithCertsAsync( + jwtToken, + response.pubkeys, + expectedAudience, + ['https://cloud.google.com/iap'] + ); + return ticket.payload; + } + + try { + const user = await verify(); + const resolvedEntity = await this.options.identityResolutionCallback( + { + email: user.email + }, + this.catalogClient, + ); + res.json(resolvedEntity); + } catch (e) { + const resolvedEntity = await this.options.identityResolutionCallback( + {}, + this.catalogClient, + ); + res.json(resolvedEntity); + this.logger.error('Verification failed with', e); + + res.status(401); + res.end(); + } + res.status(200); + res.end(); + } + + start(): Promise { + return Promise.resolve(undefined); + } +} + + +export const createGcpIAPProvider = (_options?: GcpIAPProviderOptions) => { + return ({ + logger, + catalogApi, + config, + identityResolver, + }: AuthProviderFactoryOptions) => { + const audience = config.getString('audience'); + if (identityResolver !== undefined) { + return new GcpIAPProvider(logger, catalogApi, { + audience, + identityResolutionCallback: identityResolver, + }); + } + throw new Error( + 'Identity resolver is required to use this authentication provider', + ); + }; +};