From 079fdc22523f288727cdd3a54c69939decd3a2e0 Mon Sep 17 00:00:00 2001 From: Carlos Esteban Lopez Date: Wed, 15 Nov 2023 18:43:20 -0500 Subject: [PATCH] feat: Add VMWare CSP auth backend module provider Signed-off-by: Carlos Esteban Lopez --- .../.eslintrc.js | 1 + .../README.md | 7 + .../config.d.ts | 31 +++ .../dev/index.ts | 24 ++ .../package.json | 44 ++++ .../src/authenticator.ts | 221 ++++++++++++++++++ .../src/index.ts | 25 ++ .../src/module.ts | 46 ++++ .../src/resolvers.ts | 75 ++++++ yarn.lock | 19 ++ 10 files changed, 493 insertions(+) create mode 100644 plugins/auth-backend-module-vmware-csp-provider/.eslintrc.js create mode 100644 plugins/auth-backend-module-vmware-csp-provider/README.md create mode 100644 plugins/auth-backend-module-vmware-csp-provider/config.d.ts create mode 100644 plugins/auth-backend-module-vmware-csp-provider/dev/index.ts create mode 100644 plugins/auth-backend-module-vmware-csp-provider/package.json create mode 100644 plugins/auth-backend-module-vmware-csp-provider/src/authenticator.ts create mode 100644 plugins/auth-backend-module-vmware-csp-provider/src/index.ts create mode 100644 plugins/auth-backend-module-vmware-csp-provider/src/module.ts create mode 100644 plugins/auth-backend-module-vmware-csp-provider/src/resolvers.ts diff --git a/plugins/auth-backend-module-vmware-csp-provider/.eslintrc.js b/plugins/auth-backend-module-vmware-csp-provider/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/auth-backend-module-vmware-csp-provider/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/auth-backend-module-vmware-csp-provider/README.md b/plugins/auth-backend-module-vmware-csp-provider/README.md new file mode 100644 index 0000000000..75ee770828 --- /dev/null +++ b/plugins/auth-backend-module-vmware-csp-provider/README.md @@ -0,0 +1,7 @@ +# Auth Module: VMWare CSP Provider + +This module provides an VMWare CSP auth provider implementation for `@backstage/plugin-auth-backend`. + +## Links + +- [Backstage](https://backstage.io) diff --git a/plugins/auth-backend-module-vmware-csp-provider/config.d.ts b/plugins/auth-backend-module-vmware-csp-provider/config.d.ts new file mode 100644 index 0000000000..10b3566899 --- /dev/null +++ b/plugins/auth-backend-module-vmware-csp-provider/config.d.ts @@ -0,0 +1,31 @@ +/* + * 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. + */ + +export interface Config { + auth?: { + providers?: { + /** @visibility frontend */ + vmwareCloudServices?: { + [authEnv: string]: { + clientId: string; + organizationId: string; + scope?: string; + consoleEndpoint?: string; + }; + }; + }; + }; +} diff --git a/plugins/auth-backend-module-vmware-csp-provider/dev/index.ts b/plugins/auth-backend-module-vmware-csp-provider/dev/index.ts new file mode 100644 index 0000000000..d3c18c1d48 --- /dev/null +++ b/plugins/auth-backend-module-vmware-csp-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-vmware-csp-provider/package.json b/plugins/auth-backend-module-vmware-csp-provider/package.json new file mode 100644 index 0000000000..b52d62cfca --- /dev/null +++ b/plugins/auth-backend-module-vmware-csp-provider/package.json @@ -0,0 +1,44 @@ +{ + "name": "@backstage/plugin-auth-backend-module-vmware-csp-provider", + "description": "The vmware-csp-provider backend module for the auth plugin.", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin-module" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-common": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/catalog-model": "workspace:^", + "@backstage/errors": "workspace:^", + "@backstage/plugin-auth-node": "workspace:^", + "jwt-decode": "^3.1.0", + "passport-oauth2": "^1.6.1" + }, + "devDependencies": { + "@backstage/backend-defaults": "workspace:^", + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^", + "@backstage/plugin-auth-backend": "workspace:^", + "@types/jwt-decode": "^3.1.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/auth-backend-module-vmware-csp-provider/src/authenticator.ts b/plugins/auth-backend-module-vmware-csp-provider/src/authenticator.ts new file mode 100644 index 0000000000..bd97111de6 --- /dev/null +++ b/plugins/auth-backend-module-vmware-csp-provider/src/authenticator.ts @@ -0,0 +1,221 @@ +/* + * 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 { + createOAuthAuthenticator, + decodeOAuthState, + encodeOAuthState, + OAuthState, + PassportOAuthAuthenticatorHelper, + PassportOAuthDoneCallback, + PassportProfile, +} from '@backstage/plugin-auth-node'; +import jwtDecoder from 'jwt-decode'; +import { + Metadata, + StateStoreStoreCallback, + StateStoreVerifyCallback, + Strategy as OAuth2Strategy, +} from 'passport-oauth2'; + +export interface vmWareCSPAuthenticatorContext { + organizationId?: string; + providerStrategy: OAuth2Strategy; + helper: PassportOAuthAuthenticatorHelper; +} + +type CSPPassportProfile = PassportProfile & { + organizationId?: string; +}; + +/** + * VMWare CSP Authenticator to be used by `createOAuthProviderFactory` + */ +export const vmWareCSPAuthenticator = createOAuthAuthenticator< + vmWareCSPAuthenticatorContext, + CSPPassportProfile +>({ + defaultProfileTransform: async input => { + if (!input.session.idToken) { + throw new Error( + `Failed to parse id token and get profile info, missing token from session`, + ); + } + + const identity: Record = jwtDecoder(input.session.idToken); + const missingClaims = [ + 'email', + 'given_name', + 'family_name', + 'context_name', + ].filter(key => !(key in identity)); + + if (missingClaims.length > 0) { + throw new Error( + `ID token missing required claims: ${missingClaims.join(', ')}`, + ); + } + + if (identity.context_name !== input.fullProfile.organizationId) { + throw new Error(`ID token organizationId mismatch`); + } + + return { + profile: { + displayName: `${identity.given_name} ${identity.family_name}`, + email: identity.email, + }, + }; + }, + initialize({ callbackUrl, config }) { + const consoleEndpoint = + config.getOptionalString('consoleEndpoint') ?? + 'https://console.cloud.vmware.com'; + const organizationId = config.getString('organizationId'); + + const clientId = config.getString('clientId'); + const clientSecret = ''; + const authorizationUrl = `${consoleEndpoint}/csp/gateway/discovery`; + const tokenUrl = `${consoleEndpoint}/csp/gateway/am/api/auth/token`; + const scope = config.getOptionalString('scope') ?? 'openid offline_access'; + + const providerStrategy = new OAuth2Strategy( + { + clientID: clientId, + clientSecret: clientSecret, + callbackURL: callbackUrl, + authorizationURL: authorizationUrl, + tokenURL: tokenUrl, + passReqToCallback: false, + pkce: true, + state: true, + scope: scope, + customHeaders: { + Authorization: `Basic ${encodeClientCredentials( + clientId, + clientSecret, + )}`, + }, + }, + ( + accessToken: any, + refreshToken: any, + params: any, + fullProfile: PassportProfile, + done: PassportOAuthDoneCallback, + ) => { + done(undefined, { fullProfile, params, accessToken }, { refreshToken }); + }, + ); + + // Both VMWare & OAuth2Strategy fight over control of the state when PKCE is on, thus this hack + const pkceSessionStore = Object.create( + (providerStrategy as any)._stateStore, + ); + (providerStrategy as any)._stateStore = { + verify(req: Request, state: string, callback: StateStoreVerifyCallback) { + pkceSessionStore.verify( + req, + (decodeOAuthState(state) as any).handle, + callback, + ); + }, + store( + req: Request & { + scope: string; + state: OAuthState; + }, + verifier: string, + state: any, + meta: Metadata, + callback: StateStoreStoreCallback, + ) { + pkceSessionStore.store( + req, + verifier, + state, + meta, + (err: Error, handle: string) => { + callback( + err, + encodeOAuthState({ + handle, + ...state, + ...req.state, + } as OAuthState), + ); + }, + ); + }, + }; + + return { + organizationId, + providerStrategy, + helper: PassportOAuthAuthenticatorHelper.from(providerStrategy), + }; + }, + + async start(input, ctx) { + return new Promise((resolve, reject) => { + const strategy: OAuth2Strategy = Object.create(ctx.providerStrategy); + + strategy.redirect = (url: string, status?: number) => { + const parsed = new URL(url); + if (ctx.organizationId) { + parsed.searchParams.set('orgId', ctx.organizationId); + } + resolve({ url: parsed.toString(), status: status ?? undefined }); + }; + strategy.error = (error: Error) => { + reject(error); + }; + strategy.authenticate(input.req, { + scope: input.scope, + state: decodeOAuthState(input.state), + accessType: 'offline', + prompt: 'consent', + }); + }); + }, + + async authenticate(input, ctx) { + return ctx.helper.authenticate(input).then(result => ({ + ...result, + fullProfile: { + ...result.fullProfile, + organizationId: ctx.organizationId, + } as CSPPassportProfile, + })); + }, + + async refresh(input, ctx) { + return ctx.helper.refresh(input).then(result => ({ + ...result, + fullProfile: { + ...result.fullProfile, + organizationId: ctx.organizationId, + } as CSPPassportProfile, + })); + }, +}); + +/** @private */ +function encodeClientCredentials( + clientID: string, + clientSecret: string, +): string { + return Buffer.from(`${clientID}:${clientSecret}`).toString('base64'); +} diff --git a/plugins/auth-backend-module-vmware-csp-provider/src/index.ts b/plugins/auth-backend-module-vmware-csp-provider/src/index.ts new file mode 100644 index 0000000000..3a0770836a --- /dev/null +++ b/plugins/auth-backend-module-vmware-csp-provider/src/index.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** + * The vmware-csp-provider backend module for the auth plugin. + * + * @packageDocumentation + */ + +export { vmWareCSPAuthenticator } from './authenticator'; +export { authModuleVmwareCspProvider as default } from './module'; +export { vmwareCSPSignInResolvers } from './resolvers'; diff --git a/plugins/auth-backend-module-vmware-csp-provider/src/module.ts b/plugins/auth-backend-module-vmware-csp-provider/src/module.ts new file mode 100644 index 0000000000..a0dc7ef566 --- /dev/null +++ b/plugins/auth-backend-module-vmware-csp-provider/src/module.ts @@ -0,0 +1,46 @@ +/* + * 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 { createBackendModule } from '@backstage/backend-plugin-api'; +import { + authProvidersExtensionPoint, + commonSignInResolvers, + createOAuthProviderFactory, +} from '@backstage/plugin-auth-node'; + +import { vmWareCSPAuthenticator } from './authenticator'; +import { vmwareCSPSignInResolvers } from './resolvers'; + +export const authModuleVmwareCspProvider = createBackendModule({ + pluginId: 'auth', + moduleId: 'vmware-csp-provider', + register(reg) { + reg.registerInit({ + deps: { providers: authProvidersExtensionPoint }, + async init({ providers }) { + providers.registerProvider({ + providerId: 'oauth2', + factory: createOAuthProviderFactory({ + authenticator: vmWareCSPAuthenticator, + signInResolverFactories: { + ...vmwareCSPSignInResolvers, + ...commonSignInResolvers, + }, + }), + }); + }, + }); + }, +}); diff --git a/plugins/auth-backend-module-vmware-csp-provider/src/resolvers.ts b/plugins/auth-backend-module-vmware-csp-provider/src/resolvers.ts new file mode 100644 index 0000000000..7cbd0967c3 --- /dev/null +++ b/plugins/auth-backend-module-vmware-csp-provider/src/resolvers.ts @@ -0,0 +1,75 @@ +/* + * 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 { stringifyEntityRef } from '@backstage/catalog-model'; +import { NotFoundError } from '@backstage/errors'; +import { + createSignInResolverFactory, + OAuthAuthenticatorResult, + PassportProfile, + SignInInfo, +} from '@backstage/plugin-auth-node'; + +/** + * Available sign-in resolvers for the VMWare CSP auth provider. + * + * @public + */ +export namespace vmwareCSPSignInResolvers { + /** + * Looks up the user by matching their profile email to the entity's profile email. + * If that fails, sign in the user without associating with a catalog user. + */ + export const usernameMatchingUserEntityName = createSignInResolverFactory({ + create() { + return async ( + info: SignInInfo>, + ctx, + ) => { + const email = info.profile.email; + + if (!email) { + throw new Error( + 'VMware login failed, user profile does not contain an email', + ); + } + + const userEntityRef = stringifyEntityRef({ + kind: 'User', + name: email, + }); + + try { + // we await here so that signInWithCatalogUser throws in the current `try` + return await ctx.signInWithCatalogUser({ + filter: { + 'spec.profile.email': email, + }, + }); + } catch (e) { + if (!(e instanceof NotFoundError)) { + throw e; + } + return ctx.issueToken({ + claims: { + sub: userEntityRef, + ent: [userEntityRef], + }, + }); + } + }; + }, + }); +} diff --git a/yarn.lock b/yarn.lock index c9013217c7..f8569c5dc6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4840,6 +4840,25 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-auth-backend-module-vmware-csp-provider@workspace:plugins/auth-backend-module-vmware-csp-provider": + version: 0.0.0-use.local + resolution: "@backstage/plugin-auth-backend-module-vmware-csp-provider@workspace:plugins/auth-backend-module-vmware-csp-provider" + dependencies: + "@backstage/backend-common": "workspace:^" + "@backstage/backend-defaults": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" + "@backstage/catalog-model": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/plugin-auth-backend": "workspace:^" + "@backstage/plugin-auth-node": "workspace:^" + "@types/jwt-decode": ^3.1.0 + jwt-decode: ^3.1.0 + passport-oauth2: ^1.6.1 + languageName: unknown + linkType: soft + "@backstage/plugin-auth-backend@workspace:^, @backstage/plugin-auth-backend@workspace:plugins/auth-backend": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend@workspace:plugins/auth-backend"