From 0f9e910d23739913ea9ea58c746791a9a3ef16ab Mon Sep 17 00:00:00 2001 From: "Jason Diaz G." Date: Wed, 3 Jul 2024 20:46:51 -0600 Subject: [PATCH 1/6] feat(cloudflare-auth-access-provider): Add support for cloudflare custom headers and cookie auth name Signed-off-by: Jason Diaz G. --- .../config.d.ts | 2 + .../src/helpers.test.ts | 79 +++++++++++++++++++ .../src/helpers.ts | 19 +++-- 3 files changed, 93 insertions(+), 7 deletions(-) diff --git a/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts b/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts index ff7d722d48..235cac8446 100644 --- a/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts +++ b/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts @@ -27,6 +27,8 @@ export interface Config { token: string; subject: string; }>; + customHeader?: string; + customCookieAuthName?: string; }; /** * The backstage token expiration. diff --git a/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.test.ts b/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.test.ts index 9efab07ba5..be91583e16 100644 --- a/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.test.ts +++ b/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.test.ts @@ -239,4 +239,83 @@ describe('helpers', () => { token: token, }); }); + + it('works for regular tokens, through custom header auth', async () => { + jest.useFakeTimers({ + now: 1600000004000, + }); + + const helper = AuthHelper.fromConfig( + new ConfigReader({ teamName: 'mock-team', customHeader: 'X-Auth-Token' }), + { cache }, + ); + const token = await tokenFactory.userToken(); + const request = createRequest({ + headers: { ['X-Auth-Token']: token }, + }); + + const expected = { + cfIdentity: { + email: 'hello@example.com', + groups: [{ id: '123', email: 'foo@bar.com', name: 'foo' }], + id: '1234567890', + name: 'User Name', + }, + claims: { + iss: `https://mock-team.cloudflareaccess.com`, + sub: '1234567890', + name: 'User Name', + iat: 1600000000, + exp: 1600000005, + }, + expiresInSeconds: 5, + }; + + await expect(helper.authenticate(request)).resolves.toEqual({ + ...expected, + token: token, + }); + expect(cache.set).toHaveBeenCalledTimes(1); + expect(cache.set.mock.calls[0][0]).toBe( + 'providers/cloudflare-access/profile-v1/1234567890', + ); + expect(JSON.parse(cache.set.mock.calls[0][1] as string)).toEqual(expected); + }); + + it('works for regular tokens, through custom cookie auth name', async () => { + jest.useFakeTimers({ + now: 1600000004000, + }); + + const helper = AuthHelper.fromConfig( + new ConfigReader({ teamName: 'mock-team', customCookieAuthName: 'CF_Custom_Auth' }), + { cache }, + ); + const token = await tokenFactory.userToken(); + const request = createRequest({ + cookies: { 'CF_Custom_Auth': token }, + }); + + const expected = { + cfIdentity: { + email: 'hello@example.com', + groups: [{ id: '123', email: 'foo@bar.com', name: 'foo' }], + id: '1234567890', + name: 'User Name', + }, + claims: { + iss: `https://mock-team.cloudflareaccess.com`, + sub: '1234567890', + name: 'User Name', + iat: 1600000000, + exp: 1600000005, + }, + expiresInSeconds: 5, + }; + + await expect(helper.authenticate(request)).resolves.toEqual({ + ...expected, + token: token, + }); + }); }); diff --git a/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.ts b/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.ts index c1a14ae6b4..298fd82a89 100644 --- a/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.ts +++ b/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.ts @@ -40,6 +40,8 @@ export class AuthHelper { options?: { cache?: CacheService }, ): AuthHelper { const teamName = config.getString('teamName'); + const customHeader = config.getString('customHeader'); + const customCookieAuthName = config.getString('customCookieAuthName'); const serviceTokens = ( config.getOptionalConfigArray('serviceTokens') ?? [] )?.map(cfg => { @@ -53,7 +55,7 @@ export class AuthHelper { new URL(`https://${teamName}.cloudflareaccess.com/cdn-cgi/access/certs`), ); - return new AuthHelper(teamName, serviceTokens, keySet, options?.cache); + return new AuthHelper(teamName, serviceTokens, keySet, options?.cache, customHeader, customCookieAuthName); } private constructor( @@ -61,20 +63,23 @@ export class AuthHelper { private readonly serviceTokens: ServiceToken[], private readonly keySet: ReturnType, private readonly cache?: CacheService, - ) {} + private readonly customHeader?: string, + private readonly customCookieAuthName?: string, + ) { } async authenticate(req: express.Request): Promise { // JWTs generated by Access are available in a request header as // Cf-Access-Jwt-Assertion and as cookies as CF_Authorization. - let jwt = req.header(CF_JWT_HEADER); + let jwt = req.header(this.customHeader || CF_JWT_HEADER); if (!jwt) { - jwt = req.cookies.CF_Authorization; + jwt = req.cookies[this.customCookieAuthName || COOKIE_AUTH_NAME]; } if (!jwt) { // Only throw if both are not provided by Cloudflare Access since either // can be used. throw new AuthenticationError( - `Missing ${CF_JWT_HEADER} from Cloudflare Access`, + `Missing ${this.customHeader || CF_JWT_HEADER} and + ${this.customCookieAuthName || COOKIE_AUTH_NAME} from Cloudflare Access`, ); } @@ -155,8 +160,8 @@ export class AuthHelper { ): Promise { const headers = new Headers(); // set both headers just the way inbound responses are set - headers.set(CF_JWT_HEADER, jwt); - headers.set('cookie', `${COOKIE_AUTH_NAME}=${jwt}`); + headers.set(this.customHeader || CF_JWT_HEADER, jwt); + headers.set('cookie', `${this.customCookieAuthName || COOKIE_AUTH_NAME}=${jwt}`); try { const res = await fetch( `https://${this.teamName}.cloudflareaccess.com/cdn-cgi/access/get-identity`, From 75d026ab1f9587a3498b23be9cd5ef63c7e5e777 Mon Sep 17 00:00:00 2001 From: "Jason Diaz G." Date: Wed, 3 Jul 2024 21:11:53 -0600 Subject: [PATCH 2/6] chore(cloudflare-auth-access-provider): Add changeset for the PR Signed-off-by: Jason Diaz G. --- .changeset/strong-chicken-kiss.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/strong-chicken-kiss.md diff --git a/.changeset/strong-chicken-kiss.md b/.changeset/strong-chicken-kiss.md new file mode 100644 index 0000000000..26f667c5ab --- /dev/null +++ b/.changeset/strong-chicken-kiss.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-cloudflare-access-provider': minor +--- + +Support for Cloudflare Custom Headers and Custom Cookie Auth Name From eeadefbb09b2f1a8b5aa0a77d92400d0680c9054 Mon Sep 17 00:00:00 2001 From: "Jason Diaz G." Date: Thu, 4 Jul 2024 11:15:44 -0600 Subject: [PATCH 3/6] fix(cloudflare-auth-access-provider): broken tests fix Signed-off-by: Jason Diaz G. --- .../src/helpers.ts | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.ts b/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.ts index 298fd82a89..45718ffab1 100644 --- a/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.ts +++ b/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.ts @@ -40,8 +40,10 @@ export class AuthHelper { options?: { cache?: CacheService }, ): AuthHelper { const teamName = config.getString('teamName'); - const customHeader = config.getString('customHeader'); - const customCookieAuthName = config.getString('customCookieAuthName'); + const customHeader = config.getOptionalString('customHeader'); + const customCookieAuthName = config.getOptionalString( + 'customCookieAuthName', + ); const serviceTokens = ( config.getOptionalConfigArray('serviceTokens') ?? [] )?.map(cfg => { @@ -55,7 +57,14 @@ export class AuthHelper { new URL(`https://${teamName}.cloudflareaccess.com/cdn-cgi/access/certs`), ); - return new AuthHelper(teamName, serviceTokens, keySet, options?.cache, customHeader, customCookieAuthName); + return new AuthHelper( + teamName, + serviceTokens, + keySet, + options?.cache, + customHeader, + customCookieAuthName, + ); } private constructor( @@ -65,7 +74,7 @@ export class AuthHelper { private readonly cache?: CacheService, private readonly customHeader?: string, private readonly customCookieAuthName?: string, - ) { } + ) {} async authenticate(req: express.Request): Promise { // JWTs generated by Access are available in a request header as @@ -79,7 +88,9 @@ export class AuthHelper { // can be used. throw new AuthenticationError( `Missing ${this.customHeader || CF_JWT_HEADER} and - ${this.customCookieAuthName || COOKIE_AUTH_NAME} from Cloudflare Access`, + ${ + this.customCookieAuthName || COOKIE_AUTH_NAME + } from Cloudflare Access`, ); } @@ -161,7 +172,10 @@ export class AuthHelper { const headers = new Headers(); // set both headers just the way inbound responses are set headers.set(this.customHeader || CF_JWT_HEADER, jwt); - headers.set('cookie', `${this.customCookieAuthName || COOKIE_AUTH_NAME}=${jwt}`); + headers.set( + 'cookie', + `${this.customCookieAuthName || COOKIE_AUTH_NAME}=${jwt}`, + ); try { const res = await fetch( `https://${this.teamName}.cloudflareaccess.com/cdn-cgi/access/get-identity`, From 8a7db0dd15954f0ce50e18495f46352188625d18 Mon Sep 17 00:00:00 2001 From: "Jason Diaz G." Date: Thu, 4 Jul 2024 20:03:06 -0600 Subject: [PATCH 4/6] fix(cloudflare-auth-access-provider): change default names for custom variables Signed-off-by: Jason Diaz G. --- .../config.d.ts | 4 +-- .../src/helpers.test.ts | 16 ++++++++---- .../src/helpers.ts | 26 +++++++++---------- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts b/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts index 235cac8446..9715eb3a39 100644 --- a/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts +++ b/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts @@ -27,8 +27,8 @@ export interface Config { token: string; subject: string; }>; - customHeader?: string; - customCookieAuthName?: string; + jwtHeaderName?: string; + authorizationCookieName?: string; }; /** * The backstage token expiration. diff --git a/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.test.ts b/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.test.ts index be91583e16..d5a1c8be0f 100644 --- a/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.test.ts +++ b/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.test.ts @@ -240,13 +240,16 @@ describe('helpers', () => { }); }); - it('works for regular tokens, through custom header auth', async () => { + it('works for regular tokens, through jwtHeaderName header', async () => { jest.useFakeTimers({ now: 1600000004000, }); const helper = AuthHelper.fromConfig( - new ConfigReader({ teamName: 'mock-team', customHeader: 'X-Auth-Token' }), + new ConfigReader({ + teamName: 'mock-team', + jwtHeaderName: 'X-Auth-Token', + }), { cache }, ); const token = await tokenFactory.userToken(); @@ -282,18 +285,21 @@ describe('helpers', () => { expect(JSON.parse(cache.set.mock.calls[0][1] as string)).toEqual(expected); }); - it('works for regular tokens, through custom cookie auth name', async () => { + it('works for regular tokens, through authorizationCookieName cookie name', async () => { jest.useFakeTimers({ now: 1600000004000, }); const helper = AuthHelper.fromConfig( - new ConfigReader({ teamName: 'mock-team', customCookieAuthName: 'CF_Custom_Auth' }), + new ConfigReader({ + teamName: 'mock-team', + authorizationCookieName: 'CF_Auth', + }), { cache }, ); const token = await tokenFactory.userToken(); const request = createRequest({ - cookies: { 'CF_Custom_Auth': token }, + cookies: { CF_Custom_Auth: token }, }); const expected = { diff --git a/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.ts b/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.ts index 45718ffab1..b3fefb8789 100644 --- a/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.ts +++ b/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.ts @@ -40,9 +40,9 @@ export class AuthHelper { options?: { cache?: CacheService }, ): AuthHelper { const teamName = config.getString('teamName'); - const customHeader = config.getOptionalString('customHeader'); - const customCookieAuthName = config.getOptionalString( - 'customCookieAuthName', + const jwtHeaderName = config.getOptionalString('jwtHeaderName'); + const authorizationCookieName = config.getOptionalString( + 'authorizationCookieName', ); const serviceTokens = ( config.getOptionalConfigArray('serviceTokens') ?? [] @@ -62,8 +62,8 @@ export class AuthHelper { serviceTokens, keySet, options?.cache, - customHeader, - customCookieAuthName, + jwtHeaderName, + authorizationCookieName, ); } @@ -72,24 +72,24 @@ export class AuthHelper { private readonly serviceTokens: ServiceToken[], private readonly keySet: ReturnType, private readonly cache?: CacheService, - private readonly customHeader?: string, - private readonly customCookieAuthName?: string, + private readonly jwtHeaderName?: string, + private readonly authorizationCookieName?: string, ) {} async authenticate(req: express.Request): Promise { // JWTs generated by Access are available in a request header as // Cf-Access-Jwt-Assertion and as cookies as CF_Authorization. - let jwt = req.header(this.customHeader || CF_JWT_HEADER); + let jwt = req.header(this.jwtHeaderName || CF_JWT_HEADER); if (!jwt) { - jwt = req.cookies[this.customCookieAuthName || COOKIE_AUTH_NAME]; + jwt = req.cookies[this.authorizationCookieName || COOKIE_AUTH_NAME]; } if (!jwt) { // Only throw if both are not provided by Cloudflare Access since either // can be used. throw new AuthenticationError( - `Missing ${this.customHeader || CF_JWT_HEADER} and + `Missing ${this.jwtHeaderName || CF_JWT_HEADER} and ${ - this.customCookieAuthName || COOKIE_AUTH_NAME + this.authorizationCookieName || COOKIE_AUTH_NAME } from Cloudflare Access`, ); } @@ -171,10 +171,10 @@ export class AuthHelper { ): Promise { const headers = new Headers(); // set both headers just the way inbound responses are set - headers.set(this.customHeader || CF_JWT_HEADER, jwt); + headers.set(this.jwtHeaderName || CF_JWT_HEADER, jwt); headers.set( 'cookie', - `${this.customCookieAuthName || COOKIE_AUTH_NAME}=${jwt}`, + `${this.authorizationCookieName || COOKIE_AUTH_NAME}=${jwt}`, ); try { const res = await fetch( From 9614e649ed63b9fdcd29c474aa2d09f96520e834 Mon Sep 17 00:00:00 2001 From: "Jason Diaz G." Date: Fri, 5 Jul 2024 08:36:01 -0600 Subject: [PATCH 5/6] fix(cloudflare-auth-access-provider): fix broken unit tests Signed-off-by: Jason Diaz G. --- .../src/helpers.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.test.ts b/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.test.ts index d5a1c8be0f..07e9860d98 100644 --- a/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.test.ts +++ b/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.test.ts @@ -299,7 +299,7 @@ describe('helpers', () => { ); const token = await tokenFactory.userToken(); const request = createRequest({ - cookies: { CF_Custom_Auth: token }, + cookies: { CF_Auth: token }, }); const expected = { From 034c0b3cb29e73b9cf3598a0d65c0d216ab7ed96 Mon Sep 17 00:00:00 2001 From: "Jason Diaz G." Date: Thu, 18 Jul 2024 16:45:13 -0600 Subject: [PATCH 6/6] feat(cloudflare-auth-access-provider): Change defaults and add provider docs Signed-off-by: Jason Diaz G. --- docs/auth/cloudflare/provider.md | 6 ++++ .../src/helpers.ts | 33 ++++++++----------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/docs/auth/cloudflare/provider.md b/docs/auth/cloudflare/provider.md index 516ea9d647..a1946580a7 100644 --- a/docs/auth/cloudflare/provider.md +++ b/docs/auth/cloudflare/provider.md @@ -32,6 +32,12 @@ auth: serviceTokens: - token: '1uh2fh19efvfh129f1f919u21f2f19jf2.access' subject: 'bot-user@your-company.com' + # You can customize the header name that contains the jwt token, by default + # cf-access-jwt-assertion is used + jwtHeaderName: + # You can customize the authorization cookie name, by default + # CF_Authorization is used + authorizationCookieName: # This picks what sign in resolver(s) you want to use. signIn: resolvers: diff --git a/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.ts b/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.ts index b3fefb8789..3077258dd6 100644 --- a/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.ts +++ b/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.ts @@ -40,10 +40,10 @@ export class AuthHelper { options?: { cache?: CacheService }, ): AuthHelper { const teamName = config.getString('teamName'); - const jwtHeaderName = config.getOptionalString('jwtHeaderName'); - const authorizationCookieName = config.getOptionalString( - 'authorizationCookieName', - ); + const jwtHeaderName = + config.getOptionalString('jwtHeaderName') ?? CF_JWT_HEADER; + const authorizationCookieName = + config.getOptionalString('authorizationCookieName') ?? COOKIE_AUTH_NAME; const serviceTokens = ( config.getOptionalConfigArray('serviceTokens') ?? [] )?.map(cfg => { @@ -60,37 +60,35 @@ export class AuthHelper { return new AuthHelper( teamName, serviceTokens, - keySet, - options?.cache, jwtHeaderName, authorizationCookieName, + keySet, + options?.cache, ); } private constructor( private readonly teamName: string, private readonly serviceTokens: ServiceToken[], + private readonly jwtHeaderName: string, + private readonly authorizationCookieName: string, private readonly keySet: ReturnType, private readonly cache?: CacheService, - private readonly jwtHeaderName?: string, - private readonly authorizationCookieName?: string, ) {} async authenticate(req: express.Request): Promise { // JWTs generated by Access are available in a request header as // Cf-Access-Jwt-Assertion and as cookies as CF_Authorization. - let jwt = req.header(this.jwtHeaderName || CF_JWT_HEADER); + let jwt = req.header(this.jwtHeaderName); if (!jwt) { - jwt = req.cookies[this.authorizationCookieName || COOKIE_AUTH_NAME]; + jwt = req.cookies[this.authorizationCookieName]; } if (!jwt) { // Only throw if both are not provided by Cloudflare Access since either // can be used. throw new AuthenticationError( - `Missing ${this.jwtHeaderName || CF_JWT_HEADER} and - ${ - this.authorizationCookieName || COOKIE_AUTH_NAME - } from Cloudflare Access`, + `Missing ${this.jwtHeaderName} and + ${this.authorizationCookieName} from Cloudflare Access`, ); } @@ -171,11 +169,8 @@ export class AuthHelper { ): Promise { const headers = new Headers(); // set both headers just the way inbound responses are set - headers.set(this.jwtHeaderName || CF_JWT_HEADER, jwt); - headers.set( - 'cookie', - `${this.authorizationCookieName || COOKIE_AUTH_NAME}=${jwt}`, - ); + headers.set(this.jwtHeaderName, jwt); + headers.set('cookie', `${this.authorizationCookieName}=${jwt}`); try { const res = await fetch( `https://${this.teamName}.cloudflareaccess.com/cdn-cgi/access/get-identity`,