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(),
};