add support to include cloudflare groups and implement caching
Signed-off-by: Renlord Yang <renlord@cloudflare.com> Signed-off-by: Renlord Yang <me@renlord.com>
This commit is contained in:
committed by
Renlord Yang
parent
3cedfd8365
commit
9dce0535d6
@@ -1,5 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend': minor
|
||||
'@backstage/plugin-auth-backend': patch
|
||||
---
|
||||
|
||||
add Cloudflare Access auth provider to auth-backend
|
||||
|
||||
@@ -363,4 +363,3 @@ zoomable
|
||||
zsh
|
||||
Alef
|
||||
Cloudflare
|
||||
cloudflare
|
||||
|
||||
@@ -97,4 +97,17 @@ installed in `packages/app/src/App.tsx` like this:
|
||||
+ SignInPage: props => <ProxiedSignInPage {...props} provider="cfaccess" />,
|
||||
```
|
||||
|
||||
See the [Sign-In with Proxy Providers](../index.md#sign-in-with-proxy-providers) section for more information.
|
||||
## Adding the provider to the Backstage frontend
|
||||
|
||||
It is recommended to use the `ProxiedSignInPage` for this provider, which is
|
||||
installed in `packages/app/src/App.tsx` like this:
|
||||
|
||||
```diff
|
||||
+import { ProxiedSignInPage } from '@backstage/core-components';
|
||||
|
||||
const app = createApp({
|
||||
components: {
|
||||
+ SignInPage: props => <ProxiedSignInPage {...props} provider="cfaccess" />,
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -23,16 +23,10 @@ import {
|
||||
} from './provider';
|
||||
import { makeProfileInfo } from '../../lib/passport';
|
||||
import { AuthResolverContext } from '../types';
|
||||
import fetch from 'node-fetch';
|
||||
import * as winston from 'winston';
|
||||
|
||||
const jwtMock = jwtVerify as jest.Mocked<any>;
|
||||
|
||||
const mockKey = async () => {
|
||||
return `-----BEGIN PUBLIC KEY-----
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnuN4LlaJhaUpx+qZFTzYCrSBLk0I
|
||||
yOlxJ2VW88mLAQGJ7HPAvOdylxZsItMnzCuqNzZvie8m/NJsOjhDncVkrw==
|
||||
-----END PUBLIC KEY-----
|
||||
`;
|
||||
};
|
||||
const mockJwt =
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IktFWV9JRCIsImlzcyI6IklTU1VFUl9VUkwifQ.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlVzZXIgTmFtZSIsImlhdCI6MTUxNjIzOTAyMn0.uMCSBGhij1xn5pnot8XgD-huQuTIBOFGs6kkW_p_X94';
|
||||
const mockClaims = {
|
||||
@@ -45,26 +39,96 @@ const mockClaims = {
|
||||
exp: 1632833763,
|
||||
iss: 'ISSUER_URL',
|
||||
};
|
||||
const mockCfIdentity = {
|
||||
name: 'foo',
|
||||
id: '123',
|
||||
email: 'foo@bar.com',
|
||||
groups: [
|
||||
{
|
||||
id: '123',
|
||||
email: 'foo@bar.com',
|
||||
name: 'foo',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// For cases where fetch resolves, but not ok and;
|
||||
// fetch failed and returns rejected promise
|
||||
const identityFailResponse = {
|
||||
backstageIdentity: {
|
||||
token:
|
||||
'eyblob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iLCJlbnQiOlsidXNlcjpkZWZhdWx0L2ppbW15bWFya3VtIl19.eyblob',
|
||||
identity: {
|
||||
ownershipEntityRefs: ['user:default/jimmymarkum'],
|
||||
type: 'user',
|
||||
userEntityRef: 'user:default/jimmymarkum',
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
displayName: 'User Name',
|
||||
email: 'user.name@email.test',
|
||||
},
|
||||
providerInfo: {
|
||||
expiresInSeconds: mockClaims.exp - mockClaims.iat,
|
||||
},
|
||||
};
|
||||
|
||||
const identityOkResponse = {
|
||||
backstageIdentity: {
|
||||
identity: {
|
||||
ownershipEntityRefs: ['user:default/jimmymarkum'],
|
||||
type: 'user',
|
||||
userEntityRef: 'user:default/jimmymarkum',
|
||||
},
|
||||
token:
|
||||
'eyblob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iLCJlbnQiOlsidXNlcjpkZWZhdWx0L2ppbW15bWFya3VtIl19.eyblob',
|
||||
},
|
||||
profile: {
|
||||
displayName: 'foo',
|
||||
email: 'foo@bar.com',
|
||||
picture: undefined,
|
||||
},
|
||||
providerInfo: {
|
||||
cfAccessIdentityProfile: {
|
||||
email: 'foo@bar.com',
|
||||
groups: [
|
||||
{
|
||||
email: 'foo@bar.com',
|
||||
id: '123',
|
||||
name: 'foo',
|
||||
},
|
||||
],
|
||||
id: '123',
|
||||
name: 'foo',
|
||||
},
|
||||
expiresInSeconds: 3,
|
||||
},
|
||||
};
|
||||
|
||||
const mockAuthenticatedUserEmail = 'user.name@email.test';
|
||||
const mockCacheClient = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('jose');
|
||||
jest.mock('node-fetch', () => ({
|
||||
__esModule: true,
|
||||
default: async () => {
|
||||
return {
|
||||
text: async () => {
|
||||
return mockKey();
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
jest.mock('node-fetch', () => {
|
||||
const original = jest.requireActual('node-fetch');
|
||||
return {
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
Headers: original.Headers,
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('CloudflareAccessAuthProvider', () => {
|
||||
const mockRequest = {
|
||||
// Cloudflare access provides jwt in two ways.
|
||||
const mockRequestWithJwtHeader = {
|
||||
header: jest.fn(name => {
|
||||
if (name === CF_JWT_HEADER) {
|
||||
return mockJwt;
|
||||
@@ -74,6 +138,15 @@ describe('CloudflareAccessAuthProvider', () => {
|
||||
return undefined;
|
||||
}),
|
||||
} as unknown as express.Request;
|
||||
const mockRequestWithJwtCookie = {
|
||||
header: jest.fn(_ => {
|
||||
return undefined;
|
||||
}),
|
||||
cookies: {
|
||||
CF_Authorization: `${mockJwt}`,
|
||||
},
|
||||
} as unknown as express.Request;
|
||||
|
||||
const mockRequestWithoutJwt = {
|
||||
header: jest.fn(_ => {
|
||||
return undefined;
|
||||
@@ -83,30 +156,106 @@ describe('CloudflareAccessAuthProvider', () => {
|
||||
const mockResponse = {
|
||||
end: jest.fn(),
|
||||
header: () => jest.fn(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
json: jest.fn(),
|
||||
status: jest.fn(),
|
||||
} as unknown as express.Response;
|
||||
|
||||
describe('should transform to type CloudflareAccessResponse', () => {
|
||||
it('when JWT is valid and identity is resolved successfully', async () => {
|
||||
const provider = new CloudflareAccessAuthProvider({
|
||||
teamName: 'foobar',
|
||||
resolverContext: {} as AuthResolverContext,
|
||||
authHandler: async ({ fullProfile }) => ({
|
||||
profile: makeProfileInfo(fullProfile),
|
||||
}),
|
||||
signInResolver: async () => {
|
||||
return {
|
||||
token:
|
||||
'eyblob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iLCJlbnQiOlsidXNlcjpkZWZhdWx0L2ppbW15bWFya3VtIl19.eyblob',
|
||||
};
|
||||
},
|
||||
const mockFetch = fetch as unknown as jest.MockedFn<any>;
|
||||
|
||||
const provider = new CloudflareAccessAuthProvider({
|
||||
teamName: 'foobar',
|
||||
resolverContext: {} as AuthResolverContext,
|
||||
authHandler: async ({ fullProfile }) => ({
|
||||
profile: makeProfileInfo(fullProfile),
|
||||
}),
|
||||
signInResolver: async () => {
|
||||
return {
|
||||
token:
|
||||
'eyblob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iLCJlbnQiOlsidXNlcjpkZWZhdWx0L2ppbW15bWFya3VtIl19.eyblob',
|
||||
};
|
||||
},
|
||||
cache: mockCacheClient,
|
||||
logger: winston.createLogger({
|
||||
transports: [new winston.transports.File({ filename: '/dev/null' })],
|
||||
}),
|
||||
});
|
||||
|
||||
describe('when JWT is valid', () => {
|
||||
describe('resolves when passed in header', () => {
|
||||
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('does not return cfidentity when get-identity returns bad status code', async () => {
|
||||
// when fetch resolves, but response code is bad
|
||||
mockFetch.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: () => {
|
||||
return { err: 'bad request' };
|
||||
},
|
||||
}),
|
||||
);
|
||||
await provider.refresh(mockRequestWithJwtHeader, mockResponse);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(identityFailResponse);
|
||||
});
|
||||
|
||||
it('does not return cfidentity when get-identity fetch fails', async () => {
|
||||
// when fetch rejects
|
||||
mockFetch.mockReturnValueOnce(Promise.reject());
|
||||
await provider.refresh(mockRequestWithJwtHeader, mockResponse);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(identityFailResponse);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// when mockFetch rejects
|
||||
mockFetch.mockReturnValueOnce(Promise.reject());
|
||||
await provider.refresh(mockRequestWithJwtCookie, mockResponse);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(identityFailResponse);
|
||||
});
|
||||
|
||||
it('should return a response error when response code is not 200', async () => {
|
||||
// when get-identity api responds and responds with status 400
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: () => {
|
||||
return Promise.resolve({
|
||||
err: 'bad request',
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
jwtMock.mockReturnValueOnce(Promise.resolve({ payload: mockClaims }));
|
||||
|
||||
await provider.refresh(mockRequest, mockResponse);
|
||||
|
||||
await provider.refresh(mockRequestWithJwtCookie, mockResponse);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
backstageIdentity: {
|
||||
token:
|
||||
@@ -126,83 +275,108 @@ describe('CloudflareAccessAuthProvider', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
backstageIdentity: {
|
||||
token:
|
||||
'eyblob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iLCJlbnQiOlsidXNlcjpkZWZhdWx0L2ppbW15bWFya3VtIl19.eyblob',
|
||||
identity: {
|
||||
ownershipEntityRefs: ['user:default/jimmymarkum'],
|
||||
type: 'user',
|
||||
userEntityRef: 'user:default/jimmymarkum',
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
displayName: 'foo',
|
||||
email: 'foo@bar.com',
|
||||
picture: undefined,
|
||||
},
|
||||
providerInfo: {
|
||||
expiresInSeconds: mockClaims.exp - mockClaims.iat,
|
||||
cfAccessIdentityProfile: {
|
||||
name: 'foo',
|
||||
id: '123',
|
||||
email: 'foo@bar.com',
|
||||
groups: [
|
||||
{
|
||||
id: '123',
|
||||
email: 'foo@bar.com',
|
||||
name: 'foo',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('should fail when', () => {
|
||||
it('JWT is missing', async () => {
|
||||
const provider = new CloudflareAccessAuthProvider({
|
||||
teamName: 'foobar',
|
||||
resolverContext: {} as AuthResolverContext,
|
||||
authHandler: async ({ fullProfile }) => ({
|
||||
profile: makeProfileInfo(fullProfile),
|
||||
}),
|
||||
signInResolver: async () => {
|
||||
return { id: 'user.name', token: 'TOKEN' };
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.refresh(mockRequestWithoutJwt, mockResponse),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('JWT is invalid', async () => {
|
||||
const provider = new CloudflareAccessAuthProvider({
|
||||
teamName: 'foobar',
|
||||
resolverContext: {} as AuthResolverContext,
|
||||
authHandler: async ({ fullProfile }) => ({
|
||||
profile: makeProfileInfo(fullProfile),
|
||||
}),
|
||||
signInResolver: async () => {
|
||||
return { id: 'user.name', token: 'TOKEN' };
|
||||
},
|
||||
});
|
||||
|
||||
jwtMock.mockImplementationOnce(() => {
|
||||
jwtMock.mockImplementation(() => {
|
||||
throw new Error('bad JWT');
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.refresh(mockRequest, mockResponse),
|
||||
provider.refresh(mockRequestWithJwtCookie, mockResponse),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
provider.refresh(mockRequestWithJwtHeader, mockResponse),
|
||||
).rejects.toThrow();
|
||||
jwtMock.mockReset();
|
||||
});
|
||||
|
||||
it('SignInResolver rejects', async () => {
|
||||
const provider = new CloudflareAccessAuthProvider({
|
||||
teamName: 'foobar',
|
||||
resolverContext: {} as AuthResolverContext,
|
||||
authHandler: async ({ fullProfile }) => ({
|
||||
profile: makeProfileInfo(fullProfile),
|
||||
}),
|
||||
signInResolver: async () => {
|
||||
throw new Error();
|
||||
},
|
||||
});
|
||||
|
||||
jwtMock.mockReturnValueOnce(mockClaims);
|
||||
|
||||
jwtMock.mockReturnValue(mockClaims);
|
||||
await expect(
|
||||
provider.refresh(mockRequest, mockResponse),
|
||||
provider.refresh(mockRequestWithJwtCookie, mockResponse),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
provider.refresh(mockRequestWithJwtHeader, mockResponse),
|
||||
).rejects.toThrow();
|
||||
jwtMock.mockReset();
|
||||
});
|
||||
|
||||
it('AuthHandler rejects', async () => {
|
||||
const provider = new CloudflareAccessAuthProvider({
|
||||
teamName: 'foobar',
|
||||
resolverContext: {} as AuthResolverContext,
|
||||
authHandler: async () => {
|
||||
throw new Error();
|
||||
},
|
||||
signInResolver: async () => {
|
||||
return { id: 'user.name', token: 'TOKEN' };
|
||||
},
|
||||
});
|
||||
|
||||
jwtMock.mockReturnValueOnce(mockClaims);
|
||||
jwtMock.mockReturnValue(mockClaims);
|
||||
|
||||
await expect(
|
||||
provider.refresh(mockRequest, mockResponse),
|
||||
provider.refresh(mockRequestWithJwtCookie, mockResponse),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
provider.refresh(mockRequestWithJwtHeader, mockResponse),
|
||||
).rejects.toThrow();
|
||||
jwtMock.mockReset();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,17 +22,29 @@ import {
|
||||
} from '../types';
|
||||
import express from 'express';
|
||||
import * as _ from 'lodash';
|
||||
import fetch, { Headers, Response as FetchResponse } from 'node-fetch';
|
||||
import { jwtVerify, createRemoteJWKSet } from 'jose';
|
||||
import { Profile as PassportProfile } from 'passport';
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
import { AuthenticationError, ResponseError } from '@backstage/errors';
|
||||
import { CacheClient } from '@backstage/backend-common';
|
||||
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
|
||||
import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse';
|
||||
import { makeProfileInfo } from '../../lib/passport';
|
||||
import { commonByEmailResolver } from '../resolvers';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
// 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';
|
||||
|
||||
/**
|
||||
* Default cache TTL
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const CF_DEFAULT_CACHE_TTL = 3600;
|
||||
|
||||
/** @public */
|
||||
export type Options = {
|
||||
@@ -47,6 +59,8 @@ export type Options = {
|
||||
authHandler: AuthHandler<CloudflareAccessResult>;
|
||||
signInResolver: SignInResolver<CloudflareAccessResult>;
|
||||
resolverContext: AuthResolverContext;
|
||||
logger: Logger;
|
||||
cache?: CacheClient;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
@@ -88,17 +102,37 @@ export type CloudflareAccessClaims = {
|
||||
custom: string;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
type CloudflareAccessGroup = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
type CloudflareAccessIdentityProfile = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
groups: CloudflareAccessGroup[];
|
||||
};
|
||||
|
||||
export type CloudflareAccessResult = {
|
||||
fullProfile: PassportProfile;
|
||||
cfIdentity?: CloudflareAccessIdentityProfile;
|
||||
expiresInSeconds?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type CloudflareAccessProviderInfo = {
|
||||
/**
|
||||
* Expiry of the access token in seconds.
|
||||
*/
|
||||
expiresInSeconds?: number;
|
||||
/**
|
||||
* Cloudflare access identity profile with cloudflare access groups
|
||||
*/
|
||||
cfAccessIdentityProfile?: CloudflareAccessIdentityProfile;
|
||||
};
|
||||
|
||||
export type CloudflareAccessResponse =
|
||||
@@ -110,6 +144,8 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers {
|
||||
private readonly authHandler: AuthHandler<CloudflareAccessResult>;
|
||||
private readonly signInResolver: SignInResolver<CloudflareAccessResult>;
|
||||
private readonly jwtKeySet: any;
|
||||
private readonly logger: Logger;
|
||||
private readonly cache?: CacheClient;
|
||||
|
||||
constructor(options: Options) {
|
||||
this.teamName = options.teamName;
|
||||
@@ -121,6 +157,8 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers {
|
||||
`https://${this.teamName}.cloudflareaccess.com/cdn-cgi/access/certs`,
|
||||
),
|
||||
);
|
||||
this.logger = options.logger;
|
||||
this.cache = options.cache;
|
||||
}
|
||||
|
||||
frameHandler(): Promise<void> {
|
||||
@@ -140,6 +178,30 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers {
|
||||
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: FetchResponse = await fetch(
|
||||
`https://${this.teamName}.cloudflareaccess.com/cdn-cgi/access/get-identity`,
|
||||
{ headers },
|
||||
);
|
||||
if (!res.ok) {
|
||||
// Cast to work around https://github.com/backstage/backstage/issues/12166
|
||||
throw await ResponseError.fromResponse(res as unknown as Response);
|
||||
}
|
||||
const cfIdentity = await res.json();
|
||||
return cfIdentity as unknown as CloudflareAccessIdentityProfile;
|
||||
} catch (err) {
|
||||
this.logger.error(`getIdentityProfile failed: ${err}`);
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
|
||||
private async getResult(
|
||||
req: express.Request,
|
||||
): Promise<CloudflareAccessResult> {
|
||||
@@ -161,26 +223,44 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers {
|
||||
// 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, {
|
||||
// Cloudflare signs the JWT using the RSA Signature with SHA-256 (RS256).
|
||||
algorithms: ['RS256'],
|
||||
issuer: `https://${this.teamName}.cloudflareaccess.com`,
|
||||
});
|
||||
const cfAccessResultStr = await this.cache?.get(jwt);
|
||||
if (typeof cfAccessResultStr === 'string') {
|
||||
return JSON.parse(cfAccessResultStr) as CloudflareAccessResult;
|
||||
}
|
||||
const claims = verifyResult.payload as CloudflareAccessClaims;
|
||||
|
||||
// Builds a passport profile from JWT claims first
|
||||
const username = claims.email.split('@')[0].toLowerCase();
|
||||
const fullProfile: PassportProfile = {
|
||||
provider: 'cfAccess',
|
||||
id: claims.sub,
|
||||
displayName: _.startCase(
|
||||
claims.email.split('@')[0].toLowerCase().replace('.', ' '),
|
||||
),
|
||||
username: claims.email.split('@')[0].toLowerCase(),
|
||||
emails: [{ value: claims.email.toLowerCase() }],
|
||||
displayName: _.startCase(username.replace('.', ' ')),
|
||||
username: username,
|
||||
emails: [{ value: claims.email }],
|
||||
};
|
||||
|
||||
return {
|
||||
const cfAccessResult: CloudflareAccessResult = {
|
||||
fullProfile,
|
||||
expiresInSeconds: claims.exp - claims.iat,
|
||||
};
|
||||
try {
|
||||
// If we successfully fetch the get-identity endpoint,
|
||||
// We supplement the passport profile with richer user identity
|
||||
// information here.
|
||||
const cfIdentity = await this.getIdentityProfile(jwt);
|
||||
fullProfile.displayName = cfIdentity.name;
|
||||
fullProfile.emails = [{ value: cfIdentity.email }];
|
||||
fullProfile.username = cfIdentity.email.split('@')[0].toLowerCase();
|
||||
cfAccessResult.cfIdentity = cfIdentity;
|
||||
// Stores a stringified JSON object in cfaccess provider cache only when
|
||||
// we complete all steps
|
||||
this.cache?.set(jwt, JSON.stringify(cfAccessResult));
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`failed to populate access identity information: ${err}`,
|
||||
);
|
||||
}
|
||||
return cfAccessResult;
|
||||
}
|
||||
|
||||
private async handleResult(
|
||||
@@ -198,6 +278,7 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers {
|
||||
return {
|
||||
providerInfo: {
|
||||
expiresInSeconds: result.expiresInSeconds,
|
||||
cfAccessIdentityProfile: result.cfIdentity,
|
||||
},
|
||||
backstageIdentity: prepareBackstageIdentityResponse(backstageIdentity),
|
||||
profile,
|
||||
@@ -211,7 +292,7 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers {
|
||||
* @public
|
||||
*/
|
||||
export const cfAccess = createAuthProviderIntegration({
|
||||
create(options?: {
|
||||
create(options: {
|
||||
/**
|
||||
* The profile transformation function used to verify and convert the auth response
|
||||
* into the profile that will be presented to the user.
|
||||
@@ -227,11 +308,16 @@ 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 }) => {
|
||||
return ({ config, logger, resolverContext }) => {
|
||||
const teamName = config.getString('teamName');
|
||||
|
||||
if (!options?.signIn.resolver) {
|
||||
if (!options.signIn.resolver) {
|
||||
throw new Error(
|
||||
'SignInResolver is required to use this authentication provider',
|
||||
);
|
||||
@@ -249,7 +335,15 @@ export const cfAccess = createAuthProviderIntegration({
|
||||
signInResolver: options?.signIn.resolver,
|
||||
authHandler,
|
||||
resolverContext,
|
||||
logger,
|
||||
...(options.cache && { cache: options.cache }),
|
||||
});
|
||||
};
|
||||
},
|
||||
resolvers: {
|
||||
/**
|
||||
* Looks up the user by matching their email to the entity email.
|
||||
*/
|
||||
emailMatchingUserEntityProfileEmail: () => commonByEmailResolver,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ export const providers = Object.freeze({
|
||||
auth0,
|
||||
awsAlb,
|
||||
bitbucket,
|
||||
cfAccess,
|
||||
gcpIap,
|
||||
github,
|
||||
gitlab,
|
||||
@@ -76,5 +77,4 @@ export const defaultAuthProviderFactories: {
|
||||
awsalb: awsAlb.create(),
|
||||
bitbucket: bitbucket.create(),
|
||||
atlassian: atlassian.create(),
|
||||
cfaccess: cfAccess.create(),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user