From 3cedfd836575a9fdb5ac467704d3ac4793b80a45 Mon Sep 17 00:00:00 2001 From: Renlord Yang Date: Thu, 23 Jun 2022 03:39:27 +0000 Subject: [PATCH 1/5] 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(), }; From 9dce0535d61e5d70ec600a78e6d5c729b8f2d731 Mon Sep 17 00:00:00 2001 From: Renlord Yang Date: Thu, 14 Jul 2022 04:32:44 +0000 Subject: [PATCH 2/5] 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(), }; From 339a4213b85daf44eb78b07a19e76ade3f4dd7b6 Mon Sep 17 00:00:00 2001 From: Renlord Yang Date: Thu, 14 Jul 2022 06:24:27 +0000 Subject: [PATCH 3/5] return cloudflare access claims directly Signed-off-by: Renlord Yang Signed-off-by: Renlord Yang --- .../cloudflare-access/provider.test.ts | 171 +++--------------- .../providers/cloudflare-access/provider.ts | 79 ++++---- 2 files changed, 66 insertions(+), 184 deletions(-) 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 6163add891..21ec7371e5 100644 --- a/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts +++ b/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts @@ -21,19 +21,14 @@ import { CF_AUTH_IDENTITY, CloudflareAccessAuthProvider, } 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 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, @@ -52,27 +47,6 @@ const mockCfIdentity = { ], }; -// 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: { @@ -84,9 +58,7 @@ const identityOkResponse = { 'eyblob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iLCJlbnQiOlsidXNlcjpkZWZhdWx0L2ppbW15bWFya3VtIl19.eyblob', }, profile: { - displayName: 'foo', - email: 'foo@bar.com', - picture: undefined, + email: 'user.name@email.test', }, providerInfo: { cfAccessIdentityProfile: { @@ -101,6 +73,7 @@ const identityOkResponse = { id: '123', name: 'foo', }, + claims: mockClaims, expiresInSeconds: 3, }, }; @@ -160,13 +133,15 @@ describe('CloudflareAccessAuthProvider', () => { status: jest.fn(), } as unknown as express.Response; - const mockFetch = fetch as unknown as jest.MockedFn; + const mockFetch = fetch as unknown as jest.Mocked; const provider = new CloudflareAccessAuthProvider({ teamName: 'foobar', resolverContext: {} as AuthResolverContext, - authHandler: async ({ fullProfile }) => ({ - profile: makeProfileInfo(fullProfile), + authHandler: async ({ claims }) => ({ + profile: { + email: claims.email, + }, }), signInResolver: async () => { return { @@ -175,49 +150,22 @@ describe('CloudflareAccessAuthProvider', () => { }; }, 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('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 () => { @@ -234,46 +182,6 @@ describe('CloudflareAccessAuthProvider', () => { ); 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(mockRequestWithJwtCookie, 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, - }, - }); }); it('should resolve an identity and populate access groups when there are groups', async () => { @@ -302,37 +210,14 @@ describe('CloudflareAccessAuthProvider', () => { ); 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', - }, - ], - }, - }, - }); + 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(); }); }); diff --git a/plugins/auth-backend/src/providers/cloudflare-access/provider.ts b/plugins/auth-backend/src/providers/cloudflare-access/provider.ts index e5254a2fe0..b862e3c6de 100644 --- a/plugins/auth-backend/src/providers/cloudflare-access/provider.ts +++ b/plugins/auth-backend/src/providers/cloudflare-access/provider.ts @@ -20,24 +20,26 @@ import { AuthResponse, SignInResolver, } from '../types'; +import fetch, { Headers } from 'node-fetch'; 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, ResponseError } from '@backstage/errors'; +import { + AuthenticationError, + ResponseError, + ForwardedError, +} 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'; +const CACHE_PREFIX = 'providers/cloudflare-access/profile-v1'; /** * Default cache TTL @@ -59,7 +61,6 @@ export type Options = { authHandler: AuthHandler; signInResolver: SignInResolver; resolverContext: AuthResolverContext; - logger: Logger; cache?: CacheClient; }; @@ -116,8 +117,8 @@ type CloudflareAccessIdentityProfile = { }; export type CloudflareAccessResult = { - fullProfile: PassportProfile; - cfIdentity?: CloudflareAccessIdentityProfile; + claims: CloudflareAccessClaims; + cfIdentity: CloudflareAccessIdentityProfile; expiresInSeconds?: number; }; @@ -133,6 +134,10 @@ export type CloudflareAccessProviderInfo = { * Cloudflare access identity profile with cloudflare access groups */ cfAccessIdentityProfile?: CloudflareAccessIdentityProfile; + /** + * Cloudflare access claims + */ + claims: CloudflareAccessClaims; }; export type CloudflareAccessResponse = @@ -144,7 +149,6 @@ 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) { @@ -157,7 +161,6 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { `https://${this.teamName}.cloudflareaccess.com/cdn-cgi/access/certs`, ), ); - this.logger = options.logger; this.cache = options.cache; } @@ -186,19 +189,17 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { headers.set(CF_JWT_HEADER, jwt); headers.set('cookie', `${COOKIE_AUTH_NAME}=${jwt}`); try { - const res: FetchResponse = await fetch( + const res = 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); + throw ResponseError.fromResponse(res); } const cfIdentity = await res.json(); return cfIdentity as unknown as CloudflareAccessIdentityProfile; } catch (err) { - this.logger.error(`getIdentityProfile failed: ${err}`); - return Promise.reject(err); + throw new ForwardedError('getIdentityProfile failed', err); } } @@ -225,42 +226,33 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { const verifyResult = await jwtVerify(jwt, this.jwtKeySet, { issuer: `https://${this.teamName}.cloudflareaccess.com`, }); - const cfAccessResultStr = await this.cache?.get(jwt); + 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 - const username = claims.email.split('@')[0].toLowerCase(); - const fullProfile: PassportProfile = { - provider: 'cfAccess', - id: claims.sub, - displayName: _.startCase(username.replace('.', ' ')), - username: username, - emails: [{ value: claims.email }], - }; - 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)); + const cfAccessResult: CloudflareAccessResult = { + claims, + cfIdentity, + expiresInSeconds: claims.exp - claims.iat, + }; + this.cache?.set(`${CACHE_PREFIX}/${sub}`, JSON.stringify(cfAccessResult)); + return cfAccessResult; } catch (err) { - this.logger.error( - `failed to populate access identity information: ${err}`, + throw new ForwardedError( + 'Failed to populate access identity information', + err, ); } - return cfAccessResult; } private async handleResult( @@ -278,6 +270,7 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { return { providerInfo: { expiresInSeconds: result.expiresInSeconds, + claims: result.claims, cfAccessIdentityProfile: result.cfIdentity, }, backstageIdentity: prepareBackstageIdentityResponse(backstageIdentity), @@ -314,7 +307,7 @@ export const cfAccess = createAuthProviderIntegration({ */ cache?: CacheClient; }) { - return ({ config, logger, resolverContext }) => { + return ({ config, resolverContext }) => { const teamName = config.getString('teamName'); if (!options.signIn.resolver) { @@ -326,16 +319,20 @@ export const cfAccess = createAuthProviderIntegration({ const authHandler: AuthHandler = options?.authHandler ? options.authHandler - : async ({ fullProfile }) => ({ - profile: makeProfileInfo(fullProfile), - }); + : async ({ claims, cfIdentity }) => { + return { + profile: { + email: claims.email, + displayName: cfIdentity.name, + }, + }; + }; return new CloudflareAccessAuthProvider({ teamName, signInResolver: options?.signIn.resolver, authHandler, resolverContext, - logger, ...(options.cache && { cache: options.cache }), }); }; From abc1d5c7a4c9e18b9d2612ab682fa75c8ccd01b4 Mon Sep 17 00:00:00 2001 From: Renlord Yang Date: Thu, 14 Jul 2022 23:03:29 +0800 Subject: [PATCH 4/5] update api-reports in auth-backend Signed-off-by: Renlord Yang --- plugins/auth-backend/api-report.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 38018ee294..0e7219ba88 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,9 +171,12 @@ export class CatalogIdentityClient { resolveCatalogMembership(query: MemberClaimQuery): Promise; } +// Warning: (ae-missing-release-tag) "CloudflareAccessResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// // @public (undocumented) export type CloudflareAccessResult = { - fullProfile: Profile; + claims: CloudflareAccessClaims; + cfIdentity: CloudflareAccessIdentityProfile; expiresInSeconds?: number; }; @@ -478,6 +482,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; @@ -724,4 +740,9 @@ export type WebMessageResponse = type: 'authorization_response'; error: Error; }; + +// Warnings were encountered during analysis: +// +// src/providers/cloudflare-access/provider.d.ts:77:5 - (ae-forgotten-export) The symbol "CloudflareAccessClaims" needs to be exported by the entry point index.d.ts +// src/providers/cloudflare-access/provider.d.ts:78:5 - (ae-forgotten-export) The symbol "CloudflareAccessIdentityProfile" needs to be exported by the entry point index.d.ts ``` From 154544f43e27fa8df2cdeb8d29d22a2004f277d5 Mon Sep 17 00:00:00 2001 From: Renlord Yang Date: Sat, 16 Jul 2022 11:09:29 +0800 Subject: [PATCH 5/5] Properly export CloudflareAccessClaims and CloudflareAccessIdentityProfile types This allows these types to be used externally when injecting a custom auth handler. Signed-off-by: Renlord Yang --- plugins/auth-backend/api-report.md | 35 +++++++++++++---- .../src/providers/cloudflare-access/index.ts | 7 +++- .../providers/cloudflare-access/provider.ts | 39 +++++++++++++++++-- plugins/auth-backend/src/providers/index.ts | 7 +++- 4 files changed, 76 insertions(+), 12 deletions(-) diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 0e7219ba88..649a31c963 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -171,8 +171,34 @@ export class CatalogIdentityClient { resolveCatalogMembership(query: MemberClaimQuery): Promise; } -// Warning: (ae-missing-release-tag) "CloudflareAccessResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// +// @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; @@ -740,9 +766,4 @@ export type WebMessageResponse = type: 'authorization_response'; error: Error; }; - -// Warnings were encountered during analysis: -// -// src/providers/cloudflare-access/provider.d.ts:77:5 - (ae-forgotten-export) The symbol "CloudflareAccessClaims" needs to be exported by the entry point index.d.ts -// src/providers/cloudflare-access/provider.d.ts:78:5 - (ae-forgotten-export) The symbol "CloudflareAccessIdentityProfile" needs to be exported by the entry point index.d.ts ``` diff --git a/plugins/auth-backend/src/providers/cloudflare-access/index.ts b/plugins/auth-backend/src/providers/cloudflare-access/index.ts index c56b0bba5f..10390af7de 100644 --- a/plugins/auth-backend/src/providers/cloudflare-access/index.ts +++ b/plugins/auth-backend/src/providers/cloudflare-access/index.ts @@ -14,4 +14,9 @@ * limitations under the License. */ export { cfAccess } from './provider'; -export type { CloudflareAccessResult } from './provider'; +export type { + CloudflareAccessClaims, + CloudflareAccessGroup, + CloudflareAccessResult, + CloudflareAccessIdentityProfile, +} from './provider'; diff --git a/plugins/auth-backend/src/providers/cloudflare-access/provider.ts b/plugins/auth-backend/src/providers/cloudflare-access/provider.ts index b862e3c6de..a2c16fcebf 100644 --- a/plugins/auth-backend/src/providers/cloudflare-access/provider.ts +++ b/plugins/auth-backend/src/providers/cloudflare-access/provider.ts @@ -64,7 +64,14 @@ export type Options = { cache?: CacheClient; }; -/** @public */ +/** + * 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. @@ -103,19 +110,45 @@ export type CloudflareAccessClaims = { custom: string; }; -type CloudflareAccessGroup = { +/** + * 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; }; -type CloudflareAccessIdentityProfile = { +/** + * 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; diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 363c89cb28..83dd1c6c1f 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -20,7 +20,12 @@ export type { BitbucketOAuthResult, BitbucketPassportProfile, } from './bitbucket'; -export type { CloudflareAccessResult } from './cloudflare-access'; +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';