diff --git a/.changeset/lazy-ads-cheer.md b/.changeset/lazy-ads-cheer.md new file mode 100644 index 0000000000..448a2df373 --- /dev/null +++ b/.changeset/lazy-ads-cheer.md @@ -0,0 +1,5 @@ +--- +'@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 8428dc2d0e..cd01425a5e 100644 --- a/.github/vale/Vocab/Backstage/accept.txt +++ b/.github/vale/Vocab/Backstage/accept.txt @@ -362,3 +362,4 @@ Zhou zoomable zsh Alef +Cloudflare diff --git a/docs/auth/cloudflare/access.md b/docs/auth/cloudflare/access.md new file mode 100644 index 0000000000..340817097b --- /dev/null +++ b/docs/auth/cloudflare/access.md @@ -0,0 +1,113 @@ +--- +id: cfaccess +title: Cloudflare Access Provider +sidebar_label: cfaccess +# prettier-ignore +description: Adding Cloudflare Access as an authentication provider in Backstage +--- + +Similar to GCP IAP Proxy Provider or AWS ALB provider, developers can offload authentication +support to Cloudflare Access. + +This tutorial shows how to use authentication on Cloudflare Access sitting in +front of Backstage. + +It is assumed a Cloudflare tunnel is already serving traffic in front of a +Backstage instance configured to serve the frontend app from the backend and is +already gated using Cloudflare Access. + +## Configuration + +Let's start by adding the following `auth` configuration in your +`app-config.yaml` or `app-config.production.yaml` or similar: + +```yaml +auth: + providers: + cfaccess: + teamName: +``` + +You can find the team name in the Cloudflare Zero Trust dashboard. + +This config section must be in place for the provider to load at all. Now let's +add the provider itself. + +## Backend Changes + +Add a `providerFactories` entry to the router in +`packages/backend/plugin/auth.ts`. + +```ts +import { providers } from '@backstage/plugin-auth-backend'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + return await createRouter({ + logger: env.logger, + config: env.config, + database: env.database, + discovery: env.discovery, + providerFactories: { + 'cfaccess': providers.cfAccess.create({ + // Replace the auth handler if you want to customize the returned user + // profile info (can be left out; the default implementation is shown + // below which only returns the email). You may want to amend this code + // with something that loads additional user profile data out. + async authHandler({ accessToken }) { + return { profile: { email: accessToken.email } }; + }, + signIn: { + // You need to supply an identity resolver, that takes the profile + // and the access token and produces the Backstage token with the + // relevant user info. + async resolver({ profile, result }, ctx) { + // Somehow compute the Backstage token claims. Just some dummy code + // shown here, but you may want to query your LDAP server, or + // https://.cloudflareaccess.com/cdn-cgi/access/get-identity + // https://developers.cloudflare.com/cloudflare-one/identity/users/validating-json/#groups-within-a-jwt + const id = profile.email.split('@')[0]; + const sub = stringifyEntityRef({ kind: 'User', name: id }); + const ent = [sub, stringifyEntityRef({ kind: 'Group', name: 'team-name' }); + return ctx.issueToken({ claims: { sub, ent } }); + }, + }, + }), + }, + }); +} +``` + +Now the backend is ready to serve auth requests on the +`/api/auth/cfaccess/refresh` endpoint. All that's left is to update the +frontend sign-in mechanism to poll that endpoint through Cloudflare Access, on +the user's behalf. + +## Frontend Changes + +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 => , +``` + +## 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/docs/auth/index.md b/docs/auth/index.md index e713e722ef..8d303c776d 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -18,6 +18,7 @@ Backstage comes with many common authentication providers in the core library: - [Auth0](auth0/provider.md) - [Azure](microsoft/provider.md) - [Bitbucket](bitbucket/provider.md) +- [Cloudflare Access](cloudflare/access.md) - [GitHub](github/provider.md) - [GitLab](gitlab/provider.md) - [Google](google/provider.md) @@ -131,7 +132,7 @@ allows allowing guest access: Some auth providers are so-called "proxy" providers, meaning they're meant to be used behind an authentication proxy. Examples of these are -[AWS ALB](https://github.com/backstage/backstage/blob/master/contrib/docs/tutorials/aws-alb-aad-oidc-auth.md), +[AWS ALB](https://github.com/backstage/backstage/blob/master/contrib/docs/tutorials/aws-alb-aad-oidc-auth.md), [Cloudflare Access](./cloudflare/access.md), [GCP IAP](./google/gcp-iap-auth.md), and [OAuth2 Proxy](./oauth2-proxy/provider.md). When using a proxy provider, you'll end up wanting to use a different sign-in page, as diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 8c9ec14c9e..649a31c963 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -7,6 +7,7 @@ import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; import { BackstageSignInResult } from '@backstage/plugin-auth-node'; +import { CacheClient } from '@backstage/backend-common'; import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; import { Entity } from '@backstage/catalog-model'; @@ -170,6 +171,41 @@ export class CatalogIdentityClient { resolveCatalogMembership(query: MemberClaimQuery): Promise; } +// @public +export type CloudflareAccessClaims = { + aud: string[]; + email: string; + exp: number; + iat: number; + nonce: string; + identity_nonce: string; + sub: string; + iss: string; + custom: string; +}; + +// @public +export type CloudflareAccessGroup = { + id: string; + name: string; + email: string; +}; + +// @public +export type CloudflareAccessIdentityProfile = { + id: string; + name: string; + email: string; + groups: CloudflareAccessGroup[]; +}; + +// @public (undocumented) +export type CloudflareAccessResult = { + claims: CloudflareAccessClaims; + cfIdentity: CloudflareAccessIdentityProfile; + expiresInSeconds?: number; +}; + // @public export type CookieConfigurer = (ctx: { providerId: string; @@ -472,6 +508,18 @@ export const providers: Readonly<{ userIdMatchingUserEntityAnnotation(): SignInResolver; }>; }>; + cfAccess: Readonly<{ + create: (options: { + authHandler?: AuthHandler | undefined; + signIn: { + resolver: SignInResolver; + }; + cache?: CacheClient | undefined; + }) => AuthProviderFactory; + resolvers: Readonly<{ + emailMatchingUserEntityProfileEmail: () => SignInResolver; + }>; + }>; gcpIap: Readonly<{ create: (options: { authHandler?: AuthHandler | undefined; diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index af5a3c24be..5aff9df90a 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -116,6 +116,9 @@ export interface Config { issuer?: string; region: string; }; + cfaccess?: { + teamName: string; + }; }; }; } diff --git a/plugins/auth-backend/src/providers/cloudflare-access/index.ts b/plugins/auth-backend/src/providers/cloudflare-access/index.ts new file mode 100644 index 0000000000..10390af7de --- /dev/null +++ b/plugins/auth-backend/src/providers/cloudflare-access/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2022 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. + */ +export { cfAccess } from './provider'; +export type { + CloudflareAccessClaims, + CloudflareAccessGroup, + CloudflareAccessResult, + CloudflareAccessIdentityProfile, +} from './provider'; diff --git a/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts b/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts new file mode 100644 index 0000000000..21ec7371e5 --- /dev/null +++ b/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts @@ -0,0 +1,267 @@ +/* + * Copyright 2022 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 { + CF_JWT_HEADER, + CF_AUTH_IDENTITY, + CloudflareAccessAuthProvider, +} from './provider'; +import { AuthResolverContext } from '../types'; +import fetch from 'node-fetch'; + +const jwtMock = jwtVerify as jest.Mocked; +const mockJwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IktFWV9JRCIsImlzcyI6IklTU1VFUl9VUkwifQ.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlVzZXIgTmFtZSIsImlhdCI6MTUxNjIzOTAyMn0.uMCSBGhij1xn5pnot8XgD-huQuTIBOFGs6kkW_p_X94'; +const mockClaims = { + sub: '1234567890', + email: 'user.name@email.test', + iat: 1632833760, + exp: 1632833763, + iss: 'ISSUER_URL', +}; +const mockCfIdentity = { + name: 'foo', + id: '123', + email: 'foo@bar.com', + groups: [ + { + id: '123', + email: 'foo@bar.com', + name: 'foo', + }, + ], +}; + +const identityOkResponse = { + backstageIdentity: { + identity: { + ownershipEntityRefs: ['user:default/jimmymarkum'], + type: 'user', + userEntityRef: 'user:default/jimmymarkum', + }, + token: + 'eyblob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iLCJlbnQiOlsidXNlcjpkZWZhdWx0L2ppbW15bWFya3VtIl19.eyblob', + }, + profile: { + email: 'user.name@email.test', + }, + providerInfo: { + cfAccessIdentityProfile: { + email: 'foo@bar.com', + groups: [ + { + email: 'foo@bar.com', + id: '123', + name: 'foo', + }, + ], + id: '123', + name: 'foo', + }, + claims: mockClaims, + 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', () => { + const original = jest.requireActual('node-fetch'); + return { + __esModule: true, + default: jest.fn(), + Headers: original.Headers, + }; +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('CloudflareAccessAuthProvider', () => { + // Cloudflare access provides jwt in two ways. + const mockRequestWithJwtHeader = { + header: jest.fn(name => { + if (name === CF_JWT_HEADER) { + return mockJwt; + } else if (name === CF_AUTH_IDENTITY) { + return mockAuthenticatedUserEmail; + } + 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; + }), + } as unknown as express.Request; + + const mockResponse = { + end: jest.fn(), + header: () => jest.fn(), + json: jest.fn(), + status: jest.fn(), + } as unknown as express.Response; + + const mockFetch = fetch as unknown as jest.Mocked; + + const provider = new CloudflareAccessAuthProvider({ + teamName: 'foobar', + resolverContext: {} as AuthResolverContext, + authHandler: async ({ claims }) => ({ + profile: { + email: claims.email, + }, + }), + signInResolver: async () => { + return { + token: + 'eyblob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iLCJlbnQiOlsidXNlcjpkZWZhdWx0L2ppbW15bWFya3VtIl19.eyblob', + }; + }, + cache: mockCacheClient, + }); + + describe('when JWT is valid', () => { + 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('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); + }); + + 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(identityOkResponse); + }); + + it('should throw an error when get-identity fails', async () => { + mockFetch.mockReturnValue(Promise.reject()); + await expect( + provider.refresh(mockRequestWithJwtCookie, mockResponse), + ).rejects.toThrowError(); + }); + }); + + describe('should fail when', () => { + it('JWT is missing', async () => { + await expect( + provider.refresh(mockRequestWithoutJwt, mockResponse), + ).rejects.toThrow(); + }); + + it('JWT is invalid', async () => { + jwtMock.mockImplementation(() => { + throw new Error('bad JWT'); + }); + await expect( + provider.refresh(mockRequestWithJwtCookie, mockResponse), + ).rejects.toThrow(); + await expect( + provider.refresh(mockRequestWithJwtHeader, mockResponse), + ).rejects.toThrow(); + jwtMock.mockReset(); + }); + + it('SignInResolver rejects', async () => { + jwtMock.mockReturnValue(mockClaims); + await expect( + provider.refresh(mockRequestWithJwtCookie, mockResponse), + ).rejects.toThrow(); + await expect( + provider.refresh(mockRequestWithJwtHeader, mockResponse), + ).rejects.toThrow(); + jwtMock.mockReset(); + }); + + it('AuthHandler rejects', async () => { + jwtMock.mockReturnValue(mockClaims); + + await expect( + 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 new file mode 100644 index 0000000000..a2c16fcebf --- /dev/null +++ b/plugins/auth-backend/src/providers/cloudflare-access/provider.ts @@ -0,0 +1,379 @@ +/* + * Copyright 2022 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 { + AuthHandler, + AuthProviderRouteHandlers, + AuthResolverContext, + AuthResponse, + SignInResolver, +} from '../types'; +import fetch, { Headers } from 'node-fetch'; +import express from 'express'; +import * as _ from 'lodash'; +import { jwtVerify, createRemoteJWKSet } from 'jose'; +import { + AuthenticationError, + ResponseError, + ForwardedError, +} from '@backstage/errors'; +import { CacheClient } from '@backstage/backend-common'; +import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; +import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse'; +import { commonByEmailResolver } from '../resolvers'; + +// 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'; +const CACHE_PREFIX = 'providers/cloudflare-access/profile-v1'; + +/** + * Default cache TTL + * + * @public + */ +export const CF_DEFAULT_CACHE_TTL = 3600; + +/** @public */ +export type Options = { + /** + * Access team name + * + * When you configure Access, the public certificates are available at this + * URL, where your-team-name is your team name: + * https://.cloudflareaccess.com/cdn-cgi/access/certs + */ + teamName: string; + authHandler: AuthHandler; + signInResolver: SignInResolver; + resolverContext: AuthResolverContext; + cache?: CacheClient; +}; + +/** + * CloudflareAccessClaims + * + * Can be used in externally provided auth handler or sign in resolver to + * enrich user profile for sign-in user entity + * + * @public + */ +export type CloudflareAccessClaims = { + /** + * `aud` identifies the application to which the JWT is issued. + */ + aud: string[]; + /** + * `email` contains the email address of the authenticated user. + */ + email: string; + /** + * iat and exp are the issuance and expiration timestamps. + */ + exp: number; + iat: number; + /** + * `nonce` is the session identifier. + */ + nonce: string; + /** + * `identity_nonce` is available in the Application Token and can be used to + * query all group membership for a given user. + */ + identity_nonce: string; + /** + * `sub` contains the identifier of the authenticated user. + */ + sub: string; + /** + * `iss` the issuer is the application’s Cloudflare Access Domain URL. + */ + iss: string; + /** + * `custom` contains SAML attributes in the Application Token specified by an + * administrator in the identity provider configuration. + */ + custom: string; +}; + +/** + * CloudflareAccessGroup + * + * @public + */ +export type CloudflareAccessGroup = { + /** + * Group id + */ + id: string; + /** + * Name of group as defined in Cloudflare zero trust dashboard + */ + name: string; + /** + * Access group email address + */ + email: string; +}; + +/** + * CloudflareAccessIdentityProfile + * + * Can be used in externally provided auth handler or sign in resolver to + * enrich user profile for sign-in user entity + * + * @public + */ +export type CloudflareAccessIdentityProfile = { + id: string; + name: string; + email: string; + groups: CloudflareAccessGroup[]; +}; + +/** + * + * @public + */ +export type CloudflareAccessResult = { + claims: CloudflareAccessClaims; + 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; + /** + * Cloudflare access claims + */ + claims: CloudflareAccessClaims; +}; + +export type CloudflareAccessResponse = + AuthResponse; + +export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { + private readonly teamName: string; + private readonly resolverContext: AuthResolverContext; + private readonly authHandler: AuthHandler; + private readonly signInResolver: SignInResolver; + private readonly jwtKeySet: any; + private readonly cache?: CacheClient; + + constructor(options: Options) { + this.teamName = options.teamName; + this.authHandler = options.authHandler; + this.signInResolver = options.signInResolver; + this.resolverContext = options.resolverContext; + this.jwtKeySet = createRemoteJWKSet( + new URL( + `https://${this.teamName}.cloudflareaccess.com/cdn-cgi/access/certs`, + ), + ); + this.cache = options.cache; + } + + frameHandler(): Promise { + return Promise.resolve(); + } + + async refresh(req: express.Request, res: express.Response): Promise { + // ProxiedSignInPage calls `/refresh` implicitly each time the backstage + // app is refreshed on the browser. + // User authentication is then checked here. + const result = await this.getResult(req); + const response = await this.handleResult(result); + res.json(response); + } + + start(): Promise { + 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 = await fetch( + `https://${this.teamName}.cloudflareaccess.com/cdn-cgi/access/get-identity`, + { headers }, + ); + if (!res.ok) { + throw ResponseError.fromResponse(res); + } + const cfIdentity = await res.json(); + return cfIdentity as unknown as CloudflareAccessIdentityProfile; + } catch (err) { + throw new ForwardedError('getIdentityProfile failed', err); + } + } + + private async getResult( + req: express.Request, + ): Promise { + // JWTs generated by Access are available in a request header as + // Cf-Access-Jwt-Assertion and as cookies as CF_Authorization. + let jwt = req.header(CF_JWT_HEADER); + if (!jwt) { + jwt = req.cookies.CF_Authorization; + } + if (!jwt) { + // Only throw if both are not provided by Cloudflare Access since either + // can be used. + throw new AuthenticationError( + `Missing ${CF_JWT_HEADER} from Cloudflare Access`, + ); + } + + // Cloudflare signs the JWT using the RSA Signature with SHA-256 (RS256). + // 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, { + issuer: `https://${this.teamName}.cloudflareaccess.com`, + }); + const sub = verifyResult.payload.sub; + const cfAccessResultStr = await this.cache?.get(`${CACHE_PREFIX}/${sub}`); + if (typeof cfAccessResultStr === 'string') { + return JSON.parse(cfAccessResultStr) as CloudflareAccessResult; + } + const claims = verifyResult.payload as CloudflareAccessClaims; + // Builds a passport profile from JWT claims first + 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); + // Stores a stringified JSON object in cfaccess provider cache only when + // we complete all steps + const cfAccessResult: CloudflareAccessResult = { + claims, + cfIdentity, + expiresInSeconds: claims.exp - claims.iat, + }; + this.cache?.set(`${CACHE_PREFIX}/${sub}`, JSON.stringify(cfAccessResult)); + return cfAccessResult; + } catch (err) { + throw new ForwardedError( + 'Failed to populate access identity information', + err, + ); + } + } + + private async handleResult( + result: CloudflareAccessResult, + ): Promise { + const { profile } = await this.authHandler(result, this.resolverContext); + const backstageIdentity = await this.signInResolver( + { + result, + profile, + }, + this.resolverContext, + ); + + return { + providerInfo: { + expiresInSeconds: result.expiresInSeconds, + claims: result.claims, + cfAccessIdentityProfile: result.cfIdentity, + }, + backstageIdentity: prepareBackstageIdentityResponse(backstageIdentity), + profile, + }; + } +} + +/** + * Auth provider integration for Cloudflare Access auth + * + * @public + */ +export const cfAccess = 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. + */ + authHandler?: AuthHandler; + + /** + * 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; + }; + /** + * CacheClient object that was configured for the Backstage backend, + * should be provided via the backend auth plugin. + */ + cache?: CacheClient; + }) { + return ({ config, resolverContext }) => { + const teamName = config.getString('teamName'); + + if (!options.signIn.resolver) { + throw new Error( + 'SignInResolver is required to use this authentication provider', + ); + } + + const authHandler: AuthHandler = + options?.authHandler + ? options.authHandler + : async ({ claims, cfIdentity }) => { + return { + profile: { + email: claims.email, + displayName: cfIdentity.name, + }, + }; + }; + + return new CloudflareAccessAuthProvider({ + teamName, + signInResolver: options?.signIn.resolver, + authHandler, + resolverContext, + ...(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/index.ts b/plugins/auth-backend/src/providers/index.ts index 41ee44beae..83dd1c6c1f 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -20,6 +20,12 @@ export type { BitbucketOAuthResult, BitbucketPassportProfile, } from './bitbucket'; +export type { + CloudflareAccessClaims, + CloudflareAccessGroup, + CloudflareAccessResult, + CloudflareAccessIdentityProfile, +} from './cloudflare-access'; export type { GithubOAuthResult } from './github'; export type { OAuth2ProxyResult } from './oauth2-proxy'; export type { OidcAuthResult } from './oidc'; diff --git a/plugins/auth-backend/src/providers/providers.ts b/plugins/auth-backend/src/providers/providers.ts index 71df8223ef..b2795c25f0 100644 --- a/plugins/auth-backend/src/providers/providers.ts +++ b/plugins/auth-backend/src/providers/providers.ts @@ -18,6 +18,7 @@ import { atlassian } from './atlassian'; import { auth0 } from './auth0'; import { awsAlb } from './aws-alb'; import { bitbucket } from './bitbucket'; +import { cfAccess } from './cloudflare-access'; import { gcpIap } from './gcp-iap'; import { github } from './github'; import { gitlab } from './gitlab'; @@ -41,6 +42,7 @@ export const providers = Object.freeze({ auth0, awsAlb, bitbucket, + cfAccess, gcpIap, github, gitlab,