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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend': minor
|
||||
---
|
||||
|
||||
Add support for Service Tokens to Cloudflare Access auth provider
|
||||
@@ -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.
|
||||
|
||||
Vendored
+5
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user