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
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': minor
---
Add support for Service Tokens to Cloudflare Access auth provider
+6 -1
View File
@@ -25,9 +25,14 @@ auth:
providers:
cfaccess:
teamName: <Team Name>
serviceTokens:
- token: '1uh2fh19efvfh129f1f919u21f2f19jf2.access'
subject: 'bot-user@your-company.com'
```
You can find the team name in the Cloudflare Zero Trust dashboard.
You can find the team name in the Cloudflare Zero Trust dashboard. The Service
Tokens section is optional -- you only need it if you have some Cloudflare
Service Tokens that you want to be able to log in to your Backstage instance.
This config section must be in place for the provider to load at all. Now let's
add the provider itself.
+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,