From c26218d35157aeaadd19058d39c3b8c9e11d89b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 4 Apr 2024 23:22:47 +0200 Subject: [PATCH] extract the cloudflare access auth provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/gold-dodos-hammer.md | 5 + .changeset/violet-mugs-doubt.md | 5 + .../.eslintrc.js | 1 + .../README.md | 3 + .../api-report.md | 63 +++ .../catalog-info.yaml | 10 + .../config.d.ts | 37 ++ .../dev/index.ts | 24 + .../knip-report.md | 14 + .../package.json | 54 +++ .../src/authenticator.test.ts | 41 ++ .../src/authenticator.ts | 59 +++ .../src/helpers.test.ts | 242 ++++++++++ .../src/helpers.ts | 173 ++++++++ .../src/index.ts | 31 ++ .../src/module.ts | 57 +++ .../src/resolvers.test.ts | 44 ++ .../src/resolvers.ts | 52 +++ .../src/types.ts | 109 +++++ plugins/auth-backend/api-report.md | 13 +- plugins/auth-backend/config.d.ts | 9 - plugins/auth-backend/package.json | 1 + .../src/providers/cloudflare-access/index.ts | 1 + .../cloudflare-access/provider.test.ts | 415 ------------------ .../providers/cloudflare-access/provider.ts | 321 +------------- yarn.lock | 74 +++- 26 files changed, 1115 insertions(+), 743 deletions(-) create mode 100644 .changeset/gold-dodos-hammer.md create mode 100644 .changeset/violet-mugs-doubt.md create mode 100644 plugins/auth-backend-module-cloudflare-access-provider/.eslintrc.js create mode 100644 plugins/auth-backend-module-cloudflare-access-provider/README.md create mode 100644 plugins/auth-backend-module-cloudflare-access-provider/api-report.md create mode 100644 plugins/auth-backend-module-cloudflare-access-provider/catalog-info.yaml create mode 100644 plugins/auth-backend-module-cloudflare-access-provider/config.d.ts create mode 100644 plugins/auth-backend-module-cloudflare-access-provider/dev/index.ts create mode 100644 plugins/auth-backend-module-cloudflare-access-provider/knip-report.md create mode 100644 plugins/auth-backend-module-cloudflare-access-provider/package.json create mode 100644 plugins/auth-backend-module-cloudflare-access-provider/src/authenticator.test.ts create mode 100644 plugins/auth-backend-module-cloudflare-access-provider/src/authenticator.ts create mode 100644 plugins/auth-backend-module-cloudflare-access-provider/src/helpers.test.ts create mode 100644 plugins/auth-backend-module-cloudflare-access-provider/src/helpers.ts create mode 100644 plugins/auth-backend-module-cloudflare-access-provider/src/index.ts create mode 100644 plugins/auth-backend-module-cloudflare-access-provider/src/module.ts create mode 100644 plugins/auth-backend-module-cloudflare-access-provider/src/resolvers.test.ts create mode 100644 plugins/auth-backend-module-cloudflare-access-provider/src/resolvers.ts create mode 100644 plugins/auth-backend-module-cloudflare-access-provider/src/types.ts delete mode 100644 plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts diff --git a/.changeset/gold-dodos-hammer.md b/.changeset/gold-dodos-hammer.md new file mode 100644 index 0000000000..a5d8329702 --- /dev/null +++ b/.changeset/gold-dodos-hammer.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-cloudflare-access-provider': minor +--- + +Created a separate module for the Cloudflare Access auth provider diff --git a/.changeset/violet-mugs-doubt.md b/.changeset/violet-mugs-doubt.md new file mode 100644 index 0000000000..b92ea623cb --- /dev/null +++ b/.changeset/violet-mugs-doubt.md @@ -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` diff --git a/plugins/auth-backend-module-cloudflare-access-provider/.eslintrc.js b/plugins/auth-backend-module-cloudflare-access-provider/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/auth-backend-module-cloudflare-access-provider/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/auth-backend-module-cloudflare-access-provider/README.md b/plugins/auth-backend-module-cloudflare-access-provider/README.md new file mode 100644 index 0000000000..c699bfd455 --- /dev/null +++ b/plugins/auth-backend-module-cloudflare-access-provider/README.md @@ -0,0 +1,3 @@ +# @backstage/plugin-auth-backend-module-cloudflare-access-provider + +The Cloudflare Access provider backend module for the auth plugin. diff --git a/plugins/auth-backend-module-cloudflare-access-provider/api-report.md b/plugins/auth-backend-module-cloudflare-access-provider/api-report.md new file mode 100644 index 0000000000..5a1b98e391 --- /dev/null +++ b/plugins/auth-backend-module-cloudflare-access-provider/api-report.md @@ -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; +``` diff --git a/plugins/auth-backend-module-cloudflare-access-provider/catalog-info.yaml b/plugins/auth-backend-module-cloudflare-access-provider/catalog-info.yaml new file mode 100644 index 0000000000..cdd42a040b --- /dev/null +++ b/plugins/auth-backend-module-cloudflare-access-provider/catalog-info.yaml @@ -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 diff --git a/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts b/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts new file mode 100644 index 0000000000..ff7d722d48 --- /dev/null +++ b/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts @@ -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; + }; + }; +} diff --git a/plugins/auth-backend-module-cloudflare-access-provider/dev/index.ts b/plugins/auth-backend-module-cloudflare-access-provider/dev/index.ts new file mode 100644 index 0000000000..d3c18c1d48 --- /dev/null +++ b/plugins/auth-backend-module-cloudflare-access-provider/dev/index.ts @@ -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(); diff --git a/plugins/auth-backend-module-cloudflare-access-provider/knip-report.md b/plugins/auth-backend-module-cloudflare-access-provider/knip-report.md new file mode 100644 index 0000000000..1768e133a8 --- /dev/null +++ b/plugins/auth-backend-module-cloudflare-access-provider/knip-report.md @@ -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 | + diff --git a/plugins/auth-backend-module-cloudflare-access-provider/package.json b/plugins/auth-backend-module-cloudflare-access-provider/package.json new file mode 100644 index 0000000000..8b18336cc9 --- /dev/null +++ b/plugins/auth-backend-module-cloudflare-access-provider/package.json @@ -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" +} diff --git a/plugins/auth-backend-module-cloudflare-access-provider/src/authenticator.test.ts b/plugins/auth-backend-module-cloudflare-access-provider/src/authenticator.test.ts new file mode 100644 index 0000000000..678e513412 --- /dev/null +++ b/plugins/auth-backend-module-cloudflare-access-provider/src/authenticator.test.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', + }, + }); + }); +}); diff --git a/plugins/auth-backend-module-cloudflare-access-provider/src/authenticator.ts b/plugins/auth-backend-module-cloudflare-access-provider/src/authenticator.ts new file mode 100644 index 0000000000..c15a1a19a0 --- /dev/null +++ b/plugins/auth-backend-module-cloudflare-access-provider/src/authenticator.ts @@ -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, + }; + }, + }); +} diff --git a/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.test.ts b/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.test.ts new file mode 100644 index 0000000000..8043f4e214 --- /dev/null +++ b/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.test.ts @@ -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 { + use: 'sig'; + alg: string; + kid: string; + kty: string; +} + +class MockTokenFactory { + private readonly publicKeys = new Array(); + + async userToken(): Promise { + 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 { + 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, + }); + }); +}); diff --git a/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.ts b/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.ts new file mode 100644 index 0000000000..60411e53d5 --- /dev/null +++ b/plugins/auth-backend-module-cloudflare-access-provider/src/helpers.ts @@ -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, + private readonly cache?: CacheService, + ) {} + + async authenticate(req: express.Request): Promise { + // 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 { + 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); + } + } +} diff --git a/plugins/auth-backend-module-cloudflare-access-provider/src/index.ts b/plugins/auth-backend-module-cloudflare-access-provider/src/index.ts new file mode 100644 index 0000000000..8b0706a959 --- /dev/null +++ b/plugins/auth-backend-module-cloudflare-access-provider/src/index.ts @@ -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'; diff --git a/plugins/auth-backend-module-cloudflare-access-provider/src/module.ts b/plugins/auth-backend-module-cloudflare-access-provider/src/module.ts new file mode 100644 index 0000000000..3a0b7b09fe --- /dev/null +++ b/plugins/auth-backend-module-cloudflare-access-provider/src/module.ts @@ -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, + }, + }), + }); + }, + }); + }, +}); diff --git a/plugins/auth-backend-module-cloudflare-access-provider/src/resolvers.test.ts b/plugins/auth-backend-module-cloudflare-access-provider/src/resolvers.test.ts new file mode 100644 index 0000000000..27cdfa001c --- /dev/null +++ b/plugins/auth-backend-module-cloudflare-access-provider/src/resolvers.test.ts @@ -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 = { + 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; + + await resolver(info, context as any); + expect(context.signInWithCatalogUser).toHaveBeenCalledWith({ + filter: { 'spec.profile.email': 'hello@example.com' }, + }); + }); +}); diff --git a/plugins/auth-backend-module-cloudflare-access-provider/src/resolvers.ts b/plugins/auth-backend-module-cloudflare-access-provider/src/resolvers.ts new file mode 100644 index 0000000000..ad0b512a65 --- /dev/null +++ b/plugins/auth-backend-module-cloudflare-access-provider/src/resolvers.ts @@ -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, 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, + }, + }); + }; + }, + }); +} diff --git a/plugins/auth-backend-module-cloudflare-access-provider/src/types.ts b/plugins/auth-backend-module-cloudflare-access-provider/src/types.ts new file mode 100644 index 0000000000..00f2f82a03 --- /dev/null +++ b/plugins/auth-backend-module-cloudflare-access-provider/src/types.ts @@ -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; +}; diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 1beddb6419..2a06f36128 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -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; } -// @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; - }>; + resolvers: Readonly; }>; gcpIap: Readonly<{ create: (options: { diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index f5f4dd29fd..f0ceaa4224 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -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. */ diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 001c05b411..6ede92f6e1 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -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:^", diff --git a/plugins/auth-backend/src/providers/cloudflare-access/index.ts b/plugins/auth-backend/src/providers/cloudflare-access/index.ts index 10390af7de..19b56bd825 100644 --- a/plugins/auth-backend/src/providers/cloudflare-access/index.ts +++ b/plugins/auth-backend/src/providers/cloudflare-access/index.ts @@ -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, diff --git a/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts b/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts deleted file mode 100644 index f5f1233ecc..0000000000 --- a/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts +++ /dev/null @@ -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; -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; - - 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(); - }); - }); -}); diff --git a/plugins/auth-backend/src/providers/cloudflare-access/provider.ts b/plugins/auth-backend/src/providers/cloudflare-access/provider.ts index e4d317f8f6..78036ee0cd 100644 --- a/plugins/auth-backend/src/providers/cloudflare-access/provider.ts +++ b/plugins/auth-backend/src/providers/cloudflare-access/provider.ts @@ -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://.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; - signInResolver: SignInResolver; - 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; - -export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { - private readonly teamName: string; - private readonly serviceTokens: ServiceToken[]; - private readonly resolverContext: AuthResolverContext; - private readonly authHandler: AuthHandler; - private readonly signInResolver: SignInResolver; - 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 { - return Promise.resolve(); - } - - async refresh(req: express.Request, res: express.Response): Promise { - // 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 { - return Promise.resolve(); - } - - private async getIdentityProfile( - jwt: string, - ): Promise { - 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 { - // 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 { - 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; }; + /** * 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 = - 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, }); diff --git a/yarn.lock b/yarn.lock index b9f6c22171..ec5e15e41c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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: