Merge pull request #22211 from tylerd-canva/tylerd/cfaccess-token-poc

Add support for Cloudflare Access Tokens to `cfaccess` Auth Provider
This commit is contained in:
Fredrik Adelöw
2024-02-27 10:20:32 +01:00
committed by GitHub
5 changed files with 210 additions and 9 deletions
+5
View File
@@ -185,6 +185,11 @@ export interface Config {
/** @visibility frontend */
cfaccess?: {
teamName: string;
/** @deepVisibility secret */
serviceTokens?: Array<{
token: string;
subject: string;
}>;
};
/**
* The backstage token expiration.
@@ -34,6 +34,24 @@ const mockClaims = {
exp: 1632833763,
iss: 'ISSUER_URL',
};
const mockServiceTokenJwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IktFWV9JRCIsImlzcyI6IklTU1VFUl9VUkwifQ.eyJzdWIiOiIiLCJuYW1lIjoiQm90IiwiY29tbW9uX25hbWUiOiJ0ZXN0X3Rva2VuX2lkLmFjY2VzcyIsImlhdCI6MTUxNjIzOTAyMn0.KEe-qBHuN8HKh1LobtDQnCJ3rxZOhW-lMSDad8uV_l0';
const mockServiceTokenClaims = {
sub: '',
common_name: 'test_token_id.access',
iat: 1632833760,
exp: 1632833763,
iss: 'ISSUER_URL',
};
const mockServiceTokenDisallowedJwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IktFWV9JRCIsImlzcyI6IklTU1VFUl9VUkwifQ.eyJzdWIiOiIiLCJuYW1lIjoiQm90IiwiY29tbW9uX25hbWUiOiJzb21lX290aGVyX3Rva2VuX2lkLmFjY2VzcyIsImlhdCI6MTUxNjIzOTAyMn0.qQeeQW_urYrrTq-tuKZWURwTUrjzgyFyZA9ViQtD-FM';
const mockServiceTokenDisallowedClaims = {
sub: '',
common_name: 'some_other_token_id.access',
iat: 1632833760,
exp: 1632833763,
iss: 'ISSUER_URL',
};
const mockCfIdentity = {
name: 'foo',
id: '123',
@@ -78,6 +96,32 @@ const identityOkResponse = {
},
};
const identityOkServiceTokenResponse = {
backstageIdentity: {
expiresInSeconds: undefined,
identity: {
ownershipEntityRefs: ['user:default/jimmymarkum'],
type: 'user',
userEntityRef: 'user:default/jimmymarkum',
},
token:
'eyblob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iLCJlbnQiOlsidXNlcjpkZWZhdWx0L2ppbW15bWFya3VtIl19.eyblob',
},
profile: {
email: undefined,
},
providerInfo: {
cfAccessIdentityProfile: {
email: 'test_token_id.access@foobar.com',
groups: [],
id: 'test_token_id.access',
name: 'Bot',
},
claims: mockServiceTokenClaims,
expiresInSeconds: 3,
},
};
const mockAuthenticatedUserEmail = 'user.name@email.test';
const mockCacheClient = {
get: jest.fn(),
@@ -121,6 +165,18 @@ describe('CloudflareAccessAuthProvider', () => {
},
} as unknown as express.Request;
const mockRequestWithSericeTokenJwtHeader = {
header: jest.fn(() => {
return mockServiceTokenJwt;
}),
} as unknown as express.Request;
const mockRequestWithSericeTokenDisallowedJwtHeader = {
header: jest.fn(() => {
return mockServiceTokenDisallowedJwt;
}),
} as unknown as express.Request;
const mockRequestWithoutJwt = {
header: jest.fn(_ => {
return undefined;
@@ -138,6 +194,7 @@ describe('CloudflareAccessAuthProvider', () => {
const provider = new CloudflareAccessAuthProvider({
teamName: 'foobar',
serviceTokens: [],
resolverContext: {} as AuthResolverContext,
authHandler: async result => {
expect(result).toEqual(
@@ -169,7 +226,81 @@ describe('CloudflareAccessAuthProvider', () => {
cache: mockCacheClient,
});
const providerServiceToken = new CloudflareAccessAuthProvider({
teamName: 'foobar',
serviceTokens: [
{
token: 'test_token_id.access',
subject: 'test_token_id.access@foobar.com',
},
],
resolverContext: {} as AuthResolverContext,
authHandler: async result => {
expect(result).toEqual(
expect.objectContaining({
claims: mockServiceTokenClaims,
cfIdentity: {
email: 'test_token_id.access@foobar.com',
groups: [],
id: 'test_token_id.access',
name: 'Bot',
},
token: mockServiceTokenJwt,
}),
);
return {
profile: {
email: result.claims.email,
},
};
},
signInResolver: async ({ result }) => {
expect(result).toEqual(
expect.objectContaining({
claims: mockServiceTokenClaims,
cfIdentity: {
email: 'test_token_id.access@foobar.com',
groups: [],
id: 'test_token_id.access',
name: 'Bot',
},
token: mockServiceTokenJwt,
}),
);
return {
token:
'eyblob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iLCJlbnQiOlsidXNlcjpkZWZhdWx0L2ppbW15bWFya3VtIl19.eyblob',
};
},
cache: mockCacheClient,
});
describe('when JWT is valid', () => {
it('validates a service token JWT without calling get-identity', async () => {
jwtMock.mockReturnValue(
Promise.resolve({ payload: mockServiceTokenClaims }),
);
await providerServiceToken.refresh(
mockRequestWithSericeTokenJwtHeader,
mockResponse,
);
expect(mockResponse.json).toHaveBeenCalledWith(
identityOkServiceTokenResponse,
);
});
it('rejects a disallowed service token JWT without calling get-identity', async () => {
jwtMock.mockReturnValue(
Promise.resolve({ payload: mockServiceTokenDisallowedClaims }),
);
await expect(
providerServiceToken.refresh(
mockRequestWithSericeTokenDisallowedJwtHeader,
mockResponse,
),
).rejects.toThrow();
});
it('returns cfidentity also when get-identity succeeds', async () => {
jwtMock.mockReturnValue(Promise.resolve({ payload: mockClaims }));
mockFetch.mockReturnValueOnce(
@@ -17,7 +17,6 @@
import { AuthHandler } from '../types';
import fetch, { Headers } from 'node-fetch';
import express from 'express';
import * as _ from 'lodash';
import { jwtVerify, createRemoteJWKSet } from 'jose';
import {
AuthenticationError,
@@ -49,6 +48,11 @@ const CACHE_PREFIX = 'providers/cloudflare-access/profile-v1';
*/
export const CF_DEFAULT_CACHE_TTL = 3600;
type ServiceToken = {
token: string;
subject: string;
};
/** @public */
export type Options = {
/**
@@ -59,6 +63,15 @@ export type Options = {
* https://<your-team-name>.cloudflareaccess.com/cdn-cgi/access/certs
*/
teamName: string;
/**
* Allowed Cloudflare Service Tokens
*
* Cloudflare does not currently allow assigning any sort of identity to
* Service Tokens. Therefore, this allows you to build an allow list mapping
* the Client ID of any Service Tokens that should be allowed to pass the
* auth check to the identity (email) you would like to associate with it.
*/
serviceTokens: ServiceToken[];
authHandler: AuthHandler<CloudflareAccessResult>;
signInResolver: SignInResolver<CloudflareAccessResult>;
resolverContext: AuthResolverContext;
@@ -179,6 +192,7 @@ export type CloudflareAccessResponse =
export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers {
private readonly teamName: string;
private readonly serviceTokens: ServiceToken[];
private readonly resolverContext: AuthResolverContext;
private readonly authHandler: AuthHandler<CloudflareAccessResult>;
private readonly signInResolver: SignInResolver<CloudflareAccessResult>;
@@ -187,6 +201,7 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers {
constructor(options: Options) {
this.teamName = options.teamName;
this.serviceTokens = options.serviceTokens;
this.authHandler = options.authHandler;
this.signInResolver = options.signInResolver;
this.resolverContext = options.resolverContext;
@@ -260,8 +275,27 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers {
const verifyResult = await jwtVerify(jwt, this.jwtKeySet, {
issuer: `https://${this.teamName}.cloudflareaccess.com`,
});
const sub = verifyResult.payload.sub;
const cfAccessResultStr = await this.cache?.get(`${CACHE_PREFIX}/${sub}`);
const isServiceToken = !verifyResult.payload.sub;
const subject = isServiceToken
? (verifyResult.payload.common_name as string)
: verifyResult.payload.sub;
if (!subject) {
throw new AuthenticationError(
`Missing both sub and common_name from Cloudflare Access JWT`,
);
}
const serviceToken = this.serviceTokens.find(st => st.token === subject);
if (isServiceToken && !serviceToken) {
throw new AuthenticationError(
`${subject} is not a permitted Service Token.`,
);
}
const cacheKey = `${CACHE_PREFIX}/${subject}`;
const cfAccessResultStr = await this.cache?.get(cacheKey);
if (typeof cfAccessResultStr === 'string') {
const result = JSON.parse(cfAccessResultStr) as CloudflareAccessResult;
return {
@@ -270,12 +304,23 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers {
};
}
const claims = verifyResult.payload as CloudflareAccessClaims;
// Builds a passport profile from JWT claims first
try {
// If we successfully fetch the get-identity endpoint,
// We supplement the passport profile with richer user identity
// information here.
const cfIdentity = await this.getIdentityProfile(jwt);
let cfIdentity: CloudflareAccessIdentityProfile;
if (serviceToken) {
cfIdentity = {
id: subject,
name: 'Bot',
email: serviceToken.subject,
groups: [],
};
} else {
// If we successfully fetch the get-identity endpoint,
// We supplement the passport profile with richer user identity
// information here.
cfIdentity = await this.getIdentityProfile(jwt);
}
// Stores a stringified JSON object in cfaccess provider cache only when
// we complete all steps
const cfAccessResult = {
@@ -283,7 +328,7 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers {
cfIdentity,
expiresInSeconds: claims.exp - claims.iat,
};
this.cache?.set(`${CACHE_PREFIX}/${sub}`, JSON.stringify(cfAccessResult));
this.cache?.set(cacheKey, JSON.stringify(cfAccessResult));
return {
...cfAccessResult,
token: jwt,
@@ -350,6 +395,15 @@ export const cfAccess = createAuthProviderIntegration({
}) {
return ({ config, resolverContext }) => {
const teamName = config.getString('teamName');
const serviceTokensConfig =
config.getOptionalConfigArray('serviceTokens');
const serviceTokens =
serviceTokensConfig?.map(cfg => {
return {
token: cfg.getString('token'),
subject: cfg.getString('subject'),
} as ServiceToken;
}) || [];
if (!options.signIn.resolver) {
throw new Error(
@@ -371,6 +425,7 @@ export const cfAccess = createAuthProviderIntegration({
return new CloudflareAccessAuthProvider({
teamName,
serviceTokens,
signInResolver: options?.signIn.resolver,
authHandler,
resolverContext,