From 3cedfd836575a9fdb5ac467704d3ac4793b80a45 Mon Sep 17 00:00:00 2001 From: Renlord Yang Date: Thu, 23 Jun 2022 03:39:27 +0000 Subject: [PATCH] add Cloudflare Access authentication provider Signed-off-by: Renlord Yang Signed-off-by: Renlord Yang --- .changeset/lazy-ads-cheer.md | 5 + .github/vale/Vocab/Backstage/accept.txt | 2 + docs/auth/cloudflare/access.md | 100 +++++++ docs/auth/index.md | 3 +- plugins/auth-backend/api-report.md | 6 + plugins/auth-backend/config.d.ts | 3 + .../src/providers/cloudflare-access/index.ts | 17 ++ .../cloudflare-access/provider.test.ts | 208 ++++++++++++++ .../providers/cloudflare-access/provider.ts | 255 ++++++++++++++++++ plugins/auth-backend/src/providers/index.ts | 1 + .../auth-backend/src/providers/providers.ts | 2 + 11 files changed, 601 insertions(+), 1 deletion(-) create mode 100644 .changeset/lazy-ads-cheer.md create mode 100644 docs/auth/cloudflare/access.md create mode 100644 plugins/auth-backend/src/providers/cloudflare-access/index.ts create mode 100644 plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts create mode 100644 plugins/auth-backend/src/providers/cloudflare-access/provider.ts diff --git a/.changeset/lazy-ads-cheer.md b/.changeset/lazy-ads-cheer.md new file mode 100644 index 0000000000..477e63bc46 --- /dev/null +++ b/.changeset/lazy-ads-cheer.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': minor +--- + +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..e2391cd2ae 100644 --- a/.github/vale/Vocab/Backstage/accept.txt +++ b/.github/vale/Vocab/Backstage/accept.txt @@ -362,3 +362,5 @@ Zhou zoomable zsh Alef +Cloudflare +cloudflare diff --git a/docs/auth/cloudflare/access.md b/docs/auth/cloudflare/access.md new file mode 100644 index 0000000000..827d398dc0 --- /dev/null +++ b/docs/auth/cloudflare/access.md @@ -0,0 +1,100 @@ +--- +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 => , +``` + +See the [Sign-In with Proxy Providers](../index.md#sign-in-with-proxy-providers) section for more information. 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..38018ee294 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -170,6 +170,12 @@ export class CatalogIdentityClient { resolveCatalogMembership(query: MemberClaimQuery): Promise; } +// @public (undocumented) +export type CloudflareAccessResult = { + fullProfile: Profile; + expiresInSeconds?: number; +}; + // @public export type CookieConfigurer = (ctx: { providerId: string; 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..c56b0bba5f --- /dev/null +++ b/plugins/auth-backend/src/providers/cloudflare-access/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { CloudflareAccessResult } 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..8a21b9440d --- /dev/null +++ b/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts @@ -0,0 +1,208 @@ +/* + * 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 { makeProfileInfo } from '../../lib/passport'; +import { AuthResolverContext } from '../types'; + +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 = { + sub: '1234567890', + name: 'User Name', + family_name: 'Name', + given_name: 'User', + email: 'user.name@email.test', + iat: 1632833760, + exp: 1632833763, + iss: 'ISSUER_URL', +}; +const mockAuthenticatedUserEmail = 'user.name@email.test'; + +jest.mock('jose'); +jest.mock('node-fetch', () => ({ + __esModule: true, + default: async () => { + return { + text: async () => { + return mockKey(); + }, + }; + }, +})); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('CloudflareAccessAuthProvider', () => { + const mockRequest = { + 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 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 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', + }; + }, + }); + + 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', + }, + providerInfo: { + expiresInSeconds: mockClaims.exp - mockClaims.iat, + }, + }); + }); + }); + + 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(() => { + throw new Error('bad JWT'); + }); + + await expect( + provider.refresh(mockRequest, mockResponse), + ).rejects.toThrow(); + }); + + 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); + + await expect( + provider.refresh(mockRequest, mockResponse), + ).rejects.toThrow(); + }); + + 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); + + await expect( + provider.refresh(mockRequest, mockResponse), + ).rejects.toThrow(); + }); + }); +}); 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..812f0c4aba --- /dev/null +++ b/plugins/auth-backend/src/providers/cloudflare-access/provider.ts @@ -0,0 +1,255 @@ +/* + * 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 express from 'express'; +import * as _ from 'lodash'; +import { jwtVerify, createRemoteJWKSet } from 'jose'; +import { Profile as PassportProfile } from 'passport'; +import { AuthenticationError } from '@backstage/errors'; +import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; +import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse'; +import { makeProfileInfo } from '../../lib/passport'; + +// 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'; + +/** @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; +}; + +/** @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; +}; + +/** @public */ +export type CloudflareAccessResult = { + fullProfile: PassportProfile; + expiresInSeconds?: number; +}; + +export type CloudflareAccessProviderInfo = { + /** + * Expiry of the access token in seconds. + */ + expiresInSeconds?: number; +}; + +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; + + 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`, + ), + ); + } + + 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 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, { + // Cloudflare signs the JWT using the RSA Signature with SHA-256 (RS256). + algorithms: ['RS256'], + issuer: `https://${this.teamName}.cloudflareaccess.com`, + }); + const claims = verifyResult.payload as CloudflareAccessClaims; + + 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() }], + }; + + return { + fullProfile, + expiresInSeconds: claims.exp - claims.iat, + }; + } + + 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, + }, + 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; + }; + }) { + 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 ({ fullProfile }) => ({ + profile: makeProfileInfo(fullProfile), + }); + + return new CloudflareAccessAuthProvider({ + teamName, + signInResolver: options?.signIn.resolver, + authHandler, + resolverContext, + }); + }; + }, +}); diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 41ee44beae..363c89cb28 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -20,6 +20,7 @@ export type { BitbucketOAuthResult, BitbucketPassportProfile, } from './bitbucket'; +export type { CloudflareAccessResult } 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..81d9711a52 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'; @@ -75,4 +76,5 @@ export const defaultAuthProviderFactories: { awsalb: awsAlb.create(), bitbucket: bitbucket.create(), atlassian: atlassian.create(), + cfaccess: cfAccess.create(), };