diff --git a/.changeset/soft-otters-report.md b/.changeset/soft-otters-report.md new file mode 100644 index 0000000000..d71d3a2e9a --- /dev/null +++ b/.changeset/soft-otters-report.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': minor +--- + +Add support for Service Tokens to Cloudflare Access auth provider diff --git a/docs/auth/cloudflare/access.md b/docs/auth/cloudflare/access.md index b1d8e4c676..509c0ba052 100644 --- a/docs/auth/cloudflare/access.md +++ b/docs/auth/cloudflare/access.md @@ -25,9 +25,14 @@ auth: providers: cfaccess: teamName: + 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. diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index 4c8430b33f..f5f4dd29fd 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -185,6 +185,11 @@ export interface Config { /** @visibility frontend */ cfaccess?: { teamName: string; + /** @deepVisibility secret */ + serviceTokens?: Array<{ + token: string; + subject: string; + }>; }; /** * The backstage token expiration. 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 95e4b0901b..f5f1233ecc 100644 --- a/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts +++ b/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts @@ -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( diff --git a/plugins/auth-backend/src/providers/cloudflare-access/provider.ts b/plugins/auth-backend/src/providers/cloudflare-access/provider.ts index fe271f9058..e4d317f8f6 100644 --- a/plugins/auth-backend/src/providers/cloudflare-access/provider.ts +++ b/plugins/auth-backend/src/providers/cloudflare-access/provider.ts @@ -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://.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; signInResolver: SignInResolver; 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; private readonly signInResolver: SignInResolver; @@ -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,