extract the cloudflare access auth provider
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user