From 1756d252ceeeb37d773a719c3a8231f12f3f1935 Mon Sep 17 00:00:00 2001 From: Fabian Hippmann Date: Tue, 6 Jul 2021 15:13:25 +0200 Subject: [PATCH] update tests, add dependencies Signed-off-by: Fabian Hippmann --- docs/auth/google/gcp-iap-auth.md | 66 ++++++++++------ plugins/auth-backend/package.json | 2 +- .../src/providers/gcp-iap/provider.test.ts | 79 +++---------------- .../src/providers/gcp-iap/provider.ts | 37 +++++---- 4 files changed, 74 insertions(+), 110 deletions(-) diff --git a/docs/auth/google/gcp-iap-auth.md b/docs/auth/google/gcp-iap-auth.md index 2188ca5465..bafc573d18 100644 --- a/docs/auth/google/gcp-iap-auth.md +++ b/docs/auth/google/gcp-iap-auth.md @@ -2,16 +2,21 @@ id: provider title: Google Identity Aware Proxy Provider sidebar_label: Google IAP -description: Adding Google Identity Aware Proxy as an authentication provider in Backstage +description: + Adding Google Identity Aware Proxy as an authentication provider in Backstage --- # Using Google Identity Aware Proxy to authenticate requests -Backstage allows offloading the responsibility of authenticating users to an Google HTTPS Load Balancer & [IAP](https://cloud.google.com/iap), leveraging the authentication support on IAP. +Backstage allows offloading the responsibility of authenticating users to an +Google HTTPS Load Balancer & [IAP](https://cloud.google.com/iap), leveraging the +authentication support on IAP. -This tutorial shows how to use authentication on an ALB sitting in front of Backstage. +This tutorial shows how to use authentication on an ALB sitting in front of +Backstage. -It is assumed an IAP is already serving traffic in front of a Backstage instance configured to serve the frontend app from the backend. +It is assumed an IAP is already serving traffic in front of a Backstage instance +configured to serve the frontend app from the backend. ## Infrastructure setup @@ -19,16 +24,21 @@ It is assumed an IAP is already serving traffic in front of a Backstage instance ### 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. +The Backstage App needs a SignInPage when authentication is required. When using +IAP Proxy 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`): +- 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 baseUrl = await discoveryApiConfig.getBaseUrl('auth'); const shouldAuth = !!config.getOptionalConfig('auth.providers.gcp-iap'); if (!shouldAuth) { @@ -43,18 +53,17 @@ const refreshToken = async ({ props, discoveryApiConfig, config }) => { return; } try { - const request = await fetch(`${baseUrl}/gcp-iap/refresh`, { headers: { - "x-requested-with": "XMLHttpRequest" + 'x-requested-with': 'XMLHttpRequest', }, - credentials: "include" + credentials: 'include', }); - const data = await request.json() + const data = await request.json(); props.onResult({ - userId: data.backstageIdentity.id ?? "nouser@ms.at", - profile: data.profile ?? "nouser@ms.at", + userId: data.backstageIdentity.id ?? 'nouser@ms.at', + profile: data.profile ?? 'nouser@ms.at', }); } catch (e) { props.onResult({ @@ -71,7 +80,7 @@ const DummySignInComponent: any = (props: any) => { try { const config = useApi(configApiRef); const discoveryApiConfig = useApi(discoveryApiRef); - refreshToken({ props, discoveryApiConfig, config }) + refreshToken({ props, discoveryApiConfig, config }); return
; } catch (err) { return
{err.message}
; @@ -81,7 +90,9 @@ const DummySignInComponent: any = (props: any) => { ### 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. +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 @@ -116,10 +127,17 @@ export default async function createPlugin({ }); }; return await createRouter({ - logger, config, database, discovery, providerFactories: { - "gcp-iap": (options: AuthProviderFactoryOptions) => { - return createGcpIAPProvider({ ...options, identityResolver })({ ...options, identityResolver }) - } + logger, + config, + database, + discovery, + providerFactories: { + 'gcp-iap': (options: AuthProviderFactoryOptions) => { + return createGcpIAPProvider({ ...options, identityResolver })({ + ...options, + identityResolver, + }); + }, }, }); } @@ -133,10 +151,10 @@ Use the following `auth` configuration when running Backstage on AWS: auth: providers: gcp-iap: - audience: "/projects/0123456/global/backendServices/1242345678765434567" + 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. +Once it's deployed, after going through the AAD authentication flow, Backstage +should display the AAD user details. diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 620c8b397a..25f3a7fde8 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -45,7 +45,7 @@ "express": "^4.17.1", "express-promise-router": "^4.1.0", "express-session": "^1.17.1", - "fs-extra": "9.1.0", + "google-auth-library": "^7.2.0", "helmet": "^4.0.0", "jose": "^1.27.1", "jwt-decode": "^3.1.0", diff --git a/plugins/auth-backend/src/providers/gcp-iap/provider.test.ts b/plugins/auth-backend/src/providers/gcp-iap/provider.test.ts index 3fa168665a..0d39987c93 100644 --- a/plugins/auth-backend/src/providers/gcp-iap/provider.test.ts +++ b/plugins/auth-backend/src/providers/gcp-iap/provider.test.ts @@ -15,33 +15,14 @@ */ import { getVoidLogger } from '@backstage/backend-common'; import express from 'express'; -import { JWT } from 'jose'; -import { AwsAlbAuthProvider } from './provider'; +import { GcpIAPProvider } from './provider'; import { AuthResponse } from '../types'; -const jwtMock = JWT as jest.Mocked; +jest.mock('google-auth-library'); -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 setCredentialsMock = jest.fn(); +const getAccessTokenMock = jest.fn(); const identityResolutionCallbackMock = async (): Promise> => { return { @@ -66,7 +47,7 @@ beforeEach(() => { jest.clearAllMocks(); }); -describe('AwsALBAuthProvider', () => { +describe('GcpIAPProvider', () => { const catalogApi = { /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ addLocation: jest.fn(), @@ -98,16 +79,11 @@ describe('AwsALBAuthProvider', () => { 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', + const provider = new GcpIAPProvider(getVoidLogger(), catalogApi, { identityResolutionCallback: identityResolutionCallbackMock, - issuer: 'foo', + audience: 'foo', }); - jwtMock.verify.mockImplementationOnce(() => ({ - sub: 'foo', - })); - await provider.refresh(mockRequest, mockResponse); expect(mockResponse.json).toHaveBeenCalledWith({ @@ -124,10 +100,9 @@ describe('AwsALBAuthProvider', () => { }); describe('should fail when', () => { it('JWT is missing', async () => { - const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { - region: 'us-west-2', + const provider = new GcpIAPProvider(getVoidLogger(), catalogApi, { identityResolutionCallback: identityResolutionCallbackMock, - issuer: 'foo', + audience: 'foo', }); await provider.refresh(mockRequestWithoutJwt, mockResponse); @@ -135,44 +110,12 @@ describe('AwsALBAuthProvider', () => { 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', + const provider = new GcpIAPProvider(getVoidLogger(), catalogApi, { identityResolutionCallback: identityResolutionCallbackRejectedMock, - issuer: 'foo', + audience: 'foo', }); - jwtMock.verify.mockReturnValueOnce({}); - await provider.refresh(mockRequest, mockResponse); expect(mockResponse.status).toHaveBeenCalledWith(401); diff --git a/plugins/auth-backend/src/providers/gcp-iap/provider.ts b/plugins/auth-backend/src/providers/gcp-iap/provider.ts index 0226855a88..c437dadda0 100644 --- a/plugins/auth-backend/src/providers/gcp-iap/provider.ts +++ b/plugins/auth-backend/src/providers/gcp-iap/provider.ts @@ -13,16 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - AuthProviderFactoryOptions, - AuthProviderRouteHandlers, - AuthResponse -} from '@backstage/plugin-auth-backend'; import { + AuthProviderRouteHandlers, + AuthProviderFactoryOptions, ExperimentalIdentityResolver, } from '../types'; + import express from 'express'; + import { Logger } from 'winston'; import { CatalogApi } from '@backstage/catalog-client'; @@ -54,9 +53,12 @@ export class GcpIAPProvider implements AuthProviderRouteHandlers { async refresh(req: express.Request, res: express.Response): Promise { const expectedAudience = this.options.audience; - const jwtToken = req.header(IAP_JWT_HEADER); - + if (jwtToken === undefined) { + res.status(401); + res.end(); + return; + } const oAuth2Client = new OAuth2Client(); const verify = async () => { const response = await oAuth2Client.getIapPublicKeys(); @@ -64,30 +66,32 @@ export class GcpIAPProvider implements AuthProviderRouteHandlers { jwtToken, response.pubkeys, expectedAudience, - ['https://cloud.google.com/iap'] + ['https://cloud.google.com/iap'], ); - return ticket.payload; - } + return ticket.getPayload(); + }; try { const user = await verify(); + if (user === undefined) { + this.logger.error('gcp iap proxy user returned undefined'); + res.status(401); + res.end(); + return; + } const resolvedEntity = await this.options.identityResolutionCallback( { - email: user.email + 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(); + return; } res.status(200); res.end(); @@ -98,7 +102,6 @@ export class GcpIAPProvider implements AuthProviderRouteHandlers { } } - export const createGcpIAPProvider = (_options?: GcpIAPProviderOptions) => { return ({ logger,