Merge pull request #21337 from luchillo17/feat/BCKSTG-189-open-source-vmware-cloud-serv

feat: Add VMware Cloud auth backend module provider
This commit is contained in:
Fredrik Adelöw
2023-11-23 14:44:41 +01:00
committed by GitHub
16 changed files with 1377 additions and 5 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-vmware-cloud-provider': minor
---
Add VMware Cloud auth backend module provider
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,7 @@
# Auth Module: VMware Cloud Provider
This module provides an VMware Cloud auth provider implementation for `@backstage/plugin-auth-backend`.
## Links
- [Backstage](https://backstage.io)
@@ -0,0 +1,46 @@
## API Report File for "@backstage/plugin-auth-backend-module-vmware-cloud-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 { OAuthAuthenticator } from '@backstage/plugin-auth-node';
import { OAuthAuthenticatorResult } from '@backstage/plugin-auth-node';
import { PassportOAuthAuthenticatorHelper } from '@backstage/plugin-auth-node';
import { PassportProfile } from '@backstage/plugin-auth-node';
import { SignInResolverFactory } from '@backstage/plugin-auth-node';
import { Strategy } from 'passport-oauth2';
// @public
const authModuleVmwareCloudProvider: () => BackendFeature;
export default authModuleVmwareCloudProvider;
// @public
export const vmwareCloudAuthenticator: OAuthAuthenticator<
VMwareCloudAuthenticatorContext,
VMwarePassportProfile
>;
// @public (undocumented)
export interface VMwareCloudAuthenticatorContext {
// (undocumented)
helper: PassportOAuthAuthenticatorHelper;
// (undocumented)
organizationId?: string;
// (undocumented)
providerStrategy: Strategy;
}
// @public
export namespace vmwareCloudSignInResolvers {
const profileEmailMatchingUserEntityEmail: SignInResolverFactory<
OAuthAuthenticatorResult<PassportProfile>,
unknown
>;
}
// @public (undocumented)
export type VMwarePassportProfile = PassportProfile & {
organizationId?: string;
};
```
@@ -0,0 +1,10 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-plugin-auth-backend-module-vmware-cloud-provider
title: '@backstage/plugin-auth-backend-module-vmware-cloud-provider'
description: The vmware-cloud-provider backend module for the auth plugin.
spec:
lifecycle: experimental
type: backstage-backend-plugin-module
owner: maintainers
@@ -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;
};
};
};
};
}
@@ -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();
@@ -0,0 +1,47 @@
{
"name": "@backstage/plugin-auth-backend-module-vmware-cloud-provider",
"description": "The vmware-cloud-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/plugin-auth-node": "workspace:^",
"@types/passport-oauth2": "^1.4.15",
"jose": "^4.6.0",
"passport-oauth2": "^1.6.1"
},
"devDependencies": {
"@backstage/backend-defaults": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/plugin-auth-backend": "workspace:^",
"msw": "^2.0.8",
"supertest": "^6.3.3"
},
"files": [
"dist"
]
}
@@ -0,0 +1,477 @@
/*
* 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 { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
import {
AuthResolverContext,
encodeOAuthState,
OAuthAuthenticatorAuthenticateInput,
OAuthAuthenticatorRefreshInput,
OAuthAuthenticatorStartInput,
OAuthState,
} from '@backstage/plugin-auth-node';
import { SignJWT } from 'jose';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import {
vmwareCloudAuthenticator,
VMwareCloudAuthenticatorContext,
} from './authenticator';
jest.mock('uid2', () => jest.fn().mockReturnValue('sessionid'));
describe('vmwareCloudAuthenticator', () => {
const server = setupServer();
setupRequestMockHandlers(server);
let oAuthState: OAuthState = {
nonce: 'nonce',
env: 'env',
};
const signInInfo: Record<string, string> = {
given_name: 'Givenname',
family_name: 'Familyname',
context_name: 'orgId',
email: 'user@example.com',
};
let idToken: string;
let authResponse: {
access_token: string;
refresh_token: string;
id_token: typeof idToken;
};
let fakeSession: Record<string, any>;
let authenticatorCtx: VMwareCloudAuthenticatorContext;
beforeAll(async () => {
idToken = await new SignJWT(signInInfo)
.setProtectedHeader({ alg: 'HS256' })
.sign(Buffer.from('signing key'));
authResponse = {
access_token: 'accessToken',
refresh_token: 'refreshToken',
id_token: idToken,
};
});
beforeEach(() => {
server.use(
http.post(
'https://console.cloud.vmware.com/csp/gateway/am/api/auth/token',
({ request }) =>
request.headers.get('Authorization')
? HttpResponse.json(authResponse)
: HttpResponse.json(null, { status: 500 }),
),
);
authenticatorCtx = vmwareCloudAuthenticator.initialize({
callbackUrl: 'http://callbackUrl',
config: new ConfigReader({
clientId: 'placeholderClientId',
organizationId: 'orgId',
}),
});
});
describe('#initialize', () => {
it('fails when organizationId is not configured', () => {
return expect(() =>
vmwareCloudAuthenticator.initialize({
callbackUrl: 'http://callbackUrl',
config: new ConfigReader({
clientId: 'placeholderClientId',
}),
}),
).toThrow(`Missing required config value at 'organizationId'`);
});
});
describe('#start', () => {
let startRequest: OAuthAuthenticatorStartInput;
beforeEach(() => {
fakeSession = {};
startRequest = {
state: encodeOAuthState(oAuthState),
req: {
query: {},
session: fakeSession,
},
} as OAuthAuthenticatorStartInput;
});
it('redirects to the Cloud Services Console consent page', async () => {
const startResponse = await vmwareCloudAuthenticator.start(
startRequest,
authenticatorCtx,
);
const url = new URL(startResponse.url);
expect(url.protocol).toBe('https:');
expect(url.hostname).toBe('console.cloud.vmware.com');
expect(url.pathname).toBe('/csp/gateway/discovery');
});
it('passes client ID from config', async () => {
const startResponse = await vmwareCloudAuthenticator.start(
startRequest,
authenticatorCtx,
);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('client_id')).toBe('placeholderClientId');
});
it('passes organizationId from config', async () => {
const startResponse = await vmwareCloudAuthenticator.start(
startRequest,
authenticatorCtx,
);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('orgId')).toBe('orgId');
});
it('passes callback URL', async () => {
const startResponse = await vmwareCloudAuthenticator.start(
startRequest,
authenticatorCtx,
);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('redirect_uri')).toBe('http://callbackUrl');
});
it('requests scopes for ID and refresh token', async () => {
const startResponse = await vmwareCloudAuthenticator.start(
startRequest,
authenticatorCtx,
);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('scope')).toBe('openid offline_access');
});
it('generates PKCE challenge', async () => {
const startResponse = await vmwareCloudAuthenticator.start(
startRequest,
authenticatorCtx,
);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('code_challenge_method')).toBe('S256');
expect(searchParams.get('code_challenge')).not.toBeNull();
});
it('stores PKCE verifier in session', async () => {
await vmwareCloudAuthenticator.start(startRequest, authenticatorCtx);
expect(
fakeSession['oauth2:console.cloud.vmware.com'].state.code_verifier,
).toBeDefined();
});
it('fails when request has no session', () => {
return expect(
vmwareCloudAuthenticator.start(
{
state: encodeOAuthState(oAuthState),
req: {
query: {},
},
} as OAuthAuthenticatorStartInput,
authenticatorCtx,
),
).rejects.toThrow('requires session support');
});
it('adds session ID handle to state param', async () => {
const startResponse = await vmwareCloudAuthenticator.start(
startRequest,
authenticatorCtx,
);
const stateParam = new URL(startResponse.url).searchParams.get('state');
const state = Object.fromEntries(
new URLSearchParams(Buffer.from(stateParam!, 'hex').toString('utf-8')),
);
const { handle } = fakeSession['oauth2:console.cloud.vmware.com'].state;
expect(state.handle).toBe(handle);
});
});
describe('#authenticate', () => {
let resolverContext: jest.Mocked<AuthResolverContext>;
let authenticateRequest: OAuthAuthenticatorAuthenticateInput;
beforeEach(() => {
resolverContext = {
issueToken: jest.fn().mockResolvedValue({
token: 'defaultBackstageToken',
}),
findCatalogUser: jest.fn(),
signInWithCatalogUser: jest.fn().mockResolvedValue({
token: 'backstageToken',
}),
};
oAuthState = {
code_verifier: 'foo',
handle: 'sessionid',
nonce: 'nonce',
env: 'development',
} as OAuthState;
fakeSession = {
['oauth2:console.cloud.vmware.com']: {
state: oAuthState,
},
};
authenticateRequest = {
req: {
query: {
code: 'foo',
state: encodeOAuthState(oAuthState),
} as unknown,
session: fakeSession,
},
} as OAuthAuthenticatorAuthenticateInput;
});
it('stores refresh token in cookie', async () => {
const {
session: { refreshToken },
} = await vmwareCloudAuthenticator.authenticate(
authenticateRequest,
authenticatorCtx,
);
expect(refreshToken).toBe('refreshToken');
});
it('responds with ID token', async () => {
const { session } = await vmwareCloudAuthenticator.authenticate(
authenticateRequest,
authenticatorCtx,
);
expect(session.idToken).toBe(idToken);
});
it('default transform decodes ID token', async () => {
const result = await vmwareCloudAuthenticator.authenticate(
authenticateRequest,
authenticatorCtx,
);
const { profile } =
await vmwareCloudAuthenticator.defaultProfileTransform(
result,
resolverContext,
);
expect(profile).toStrictEqual({
email: signInInfo.email,
displayName: `${signInInfo.given_name} ${signInInfo.family_name}`,
});
});
it('default transform fails if claims are missing', async () => {
authenticatorCtx = vmwareCloudAuthenticator.initialize({
callbackUrl: 'http://callbackUrl',
config: new ConfigReader({
clientId: 'placeholderClientId',
organizationId: 'myOrgId',
}),
});
const result = await vmwareCloudAuthenticator.authenticate(
authenticateRequest,
authenticatorCtx,
);
return expect(
vmwareCloudAuthenticator.defaultProfileTransform(
result,
resolverContext,
),
).rejects.toThrow('ID token organizationId mismatch');
});
it('default transform fails if organizationId mismatch', async () => {
const inadequateIdToken: string = await new SignJWT({ sub: 'unusual' })
.setProtectedHeader({ alg: 'HS256' })
.sign(Buffer.from('signing key'));
server.use(
http.post(
'https://console.cloud.vmware.com/csp/gateway/am/api/auth/token',
() =>
HttpResponse.json({
access_token: 'accessToken',
id_token: inadequateIdToken,
}),
),
);
const result = await vmwareCloudAuthenticator.authenticate(
authenticateRequest,
authenticatorCtx,
);
return expect(
vmwareCloudAuthenticator.defaultProfileTransform(
result,
resolverContext,
),
).rejects.toThrow(
'ID token missing required claims: email, given_name, family_name',
);
});
it('fails when request has no session', () => {
return expect(
vmwareCloudAuthenticator.authenticate(
{
req: {
query: {},
},
} as OAuthAuthenticatorStartInput,
authenticatorCtx,
),
).rejects.toThrow('requires session support');
});
it('fails when request has no authorization code', () => {
return expect(
vmwareCloudAuthenticator.authenticate(
{
req: {
query: {},
session: fakeSession,
},
} as OAuthAuthenticatorStartInput,
authenticatorCtx,
),
).rejects.toThrow('Unexpected redirect');
});
});
describe('integration between #start and #authenticate', () => {
beforeEach(() => {
fakeSession = {
['oauth2:console.cloud.vmware.com']: {
state: oAuthState,
},
};
});
it('state param is compatible', async () => {
const startResponse = await vmwareCloudAuthenticator.start(
{
req: {
query: {},
session: {},
},
state: encodeOAuthState(oAuthState),
} as OAuthAuthenticatorStartInput,
authenticatorCtx,
);
const { searchParams } = new URL(startResponse.url);
const { session } = await vmwareCloudAuthenticator.authenticate(
{
req: {
query: {
code: 'authorization_code',
state: searchParams.get('state'),
} as unknown,
session: fakeSession,
},
} as OAuthAuthenticatorAuthenticateInput,
authenticatorCtx,
);
expect(session).toBeDefined();
expect(session.idToken).toBe(idToken);
});
});
describe('#refresh', () => {
let refreshRequest: OAuthAuthenticatorRefreshInput;
let resolverContext: jest.Mocked<AuthResolverContext>;
beforeEach(() => {
resolverContext = {
issueToken: jest.fn().mockResolvedValue({
token: 'defaultBackstageToken',
}),
findCatalogUser: jest.fn(),
signInWithCatalogUser: jest.fn().mockResolvedValue({
token: 'backstageToken',
}),
};
refreshRequest = {
req: {
query: {
code: 'foo',
state: 'sessionid',
} as unknown,
session: fakeSession,
},
} as OAuthAuthenticatorRefreshInput;
});
it('gets new refresh token', async () => {
const {
session: { refreshToken },
} = await vmwareCloudAuthenticator.refresh(
refreshRequest,
authenticatorCtx,
);
expect(refreshToken).toBe('refreshToken');
});
it('default transform decodes ID token', async () => {
const result = await vmwareCloudAuthenticator.refresh(
refreshRequest,
authenticatorCtx,
);
const { profile } =
await vmwareCloudAuthenticator.defaultProfileTransform(
result,
resolverContext,
);
expect(profile).toStrictEqual({
email: signInInfo.email,
displayName: `${signInInfo.given_name} ${signInInfo.family_name}`,
});
});
});
});
@@ -0,0 +1,238 @@
/*
* 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 { decodeJwt } from 'jose';
import {
Metadata,
StateStoreStoreCallback,
StateStoreVerifyCallback,
Strategy as OAuth2Strategy,
} from 'passport-oauth2';
/** @public */
export interface VMwareCloudAuthenticatorContext {
organizationId?: string;
providerStrategy: OAuth2Strategy;
helper: PassportOAuthAuthenticatorHelper;
}
/** @public */
export type VMwarePassportProfile = PassportProfile & {
organizationId?: string;
};
/**
* VMware Cloud Authenticator to be used by `createOAuthProviderFactory`
*
* @public
*/
export const vmwareCloudAuthenticator = createOAuthAuthenticator<
VMwareCloudAuthenticatorContext,
VMwarePassportProfile
>({
defaultProfileTransform: async input => {
if (!input.session.idToken) {
throw new Error(
`Failed to parse id token and get profile info, missing token from session`,
);
}
const vmwareClaims = ['email', 'given_name', 'family_name', 'context_name'];
const identity = decodeJwt(input.session.idToken);
const missingClaims = vmwareClaims.filter(key => !(key in identity));
if (missingClaims.length > 0) {
throw new Error(
`ID token missing required claims: ${missingClaims.join(', ')}`,
);
}
const typeMismatchClaims = vmwareClaims.filter(
key => typeof identity[key] !== 'string',
);
if (typeMismatchClaims.length > 0) {
throw new Error(
`ID token claims type mismatch: ${typeMismatchClaims.join(', ')}`,
);
}
// These claims were checked for presence & type
const { email, given_name, family_name, context_name } = identity as Record<
string,
string
>;
if (context_name !== input.fullProfile.organizationId) {
throw new Error(`ID token organizationId mismatch`);
}
return {
profile: {
displayName: `${given_name} ${family_name}`,
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 VMwarePassportProfile,
}));
},
async refresh(input, ctx) {
return ctx.helper.refresh(input).then(result => ({
...result,
fullProfile: {
...result.fullProfile,
organizationId: ctx.organizationId,
} as VMwarePassportProfile,
}));
},
});
/** @private */
function encodeClientCredentials(
clientID: string,
clientSecret: string,
): string {
return Buffer.from(`${clientID}:${clientSecret}`).toString('base64');
}
@@ -0,0 +1,29 @@
/*
* 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-cloud-provider backend module for the auth plugin.
*
* @packageDocumentation
*/
export {
vmwareCloudAuthenticator,
type VMwareCloudAuthenticatorContext,
type VMwarePassportProfile,
} from './authenticator';
export { authModuleVmwareCloudProvider as default } from './module';
export { vmwareCloudSignInResolvers } from './resolvers';
@@ -0,0 +1,90 @@
/*
* 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 { mockServices, startTestBackend } from '@backstage/backend-test-utils';
import { decodeOAuthState } from '@backstage/plugin-auth-node';
import request from 'supertest';
import { Config } from '../config';
import { authModuleVmwareCloudProvider } from './module';
describe('authModuleVmwareCloudProvider', () => {
it('should start', async () => {
const backend = await startTestBackend({
features: [
import('@backstage/plugin-auth-backend'),
authModuleVmwareCloudProvider,
mockServices.rootConfig.factory({
data: {
app: {
baseUrl: 'http://localhost:3000',
},
auth: {
session: { secret: 'test' },
providers: {
vmwareCloudServices: {
development: {
clientId: 'placeholderClientId',
organizationId: 'orgId',
},
},
},
} as Config['auth'],
},
}),
],
});
const { server } = backend;
const agent = request.agent(server);
const res = await agent.get(
'/api/auth/vmwareCloudServices/start?env=development',
);
expect(res.status).toEqual(302);
const nonceCookie = agent.jar.getCookie('vmwareCloudServices-nonce', {
domain: 'localhost',
path: '/api/auth/vmwareCloudServices/handler',
script: false,
secure: false,
});
expect(nonceCookie).toBeDefined();
const startUrl = new URL(res.get('location'));
expect(startUrl.origin).toBe('https://console.cloud.vmware.com');
expect(startUrl.pathname).toBe('/csp/gateway/discovery');
expect(Object.fromEntries(startUrl.searchParams)).toEqual({
response_type: 'code',
client_id: 'placeholderClientId',
redirect_uri: `http://localhost:${server.port()}/api/auth/vmwareCloudServices/handler/frame`,
code_challenge: expect.any(String),
state: expect.any(String),
scope: 'openid offline_access',
orgId: 'orgId',
code_challenge_method: 'S256',
});
expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({
env: 'development',
handle: expect.any(String),
nonce: decodeURIComponent(nonceCookie.value),
});
backend.stop();
});
});
@@ -0,0 +1,51 @@
/*
* 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 { vmwareCloudAuthenticator } from './authenticator';
import { vmwareCloudSignInResolvers } from './resolvers';
/**
* VMware Cloud Provider backend module for the auth plugin
*
* @public
*/
export const authModuleVmwareCloudProvider = createBackendModule({
pluginId: 'auth',
moduleId: 'vmware-cloud-provider',
register(reg) {
reg.registerInit({
deps: { providers: authProvidersExtensionPoint },
async init({ providers }) {
providers.registerProvider({
providerId: 'vmwareCloudServices',
factory: createOAuthProviderFactory({
authenticator: vmwareCloudAuthenticator,
signInResolverFactories: {
...vmwareCloudSignInResolvers,
...commonSignInResolvers,
},
}),
});
},
});
},
});
@@ -0,0 +1,90 @@
/*
* 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 { NotFoundError } from '@backstage/errors';
import {
AuthResolverContext,
OAuthAuthenticatorResult,
PassportProfile,
SignInInfo,
SignInResolver,
} from '@backstage/plugin-auth-node';
import { vmwareCloudSignInResolvers } from './resolvers';
describe('vmwareCloudResolver', () => {
let resolverContext: jest.Mocked<AuthResolverContext>;
let signInInfo: SignInInfo<OAuthAuthenticatorResult<PassportProfile>>;
let signInResolver: SignInResolver<OAuthAuthenticatorResult<PassportProfile>>;
beforeEach(() => {
resolverContext = {
issueToken: jest.fn().mockResolvedValue({
token: 'defaultBackstageToken',
}),
findCatalogUser: jest.fn(),
signInWithCatalogUser: jest.fn().mockResolvedValue({
token: 'backstageToken',
}),
};
signInInfo = {
result: {} as any, // Resolver doesn't care about the result object
profile: {
displayName: 'TestName',
email: 'user@example.com',
},
};
signInResolver =
vmwareCloudSignInResolvers.profileEmailMatchingUserEntityEmail();
});
it('looks up backstage identity by email', async () => {
const backstageIdentity = await signInResolver(signInInfo, resolverContext);
expect(backstageIdentity.token).toBe('backstageToken');
expect(resolverContext.signInWithCatalogUser).toHaveBeenCalledWith({
filter: {
'spec.profile.email': 'user@example.com',
},
});
});
it('returns "fake" backstage identity when no entity matches', async () => {
resolverContext.signInWithCatalogUser.mockRejectedValue(
new NotFoundError('User not found'),
);
const backstageIdentity = await signInResolver(signInInfo, resolverContext);
expect(backstageIdentity.token).toBe('defaultBackstageToken');
expect(resolverContext.issueToken).toHaveBeenCalledWith({
claims: {
sub: 'user:default/user@example.com',
ent: ['user:default/user@example.com'],
},
});
});
it('fails when resolver context throws other error', () => {
const error = new Error('bizarre');
resolverContext.signInWithCatalogUser.mockRejectedValue(error);
return expect(signInResolver(signInInfo, resolverContext)).rejects.toThrow(
error,
);
});
});
@@ -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 {
createSignInResolverFactory,
OAuthAuthenticatorResult,
PassportProfile,
SignInInfo,
} from '@backstage/plugin-auth-node';
/**
* Available sign-in resolvers for the VMware Cloud auth provider.
*
* @public
*/
export namespace vmwareCloudSignInResolvers {
/**
* 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 profileEmailMatchingUserEntityEmail =
createSignInResolverFactory({
create() {
return async (
info: SignInInfo<OAuthAuthenticatorResult<PassportProfile>>,
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.name !== 'NotFoundError') {
throw e;
}
return ctx.issueToken({
claims: {
sub: userEntityRef,
ent: [userEntityRef],
},
});
}
};
},
});
}
+156 -5
View File
@@ -4917,6 +4917,28 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/plugin-auth-backend-module-vmware-cloud-provider@workspace:plugins/auth-backend-module-vmware-cloud-provider":
version: 0.0.0-use.local
resolution: "@backstage/plugin-auth-backend-module-vmware-cloud-provider@workspace:plugins/auth-backend-module-vmware-cloud-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/config": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/plugin-auth-backend": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@types/passport-oauth2": ^1.4.15
jose: ^4.6.0
msw: ^2.0.8
passport-oauth2: ^1.6.1
supertest: ^6.3.3
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"
@@ -10191,6 +10213,33 @@ __metadata:
languageName: node
linkType: hard
"@bundled-es-modules/cookie@npm:^2.0.0":
version: 2.0.0
resolution: "@bundled-es-modules/cookie@npm:2.0.0"
dependencies:
cookie: ^0.5.0
checksum: 53114eabbedda20ba6c63f45dcea35c568616d22adf5d1882cef9761f65ae636bf47e0c66325572cc8e3a335e0257caf5f76ff1287990d9e9265be7bc9767a87
languageName: node
linkType: hard
"@bundled-es-modules/js-levenshtein@npm:^2.0.1":
version: 2.0.1
resolution: "@bundled-es-modules/js-levenshtein@npm:2.0.1"
dependencies:
js-levenshtein: ^1.1.6
checksum: 13d0cbd2b00e563e09a797559dcff8c7e208c1f71e1787535a3d248f7e3d33ef3f0809b9f498d41788ab5fd399882dcca79917d70d97921b7dde94a282c1b7d8
languageName: node
linkType: hard
"@bundled-es-modules/statuses@npm:^1.0.1":
version: 1.0.1
resolution: "@bundled-es-modules/statuses@npm:1.0.1"
dependencies:
statuses: ^2.0.1
checksum: bcaa7de192e73056950b5fd20e75140d8d09074b1adc4437924b2051bb02b4dbf568c96e67d53b220fb7d735c3446e2ba746599cb1793ab2d23dd2ef230a8622
languageName: node
linkType: hard
"@changesets/apply-release-plan@npm:^6.1.4":
version: 6.1.4
resolution: "@changesets/apply-release-plan@npm:6.1.4"
@@ -13144,6 +13193,13 @@ __metadata:
languageName: node
linkType: hard
"@mswjs/cookies@npm:^1.1.0":
version: 1.1.0
resolution: "@mswjs/cookies@npm:1.1.0"
checksum: 1d9be44548907b92ff6acd46795292968661be19f1c04c43fdb2beb98bc7e58b8ffcef3be19d0f2cb58df07a36a6b53b4bbc0ea34e023b7366dbc28ffee90338
languageName: node
linkType: hard
"@mswjs/interceptors@npm:^0.17.10":
version: 0.17.10
resolution: "@mswjs/interceptors@npm:0.17.10"
@@ -13160,6 +13216,20 @@ __metadata:
languageName: node
linkType: hard
"@mswjs/interceptors@npm:^0.25.11":
version: 0.25.12
resolution: "@mswjs/interceptors@npm:0.25.12"
dependencies:
"@open-draft/deferred-promise": ^2.2.0
"@open-draft/logger": ^0.3.0
"@open-draft/until": ^2.0.0
is-node-process: ^1.2.0
outvariant: ^1.2.1
strict-event-emitter: ^0.5.1
checksum: 0676808700059f55536b51ffe38e9ea07e26b4d9c284fbbdf7a52b7282b7a93703f18d93b109c384205fe5f72b585506d5550cc8f3559267892b01a0f7561d3d
languageName: node
linkType: hard
"@mui/base@npm:5.0.0-beta.24":
version: 5.0.0-beta.24
resolution: "@mui/base@npm:5.0.0-beta.24"
@@ -14092,6 +14162,23 @@ __metadata:
languageName: node
linkType: hard
"@open-draft/deferred-promise@npm:^2.2.0":
version: 2.2.0
resolution: "@open-draft/deferred-promise@npm:2.2.0"
checksum: 7f29d39725bb8ab5b62f89d88a4202ce2439ac740860979f9e3d0015dfe4bc3daddcfa5727fa4eed482fdbee770aa591b1136b98b0a0f0569a65294f35bdf56a
languageName: node
linkType: hard
"@open-draft/logger@npm:^0.3.0":
version: 0.3.0
resolution: "@open-draft/logger@npm:0.3.0"
dependencies:
is-node-process: ^1.2.0
outvariant: ^1.4.0
checksum: 7adfe3d0ed8ca32333ce2a77f9a93d561ebc89c989eaa9722f1dc8a2d2854f5de1bef6fa6894cdf58e16fa4dd9cfa99444ea1f5cac6eb1518e9247911ed042d5
languageName: node
linkType: hard
"@open-draft/until@npm:^1.0.3":
version: 1.0.3
resolution: "@open-draft/until@npm:1.0.3"
@@ -14099,6 +14186,13 @@ __metadata:
languageName: node
linkType: hard
"@open-draft/until@npm:^2.0.0, @open-draft/until@npm:^2.1.0":
version: 2.1.0
resolution: "@open-draft/until@npm:2.1.0"
checksum: 140ea3b16f4a3a6a729c1256050e20a93d408d7aa1e125648ce2665b3c526ed452510c6e4a6f4b15d95fb5e41203fb51510eb8fbc8812d5e5a91880293d66471
languageName: node
linkType: hard
"@openapi-contrib/openapi-schema-to-json-schema@npm:~3.2.0":
version: 3.2.0
resolution: "@openapi-contrib/openapi-schema-to-json-schema@npm:3.2.0"
@@ -18635,14 +18729,14 @@ __metadata:
languageName: node
linkType: hard
"@types/passport-oauth2@npm:*, @types/passport-oauth2@npm:^1.4.11":
version: 1.4.11
resolution: "@types/passport-oauth2@npm:1.4.11"
"@types/passport-oauth2@npm:*, @types/passport-oauth2@npm:^1.4.11, @types/passport-oauth2@npm:^1.4.15":
version: 1.4.15
resolution: "@types/passport-oauth2@npm:1.4.15"
dependencies:
"@types/express": "*"
"@types/oauth": "*"
"@types/passport": "*"
checksum: 09d047a6c09a05c036f7db0cf8f8c09bf5878fdd15949bb1baa09a35f439929d471048fa6595f09ea4a4ea25396ce3918b362136579cf4a2b2ee29a92c0dd1ce
checksum: 352c4e2d09a86f8fc0dcf2c917c221f302a35a14e7467e2fa3a1653c0a9a1f842c910a4e52f799856536522e6359c20d1afc319579d9edf4b2ac7ea11b183b52
languageName: node
linkType: hard
@@ -19094,6 +19188,13 @@ __metadata:
languageName: node
linkType: hard
"@types/statuses@npm:^2.0.1":
version: 2.0.4
resolution: "@types/statuses@npm:2.0.4"
checksum: 3a806c3b96d1845e3e7441fbf0839037e95f717334760ddb7c29223c9a34a7206b68e2998631f89f1a1e3ef5b67b15652f6e8fa14987ebd7f6d38587c1bffd18
languageName: node
linkType: hard
"@types/stoppable@npm:^1.1.0":
version: 1.1.3
resolution: "@types/stoppable@npm:1.1.3"
@@ -28893,6 +28994,13 @@ __metadata:
languageName: node
linkType: hard
"headers-polyfill@npm:^4.0.1":
version: 4.0.2
resolution: "headers-polyfill@npm:4.0.2"
checksum: a95280ed58df429fc86c4f49b21596be3ea3f5f3d790e7d75238668df9b90b292f15a968c7c19ae1db88c0ae036dd1bf363a71b8e771199d82848e2d8b3c6c2e
languageName: node
linkType: hard
"helmet@npm:^6.0.0":
version: 6.0.1
resolution: "helmet@npm:6.0.1"
@@ -34617,6 +34725,42 @@ __metadata:
languageName: node
linkType: hard
"msw@npm:^2.0.8":
version: 2.0.8
resolution: "msw@npm:2.0.8"
dependencies:
"@bundled-es-modules/cookie": ^2.0.0
"@bundled-es-modules/js-levenshtein": ^2.0.1
"@bundled-es-modules/statuses": ^1.0.1
"@mswjs/cookies": ^1.1.0
"@mswjs/interceptors": ^0.25.11
"@open-draft/until": ^2.1.0
"@types/cookie": ^0.4.1
"@types/js-levenshtein": ^1.1.1
"@types/statuses": ^2.0.1
chalk: ^4.1.2
chokidar: ^3.4.2
graphql: ^16.8.1
headers-polyfill: ^4.0.1
inquirer: ^8.2.0
is-node-process: ^1.2.0
js-levenshtein: ^1.1.6
outvariant: ^1.4.0
path-to-regexp: ^6.2.0
strict-event-emitter: ^0.5.0
type-fest: ^2.19.0
yargs: ^17.3.1
peerDependencies:
typescript: ">= 4.7.x <= 5.2.x"
peerDependenciesMeta:
typescript:
optional: true
bin:
msw: cli/index.js
checksum: 8737dae4cf516d8c591ad8d3751cef2f8f07d10a1f995b8cd7d19d8e955a29352dd6fc092f04bc0e1d0025663ccc3c64ae7e56e29c8d4a4b13699e3ee4747c46
languageName: node
linkType: hard
"multer@npm:^1.4.5-lts.1":
version: 1.4.5-lts.1
resolution: "multer@npm:1.4.5-lts.1"
@@ -41203,7 +41347,7 @@ __metadata:
languageName: node
linkType: hard
"statuses@npm:2.0.1":
"statuses@npm:2.0.1, statuses@npm:^2.0.1":
version: 2.0.1
resolution: "statuses@npm:2.0.1"
checksum: 18c7623fdb8f646fb213ca4051be4df7efb3484d4ab662937ca6fbef7ced9b9e12842709872eb3020cc3504b93bde88935c9f6417489627a7786f24f8031cbcb
@@ -41340,6 +41484,13 @@ __metadata:
languageName: node
linkType: hard
"strict-event-emitter@npm:^0.5.0, strict-event-emitter@npm:^0.5.1":
version: 0.5.1
resolution: "strict-event-emitter@npm:0.5.1"
checksum: 350480431bc1c28fdb601ef4976c2f8155fc364b4740f9692dd03e5bdd48aafc99a5e021fe655fbd986d0b803e9f3fc5c4b018b35cb838c4690d60f2a26f1cf3
languageName: node
linkType: hard
"strict-uri-encode@npm:^2.0.0":
version: 2.0.0
resolution: "strict-uri-encode@npm:2.0.0"