add Cloudflare Access authentication provider

Signed-off-by: Renlord Yang <renlord@cloudflare.com>
Signed-off-by: Renlord Yang <me@renlord.com>
This commit is contained in:
Renlord Yang
2022-06-23 03:39:27 +00:00
committed by Renlord Yang
parent d1c04da5d0
commit 3cedfd8365
11 changed files with 601 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': minor
---
add Cloudflare Access auth provider to auth-backend
+2
View File
@@ -362,3 +362,5 @@ Zhou
zoomable
zsh
Alef
Cloudflare
cloudflare
+100
View File
@@ -0,0 +1,100 @@
---
id: cfaccess
title: Cloudflare Access Provider
sidebar_label: cfaccess
# prettier-ignore
description: Adding Cloudflare Access as an authentication provider in Backstage
---
Similar to GCP IAP Proxy Provider or AWS ALB provider, developers can offload authentication
support to Cloudflare Access.
This tutorial shows how to use authentication on Cloudflare Access sitting in
front of Backstage.
It is assumed a Cloudflare tunnel is already serving traffic in front of a
Backstage instance configured to serve the frontend app from the backend and is
already gated using Cloudflare Access.
## Configuration
Let's start by adding the following `auth` configuration in your
`app-config.yaml` or `app-config.production.yaml` or similar:
```yaml
auth:
providers:
cfaccess:
teamName: <Team Name>
```
You can find the team name in the Cloudflare Zero Trust dashboard.
This config section must be in place for the provider to load at all. Now let's
add the provider itself.
## Backend Changes
Add a `providerFactories` entry to the router in
`packages/backend/plugin/auth.ts`.
```ts
import { providers } from '@backstage/plugin-auth-backend';
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
return await createRouter({
logger: env.logger,
config: env.config,
database: env.database,
discovery: env.discovery,
providerFactories: {
'cfaccess': providers.cfAccess.create({
// Replace the auth handler if you want to customize the returned user
// profile info (can be left out; the default implementation is shown
// below which only returns the email). You may want to amend this code
// with something that loads additional user profile data out.
async authHandler({ accessToken }) {
return { profile: { email: accessToken.email } };
},
signIn: {
// You need to supply an identity resolver, that takes the profile
// and the access token and produces the Backstage token with the
// relevant user info.
async resolver({ profile, result }, ctx) {
// Somehow compute the Backstage token claims. Just some dummy code
// shown here, but you may want to query your LDAP server, or
// https://<teamName>.cloudflareaccess.com/cdn-cgi/access/get-identity
// https://developers.cloudflare.com/cloudflare-one/identity/users/validating-json/#groups-within-a-jwt
const id = profile.email.split('@')[0];
const sub = stringifyEntityRef({ kind: 'User', name: id });
const ent = [sub, stringifyEntityRef({ kind: 'Group', name: 'team-name' });
return ctx.issueToken({ claims: { sub, ent } });
},
},
}),
},
});
}
```
Now the backend is ready to serve auth requests on the
`/api/auth/cfaccess/refresh` endpoint. All that's left is to update the
frontend sign-in mechanism to poll that endpoint through Cloudflare Access, on
the user's behalf.
## Frontend Changes
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 the [Sign-In with Proxy Providers](../index.md#sign-in-with-proxy-providers) section for more information.
+2 -1
View File
@@ -18,6 +18,7 @@ Backstage comes with many common authentication providers in the core library:
- [Auth0](auth0/provider.md)
- [Azure](microsoft/provider.md)
- [Bitbucket](bitbucket/provider.md)
- [Cloudflare Access](cloudflare/access.md)
- [GitHub](github/provider.md)
- [GitLab](gitlab/provider.md)
- [Google](google/provider.md)
@@ -131,7 +132,7 @@ allows allowing guest access:
Some auth providers are so-called "proxy" providers, meaning they're meant to be used
behind an authentication proxy. Examples of these are
[AWS ALB](https://github.com/backstage/backstage/blob/master/contrib/docs/tutorials/aws-alb-aad-oidc-auth.md),
[AWS ALB](https://github.com/backstage/backstage/blob/master/contrib/docs/tutorials/aws-alb-aad-oidc-auth.md), [Cloudflare Access](./cloudflare/access.md),
[GCP IAP](./google/gcp-iap-auth.md), and [OAuth2 Proxy](./oauth2-proxy/provider.md).
When using a proxy provider, you'll end up wanting to use a different sign-in page, as
+6
View File
@@ -170,6 +170,12 @@ export class CatalogIdentityClient {
resolveCatalogMembership(query: MemberClaimQuery): Promise<string[]>;
}
// @public (undocumented)
export type CloudflareAccessResult = {
fullProfile: Profile;
expiresInSeconds?: number;
};
// @public
export type CookieConfigurer = (ctx: {
providerId: string;
+3
View File
@@ -116,6 +116,9 @@ export interface Config {
issuer?: string;
region: string;
};
cfaccess?: {
teamName: string;
};
};
};
}
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { cfAccess } from './provider';
export type { CloudflareAccessResult } from './provider';
@@ -0,0 +1,208 @@
/*
* 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 { makeProfileInfo } from '../../lib/passport';
import { AuthResolverContext } from '../types';
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 = {
sub: '1234567890',
name: 'User Name',
family_name: 'Name',
given_name: 'User',
email: 'user.name@email.test',
iat: 1632833760,
exp: 1632833763,
iss: 'ISSUER_URL',
};
const mockAuthenticatedUserEmail = 'user.name@email.test';
jest.mock('jose');
jest.mock('node-fetch', () => ({
__esModule: true,
default: async () => {
return {
text: async () => {
return mockKey();
},
};
},
}));
beforeEach(() => {
jest.clearAllMocks();
});
describe('CloudflareAccessAuthProvider', () => {
const mockRequest = {
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 mockRequestWithoutJwt = {
header: jest.fn(_ => {
return undefined;
}),
} as unknown as express.Request;
const mockResponse = {
end: jest.fn(),
header: () => jest.fn(),
json: jest.fn().mockReturnThis(),
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',
};
},
});
jwtMock.mockReturnValueOnce(Promise.resolve({ payload: mockClaims }));
await provider.refresh(mockRequest, mockResponse);
expect(mockResponse.json).toHaveBeenCalledWith({
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,
},
});
});
});
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(() => {
throw new Error('bad JWT');
});
await expect(
provider.refresh(mockRequest, mockResponse),
).rejects.toThrow();
});
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);
await expect(
provider.refresh(mockRequest, mockResponse),
).rejects.toThrow();
});
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);
await expect(
provider.refresh(mockRequest, mockResponse),
).rejects.toThrow();
});
});
});
@@ -0,0 +1,255 @@
/*
* 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 {
AuthHandler,
AuthProviderRouteHandlers,
AuthResolverContext,
AuthResponse,
SignInResolver,
} from '../types';
import express from 'express';
import * as _ from 'lodash';
import { jwtVerify, createRemoteJWKSet } from 'jose';
import { Profile as PassportProfile } from 'passport';
import { AuthenticationError } from '@backstage/errors';
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse';
import { makeProfileInfo } from '../../lib/passport';
// 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';
/** @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;
authHandler: AuthHandler<CloudflareAccessResult>;
signInResolver: SignInResolver<CloudflareAccessResult>;
resolverContext: AuthResolverContext;
};
/** @public */
export type CloudflareAccessClaims = {
/**
* `aud` identifies the application to which the JWT is issued.
*/
aud: string[];
/**
* `email` contains the email address of the authenticated user.
*/
email: string;
/**
* iat and exp are the issuance and expiration timestamps.
*/
exp: number;
iat: number;
/**
* `nonce` is the session identifier.
*/
nonce: string;
/**
* `identity_nonce` is available in the Application Token and can be used to
* query all group membership for a given user.
*/
identity_nonce: string;
/**
* `sub` contains the identifier of the authenticated user.
*/
sub: string;
/**
* `iss` the issuer is the applications Cloudflare Access Domain URL.
*/
iss: string;
/**
* `custom` contains SAML attributes in the Application Token specified by an
* administrator in the identity provider configuration.
*/
custom: string;
};
/** @public */
export type CloudflareAccessResult = {
fullProfile: PassportProfile;
expiresInSeconds?: number;
};
export type CloudflareAccessProviderInfo = {
/**
* Expiry of the access token in seconds.
*/
expiresInSeconds?: number;
};
export type CloudflareAccessResponse =
AuthResponse<CloudflareAccessProviderInfo>;
export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers {
private readonly teamName: string;
private readonly resolverContext: AuthResolverContext;
private readonly authHandler: AuthHandler<CloudflareAccessResult>;
private readonly signInResolver: SignInResolver<CloudflareAccessResult>;
private readonly jwtKeySet: any;
constructor(options: Options) {
this.teamName = options.teamName;
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`,
),
);
}
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 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, {
// Cloudflare signs the JWT using the RSA Signature with SHA-256 (RS256).
algorithms: ['RS256'],
issuer: `https://${this.teamName}.cloudflareaccess.com`,
});
const claims = verifyResult.payload as CloudflareAccessClaims;
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() }],
};
return {
fullProfile,
expiresInSeconds: claims.exp - claims.iat,
};
}
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,
},
backstageIdentity: prepareBackstageIdentityResponse(backstageIdentity),
profile,
};
}
}
/**
* Auth provider integration for Cloudflare Access auth
*
* @public
*/
export const cfAccess = createAuthProviderIntegration({
create(options?: {
/**
* The profile transformation function used to verify and convert the auth response
* into the profile that will be presented to the user.
*/
authHandler?: AuthHandler<CloudflareAccessResult>;
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
signIn: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver: SignInResolver<CloudflareAccessResult>;
};
}) {
return ({ config, resolverContext }) => {
const teamName = config.getString('teamName');
if (!options?.signIn.resolver) {
throw new Error(
'SignInResolver is required to use this authentication provider',
);
}
const authHandler: AuthHandler<CloudflareAccessResult> =
options?.authHandler
? options.authHandler
: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
});
return new CloudflareAccessAuthProvider({
teamName,
signInResolver: options?.signIn.resolver,
authHandler,
resolverContext,
});
};
},
});
@@ -20,6 +20,7 @@ export type {
BitbucketOAuthResult,
BitbucketPassportProfile,
} from './bitbucket';
export type { CloudflareAccessResult } from './cloudflare-access';
export type { GithubOAuthResult } from './github';
export type { OAuth2ProxyResult } from './oauth2-proxy';
export type { OidcAuthResult } from './oidc';
@@ -18,6 +18,7 @@ import { atlassian } from './atlassian';
import { auth0 } from './auth0';
import { awsAlb } from './aws-alb';
import { bitbucket } from './bitbucket';
import { cfAccess } from './cloudflare-access';
import { gcpIap } from './gcp-iap';
import { github } from './github';
import { gitlab } from './gitlab';
@@ -75,4 +76,5 @@ export const defaultAuthProviderFactories: {
awsalb: awsAlb.create(),
bitbucket: bitbucket.create(),
atlassian: atlassian.create(),
cfaccess: cfAccess.create(),
};