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/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts b/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts index 95e4b0901b..f34795fa0d 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,15 @@ 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 mockCfIdentity = { name: 'foo', id: '123', @@ -78,6 +87,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 +156,12 @@ describe('CloudflareAccessAuthProvider', () => { }, } as unknown as express.Request; + const mockRequestWithSericeTokenJwtHeader = { + header: jest.fn(() => { + return mockServiceTokenJwt; + }), + } as unknown as express.Request; + const mockRequestWithoutJwt = { header: jest.fn(_ => { return undefined; @@ -169,7 +210,63 @@ describe('CloudflareAccessAuthProvider', () => { cache: mockCacheClient, }); + const providerServiceToken = new CloudflareAccessAuthProvider({ + teamName: 'foobar', + 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('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..c6217e6523 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, @@ -260,8 +259,20 @@ 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 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 +281,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 (isServiceToken) { + cfIdentity = { + id: subject, + name: 'Bot', + email: `${subject}@${this.teamName}.com`, + 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 +305,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,