extract the cloudflare access auth provider
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend-module-cloudflare-access-provider': minor
|
||||
---
|
||||
|
||||
Created a separate module for the Cloudflare Access auth provider
|
||||
@@ -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 application’s 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;
|
||||
};
|
||||
@@ -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: {
|
||||
|
||||
Vendored
-9
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user