extract the cloudflare access auth provider

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2024-04-04 23:22:47 +02:00
parent 8232cd9934
commit c26218d351
26 changed files with 1115 additions and 743 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-cloudflare-access-provider': minor
---
Created a separate module for the Cloudflare Access auth provider
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Deprecated some of the Cloudflare Access types and used the implementation from `@backstage/plugin-auth-backend-module-cloudflare-access-provider`
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,3 @@
# @backstage/plugin-auth-backend-module-cloudflare-access-provider
The Cloudflare Access provider backend module for the auth plugin.
@@ -0,0 +1,63 @@
## API Report File for "@backstage/plugin-auth-backend-module-cloudflare-access-provider"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackendFeature } from '@backstage/backend-plugin-api';
import { CacheService } from '@backstage/backend-plugin-api';
import { ProxyAuthenticator } from '@backstage/plugin-auth-node';
import { SignInResolverFactory } from '@backstage/plugin-auth-node';
// @public
const authModuleCloudflareAccessProvider: () => BackendFeature;
export default authModuleCloudflareAccessProvider;
// @public
export type CloudflareAccessClaims = {
aud: string[];
email: string;
exp: number;
iat: number;
nonce: string;
identity_nonce: string;
sub: string;
iss: string;
custom: string;
};
// @public (undocumented)
export type CloudflareAccessGroup = {
id: string;
name: string;
email: string;
};
// @public (undocumented)
export type CloudflareAccessIdentityProfile = {
id: string;
name: string;
email: string;
groups: CloudflareAccessGroup[];
};
// @public (undocumented)
export type CloudflareAccessResult = {
claims: CloudflareAccessClaims;
cfIdentity: CloudflareAccessIdentityProfile;
expiresInSeconds?: number;
token: string;
};
// @public
export namespace cloudflareAccessSignInResolvers {
const emailMatchingUserEntityProfileEmail: SignInResolverFactory<
CloudflareAccessResult,
unknown
>;
}
// @public
export function createCloudflareAccessAuthenticator(options?: {
cache?: CacheService;
}): ProxyAuthenticator<unknown, CloudflareAccessResult, CloudflareAccessResult>;
```
@@ -0,0 +1,10 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-plugin-auth-backend-module-cloudflare-access-provider
title: '@backstage/plugin-auth-backend-module-cloudflare-access-provider'
description: The cloudflare-access-provider backend module for the auth plugin.
spec:
lifecycle: experimental
type: backstage-backend-plugin-module
owner: maintainers
@@ -0,0 +1,37 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { HumanDuration } from '@backstage/types';
export interface Config {
auth?: {
providers?: {
/** @visibility frontend */
cfaccess?: {
teamName: string;
/** @deepVisibility secret */
serviceTokens?: Array<{
token: string;
subject: string;
}>;
};
/**
* The backstage token expiration.
*/
backstageTokenExpiration?: HumanDuration;
};
};
}
@@ -0,0 +1,24 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createBackend } from '@backstage/backend-defaults';
const backend = createBackend();
backend.add(import('@backstage/plugin-auth-backend'));
backend.add(import('../src'));
backend.start();
@@ -0,0 +1,14 @@
# Knip report
## Unused dependencies (1)
| Name | Location | Severity |
| :--------- | :----------- | :------- |
| node-fetch | package.json | error |
## Unused devDependencies (1)
| Name | Location | Severity |
| :--------------- | :----------- | :------- |
| @backstage/types | package.json | error |
@@ -0,0 +1,54 @@
{
"name": "@backstage/plugin-auth-backend-module-cloudflare-access-provider",
"version": "0.0.0",
"description": "The cloudflare-access-provider backend module for the auth plugin.",
"backstage": {
"role": "backend-plugin-module"
},
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/auth-backend-module-cloudflare-access-provider"
},
"license": "Apache-2.0",
"main": "src/index.ts",
"types": "src/index.ts",
"files": [
"config.d.ts",
"dist"
],
"scripts": {
"build": "backstage-cli package build",
"clean": "backstage-cli package clean",
"lint": "backstage-cli package lint",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"start": "backstage-cli package start",
"test": "backstage-cli package test"
},
"dependencies": {
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"express": "^4.18.2",
"jose": "^5.0.0",
"node-fetch": "^2.6.7"
},
"devDependencies": {
"@backstage/backend-defaults": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/plugin-auth-backend": "workspace:^",
"@backstage/types": "workspace:^",
"msw": "^2.0.0",
"node-mocks-http": "^1.0.0",
"uuid": "^9.0.0"
},
"configSchema": "config.d.ts"
}
@@ -0,0 +1,41 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { mockServices } from '@backstage/backend-test-utils';
import { createCloudflareAccessAuthenticator } from './authenticator';
describe('authenticator', () => {
it('createCloudflareAccessAuthenticator works', async () => {
const auth = createCloudflareAccessAuthenticator({
cache: mockServices.cache.mock(),
});
const profile = await auth.defaultProfileTransform(
{
cfIdentity: { name: 'Name' } as any,
claims: { email: 'hello@example.com' } as any,
token: 'fake',
},
{} as any,
);
expect(profile).toEqual({
profile: {
displayName: 'Name',
email: 'hello@example.com',
},
});
});
});
@@ -0,0 +1,59 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CacheService } from '@backstage/backend-plugin-api';
import {
ProxyAuthenticator,
createProxyAuthenticator,
} from '@backstage/plugin-auth-node';
import { AuthHelper } from './helpers';
import { CloudflareAccessResult } from './types';
/**
* Implements Cloudflare Access authentication.
*
* @public
*/
export function createCloudflareAccessAuthenticator(options?: {
cache?: CacheService;
}): ProxyAuthenticator<
unknown,
CloudflareAccessResult,
CloudflareAccessResult
> {
return createProxyAuthenticator({
async defaultProfileTransform(result: CloudflareAccessResult) {
return {
profile: {
email: result.claims.email,
displayName: result.cfIdentity.name,
},
};
},
initialize({ config }) {
return {
helper: AuthHelper.fromConfig(config, { cache: options?.cache }),
};
},
async authenticate({ req }, { helper }) {
const result = await helper.authenticate(req);
return {
result,
providerInfo: result,
};
},
});
}
@@ -0,0 +1,242 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
mockServices,
setupRequestMockHandlers,
} from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
import { SignJWT, exportJWK, generateKeyPair } from 'jose';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { createRequest } from 'node-mocks-http';
import { v4 as uuid } from 'uuid';
import { AuthHelper } from './helpers';
import { CF_JWT_HEADER, CloudflareAccessIdentityProfile } from './types';
interface AnyJWK extends Record<string, string> {
use: 'sig';
alg: string;
kid: string;
kty: string;
}
class MockTokenFactory {
private readonly publicKeys = new Array<AnyJWK>();
async userToken(): Promise<string> {
const { privateKey, kid } = await this.makeKeys();
return new SignJWT({
iss: `https://mock-team.cloudflareaccess.com`,
sub: '1234567890',
name: 'User Name',
iat: 1600000000,
exp: 1600000005,
})
.setProtectedHeader({
alg: 'ES256',
typ: 'JWT',
kid: kid,
})
.sign(privateKey);
}
async serviceToken(): Promise<string> {
const { privateKey, kid } = await this.makeKeys();
return new SignJWT({
iss: `https://mock-team.cloudflareaccess.com`,
name: 'Bot',
common_name: 'test_token_id.access',
iat: 1600000000,
exp: 1600000005,
})
.setProtectedHeader({
alg: 'ES256',
typ: 'JWT',
kid: kid,
})
.sign(privateKey);
}
listPublicKeys(): AnyJWK[] {
return [...this.publicKeys];
}
private async makeKeys() {
const pair = await generateKeyPair('ES256');
const publicKey = await exportJWK(pair.publicKey);
const kid = uuid();
publicKey.kid = kid;
this.publicKeys.push(publicKey as AnyJWK);
return { privateKey: pair.privateKey, kid };
}
}
describe('helpers', () => {
const tokenFactory = new MockTokenFactory();
const cache = mockServices.cache.mock();
const server = setupServer();
setupRequestMockHandlers(server);
beforeEach(() => {
jest.clearAllMocks();
server.use(
http.get(
`https://mock-team.cloudflareaccess.com/cdn-cgi/access/certs`,
() => {
return HttpResponse.json({
keys: tokenFactory.listPublicKeys(),
});
},
),
http.get(
`https://mock-team.cloudflareaccess.com/cdn-cgi/access/get-identity`,
() => {
return HttpResponse.json({
email: 'hello@example.com',
groups: [{ id: '123', email: 'foo@bar.com', name: 'foo' }],
id: '1234567890',
name: 'User Name',
} satisfies CloudflareAccessIdentityProfile);
},
),
);
});
afterEach(() => {
jest.useRealTimers();
});
it('works for regular tokens, through header auth', async () => {
jest.useFakeTimers({
now: 1600000004000,
});
const helper = AuthHelper.fromConfig(
new ConfigReader({ teamName: 'mock-team' }),
{ cache },
);
const token = await tokenFactory.userToken();
const request = createRequest({
headers: { [CF_JWT_HEADER]: 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 cookie auth', async () => {
jest.useFakeTimers({
now: 1600000004000,
});
const helper = AuthHelper.fromConfig(
new ConfigReader({ teamName: 'mock-team' }),
{ cache },
);
const token = await tokenFactory.userToken();
const request = createRequest({
cookies: { CF_Authorization: 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,
});
});
it('works for service tokens, through header auth', async () => {
jest.useFakeTimers({
now: 1600000004000,
});
const helper = AuthHelper.fromConfig(
new ConfigReader({
teamName: 'mock-team',
serviceTokens: [
{
token: 'test_token_id.access',
subject: 'test_token_id.access@example.com',
},
],
}),
{ cache },
);
const token = await tokenFactory.serviceToken();
const request = createRequest({
headers: { [CF_JWT_HEADER]: token },
});
await expect(helper.authenticate(request)).resolves.toEqual({
cfIdentity: {
email: 'test_token_id.access@example.com',
groups: [],
id: 'test_token_id.access',
name: 'Bot',
},
claims: {
iss: `https://mock-team.cloudflareaccess.com`,
name: 'Bot',
common_name: 'test_token_id.access',
iat: 1600000000,
exp: 1600000005,
},
expiresInSeconds: 5,
token: token,
});
});
});
@@ -0,0 +1,173 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CacheService } from '@backstage/backend-plugin-api';
import { Config } from '@backstage/config';
import {
AuthenticationError,
ForwardedError,
ResponseError,
} from '@backstage/errors';
import express from 'express';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import {
CACHE_PREFIX,
CF_JWT_HEADER,
COOKIE_AUTH_NAME,
CloudflareAccessClaims,
CloudflareAccessIdentityProfile,
CloudflareAccessResult,
ServiceToken,
} from './types';
export class AuthHelper {
static fromConfig(
config: Config,
options?: { cache?: CacheService },
): AuthHelper {
const teamName = config.getString('teamName');
const serviceTokens = (
config.getOptionalConfigArray('serviceTokens') ?? []
)?.map(cfg => {
return {
token: cfg.getString('token'),
subject: cfg.getString('subject'),
} as ServiceToken;
});
const keySet = createRemoteJWKSet(
new URL(`https://${teamName}.cloudflareaccess.com/cdn-cgi/access/certs`),
);
return new AuthHelper(teamName, serviceTokens, keySet, options?.cache);
}
private constructor(
private readonly teamName: string,
private readonly serviceTokens: ServiceToken[],
private readonly keySet: ReturnType<typeof createRemoteJWKSet>,
private readonly cache?: CacheService,
) {}
async authenticate(req: express.Request): Promise<CloudflareAccessResult> {
// 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);
if (!jwt) {
jwt = req.cookies.CF_Authorization;
}
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`,
);
}
// Cloudflare signs the JWT using the RSA Signature with SHA-256 (RS256).
// RS256 follows an asymmetric algorithm; a private key signs the JWTs and
// a separate public key verifies the signature.
const verifyResult = await jwtVerify(jwt, this.keySet, {
issuer: `https://${this.teamName}.cloudflareaccess.com`,
});
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 {
...result,
token: jwt,
};
}
const claims = verifyResult.payload as CloudflareAccessClaims;
// Builds a passport profile from JWT claims first
try {
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 = {
claims,
cfIdentity,
expiresInSeconds: claims.exp - claims.iat,
};
this.cache?.set(cacheKey, JSON.stringify(cfAccessResult));
return {
...cfAccessResult,
token: jwt,
};
} catch (err) {
throw new ForwardedError(
'Failed to populate access identity information',
err,
);
}
}
private async getIdentityProfile(
jwt: string,
): Promise<CloudflareAccessIdentityProfile> {
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}`);
try {
const res = await fetch(
`https://${this.teamName}.cloudflareaccess.com/cdn-cgi/access/get-identity`,
{ headers },
);
if (!res.ok) {
throw await ResponseError.fromResponse(res);
}
const cfIdentity = await res.json();
return cfIdentity as unknown as CloudflareAccessIdentityProfile;
} catch (err) {
throw new ForwardedError('getIdentityProfile failed', err);
}
}
}
@@ -0,0 +1,31 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* The Cloudflare Access provider backend module for the auth plugin.
*
* @packageDocumentation
*/
export { createCloudflareAccessAuthenticator } from './authenticator';
export { authModuleCloudflareAccessProvider as default } from './module';
export { cloudflareAccessSignInResolvers } from './resolvers';
export {
type CloudflareAccessClaims,
type CloudflareAccessGroup,
type CloudflareAccessIdentityProfile,
type CloudflareAccessResult,
} from './types';
@@ -0,0 +1,57 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
coreServices,
createBackendModule,
} from '@backstage/backend-plugin-api';
import {
authProvidersExtensionPoint,
commonSignInResolvers,
createProxyAuthProviderFactory,
} from '@backstage/plugin-auth-node';
import { createCloudflareAccessAuthenticator } from './authenticator';
import { cloudflareAccessSignInResolvers } from './resolvers';
/**
* The Cloudflare Access provider backend module for the auth plugin.
*
* @public
*/
export const authModuleCloudflareAccessProvider = createBackendModule({
pluginId: 'auth',
moduleId: 'cloudflare-access-provider',
register(reg) {
reg.registerInit({
deps: {
authProviders: authProvidersExtensionPoint,
cache: coreServices.cache,
},
async init({ authProviders, cache }) {
authProviders.registerProvider({
providerId: 'cfaccess',
factory: createProxyAuthProviderFactory({
authenticator: createCloudflareAccessAuthenticator({ cache }),
signInResolverFactories: {
...cloudflareAccessSignInResolvers,
...commonSignInResolvers,
},
}),
});
},
});
},
});
@@ -0,0 +1,44 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AuthResolverContext, SignInInfo } from '@backstage/plugin-auth-node';
import { cloudflareAccessSignInResolvers } from './resolvers';
import { CloudflareAccessResult } from './types';
describe('resolvers', () => {
it('emailMatchingUserEntityProfileEmail works', async () => {
const resolver =
cloudflareAccessSignInResolvers.emailMatchingUserEntityProfileEmail();
const info: SignInInfo<CloudflareAccessResult> = {
profile: { email: 'hello@example.com' },
result: {
cfIdentity: { email: 'hello@example.com' } as any,
claims: {} as any,
token: 'fake',
},
};
const context = {
signInWithCatalogUser: jest.fn().mockResolvedValue(undefined),
} satisfies Partial<AuthResolverContext>;
await resolver(info, context as any);
expect(context.signInWithCatalogUser).toHaveBeenCalledWith({
filter: { 'spec.profile.email': 'hello@example.com' },
});
});
});
@@ -0,0 +1,52 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createSignInResolverFactory,
SignInInfo,
} from '@backstage/plugin-auth-node';
import { CloudflareAccessResult } from './types';
/**
* Available sign-in resolvers for the Cloudflare Access auth provider.
*
* @public
*/
export namespace cloudflareAccessSignInResolvers {
/**
* Looks up the user by matching their email to the entity email.
*/
export const emailMatchingUserEntityProfileEmail =
createSignInResolverFactory({
create() {
return async (info: SignInInfo<CloudflareAccessResult>, ctx) => {
const { profile } = info;
if (!profile.email) {
throw new Error(
'Login failed, user profile does not contain an email',
);
}
return ctx.signInWithCatalogUser({
filter: {
'spec.profile.email': profile.email,
},
});
};
},
});
}
@@ -0,0 +1,109 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// JWT Web Token definitions are in the URL below
// https://developers.cloudflare.com/cloudflare-one/identity/users/validating-json/
export const CF_JWT_HEADER = 'cf-access-jwt-assertion';
export const CF_AUTH_IDENTITY = 'cf-access-authenticated-user-email';
export const COOKIE_AUTH_NAME = 'CF_Authorization';
export const CACHE_PREFIX = 'providers/cloudflare-access/profile-v1';
export type ServiceToken = {
token: string;
subject: string;
};
/**
* Can be used in externally provided auth handler or sign in resolver to
* enrich user profile for sign-in user entity
*
* @public
*/
export type CloudflareAccessClaims = {
/**
* `aud` identifies the application to which the JWT is issued.
*/
aud: string[];
/**
* `email` contains the email address of the authenticated user.
*/
email: string;
/**
* iat and exp are the issuance and expiration timestamps.
*/
exp: number;
iat: number;
/**
* `nonce` is the session identifier.
*/
nonce: string;
/**
* `identity_nonce` is available in the Application Token and can be used to
* query all group membership for a given user.
*/
identity_nonce: string;
/**
* `sub` contains the identifier of the authenticated user.
*/
sub: string;
/**
* `iss` the issuer is the applications Cloudflare Access Domain URL.
*/
iss: string;
/**
* `custom` contains SAML attributes in the Application Token specified by an
* administrator in the identity provider configuration.
*/
custom: string;
};
/**
* @public
*/
export type CloudflareAccessGroup = {
/**
* Group id
*/
id: string;
/**
* Name of group as defined in Cloudflare zero trust dashboard
*/
name: string;
/**
* Access group email address
*/
email: string;
};
/**
* @public
*/
export type CloudflareAccessIdentityProfile = {
id: string;
name: string;
email: string;
groups: CloudflareAccessGroup[];
};
/**
* @public
*/
export type CloudflareAccessResult = {
claims: CloudflareAccessClaims;
cfIdentity: CloudflareAccessIdentityProfile;
expiresInSeconds?: number;
token: string;
};
+6 -7
View File
@@ -15,6 +15,7 @@ import { BackstageSignInResult } from '@backstage/plugin-auth-node';
import { CacheService } from '@backstage/backend-plugin-api';
import { CatalogApi } from '@backstage/catalog-client';
import { ClientAuthResponse } from '@backstage/plugin-auth-node';
import { cloudflareAccessSignInResolvers } from '@backstage/plugin-auth-backend-module-cloudflare-access-provider';
import { Config } from '@backstage/config';
import { CookieConfigurer as CookieConfigurer_2 } from '@backstage/plugin-auth-node';
import { decodeOAuthState } from '@backstage/plugin-auth-node';
@@ -134,7 +135,7 @@ export class CatalogIdentityClient {
}): Promise<string[]>;
}
// @public
// @public @deprecated
export type CloudflareAccessClaims = {
aud: string[];
email: string;
@@ -147,14 +148,14 @@ export type CloudflareAccessClaims = {
custom: string;
};
// @public
// @public @deprecated
export type CloudflareAccessGroup = {
id: string;
name: string;
email: string;
};
// @public
// @public @deprecated
export type CloudflareAccessIdentityProfile = {
id: string;
name: string;
@@ -162,7 +163,7 @@ export type CloudflareAccessIdentityProfile = {
groups: CloudflareAccessGroup[];
};
// @public (undocumented)
// @public @deprecated (undocumented)
export type CloudflareAccessResult = {
claims: CloudflareAccessClaims;
cfIdentity: CloudflareAccessIdentityProfile;
@@ -454,9 +455,7 @@ export const providers: Readonly<{
};
cache?: CacheService | undefined;
}) => AuthProviderFactory_2;
resolvers: Readonly<{
emailMatchingUserEntityProfileEmail: () => SignInResolver_2<unknown>;
}>;
resolvers: Readonly<cloudflareAccessSignInResolvers>;
}>;
gcpIap: Readonly<{
create: (options: {
-9
View File
@@ -182,15 +182,6 @@ export interface Config {
iss?: string;
region: string;
};
/** @visibility frontend */
cfaccess?: {
teamName: string;
/** @deepVisibility secret */
serviceTokens?: Array<{
token: string;
subject: string;
}>;
};
/**
* The backstage token expiration.
*/
+1
View File
@@ -45,6 +45,7 @@
"@backstage/errors": "workspace:^",
"@backstage/plugin-auth-backend-module-atlassian-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-aws-alb-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-cloudflare-access-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-gcp-iap-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-github-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-gitlab-provider": "workspace:^",
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { cfAccess } from './provider';
export type {
CloudflareAccessClaims,
@@ -1,415 +0,0 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import express from 'express';
import { jwtVerify } from 'jose';
import {
CF_JWT_HEADER,
CF_AUTH_IDENTITY,
CloudflareAccessAuthProvider,
} from './provider';
import fetch from 'node-fetch';
import { AuthResolverContext } from '@backstage/plugin-auth-node';
const jwtMock = jwtVerify as jest.Mocked<any>;
const mockJwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IktFWV9JRCIsImlzcyI6IklTU1VFUl9VUkwifQ.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlVzZXIgTmFtZSIsImlhdCI6MTUxNjIzOTAyMn0.uMCSBGhij1xn5pnot8XgD-huQuTIBOFGs6kkW_p_X94';
const mockClaims = {
sub: '1234567890',
email: 'user.name@email.test',
iat: 1632833760,
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',
email: 'foo@bar.com',
groups: [
{
id: '123',
email: 'foo@bar.com',
name: 'foo',
},
],
};
const identityOkResponse = {
backstageIdentity: {
identity: {
ownershipEntityRefs: ['user:default/jimmymarkum'],
type: 'user',
userEntityRef: 'user:default/jimmymarkum',
},
token:
'eyblob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iLCJlbnQiOlsidXNlcjpkZWZhdWx0L2ppbW15bWFya3VtIl19.eyblob',
},
profile: {
email: 'user.name@email.test',
},
providerInfo: {
cfAccessIdentityProfile: {
email: 'foo@bar.com',
groups: [
{
email: 'foo@bar.com',
id: '123',
name: 'foo',
},
],
id: '123',
name: 'foo',
},
claims: mockClaims,
expiresInSeconds: 3,
},
};
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(),
set: jest.fn(),
delete: jest.fn(),
withOptions: jest.fn(),
};
jest.mock('jose');
jest.mock('node-fetch', () => {
const original = jest.requireActual('node-fetch');
return {
__esModule: true,
default: jest.fn(),
Headers: original.Headers,
};
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('CloudflareAccessAuthProvider', () => {
// Cloudflare access provides jwt in two ways.
const mockRequestWithJwtHeader = {
header: jest.fn(name => {
if (name === CF_JWT_HEADER) {
return mockJwt;
} else if (name === CF_AUTH_IDENTITY) {
return mockAuthenticatedUserEmail;
}
return undefined;
}),
} as unknown as express.Request;
const mockRequestWithJwtCookie = {
header: jest.fn(_ => {
return undefined;
}),
cookies: {
CF_Authorization: `${mockJwt}`,
},
} 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;
}),
} as unknown as express.Request;
const mockResponse = {
end: jest.fn(),
header: () => jest.fn(),
json: jest.fn(),
status: jest.fn(),
} as unknown as express.Response;
const mockFetch = fetch as unknown as jest.Mocked<any>;
const provider = new CloudflareAccessAuthProvider({
teamName: 'foobar',
serviceTokens: [],
resolverContext: {} as AuthResolverContext,
authHandler: async result => {
expect(result).toEqual(
expect.objectContaining({
claims: mockClaims,
cfIdentity: mockCfIdentity,
token: mockJwt,
}),
);
return {
profile: {
email: result.claims.email,
},
};
},
signInResolver: async ({ result }) => {
expect(result).toEqual(
expect.objectContaining({
claims: mockClaims,
cfIdentity: mockCfIdentity,
token: mockJwt,
}),
);
return {
token:
'eyblob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iLCJlbnQiOlsidXNlcjpkZWZhdWx0L2ppbW15bWFya3VtIl19.eyblob',
};
},
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(
Promise.resolve({
ok: true,
status: 200,
json: () => {
return mockCfIdentity;
},
}),
);
await provider.refresh(mockRequestWithJwtHeader, mockResponse);
expect(mockResponse.json).toHaveBeenCalledWith(identityOkResponse);
});
it('should resolve when passed in cookie', async () => {
jwtMock.mockReturnValue(Promise.resolve({ payload: mockClaims }));
// when mockFetch resolves and there nothing gets returned from /get-identity
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
json: () => {
return mockCfIdentity;
},
}),
);
await provider.refresh(mockRequestWithJwtCookie, mockResponse);
expect(mockResponse.json).toHaveBeenCalledWith(identityOkResponse);
});
it('should resolve an identity and populate access groups when there are groups', async () => {
// when get-identity api responds and responds with status 200
mockFetch.mockResolvedValueOnce(
Promise.resolve({
ok: () => {
return true;
},
status: 200,
json: () => {
return Promise.resolve({
name: 'foo',
id: '123',
email: 'foo@bar.com',
groups: [
{
id: '123',
email: 'foo@bar.com',
name: 'foo',
},
],
});
},
}),
);
jwtMock.mockReturnValueOnce(Promise.resolve({ payload: mockClaims }));
await provider.refresh(mockRequestWithJwtCookie, mockResponse);
expect(mockResponse.json).toHaveBeenCalledWith(identityOkResponse);
});
it('should throw an error when get-identity fails', async () => {
mockFetch.mockReturnValue(Promise.reject());
await expect(
provider.refresh(mockRequestWithJwtCookie, mockResponse),
).rejects.toThrow();
});
});
describe('should fail when', () => {
it('JWT is missing', async () => {
await expect(
provider.refresh(mockRequestWithoutJwt, mockResponse),
).rejects.toThrow();
});
it('JWT is invalid', async () => {
jwtMock.mockImplementation(() => {
throw new Error('bad JWT');
});
await expect(
provider.refresh(mockRequestWithJwtCookie, mockResponse),
).rejects.toThrow();
await expect(
provider.refresh(mockRequestWithJwtHeader, mockResponse),
).rejects.toThrow();
jwtMock.mockReset();
});
it('SignInResolver rejects', async () => {
jwtMock.mockReturnValue(mockClaims);
await expect(
provider.refresh(mockRequestWithJwtCookie, mockResponse),
).rejects.toThrow();
await expect(
provider.refresh(mockRequestWithJwtHeader, mockResponse),
).rejects.toThrow();
jwtMock.mockReset();
});
it('AuthHandler rejects', async () => {
jwtMock.mockReturnValue(mockClaims);
await expect(
provider.refresh(mockRequestWithJwtCookie, mockResponse),
).rejects.toThrow();
await expect(
provider.refresh(mockRequestWithJwtHeader, mockResponse),
).rejects.toThrow();
jwtMock.mockReset();
});
});
});
@@ -14,69 +14,17 @@
* limitations under the License.
*/
import { AuthHandler } from '../types';
import fetch, { Headers } from 'node-fetch';
import express from 'express';
import { jwtVerify, createRemoteJWKSet } from 'jose';
import {
AuthenticationError,
ResponseError,
ForwardedError,
} from '@backstage/errors';
import { CacheClient } from '@backstage/backend-common';
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse';
import { commonByEmailResolver } from '../resolvers';
import {
AuthProviderRouteHandlers,
AuthResolverContext,
ClientAuthResponse,
cloudflareAccessSignInResolvers,
createCloudflareAccessAuthenticator,
} from '@backstage/plugin-auth-backend-module-cloudflare-access-provider';
import {
SignInResolver,
createProxyAuthProviderFactory,
} from '@backstage/plugin-auth-node';
// JWT Web Token definitions are in the URL below
// https://developers.cloudflare.com/cloudflare-one/identity/users/validating-json/
export const CF_JWT_HEADER = 'cf-access-jwt-assertion';
export const CF_AUTH_IDENTITY = 'cf-access-authenticated-user-email';
const COOKIE_AUTH_NAME = 'CF_Authorization';
const CACHE_PREFIX = 'providers/cloudflare-access/profile-v1';
/**
* Default cache TTL
*
* @public
*/
export const CF_DEFAULT_CACHE_TTL = 3600;
type ServiceToken = {
token: string;
subject: string;
};
/** @public */
export type Options = {
/**
* Access team name
*
* When you configure Access, the public certificates are available at this
* URL, where your-team-name is your team name:
* 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;
cache?: CacheClient;
};
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
import { AuthHandler } from '../types';
/**
* CloudflareAccessClaims
@@ -85,6 +33,7 @@ export type Options = {
* enrich user profile for sign-in user entity
*
* @public
* @deprecated import from `@backstage/plugin-auth-backend-module-cloudflare-access-provider` instead
*/
export type CloudflareAccessClaims = {
/**
@@ -128,6 +77,7 @@ export type CloudflareAccessClaims = {
* CloudflareAccessGroup
*
* @public
* @deprecated import from `@backstage/plugin-auth-backend-module-cloudflare-access-provider` instead
*/
export type CloudflareAccessGroup = {
/**
@@ -151,6 +101,7 @@ export type CloudflareAccessGroup = {
* enrich user profile for sign-in user entity
*
* @public
* @deprecated import from `@backstage/plugin-auth-backend-module-cloudflare-access-provider` instead
*/
export type CloudflareAccessIdentityProfile = {
id: string;
@@ -161,6 +112,7 @@ export type CloudflareAccessIdentityProfile = {
/**
* @public
* @deprecated import from `@backstage/plugin-auth-backend-module-cloudflare-access-provider` instead
*/
export type CloudflareAccessResult = {
claims: CloudflareAccessClaims;
@@ -169,202 +121,6 @@ export type CloudflareAccessResult = {
token: string;
};
/**
* @public
*/
export type CloudflareAccessProviderInfo = {
/**
* Expiry of the access token in seconds.
*/
expiresInSeconds?: number;
/**
* Cloudflare access identity profile with cloudflare access groups
*/
cfAccessIdentityProfile?: CloudflareAccessIdentityProfile;
/**
* Cloudflare access claims
*/
claims: CloudflareAccessClaims;
};
export type CloudflareAccessResponse =
ClientAuthResponse<CloudflareAccessProviderInfo>;
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>;
private readonly jwtKeySet: any;
private readonly cache?: CacheClient;
constructor(options: Options) {
this.teamName = options.teamName;
this.serviceTokens = options.serviceTokens;
this.authHandler = options.authHandler;
this.signInResolver = options.signInResolver;
this.resolverContext = options.resolverContext;
this.jwtKeySet = createRemoteJWKSet(
new URL(
`https://${this.teamName}.cloudflareaccess.com/cdn-cgi/access/certs`,
),
);
this.cache = options.cache;
}
frameHandler(): Promise<void> {
return Promise.resolve();
}
async refresh(req: express.Request, res: express.Response): Promise<void> {
// ProxiedSignInPage calls `/refresh` implicitly each time the backstage
// app is refreshed on the browser.
// User authentication is then checked here.
const result = await this.getResult(req);
const response = await this.handleResult(result);
res.json(response);
}
start(): Promise<void> {
return Promise.resolve();
}
private async getIdentityProfile(
jwt: string,
): Promise<CloudflareAccessIdentityProfile> {
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}`);
try {
const res = await fetch(
`https://${this.teamName}.cloudflareaccess.com/cdn-cgi/access/get-identity`,
{ headers },
);
if (!res.ok) {
throw await ResponseError.fromResponse(res);
}
const cfIdentity = await res.json();
return cfIdentity as unknown as CloudflareAccessIdentityProfile;
} catch (err) {
throw new ForwardedError('getIdentityProfile failed', err);
}
}
private async getResult(
req: express.Request,
): Promise<CloudflareAccessResult> {
// 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);
if (!jwt) {
jwt = req.cookies.CF_Authorization;
}
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`,
);
}
// Cloudflare signs the JWT using the RSA Signature with SHA-256 (RS256).
// RS256 follows an asymmetric algorithm; a private key signs the JWTs and
// a separate public key verifies the signature.
const verifyResult = await jwtVerify(jwt, this.jwtKeySet, {
issuer: `https://${this.teamName}.cloudflareaccess.com`,
});
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 {
...result,
token: jwt,
};
}
const claims = verifyResult.payload as CloudflareAccessClaims;
// Builds a passport profile from JWT claims first
try {
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 = {
claims,
cfIdentity,
expiresInSeconds: claims.exp - claims.iat,
};
this.cache?.set(cacheKey, JSON.stringify(cfAccessResult));
return {
...cfAccessResult,
token: jwt,
};
} catch (err) {
throw new ForwardedError(
'Failed to populate access identity information',
err,
);
}
}
private async handleResult(
result: CloudflareAccessResult,
): Promise<CloudflareAccessResponse> {
const { profile } = await this.authHandler(result, this.resolverContext);
const backstageIdentity = await this.signInResolver(
{
result,
profile,
},
this.resolverContext,
);
return {
providerInfo: {
expiresInSeconds: result.expiresInSeconds,
claims: result.claims,
cfAccessIdentityProfile: result.cfIdentity,
},
backstageIdentity: prepareBackstageIdentityResponse(backstageIdentity),
profile,
};
}
}
/**
* Auth provider integration for Cloudflare Access auth
*
@@ -387,56 +143,21 @@ export const cfAccess = createAuthProviderIntegration({
*/
resolver: SignInResolver<CloudflareAccessResult>;
};
/**
* CacheClient object that was configured for the Backstage backend,
* should be provided via the backend auth plugin.
*/
cache?: CacheClient;
}) {
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(
'SignInResolver is required to use this authentication provider',
);
}
const authHandler: AuthHandler<CloudflareAccessResult> =
options?.authHandler
? options.authHandler
: async ({ claims, cfIdentity }) => {
return {
profile: {
email: claims.email,
displayName: cfIdentity.name,
},
};
};
return new CloudflareAccessAuthProvider({
teamName,
serviceTokens,
signInResolver: options?.signIn.resolver,
authHandler,
resolverContext,
...(options.cache && { cache: options.cache }),
});
};
},
resolvers: {
/**
* Looks up the user by matching their email to the entity email.
*/
emailMatchingUserEntityProfileEmail: () => commonByEmailResolver,
return createProxyAuthProviderFactory({
authenticator: createCloudflareAccessAuthenticator({
cache: options.cache,
}),
profileTransform: options?.authHandler,
signInResolver: options?.signIn?.resolver,
signInResolverFactories: cloudflareAccessSignInResolvers,
});
},
resolvers: cloudflareAccessSignInResolvers,
});
+62 -12
View File
@@ -4793,6 +4793,28 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/plugin-auth-backend-module-cloudflare-access-provider@workspace:^, @backstage/plugin-auth-backend-module-cloudflare-access-provider@workspace:plugins/auth-backend-module-cloudflare-access-provider":
version: 0.0.0-use.local
resolution: "@backstage/plugin-auth-backend-module-cloudflare-access-provider@workspace:plugins/auth-backend-module-cloudflare-access-provider"
dependencies:
"@backstage/backend-defaults": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/plugin-auth-backend": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@backstage/types": "workspace:^"
express: ^4.18.2
jose: ^5.0.0
msw: ^2.0.0
node-fetch: ^2.6.7
node-mocks-http: ^1.0.0
uuid: ^9.0.0
languageName: unknown
linkType: soft
"@backstage/plugin-auth-backend-module-gcp-iap-provider@workspace:^, @backstage/plugin-auth-backend-module-gcp-iap-provider@workspace:plugins/auth-backend-module-gcp-iap-provider":
version: 0.0.0-use.local
resolution: "@backstage/plugin-auth-backend-module-gcp-iap-provider@workspace:plugins/auth-backend-module-gcp-iap-provider"
@@ -5025,6 +5047,7 @@ __metadata:
"@backstage/errors": "workspace:^"
"@backstage/plugin-auth-backend-module-atlassian-provider": "workspace:^"
"@backstage/plugin-auth-backend-module-aws-alb-provider": "workspace:^"
"@backstage/plugin-auth-backend-module-cloudflare-access-provider": "workspace:^"
"@backstage/plugin-auth-backend-module-gcp-iap-provider": "workspace:^"
"@backstage/plugin-auth-backend-module-github-provider": "workspace:^"
"@backstage/plugin-auth-backend-module-gitlab-provider": "workspace:^"
@@ -19252,12 +19275,12 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:*, @types/node@npm:>=12.12.47, @types/node@npm:>=13.7.0, @types/node@npm:^20.1.1, @types/node@npm:^20.11.16":
version: 20.11.17
resolution: "@types/node@npm:20.11.17"
"@types/node@npm:*, @types/node@npm:>=12.12.47, @types/node@npm:>=13.7.0, @types/node@npm:^20.1.1, @types/node@npm:^20.10.6, @types/node@npm:^20.11.16":
version: 20.12.4
resolution: "@types/node@npm:20.12.4"
dependencies:
undici-types: ~5.26.4
checksum: 59c0dde187120adc97da30063c86511664b24b50fe777abfe1f557c217d0a0b84a68aaab5ef8ac44f5c2986b3f9cd605a15fa6e4f31195e594da96bbe9617c20
checksum: c29879642bd4f1f35ffc6e2356121c5ffdb6530d41db1e6ac013c6fbd1dfa4b213b9529a1213cf1f320f801423d00301176ffb41689f790b0cd8c1e82fe3ee74
languageName: node
linkType: hard
@@ -20855,7 +20878,7 @@ __metadata:
languageName: node
linkType: hard
"accepts@npm:~1.3.4, accepts@npm:~1.3.5, accepts@npm:~1.3.8":
"accepts@npm:^1.3.7, accepts@npm:~1.3.4, accepts@npm:~1.3.5, accepts@npm:~1.3.8":
version: 1.3.8
resolution: "accepts@npm:1.3.8"
dependencies:
@@ -24123,7 +24146,7 @@ __metadata:
languageName: node
linkType: hard
"content-disposition@npm:0.5.4":
"content-disposition@npm:0.5.4, content-disposition@npm:^0.5.3":
version: 0.5.4
resolution: "content-disposition@npm:0.5.4"
dependencies:
@@ -25521,7 +25544,7 @@ __metadata:
languageName: node
linkType: hard
"depd@npm:^1.1.2, depd@npm:~1.1.2":
"depd@npm:^1.1.0, depd@npm:^1.1.2, depd@npm:~1.1.2":
version: 1.1.2
resolution: "depd@npm:1.1.2"
checksum: 6b406620d269619852885ce15965272b829df6f409724415e0002c8632ab6a8c0a08ec1f0bd2add05dc7bd7507606f7e2cc034fa24224ab829580040b835ecd9
@@ -28672,7 +28695,7 @@ __metadata:
languageName: node
linkType: hard
"fresh@npm:0.5.2":
"fresh@npm:0.5.2, fresh@npm:^0.5.2":
version: 0.5.2
resolution: "fresh@npm:0.5.2"
checksum: 13ea8b08f91e669a64e3ba3a20eb79d7ca5379a81f1ff7f4310d54e2320645503cc0c78daedc93dfb6191287295f6479544a649c64d8e41a1c0fb0c221552346
@@ -34580,6 +34603,13 @@ __metadata:
languageName: node
linkType: hard
"merge-descriptors@npm:^1.0.1":
version: 1.0.3
resolution: "merge-descriptors@npm:1.0.3"
checksum: 52117adbe0313d5defa771c9993fe081e2d2df9b840597e966aadafde04ae8d0e3da46bac7ca4efc37d4d2b839436582659cd49c6a43eacb3fe3050896a105d1
languageName: node
linkType: hard
"merge-stream@npm:^2.0.0":
version: 2.0.0
resolution: "merge-stream@npm:2.0.0"
@@ -35004,7 +35034,7 @@ __metadata:
languageName: node
linkType: hard
"mime@npm:1.6.0":
"mime@npm:1.6.0, mime@npm:^1.3.4":
version: 1.6.0
resolution: "mime@npm:1.6.0"
bin:
@@ -36041,6 +36071,26 @@ __metadata:
languageName: node
linkType: hard
"node-mocks-http@npm:^1.0.0":
version: 1.14.1
resolution: "node-mocks-http@npm:1.14.1"
dependencies:
"@types/express": ^4.17.21
"@types/node": ^20.10.6
accepts: ^1.3.7
content-disposition: ^0.5.3
depd: ^1.1.0
fresh: ^0.5.2
merge-descriptors: ^1.0.1
methods: ^1.1.2
mime: ^1.3.4
parseurl: ^1.3.3
range-parser: ^1.2.0
type-is: ^1.6.18
checksum: cc37618fb5f44a6049f8fcfc73373c4b42943e35e4b0f7463939d3f219663fe647e00e6eb9c3b8aedd48e88e10e1542993ac1a7ff8442ee2c3f6b97f23ced0e0
languageName: node
linkType: hard
"node-releases@npm:^2.0.13":
version: 2.0.13
resolution: "node-releases@npm:2.0.13"
@@ -37262,7 +37312,7 @@ __metadata:
languageName: node
linkType: hard
"parseurl@npm:~1.3.2, parseurl@npm:~1.3.3":
"parseurl@npm:^1.3.3, parseurl@npm:~1.3.2, parseurl@npm:~1.3.3":
version: 1.3.3
resolution: "parseurl@npm:1.3.3"
checksum: 407cee8e0a3a4c5cd472559bca8b6a45b82c124e9a4703302326e9ab60fc1081442ada4e02628efef1eb16197ddc7f8822f5a91fd7d7c86b51f530aedb17dfa2
@@ -39163,7 +39213,7 @@ __metadata:
languageName: node
linkType: hard
"range-parser@npm:^1.2.1, range-parser@npm:~1.2.1":
"range-parser@npm:^1.2.0, range-parser@npm:^1.2.1, range-parser@npm:~1.2.1":
version: 1.2.1
resolution: "range-parser@npm:1.2.1"
checksum: 0a268d4fea508661cf5743dfe3d5f47ce214fd6b7dec1de0da4d669dd4ef3d2144468ebe4179049eff253d9d27e719c88dae55be64f954e80135a0cada804ec9
@@ -44103,7 +44153,7 @@ __metadata:
languageName: node
linkType: hard
"type-is@npm:^1.6.4, type-is@npm:~1.6.18":
"type-is@npm:^1.6.18, type-is@npm:^1.6.4, type-is@npm:~1.6.18":
version: 1.6.18
resolution: "type-is@npm:1.6.18"
dependencies: