From 9dce0535d61e5d70ec600a78e6d5c729b8f2d731 Mon Sep 17 00:00:00 2001 From: Renlord Yang Date: Thu, 14 Jul 2022 04:32:44 +0000 Subject: [PATCH] add support to include cloudflare groups and implement caching Signed-off-by: Renlord Yang Signed-off-by: Renlord Yang --- .changeset/lazy-ads-cheer.md | 2 +- .github/vale/Vocab/Backstage/accept.txt | 1 - docs/auth/cloudflare/access.md | 15 +- .../cloudflare-access/provider.test.ts | 352 +++++++++++++----- .../providers/cloudflare-access/provider.ts | 124 +++++- .../auth-backend/src/providers/providers.ts | 2 +- 6 files changed, 388 insertions(+), 108 deletions(-) diff --git a/.changeset/lazy-ads-cheer.md b/.changeset/lazy-ads-cheer.md index 477e63bc46..448a2df373 100644 --- a/.changeset/lazy-ads-cheer.md +++ b/.changeset/lazy-ads-cheer.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-auth-backend': minor +'@backstage/plugin-auth-backend': patch --- add Cloudflare Access auth provider to auth-backend diff --git a/.github/vale/Vocab/Backstage/accept.txt b/.github/vale/Vocab/Backstage/accept.txt index e2391cd2ae..cd01425a5e 100644 --- a/.github/vale/Vocab/Backstage/accept.txt +++ b/.github/vale/Vocab/Backstage/accept.txt @@ -363,4 +363,3 @@ zoomable zsh Alef Cloudflare -cloudflare diff --git a/docs/auth/cloudflare/access.md b/docs/auth/cloudflare/access.md index 827d398dc0..340817097b 100644 --- a/docs/auth/cloudflare/access.md +++ b/docs/auth/cloudflare/access.md @@ -97,4 +97,17 @@ installed in `packages/app/src/App.tsx` like this: + SignInPage: props => , ``` -See the [Sign-In with Proxy Providers](../index.md#sign-in-with-proxy-providers) section for more information. +## Adding the provider to the Backstage frontend + +It is recommended to use the `ProxiedSignInPage` for this provider, which is +installed in `packages/app/src/App.tsx` like this: + +```diff ++import { ProxiedSignInPage } from '@backstage/core-components'; + + const app = createApp({ + components: { ++ SignInPage: props => , +``` + +See [Sign-In with Proxy Providers](../index.md#sign-in-with-proxy-providers) for pointers on how to set up the sign-in page to also work smoothly for local development. diff --git a/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts b/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts index 8a21b9440d..6163add891 100644 --- a/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts +++ b/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts @@ -23,16 +23,10 @@ import { } from './provider'; import { makeProfileInfo } from '../../lib/passport'; import { AuthResolverContext } from '../types'; +import fetch from 'node-fetch'; +import * as winston from 'winston'; const jwtMock = jwtVerify as jest.Mocked; - -const mockKey = async () => { - return `-----BEGIN PUBLIC KEY----- -MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnuN4LlaJhaUpx+qZFTzYCrSBLk0I -yOlxJ2VW88mLAQGJ7HPAvOdylxZsItMnzCuqNzZvie8m/NJsOjhDncVkrw== ------END PUBLIC KEY----- -`; -}; const mockJwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IktFWV9JRCIsImlzcyI6IklTU1VFUl9VUkwifQ.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlVzZXIgTmFtZSIsImlhdCI6MTUxNjIzOTAyMn0.uMCSBGhij1xn5pnot8XgD-huQuTIBOFGs6kkW_p_X94'; const mockClaims = { @@ -45,26 +39,96 @@ const mockClaims = { exp: 1632833763, iss: 'ISSUER_URL', }; +const mockCfIdentity = { + name: 'foo', + id: '123', + email: 'foo@bar.com', + groups: [ + { + id: '123', + email: 'foo@bar.com', + name: 'foo', + }, + ], +}; + +// For cases where fetch resolves, but not ok and; +// fetch failed and returns rejected promise +const identityFailResponse = { + 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', + }, + providerInfo: { + expiresInSeconds: mockClaims.exp - mockClaims.iat, + }, +}; + +const identityOkResponse = { + backstageIdentity: { + identity: { + ownershipEntityRefs: ['user:default/jimmymarkum'], + type: 'user', + userEntityRef: 'user:default/jimmymarkum', + }, + token: + 'eyblob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iLCJlbnQiOlsidXNlcjpkZWZhdWx0L2ppbW15bWFya3VtIl19.eyblob', + }, + profile: { + displayName: 'foo', + email: 'foo@bar.com', + picture: undefined, + }, + providerInfo: { + cfAccessIdentityProfile: { + email: 'foo@bar.com', + groups: [ + { + email: 'foo@bar.com', + id: '123', + name: 'foo', + }, + ], + id: '123', + name: 'foo', + }, + expiresInSeconds: 3, + }, +}; + const mockAuthenticatedUserEmail = 'user.name@email.test'; +const mockCacheClient = { + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), +}; jest.mock('jose'); -jest.mock('node-fetch', () => ({ - __esModule: true, - default: async () => { - return { - text: async () => { - return mockKey(); - }, - }; - }, -})); +jest.mock('node-fetch', () => { + const original = jest.requireActual('node-fetch'); + return { + __esModule: true, + default: jest.fn(), + Headers: original.Headers, + }; +}); beforeEach(() => { jest.clearAllMocks(); }); describe('CloudflareAccessAuthProvider', () => { - const mockRequest = { + // Cloudflare access provides jwt in two ways. + const mockRequestWithJwtHeader = { header: jest.fn(name => { if (name === CF_JWT_HEADER) { return mockJwt; @@ -74,6 +138,15 @@ describe('CloudflareAccessAuthProvider', () => { return undefined; }), } as unknown as express.Request; + const mockRequestWithJwtCookie = { + header: jest.fn(_ => { + return undefined; + }), + cookies: { + CF_Authorization: `${mockJwt}`, + }, + } as unknown as express.Request; + const mockRequestWithoutJwt = { header: jest.fn(_ => { return undefined; @@ -83,30 +156,106 @@ describe('CloudflareAccessAuthProvider', () => { const mockResponse = { end: jest.fn(), header: () => jest.fn(), - json: jest.fn().mockReturnThis(), + json: jest.fn(), status: jest.fn(), } as unknown as express.Response; - describe('should transform to type CloudflareAccessResponse', () => { - it('when JWT is valid and identity is resolved successfully', async () => { - const provider = new CloudflareAccessAuthProvider({ - teamName: 'foobar', - resolverContext: {} as AuthResolverContext, - authHandler: async ({ fullProfile }) => ({ - profile: makeProfileInfo(fullProfile), - }), - signInResolver: async () => { - return { - token: - 'eyblob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iLCJlbnQiOlsidXNlcjpkZWZhdWx0L2ppbW15bWFya3VtIl19.eyblob', - }; - }, + const mockFetch = fetch as unknown as jest.MockedFn; + + const provider = new CloudflareAccessAuthProvider({ + teamName: 'foobar', + resolverContext: {} as AuthResolverContext, + authHandler: async ({ fullProfile }) => ({ + profile: makeProfileInfo(fullProfile), + }), + signInResolver: async () => { + return { + token: + 'eyblob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iLCJlbnQiOlsidXNlcjpkZWZhdWx0L2ppbW15bWFya3VtIl19.eyblob', + }; + }, + cache: mockCacheClient, + logger: winston.createLogger({ + transports: [new winston.transports.File({ filename: '/dev/null' })], + }), + }); + + describe('when JWT is valid', () => { + describe('resolves when passed in header', () => { + it('returns cfidentity also when get-identity succeeds', async () => { + jwtMock.mockReturnValue(Promise.resolve({ payload: mockClaims })); + mockFetch.mockReturnValueOnce( + Promise.resolve({ + ok: true, + status: 200, + json: () => { + return mockCfIdentity; + }, + }), + ); + await provider.refresh(mockRequestWithJwtHeader, mockResponse); + expect(mockResponse.json).toHaveBeenCalledWith(identityOkResponse); }); + it('does not return cfidentity when get-identity returns bad status code', async () => { + // when fetch resolves, but response code is bad + mockFetch.mockReturnValueOnce( + Promise.resolve({ + ok: false, + status: 400, + json: () => { + return { err: 'bad request' }; + }, + }), + ); + await provider.refresh(mockRequestWithJwtHeader, mockResponse); + expect(mockResponse.json).toHaveBeenCalledWith(identityFailResponse); + }); + + it('does not return cfidentity when get-identity fetch fails', async () => { + // when fetch rejects + mockFetch.mockReturnValueOnce(Promise.reject()); + await provider.refresh(mockRequestWithJwtHeader, mockResponse); + expect(mockResponse.json).toHaveBeenCalledWith(identityFailResponse); + }); + }); + + it('should resolve when passed in cookie', async () => { + jwtMock.mockReturnValue(Promise.resolve({ payload: mockClaims })); + // when mockFetch resolves and there nothing gets returned from /get-identity + mockFetch.mockReturnValueOnce( + Promise.resolve({ + ok: true, + status: 200, + json: () => { + return mockCfIdentity; + }, + }), + ); + await provider.refresh(mockRequestWithJwtCookie, mockResponse); + expect(mockResponse.json).toHaveBeenCalledWith(identityOkResponse); + + // when mockFetch rejects + mockFetch.mockReturnValueOnce(Promise.reject()); + await provider.refresh(mockRequestWithJwtCookie, mockResponse); + expect(mockResponse.json).toHaveBeenCalledWith(identityFailResponse); + }); + + it('should return a response error when response code is not 200', async () => { + // when get-identity api responds and responds with status 400 + mockFetch.mockResolvedValueOnce( + Promise.resolve({ + ok: false, + status: 400, + json: () => { + return Promise.resolve({ + err: 'bad request', + }); + }, + }), + ); jwtMock.mockReturnValueOnce(Promise.resolve({ payload: mockClaims })); - - await provider.refresh(mockRequest, mockResponse); - + await provider.refresh(mockRequestWithJwtCookie, mockResponse); expect(mockResponse.json).toHaveBeenCalledWith({ backstageIdentity: { token: @@ -126,83 +275,108 @@ describe('CloudflareAccessAuthProvider', () => { }, }); }); + + it('should resolve an identity and populate access groups when there are groups', async () => { + // when get-identity api responds and responds with status 200 + mockFetch.mockResolvedValueOnce( + Promise.resolve({ + ok: () => { + return true; + }, + status: 200, + json: () => { + return Promise.resolve({ + name: 'foo', + id: '123', + email: 'foo@bar.com', + groups: [ + { + id: '123', + email: 'foo@bar.com', + name: 'foo', + }, + ], + }); + }, + }), + ); + jwtMock.mockReturnValueOnce(Promise.resolve({ payload: mockClaims })); + await provider.refresh(mockRequestWithJwtCookie, mockResponse); + expect(mockResponse.json).toHaveBeenCalledWith({ + backstageIdentity: { + token: + 'eyblob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iLCJlbnQiOlsidXNlcjpkZWZhdWx0L2ppbW15bWFya3VtIl19.eyblob', + identity: { + ownershipEntityRefs: ['user:default/jimmymarkum'], + type: 'user', + userEntityRef: 'user:default/jimmymarkum', + }, + }, + profile: { + displayName: 'foo', + email: 'foo@bar.com', + picture: undefined, + }, + providerInfo: { + expiresInSeconds: mockClaims.exp - mockClaims.iat, + cfAccessIdentityProfile: { + name: 'foo', + id: '123', + email: 'foo@bar.com', + groups: [ + { + id: '123', + email: 'foo@bar.com', + name: 'foo', + }, + ], + }, + }, + }); + }); }); describe('should fail when', () => { it('JWT is missing', async () => { - const provider = new CloudflareAccessAuthProvider({ - teamName: 'foobar', - 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(); }); it('JWT is invalid', async () => { - const provider = new CloudflareAccessAuthProvider({ - teamName: 'foobar', - resolverContext: {} as AuthResolverContext, - authHandler: async ({ fullProfile }) => ({ - profile: makeProfileInfo(fullProfile), - }), - signInResolver: async () => { - return { id: 'user.name', token: 'TOKEN' }; - }, - }); - - jwtMock.mockImplementationOnce(() => { + jwtMock.mockImplementation(() => { throw new Error('bad JWT'); }); - await expect( - provider.refresh(mockRequest, mockResponse), + provider.refresh(mockRequestWithJwtCookie, mockResponse), ).rejects.toThrow(); + await expect( + provider.refresh(mockRequestWithJwtHeader, mockResponse), + ).rejects.toThrow(); + jwtMock.mockReset(); }); it('SignInResolver rejects', async () => { - const provider = new CloudflareAccessAuthProvider({ - teamName: 'foobar', - resolverContext: {} as AuthResolverContext, - authHandler: async ({ fullProfile }) => ({ - profile: makeProfileInfo(fullProfile), - }), - signInResolver: async () => { - throw new Error(); - }, - }); - - jwtMock.mockReturnValueOnce(mockClaims); - + jwtMock.mockReturnValue(mockClaims); await expect( - provider.refresh(mockRequest, mockResponse), + provider.refresh(mockRequestWithJwtCookie, mockResponse), ).rejects.toThrow(); + await expect( + provider.refresh(mockRequestWithJwtHeader, mockResponse), + ).rejects.toThrow(); + jwtMock.mockReset(); }); it('AuthHandler rejects', async () => { - const provider = new CloudflareAccessAuthProvider({ - teamName: 'foobar', - resolverContext: {} as AuthResolverContext, - authHandler: async () => { - throw new Error(); - }, - signInResolver: async () => { - return { id: 'user.name', token: 'TOKEN' }; - }, - }); - - jwtMock.mockReturnValueOnce(mockClaims); + jwtMock.mockReturnValue(mockClaims); await expect( - provider.refresh(mockRequest, mockResponse), + provider.refresh(mockRequestWithJwtCookie, mockResponse), ).rejects.toThrow(); + await expect( + provider.refresh(mockRequestWithJwtHeader, mockResponse), + ).rejects.toThrow(); + jwtMock.mockReset(); }); }); }); diff --git a/plugins/auth-backend/src/providers/cloudflare-access/provider.ts b/plugins/auth-backend/src/providers/cloudflare-access/provider.ts index 812f0c4aba..e5254a2fe0 100644 --- a/plugins/auth-backend/src/providers/cloudflare-access/provider.ts +++ b/plugins/auth-backend/src/providers/cloudflare-access/provider.ts @@ -22,17 +22,29 @@ import { } from '../types'; import express from 'express'; import * as _ from 'lodash'; +import fetch, { Headers, Response as FetchResponse } from 'node-fetch'; import { jwtVerify, createRemoteJWKSet } from 'jose'; import { Profile as PassportProfile } from 'passport'; -import { AuthenticationError } from '@backstage/errors'; +import { AuthenticationError, ResponseError } from '@backstage/errors'; +import { CacheClient } from '@backstage/backend-common'; import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse'; import { makeProfileInfo } from '../../lib/passport'; +import { commonByEmailResolver } from '../resolvers'; +import { Logger } from 'winston'; // JWT Web Token definitions are in the URL below // https://developers.cloudflare.com/cloudflare-one/identity/users/validating-json/ export const CF_JWT_HEADER = 'cf-access-jwt-assertion'; export const CF_AUTH_IDENTITY = 'cf-access-authenticated-user-email'; +const COOKIE_AUTH_NAME = 'CF_Authorization'; + +/** + * Default cache TTL + * + * @public + */ +export const CF_DEFAULT_CACHE_TTL = 3600; /** @public */ export type Options = { @@ -47,6 +59,8 @@ export type Options = { authHandler: AuthHandler; signInResolver: SignInResolver; resolverContext: AuthResolverContext; + logger: Logger; + cache?: CacheClient; }; /** @public */ @@ -88,17 +102,37 @@ export type CloudflareAccessClaims = { custom: string; }; -/** @public */ +type CloudflareAccessGroup = { + id: string; + name: string; + email: string; +}; + +type CloudflareAccessIdentityProfile = { + id: string; + name: string; + email: string; + groups: CloudflareAccessGroup[]; +}; + export type CloudflareAccessResult = { fullProfile: PassportProfile; + cfIdentity?: CloudflareAccessIdentityProfile; expiresInSeconds?: number; }; +/** + * @public + */ export type CloudflareAccessProviderInfo = { /** * Expiry of the access token in seconds. */ expiresInSeconds?: number; + /** + * Cloudflare access identity profile with cloudflare access groups + */ + cfAccessIdentityProfile?: CloudflareAccessIdentityProfile; }; export type CloudflareAccessResponse = @@ -110,6 +144,8 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { private readonly authHandler: AuthHandler; private readonly signInResolver: SignInResolver; private readonly jwtKeySet: any; + private readonly logger: Logger; + private readonly cache?: CacheClient; constructor(options: Options) { this.teamName = options.teamName; @@ -121,6 +157,8 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { `https://${this.teamName}.cloudflareaccess.com/cdn-cgi/access/certs`, ), ); + this.logger = options.logger; + this.cache = options.cache; } frameHandler(): Promise { @@ -140,6 +178,30 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { return Promise.resolve(); } + private async getIdentityProfile( + jwt: string, + ): Promise { + const headers = new Headers(); + // set both headers just the way inbound responses are set + headers.set(CF_JWT_HEADER, jwt); + headers.set('cookie', `${COOKIE_AUTH_NAME}=${jwt}`); + try { + const res: FetchResponse = await fetch( + `https://${this.teamName}.cloudflareaccess.com/cdn-cgi/access/get-identity`, + { headers }, + ); + if (!res.ok) { + // Cast to work around https://github.com/backstage/backstage/issues/12166 + throw await ResponseError.fromResponse(res as unknown as Response); + } + const cfIdentity = await res.json(); + return cfIdentity as unknown as CloudflareAccessIdentityProfile; + } catch (err) { + this.logger.error(`getIdentityProfile failed: ${err}`); + return Promise.reject(err); + } + } + private async getResult( req: express.Request, ): Promise { @@ -161,26 +223,44 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { // RS256 follows an asymmetric algorithm; a private key signs the JWTs and // a separate public key verifies the signature. const verifyResult = await jwtVerify(jwt, this.jwtKeySet, { - // Cloudflare signs the JWT using the RSA Signature with SHA-256 (RS256). - algorithms: ['RS256'], issuer: `https://${this.teamName}.cloudflareaccess.com`, }); + const cfAccessResultStr = await this.cache?.get(jwt); + if (typeof cfAccessResultStr === 'string') { + return JSON.parse(cfAccessResultStr) as CloudflareAccessResult; + } const claims = verifyResult.payload as CloudflareAccessClaims; - + // Builds a passport profile from JWT claims first + const username = claims.email.split('@')[0].toLowerCase(); const fullProfile: PassportProfile = { provider: 'cfAccess', id: claims.sub, - displayName: _.startCase( - claims.email.split('@')[0].toLowerCase().replace('.', ' '), - ), - username: claims.email.split('@')[0].toLowerCase(), - emails: [{ value: claims.email.toLowerCase() }], + displayName: _.startCase(username.replace('.', ' ')), + username: username, + emails: [{ value: claims.email }], }; - - return { + const cfAccessResult: CloudflareAccessResult = { fullProfile, expiresInSeconds: claims.exp - claims.iat, }; + try { + // If we successfully fetch the get-identity endpoint, + // We supplement the passport profile with richer user identity + // information here. + const cfIdentity = await this.getIdentityProfile(jwt); + fullProfile.displayName = cfIdentity.name; + fullProfile.emails = [{ value: cfIdentity.email }]; + fullProfile.username = cfIdentity.email.split('@')[0].toLowerCase(); + cfAccessResult.cfIdentity = cfIdentity; + // Stores a stringified JSON object in cfaccess provider cache only when + // we complete all steps + this.cache?.set(jwt, JSON.stringify(cfAccessResult)); + } catch (err) { + this.logger.error( + `failed to populate access identity information: ${err}`, + ); + } + return cfAccessResult; } private async handleResult( @@ -198,6 +278,7 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { return { providerInfo: { expiresInSeconds: result.expiresInSeconds, + cfAccessIdentityProfile: result.cfIdentity, }, backstageIdentity: prepareBackstageIdentityResponse(backstageIdentity), profile, @@ -211,7 +292,7 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { * @public */ export const cfAccess = createAuthProviderIntegration({ - create(options?: { + create(options: { /** * The profile transformation function used to verify and convert the auth response * into the profile that will be presented to the user. @@ -227,11 +308,16 @@ export const cfAccess = createAuthProviderIntegration({ */ resolver: SignInResolver; }; + /** + * CacheClient object that was configured for the Backstage backend, + * should be provided via the backend auth plugin. + */ + cache?: CacheClient; }) { - return ({ config, resolverContext }) => { + return ({ config, logger, resolverContext }) => { const teamName = config.getString('teamName'); - if (!options?.signIn.resolver) { + if (!options.signIn.resolver) { throw new Error( 'SignInResolver is required to use this authentication provider', ); @@ -249,7 +335,15 @@ export const cfAccess = createAuthProviderIntegration({ signInResolver: options?.signIn.resolver, authHandler, resolverContext, + logger, + ...(options.cache && { cache: options.cache }), }); }; }, + resolvers: { + /** + * Looks up the user by matching their email to the entity email. + */ + emailMatchingUserEntityProfileEmail: () => commonByEmailResolver, + }, }); diff --git a/plugins/auth-backend/src/providers/providers.ts b/plugins/auth-backend/src/providers/providers.ts index 81d9711a52..b2795c25f0 100644 --- a/plugins/auth-backend/src/providers/providers.ts +++ b/plugins/auth-backend/src/providers/providers.ts @@ -42,6 +42,7 @@ export const providers = Object.freeze({ auth0, awsAlb, bitbucket, + cfAccess, gcpIap, github, gitlab, @@ -76,5 +77,4 @@ export const defaultAuthProviderFactories: { awsalb: awsAlb.create(), bitbucket: bitbucket.create(), atlassian: atlassian.create(), - cfaccess: cfAccess.create(), };