add support to include cloudflare groups and implement caching

Signed-off-by: Renlord Yang <renlord@cloudflare.com>
Signed-off-by: Renlord Yang <me@renlord.com>
This commit is contained in:
Renlord Yang
2022-07-14 04:32:44 +00:00
committed by Renlord Yang
parent 3cedfd8365
commit 9dce0535d6
6 changed files with 388 additions and 108 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
---
'@backstage/plugin-auth-backend': minor
'@backstage/plugin-auth-backend': patch
---
add Cloudflare Access auth provider to auth-backend
-1
View File
@@ -363,4 +363,3 @@ zoomable
zsh
Alef
Cloudflare
cloudflare
+14 -1
View File
@@ -97,4 +97,17 @@ installed in `packages/app/src/App.tsx` like this:
+ SignInPage: props => <ProxiedSignInPage {...props} provider="cfaccess" />,
```
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 => <ProxiedSignInPage {...props} provider="cfaccess" />,
```
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.
@@ -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<any>;
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<any>;
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();
});
});
});
@@ -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<CloudflareAccessResult>;
signInResolver: SignInResolver<CloudflareAccessResult>;
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<CloudflareAccessResult>;
private readonly signInResolver: SignInResolver<CloudflareAccessResult>;
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<void> {
@@ -140,6 +178,30 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers {
return Promise.resolve();
}
private async getIdentityProfile(
jwt: string,
): Promise<CloudflareAccessIdentityProfile> {
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<CloudflareAccessResult> {
@@ -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<CloudflareAccessResult>;
};
/**
* 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,
},
});
@@ -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(),
};