Merge pull request #23997 from backstage/freben/cfaccess

auth-backend: extract the cloudflare access auth provider
This commit is contained in:
Patrik Oldsberg
2024-04-16 11:51:39 +02:00
committed by GitHub
27 changed files with 1233 additions and 796 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`
+118 -53
View File
@@ -24,76 +24,62 @@ Let's start by adding the following `auth` configuration in your
auth:
providers:
cfaccess:
# You can find the team name in the Cloudflare Zero Trust dashboard.
teamName: <Team Name>
# This 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.
serviceTokens:
- token: '1uh2fh19efvfh129f1f919u21f2f19jf2.access'
subject: 'bot-user@your-company.com'
# This picks what sign in resolver(s) you want to use.
signIn:
resolvers:
- resolver: emailMatchingUserEntityProfileEmail
```
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.
This config section must be in place for the provider to load at all. Now let's
add the provider itself.
The `signIn` section picks what sign-in resolver(s) to use for sign-in attempts.
It is responsible for matching the upstream provider's sign-in result to a
corresponding Backstage identity, or to throw an error if the attempt should be
rejected for any reason. The `emailMatchingUserEntityProfileEmail` is a common
choice: it tries to match the email of the signed-in user to a `User` kind
entity in the catalog whose profile email matches that.
If the builtin sign in resolvers do not match your needs, you can skip the
`signIn` section and instead [provide a custom resolver](#advanced-custom-sign-in-resolver).
## Backend Changes
Add a `providerFactories` entry to the router in
`packages/backend/plugin/auth.ts`.
We need to add the provider package as a dependency to our backend:
```ts title="packages/backend/plugin/auth.ts"
import { providers } from '@backstage/plugin-auth-backend';
```bash title="from your Backstage root directory"
yarn --cwd packages/backend add @backstage/plugin-auth-backend-module-cloudflare-access-provider
```
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
return await createRouter({
logger: env.logger,
config: env.config,
database: env.database,
discovery: env.discovery,
providerFactories: {
'cfaccess': providers.cfAccess.create({
// Replace the auth handler if you want to customize the returned user
// profile info (can be left out; the default implementation is shown
// below which only returns the email). You may want to amend this code
// with something that loads additional user profile data out.
async authHandler({ accessToken }) {
return { profile: { email: accessToken.email } };
},
signIn: {
// You need to supply an identity resolver, that takes the profile
// and the access token and produces the Backstage token with the
// relevant user info.
async resolver({ profile, result }, ctx) {
// Somehow compute the Backstage token claims. Just some sample code
// shown here, but you may want to query your LDAP server, or
// https://<teamName>.cloudflareaccess.com/cdn-cgi/access/get-identity
// https://developers.cloudflare.com/cloudflare-one/identity/users/validating-json/#groups-within-a-jwt
const id = profile.email.split('@')[0];
const sub = stringifyEntityRef({ kind: 'User', name: id });
const ent = [sub, stringifyEntityRef({ kind: 'Group', name: 'team-name' });
return ctx.issueToken({ claims: { sub, ent } });
},
},
}),
},
});
}
And to tell the backend to load it:
```ts title="in packages/backend/src/index.ts"
backend.add(import('@backstage/plugin-auth-backend'));
/* highlight-add-start */
backend.add(
import('@backstage/plugin-auth-backend-module-cloudflare-access-provider'),
);
/* highlight-add-end */
```
Now the backend is ready to serve auth requests on the
`/api/auth/cfaccess/refresh` endpoint. All that's left is to update the
frontend sign-in mechanism to poll that endpoint through Cloudflare Access, on
the user's behalf.
`/api/auth/cfaccess/refresh` endpoint. All that's left is to update the frontend
sign-in mechanism to poll that endpoint through Cloudflare Access, on the user's
behalf.
## Adding the provider to the Backstage frontend
## Frontend Changes
It is recommended to use the `ProxiedSignInPage` for this provider, which is
installed in `packages/app/src/App.tsx` like this:
installed in your app like this:
```tsx title="packages/app/src/App.tsx"
```tsx title="in packages/app/src/App.tsx"
/* highlight-add-next-line */
import { ProxiedSignInPage } from '@backstage/core-components';
@@ -103,8 +89,87 @@ const app = createApp({
SignInPage: props => <ProxiedSignInPage {...props} provider="cfaccess" />,
},
/* highlight-add-end */
// ..
// ...
});
```
See [Sign-In with Proxy Providers](../index.md#sign-in-with-proxy-providers) for pointers on how to set up the sign-in page to also work smoothly for local development.
See [Sign-In with Proxy Providers](../index.md#sign-in-with-proxy-providers) for
pointers on how to set up the sign-in page to also work smoothly for local
development.
## Advanced: Custom Sign-in Resolver
If none of the built-in sign in resolvers fit your needs, you need to provide a
customized version of the module. Now you should _not_
`backend.add(import(...))`, instead you will do the following.
```ts title="in packages/backend/plugin/auth.ts"
/* highlight-add-start */
import { createCloudflareAccessAuthenticator } from '@backstage/plugin-auth-backend-module-cloudflare-access-provider';
import {
coreServices,
createBackendModule,
} from '@backstage/backend-plugin-api';
import {
authProvidersExtensionPoint,
createProxyAuthProviderFactory,
} from '@backstage/plugin-auth-node';
const customAuth = createBackendModule({
// This ID must be exactly "auth" because that's the plugin it targets
pluginId: 'auth',
// This ID must be unique, but can be anything
moduleId: 'custom-auth-provider',
register(reg) {
reg.registerInit({
deps: {
providers: authProvidersExtensionPoint,
cache: coreServices.cache,
},
async init({ providers, cache }) {
providers.registerProvider({
// This ID must match the actual provider config, e.g. addressing
// auth.providers.github means that this must be "github".
providerId: 'cfaccess',
// Use createProxyAuthProviderFactory instead if it's one of the proxy
// based providers rather than an OAuth based one
factory: createProxyAuthProviderFactory({
authenticator: createCloudflareAccessAuthenticator({ cache }),
async signInResolver(info, ctx) {
// This is where the body of the sign-in resolver goes!
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,
},
});
},
}),
});
},
});
},
});
/* highlight-add-end */
backend.add(import('@backstage/plugin-auth-backend'));
/* highlight-remove-start */
backend.add(
import('@backstage/plugin-auth-backend-module-cloudflare-access-provider'),
);
/* highlight-remove-end */
/* highlight-add-next-line */
backend.add(customAuth);
```
The body of the sign-in resolver is up to you to write! The example code above
is just a copy of what `emailMatchingUserEntityProfileEmail` does. The `info`
parameter contains all of the results of the sign-in attempt so far. The `ctx`
context [has several useful
functions](https://backstage.io/docs/reference/plugin-auth-node.authresolvercontext/)
for issuing tokens in various ways.
@@ -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:^"
@@ -19235,12 +19258,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.7
resolution: "@types/node@npm:20.12.7"
dependencies:
undici-types: ~5.26.4
checksum: 59c0dde187120adc97da30063c86511664b24b50fe777abfe1f557c217d0a0b84a68aaab5ef8ac44f5c2986b3f9cd605a15fa6e4f31195e594da96bbe9617c20
checksum: 7cc979f7e2ca9a339ec71318c3901b9978555257929ef3666987f3e447123bc6dc92afcc89f6347e09e07d602fde7d51bcddea626c23aa2bb74aeaacfd1e1686
languageName: node
linkType: hard
@@ -20838,7 +20861,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:
@@ -24110,7 +24133,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:
@@ -25500,7 +25523,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
@@ -28668,7 +28691,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
@@ -34590,6 +34613,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"
@@ -35014,7 +35044,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:
@@ -36051,6 +36081,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"
@@ -37272,7 +37322,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
@@ -39223,7 +39273,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
@@ -44166,7 +44216,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: