From 3191d616e835bba03f14007090e041d0869efdfd Mon Sep 17 00:00:00 2001 From: Tyler Davis Date: Wed, 7 Feb 2024 20:26:04 +1100 Subject: [PATCH] pr feedback: make service tokens configurable Signed-off-by: Tyler Davis --- docs/auth/cloudflare/access.md | 2 ++ .../cloudflare-access/provider.test.ts | 31 +++++++++++++++++++ .../providers/cloudflare-access/provider.ts | 31 +++++++++++++++++-- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/docs/auth/cloudflare/access.md b/docs/auth/cloudflare/access.md index b1d8e4c676..457c3abd3e 100644 --- a/docs/auth/cloudflare/access.md +++ b/docs/auth/cloudflare/access.md @@ -25,6 +25,8 @@ auth: providers: cfaccess: teamName: + serviceTokens: + "1uh2fh19efvfh129f1f919u21f2f19jf2.access": "bot-user@your-company.com ``` You can find the team name in the Cloudflare Zero Trust dashboard. 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 f34795fa0d..f5f1f361ab 100644 --- a/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts +++ b/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts @@ -43,6 +43,15 @@ const mockServiceTokenClaims = { 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', @@ -162,6 +171,12 @@ describe('CloudflareAccessAuthProvider', () => { }), } as unknown as express.Request; + const mockRequestWithSericeTokenDisallowedJwtHeader = { + header: jest.fn(() => { + return mockServiceTokenDisallowedJwt; + }), + } as unknown as express.Request; + const mockRequestWithoutJwt = { header: jest.fn(_ => { return undefined; @@ -179,6 +194,7 @@ describe('CloudflareAccessAuthProvider', () => { const provider = new CloudflareAccessAuthProvider({ teamName: 'foobar', + serviceTokens: {}, resolverContext: {} as AuthResolverContext, authHandler: async result => { expect(result).toEqual( @@ -212,6 +228,9 @@ describe('CloudflareAccessAuthProvider', () => { const providerServiceToken = new CloudflareAccessAuthProvider({ teamName: 'foobar', + serviceTokens: { + 'test_token_id.access': 'test_token_id.access@foobar.com', + }, resolverContext: {} as AuthResolverContext, authHandler: async result => { expect(result).toEqual( @@ -267,6 +286,18 @@ describe('CloudflareAccessAuthProvider', () => { ); }); + 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 c6217e6523..31e6e6d391 100644 --- a/plugins/auth-backend/src/providers/cloudflare-access/provider.ts +++ b/plugins/auth-backend/src/providers/cloudflare-access/provider.ts @@ -48,6 +48,8 @@ const CACHE_PREFIX = 'providers/cloudflare-access/profile-v1'; */ export const CF_DEFAULT_CACHE_TTL = 3600; +type ServiceTokens = Record; + /** @public */ export type Options = { /** @@ -58,6 +60,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: ServiceTokens; authHandler: AuthHandler; signInResolver: SignInResolver; resolverContext: AuthResolverContext; @@ -178,6 +189,7 @@ export type CloudflareAccessResponse = export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { private readonly teamName: string; + private readonly serviceTokens: ServiceTokens; private readonly resolverContext: AuthResolverContext; private readonly authHandler: AuthHandler; private readonly signInResolver: SignInResolver; @@ -186,6 +198,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,7 +273,7 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { issuer: `https://${this.teamName}.cloudflareaccess.com`, }); - const isServiceToken = verifyResult.payload.sub === ''; + const isServiceToken = !verifyResult.payload.sub; const subject = isServiceToken ? (verifyResult.payload.common_name as string) @@ -271,6 +284,12 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { ); } + if (isServiceToken && !this.serviceTokens.hasOwnProperty(subject)) { + 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') { @@ -289,7 +308,7 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { cfIdentity = { id: subject, name: 'Bot', - email: `${subject}@${this.teamName}.com`, + email: this.serviceTokens[subject], groups: [], }; } else { @@ -372,6 +391,13 @@ export const cfAccess = createAuthProviderIntegration({ }) { return ({ config, resolverContext }) => { const teamName = config.getString('teamName'); + const serviceTokensConfig = config.getOptionalConfig('serviceTokens'); + const serviceTokens: ServiceTokens = {}; + if (serviceTokensConfig) { + serviceTokensConfig.keys().forEach(key => { + serviceTokens[key] = serviceTokensConfig.getString(key); + }); + } if (!options.signIn.resolver) { throw new Error( @@ -393,6 +419,7 @@ export const cfAccess = createAuthProviderIntegration({ return new CloudflareAccessAuthProvider({ teamName, + serviceTokens, signInResolver: options?.signIn.resolver, authHandler, resolverContext,