From f89777d76f7d2811a2afc6ae2c7853ed6b5c4110 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Fri, 8 Sep 2023 10:59:17 -0400 Subject: [PATCH 01/10] WIP:Copying gitlab folder into new oidc module Signed-off-by: Ruben Vallejo --- .../.eslintrc.js | 1 + .../README.md | 8 ++ .../catalog-info.yaml | 10 +++ .../config.d.ts | 34 ++++++++ .../dev/index.ts | 26 ++++++ .../package.json | 45 +++++++++++ .../src/authenticator.ts | 77 ++++++++++++++++++ .../src/index.ts | 25 ++++++ .../src/module.test.ts | 79 +++++++++++++++++++ .../src/module.ts | 48 +++++++++++ .../src/resolvers.ts | 50 ++++++++++++ .../src/types.d.ts | 25 ++++++ 12 files changed, 428 insertions(+) create mode 100644 plugins/auth-backend-module-oidc-provider/.eslintrc.js create mode 100644 plugins/auth-backend-module-oidc-provider/README.md create mode 100644 plugins/auth-backend-module-oidc-provider/catalog-info.yaml create mode 100644 plugins/auth-backend-module-oidc-provider/config.d.ts create mode 100644 plugins/auth-backend-module-oidc-provider/dev/index.ts create mode 100644 plugins/auth-backend-module-oidc-provider/package.json create mode 100644 plugins/auth-backend-module-oidc-provider/src/authenticator.ts create mode 100644 plugins/auth-backend-module-oidc-provider/src/index.ts create mode 100644 plugins/auth-backend-module-oidc-provider/src/module.test.ts create mode 100644 plugins/auth-backend-module-oidc-provider/src/module.ts create mode 100644 plugins/auth-backend-module-oidc-provider/src/resolvers.ts create mode 100644 plugins/auth-backend-module-oidc-provider/src/types.d.ts diff --git a/plugins/auth-backend-module-oidc-provider/.eslintrc.js b/plugins/auth-backend-module-oidc-provider/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/auth-backend-module-oidc-provider/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/auth-backend-module-oidc-provider/README.md b/plugins/auth-backend-module-oidc-provider/README.md new file mode 100644 index 0000000000..968c8a57dd --- /dev/null +++ b/plugins/auth-backend-module-oidc-provider/README.md @@ -0,0 +1,8 @@ +# Auth Module: GitLab Provider + +This module provides an GitLab auth provider implementation for `@backstage/plugin-auth-backend`. + +## Links + +- [Repository](https://oidc.com/backstage/backstage/tree/master/plugins/auth-backend-module-oidc-provider) +- [Backstage Project Homepage](https://backstage.io) diff --git a/plugins/auth-backend-module-oidc-provider/catalog-info.yaml b/plugins/auth-backend-module-oidc-provider/catalog-info.yaml new file mode 100644 index 0000000000..896738898b --- /dev/null +++ b/plugins/auth-backend-module-oidc-provider/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-auth-backend-module-oidc-provider + title: '@backstage/plugin-auth-backend-module-oidc-provider' + description: The oidc-provider backend module for the auth plugin. +spec: + lifecycle: experimental + type: backstage-backend-plugin-module + owner: maintainers diff --git a/plugins/auth-backend-module-oidc-provider/config.d.ts b/plugins/auth-backend-module-oidc-provider/config.d.ts new file mode 100644 index 0000000000..ba141d9555 --- /dev/null +++ b/plugins/auth-backend-module-oidc-provider/config.d.ts @@ -0,0 +1,34 @@ +/* + * 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 */ + oidc?: { + [authEnv: string]: { + clientId: string; + /** + * @visibility secret + */ + clientSecret: string; + audience?: string; + callbackUrl?: string; + }; + }; + }; + }; +} diff --git a/plugins/auth-backend-module-oidc-provider/dev/index.ts b/plugins/auth-backend-module-oidc-provider/dev/index.ts new file mode 100644 index 0000000000..4d027a19c2 --- /dev/null +++ b/plugins/auth-backend-module-oidc-provider/dev/index.ts @@ -0,0 +1,26 @@ +/* + * 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'; +import { authPlugin } from '@backstage/plugin-auth-backend'; +import { authModuleOidcProvider } from '../src'; + +const backend = createBackend(); + +backend.add(authPlugin); +backend.add(authModuleOidcProvider); + +backend.start(); diff --git a/plugins/auth-backend-module-oidc-provider/package.json b/plugins/auth-backend-module-oidc-provider/package.json new file mode 100644 index 0000000000..4bb750cd3f --- /dev/null +++ b/plugins/auth-backend-module-oidc-provider/package.json @@ -0,0 +1,45 @@ +{ + "name": "@backstage/plugin-auth-backend-module-oidc-provider", + "description": "The oidc-provider backend module for the auth plugin.", + "version": "0.1.0-next.1", + "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/plugin-auth-node": "workspace:^", + "express": "^4.18.2", + "passport": "^0.6.0", + "passport-oidc2": "^5.0.0" + }, + "devDependencies": { + "@backstage/backend-defaults": "workspace:^", + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^", + "@backstage/plugin-auth-backend": "workspace:^", + "supertest": "^6.3.3" + }, + "configSchema": "config.d.ts", + "files": [ + "dist", + "config.d.ts" + ] +} diff --git a/plugins/auth-backend-module-oidc-provider/src/authenticator.ts b/plugins/auth-backend-module-oidc-provider/src/authenticator.ts new file mode 100644 index 0000000000..a68b7b269d --- /dev/null +++ b/plugins/auth-backend-module-oidc-provider/src/authenticator.ts @@ -0,0 +1,77 @@ +/* + * 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 { Strategy as GitlabStrategy } from 'passport-oidc2'; +import { + createOAuthAuthenticator, + PassportOAuthAuthenticatorHelper, + PassportOAuthDoneCallback, + PassportProfile, +} from '@backstage/plugin-auth-node'; + +/** @public */ +export const oidcAuthenticator = createOAuthAuthenticator({ + defaultProfileTransform: + PassportOAuthAuthenticatorHelper.defaultProfileTransform, + initialize({ callbackUrl, config }) { + const clientId = config.getString('clientId'); + const clientSecret = config.getString('clientSecret'); + const baseUrl = + config.getOptionalString('audience') || 'https://oidc.com'; + + return PassportOAuthAuthenticatorHelper.from( + new GitlabStrategy( + { + clientID: clientId, + clientSecret: clientSecret, + callbackURL: callbackUrl, + baseURL: baseUrl, + authorizationURL: `${baseUrl}/oauth/authorize`, + tokenURL: `${baseUrl}/oauth/token`, + profileURL: `${baseUrl}/api/v4/user`, + }, + ( + accessToken: string, + refreshToken: string, + params: any, + fullProfile: PassportProfile, + done: PassportOAuthDoneCallback, + ) => { + done( + undefined, + { fullProfile, params, accessToken }, + { refreshToken }, + ); + }, + ), + ); + }, + + async start(input, helper) { + return helper.start(input, { + accessType: 'offline', + prompt: 'consent', + }); + }, + + async authenticate(input, helper) { + return helper.authenticate(input); + }, + + async refresh(input, helper) { + return helper.refresh(input); + }, +}); diff --git a/plugins/auth-backend-module-oidc-provider/src/index.ts b/plugins/auth-backend-module-oidc-provider/src/index.ts new file mode 100644 index 0000000000..4b5ddc123a --- /dev/null +++ b/plugins/auth-backend-module-oidc-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 oidc-provider backend module for the auth plugin. + * + * @packageDocumentation + */ + +export { oidcAuthenticator } from './authenticator'; +export { authModuleOidcProvider } from './module'; +export { oidcSignInResolvers } from './resolvers'; diff --git a/plugins/auth-backend-module-oidc-provider/src/module.test.ts b/plugins/auth-backend-module-oidc-provider/src/module.test.ts new file mode 100644 index 0000000000..a8e19e362a --- /dev/null +++ b/plugins/auth-backend-module-oidc-provider/src/module.test.ts @@ -0,0 +1,79 @@ +/* + * 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 { authPlugin } from '@backstage/plugin-auth-backend'; +import { authModuleOidcProvider } from './module'; +import request from 'supertest'; +import { decodeOAuthState } from '@backstage/plugin-auth-node'; + +describe('authModuleOidcProvider', () => { + it('should start', async () => { + const { server } = await startTestBackend({ + features: [ + authPlugin, + authModuleOidcProvider, + mockServices.rootConfig.factory({ + data: { + app: { + baseUrl: 'http://localhost:3000', + }, + auth: { + providers: { + oidc: { + development: { + clientId: 'my-client-id', + clientSecret: 'my-client-secret', + }, + }, + }, + }, + }, + }), + ], + }); + + const agent = request.agent(server); + + const res = await agent.get('/api/auth/oidc/start?env=development'); + + expect(res.status).toEqual(302); + + const nonceCookie = agent.jar.getCookie('oidc-nonce', { + domain: 'localhost', + path: '/api/auth/oidc/handler', + script: false, + secure: false, + }); + expect(nonceCookie).toBeDefined(); + + const startUrl = new URL(res.get('location')); + expect(startUrl.origin).toBe('https://oidc.com'); + expect(startUrl.pathname).toBe('/oauth/authorize'); + expect(Object.fromEntries(startUrl.searchParams)).toEqual({ + response_type: 'code', + scope: 'read_user', + client_id: 'my-client-id', + redirect_uri: `http://localhost:${server.port()}/api/auth/oidc/handler/frame`, + state: expect.any(String), + }); + + expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({ + env: 'development', + nonce: decodeURIComponent(nonceCookie.value), + }); + }); +}); diff --git a/plugins/auth-backend-module-oidc-provider/src/module.ts b/plugins/auth-backend-module-oidc-provider/src/module.ts new file mode 100644 index 0000000000..790d522494 --- /dev/null +++ b/plugins/auth-backend-module-oidc-provider/src/module.ts @@ -0,0 +1,48 @@ +/* + * 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 { gitlabAuthenticator } from './authenticator'; +import { gitlabSignInResolvers } from './resolvers'; + +/** @public */ +export const authModuleGitlabProvider = createBackendModule({ + pluginId: 'auth', + moduleId: 'gitlab-provider', + register(reg) { + reg.registerInit({ + deps: { + providers: authProvidersExtensionPoint, + }, + async init({ providers }) { + providers.registerProvider({ + providerId: 'gitlab', + factory: createOAuthProviderFactory({ + authenticator: gitlabAuthenticator, + signInResolverFactories: { + ...gitlabSignInResolvers, + ...commonSignInResolvers, + }, + }), + }); + }, + }); + }, +}); diff --git a/plugins/auth-backend-module-oidc-provider/src/resolvers.ts b/plugins/auth-backend-module-oidc-provider/src/resolvers.ts new file mode 100644 index 0000000000..755ed08aa0 --- /dev/null +++ b/plugins/auth-backend-module-oidc-provider/src/resolvers.ts @@ -0,0 +1,50 @@ +/* + * 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, + OAuthAuthenticatorResult, + PassportProfile, + SignInInfo, +} from '@backstage/plugin-auth-node'; + +/** + * Available sign-in resolvers for the GitLab auth provider. + * + * @public + */ +export namespace gitlabSignInResolvers { + /** + * Looks up the user by matching their GitLab username to the entity name. + */ + export const usernameMatchingUserEntityName = createSignInResolverFactory({ + create() { + return async ( + info: SignInInfo>, + ctx, + ) => { + const { result } = info; + + const id = result.fullProfile.username; + if (!id) { + throw new Error(`GitLab user profile does not contain a username`); + } + + return ctx.signInWithCatalogUser({ entityRef: { name: id } }); + }; + }, + }); +} diff --git a/plugins/auth-backend-module-oidc-provider/src/types.d.ts b/plugins/auth-backend-module-oidc-provider/src/types.d.ts new file mode 100644 index 0000000000..96535294ee --- /dev/null +++ b/plugins/auth-backend-module-oidc-provider/src/types.d.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +declare module 'passport-gitlab2' { + import { Request } from 'express'; + import { StrategyCreated } from 'passport'; + + export class Strategy { + constructor(options: any, verify: any); + authenticate(this: StrategyCreated, req: Request, options?: any): any; + } +} From c541201b1b2856b1cde74a079d90aad7bcad51a4 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Mon, 18 Sep 2023 11:38:10 -0400 Subject: [PATCH 02/10] WIP: oidc auth provider module migration Signed-off-by: Ruben Vallejo --- .../README.md | 4 +- .../package.json | 12 +- .../src/authenticator.ts | 100 ++++++--- .../src/module.test.ts | 211 +++++++++++++++--- .../src/module.ts | 14 +- .../src/resolvers.ts | 29 ++- .../src/types.d.ts | 25 --- yarn.lock | 54 +++++ 8 files changed, 352 insertions(+), 97 deletions(-) delete mode 100644 plugins/auth-backend-module-oidc-provider/src/types.d.ts diff --git a/plugins/auth-backend-module-oidc-provider/README.md b/plugins/auth-backend-module-oidc-provider/README.md index 968c8a57dd..e586f32fd7 100644 --- a/plugins/auth-backend-module-oidc-provider/README.md +++ b/plugins/auth-backend-module-oidc-provider/README.md @@ -1,6 +1,6 @@ -# Auth Module: GitLab Provider +# Auth Module: Oidc Provider -This module provides an GitLab auth provider implementation for `@backstage/plugin-auth-backend`. +This module provides an Oidc auth provider implementation for `@backstage/plugin-auth-backend`. ## Links diff --git a/plugins/auth-backend-module-oidc-provider/package.json b/plugins/auth-backend-module-oidc-provider/package.json index 4bb750cd3f..09bd1033a0 100644 --- a/plugins/auth-backend-module-oidc-provider/package.json +++ b/plugins/auth-backend-module-oidc-provider/package.json @@ -25,16 +25,22 @@ "dependencies": { "@backstage/backend-common": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", + "@backstage/plugin-auth-backend": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "express": "^4.18.2", - "passport": "^0.6.0", - "passport-oidc2": "^5.0.0" + "openid-client": "^5.5.0", + "passport": "^0.6.0" }, "devDependencies": { "@backstage/backend-defaults": "workspace:^", "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "@backstage/plugin-auth-backend": "workspace:^", + "@backstage/config": "workspace:^", + "cookie-parser": "^1.4.6", + "express-promise-router": "^4.1.1", + "express-session": "^1.17.3", + "jose": "^4.14.6", + "msw": "^1.3.1", "supertest": "^6.3.3" }, "configSchema": "config.d.ts", diff --git a/plugins/auth-backend-module-oidc-provider/src/authenticator.ts b/plugins/auth-backend-module-oidc-provider/src/authenticator.ts index a68b7b269d..7a7d57d7e2 100644 --- a/plugins/auth-backend-module-oidc-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-oidc-provider/src/authenticator.ts @@ -14,64 +14,110 @@ * limitations under the License. */ -import { Strategy as GitlabStrategy } from 'passport-oidc2'; +import { + Issuer, + ClientAuthMethod, + TokenSet, + UserinfoResponse, + Strategy as OidcStrategy, +} from 'openid-client'; import { createOAuthAuthenticator, + decodeOAuthState, + encodeOAuthState, + PassportDoneCallback, PassportOAuthAuthenticatorHelper, PassportOAuthDoneCallback, + PassportOAuthPrivateInfo, PassportProfile, } from '@backstage/plugin-auth-node'; +import { OidcAuthResult } from '@backstage/plugin-auth-backend'; /** @public */ export const oidcAuthenticator = createOAuthAuthenticator({ defaultProfileTransform: PassportOAuthAuthenticatorHelper.defaultProfileTransform, - initialize({ callbackUrl, config }) { + async initialize({ callbackUrl, config }) { const clientId = config.getString('clientId'); const clientSecret = config.getString('clientSecret'); - const baseUrl = - config.getOptionalString('audience') || 'https://oidc.com'; + const customCallbackUrl = config.getOptionalString('callbackUrl'); + const callbackUrl2 = customCallbackUrl || callbackUrl; + const metadataUrl = config.getString('metadataUrl'); + const tokenEndpointAuthMethod = config.getOptionalString( + 'tokenEndpointAuthMethod', + ) as ClientAuthMethod; + const tokenSignedResponseAlg = config.getOptionalString( + 'tokenSignedResponseAlg', + ); + const initializedScope = config.getOptionalString('scope'); + const initializedPrompt = config.getOptionalString('prompt'); + const issuer = await Issuer.discover( + `${metadataUrl}/.well-known/openid-configuration`, + ); + const client = new issuer.Client({ + access_type: 'offline', // this option must be passed to provider to receive a refresh token + client_id: clientId, + client_secret: clientSecret, + redirect_uris: [callbackUrl2], + response_types: ['code'], + token_endpoint_auth_method: + tokenEndpointAuthMethod || 'client_secret_basic', + id_token_signed_response_alg: tokenSignedResponseAlg || 'RS256', + scope: initializedScope || '', + }); - return PassportOAuthAuthenticatorHelper.from( - new GitlabStrategy( + const helper = PassportOAuthAuthenticatorHelper.from( + new OidcStrategy( { - clientID: clientId, - clientSecret: clientSecret, - callbackURL: callbackUrl, - baseURL: baseUrl, - authorizationURL: `${baseUrl}/oauth/authorize`, - tokenURL: `${baseUrl}/oauth/token`, - profileURL: `${baseUrl}/api/v4/user`, + client, + passReqToCallback: false, }, ( - accessToken: string, - refreshToken: string, - params: any, - fullProfile: PassportProfile, - done: PassportOAuthDoneCallback, + tokenset: TokenSet, + userinfo: UserinfoResponse, + done: PassportDoneCallback, ) => { + if (typeof done !== 'function') { + throw new Error( + 'OIDC IdP must provide a userinfo_endpoint in the metadata response', + ); + } done( undefined, - { fullProfile, params, accessToken }, - { refreshToken }, + { tokenset, userinfo }, + { + refreshToken: tokenset.refresh_token, + }, ); }, ), ); + + return { helper, client, initializedScope, initializedPrompt }; }, - async start(input, helper) { + async start(input, implementation) { + const { initializedScope, initializedPrompt, helper } = + await implementation; + const options: Record = { + scope: input.scope || initializedScope || 'openid profile email', + state: input.state, + }; + const prompt = initializedPrompt || 'none'; + if (prompt !== 'auto') { + options.prompt = prompt; + } + return helper.start(input, { - accessType: 'offline', - prompt: 'consent', + ...options, }); }, - async authenticate(input, helper) { - return helper.authenticate(input); + async authenticate(input, implementation) { + return (await implementation).helper.authenticate(input); }, - async refresh(input, helper) { - return helper.refresh(input); + async refresh(input, implementation) { + return (await implementation).helper.refresh(input); }, }); diff --git a/plugins/auth-backend-module-oidc-provider/src/module.test.ts b/plugins/auth-backend-module-oidc-provider/src/module.test.ts index a8e19e362a..406cc3229e 100644 --- a/plugins/auth-backend-module-oidc-provider/src/module.test.ts +++ b/plugins/auth-backend-module-oidc-provider/src/module.test.ts @@ -18,39 +18,190 @@ import { mockServices, startTestBackend } from '@backstage/backend-test-utils'; import { authPlugin } from '@backstage/plugin-auth-backend'; import { authModuleOidcProvider } from './module'; import request from 'supertest'; -import { decodeOAuthState } from '@backstage/plugin-auth-node'; +import { + AuthProviderRouteHandlers, + AuthResolverContext, + createOAuthRouteHandlers, + decodeOAuthState, +} from '@backstage/plugin-auth-node'; +import { setupServer } from 'msw'; +import { ClientMetadata, IssuerMetadata } from 'openid-client'; +import { rest } from 'msw'; +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { Server } from 'http'; +import { AddressInfo } from 'net'; +import express from 'express'; +import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose'; +import cookieParser from 'cookie-parser'; +import passport from 'passport'; +import session from 'express-session'; +import { oidcAuthenticator } from './authenticator'; +import { ConfigReader } from '@backstage/config'; +import Router from 'express-promise-router'; describe('authModuleOidcProvider', () => { - it('should start', async () => { - const { server } = await startTestBackend({ - features: [ - authPlugin, - authModuleOidcProvider, - mockServices.rootConfig.factory({ - data: { - app: { - baseUrl: 'http://localhost:3000', - }, - auth: { - providers: { - oidc: { - development: { - clientId: 'my-client-id', - clientSecret: 'my-client-secret', - }, - }, - }, - }, - }, + let app: express.Express; + let backstageServer: Server; + let appUrl: string; + let providerRouteHandler: AuthProviderRouteHandlers; + let idToken: string; + let publicKey: JWK; + + const mswServer = setupServer(); + setupRequestMockHandlers(mswServer); + + const issuerMetadata = { + issuer: 'https://oidc.test', + authorization_endpoint: 'https://oidc.test/as/authorization.oauth2', + token_endpoint: 'https://oidc.test/as/token.oauth2', + revocation_endpoint: 'https://oidc.test/as/revoke_token.oauth2', + userinfo_endpoint: 'https://oidc.test/idp/userinfo.openid', + introspection_endpoint: 'https://oidc.test/as/introspect.oauth2', + jwks_uri: 'https://oidc.test/pf/JWKS', + scopes_supported: ['openid'], + claims_supported: ['email'], + response_types_supported: ['code'], + id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + token_endpoint_auth_signing_alg_values_supported: [ + 'RS256', + 'RS512', + 'HS256', + ], + request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + }; + + const clientMetadata = { + authHandler: async input => ({ + profile: { + displayName: input.userinfo.email, + }, + }), + resolverContext: {} as AuthResolverContext, + callbackUrl: 'https://oidc.test/callback', + clientId: 'testclientid', + clientSecret: 'testclientsecret', + metadataUrl: 'https://oidc.test/.well-known/openid-configuration', + tokenEndpointAuthMethod: 'none', + tokenSignedResponseAlg: 'none', + }; + + beforeAll(async () => { + const keyPair = await generateKeyPair('ES256'); + const privateKey = await exportJWK(keyPair.privateKey); + publicKey = await exportJWK(keyPair.publicKey); + publicKey.alg = privateKey.alg = 'ES256'; + + idToken = await new SignJWT({ + sub: 'test', + iss: 'https://pinniped.test', + iat: Date.now(), + aud: 'clientId', + exp: Date.now() + 10000, + }) + .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .sign(keyPair.privateKey); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + + mswServer.use( + rest.get( + 'https://oidc.test/.well-known/openid-configuration', + (_req, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(issuerMetadata), + ), + ), + rest.get('https://oidc.test/oauth2/authorize', async (req, res, ctx) => { + const callbackUrl = new URL(req.url.searchParams.get('redirect_uri')!); + callbackUrl.searchParams.set('code', 'authorization_code'); + callbackUrl.searchParams.set( + 'state', + req.url.searchParams.get('state')!, + ); + callbackUrl.searchParams.set('scope', 'test-scope'); + return res( + ctx.status(302), + ctx.set('Location', callbackUrl.toString()), + ); + }), + ); + + const secret = 'secret'; + app = express() + .use(cookieParser(secret)) + .use( + session({ + secret, + saveUninitialized: false, + resave: false, + cookie: { secure: false }, }), - ], + ) + .use(passport.initialize()) + .use(passport.session()); + await new Promise(resolve => { + backstageServer = app.listen(0, '0.0.0.0', () => { + appUrl = `http://127.0.0.1:${ + (backstageServer.address() as AddressInfo).port + }`; + resolve(null); + }); }); - const agent = request.agent(server); + mswServer.use(rest.all(`${appUrl}/*`, req => req.passthrough())); - const res = await agent.get('/api/auth/oidc/start?env=development'); + providerRouteHandler = createOAuthRouteHandlers({ + authenticator: oidcAuthenticator, + appUrl, + baseUrl: `${appUrl}/api/auth`, + isOriginAllowed: _ => true, + providerId: 'oidc', + config: new ConfigReader({ + metadataUrl: 'https://oidc.test', + clientId: 'clientId', + clientSecret: 'clientSecret', + }), + resolverContext: { + issueToken: async _ => ({ token: '' }), + findCatalogUser: async _ => ({ + entity: { + apiVersion: '', + kind: '', + metadata: { name: '' }, + }, + }), + signInWithCatalogUser: async _ => ({ token: '' }), + }, + }); - expect(res.status).toEqual(302); + const router = Router(); + router + .use( + '/api/auth/pinniped/start', + providerRouteHandler.start.bind(providerRouteHandler), + ) + .use( + '/api/auth/pinniped/handler/frame', + providerRouteHandler.frameHandler.bind(providerRouteHandler), + ); + app.use(router); + }); + + afterEach(() => { + backstageServer.close(); + }); + + it('should start', async () => { + const agent = request.agent(''); + + const startResponse = await agent.get( + `${appUrl}/api/auth/oidc/start?env=development`, + ); + expect(startResponse.status).toEqual(302); const nonceCookie = agent.jar.getCookie('oidc-nonce', { domain: 'localhost', @@ -60,14 +211,16 @@ describe('authModuleOidcProvider', () => { }); expect(nonceCookie).toBeDefined(); - const startUrl = new URL(res.get('location')); + const startUrl = new URL(startResponse.get('location')); expect(startUrl.origin).toBe('https://oidc.com'); expect(startUrl.pathname).toBe('/oauth/authorize'); expect(Object.fromEntries(startUrl.searchParams)).toEqual({ response_type: 'code', scope: 'read_user', client_id: 'my-client-id', - redirect_uri: `http://localhost:${server.port()}/api/auth/oidc/handler/frame`, + redirect_uri: `http://localhost:${ + (backstageServer.address() as AddressInfo).port + }/api/auth/oidc/handler/frame`, state: expect.any(String), }); @@ -75,5 +228,5 @@ describe('authModuleOidcProvider', () => { env: 'development', nonce: decodeURIComponent(nonceCookie.value), }); - }); + }, 70000); }); diff --git a/plugins/auth-backend-module-oidc-provider/src/module.ts b/plugins/auth-backend-module-oidc-provider/src/module.ts index 790d522494..5680cd8603 100644 --- a/plugins/auth-backend-module-oidc-provider/src/module.ts +++ b/plugins/auth-backend-module-oidc-provider/src/module.ts @@ -19,13 +19,13 @@ import { commonSignInResolvers, createOAuthProviderFactory, } from '@backstage/plugin-auth-node'; -import { gitlabAuthenticator } from './authenticator'; -import { gitlabSignInResolvers } from './resolvers'; +import { oidcAuthenticator } from './authenticator'; +import { oidcSignInResolvers } from './resolvers'; /** @public */ -export const authModuleGitlabProvider = createBackendModule({ +export const authModuleOidcProvider = createBackendModule({ pluginId: 'auth', - moduleId: 'gitlab-provider', + moduleId: 'oidc-provider', register(reg) { reg.registerInit({ deps: { @@ -33,11 +33,11 @@ export const authModuleGitlabProvider = createBackendModule({ }, async init({ providers }) { providers.registerProvider({ - providerId: 'gitlab', + providerId: 'oidc', factory: createOAuthProviderFactory({ - authenticator: gitlabAuthenticator, + authenticator: oidcAuthenticator, signInResolverFactories: { - ...gitlabSignInResolvers, + ...oidcSignInResolvers, ...commonSignInResolvers, }, }), diff --git a/plugins/auth-backend-module-oidc-provider/src/resolvers.ts b/plugins/auth-backend-module-oidc-provider/src/resolvers.ts index 755ed08aa0..930ff462f2 100644 --- a/plugins/auth-backend-module-oidc-provider/src/resolvers.ts +++ b/plugins/auth-backend-module-oidc-provider/src/resolvers.ts @@ -22,13 +22,13 @@ import { } from '@backstage/plugin-auth-node'; /** - * Available sign-in resolvers for the GitLab auth provider. + * Available sign-in resolvers for the Oidc auth provider. * * @public */ -export namespace gitlabSignInResolvers { +export namespace oidcSignInResolvers { /** - * Looks up the user by matching their GitLab username to the entity name. + * Looks up the user by matching their Oidc username to the entity name. */ export const usernameMatchingUserEntityName = createSignInResolverFactory({ create() { @@ -40,11 +40,32 @@ export namespace gitlabSignInResolvers { const id = result.fullProfile.username; if (!id) { - throw new Error(`GitLab user profile does not contain a username`); + throw new Error(`Oidc user profile does not contain a username`); } return ctx.signInWithCatalogUser({ entityRef: { name: id } }); }; }, }); + + /** + * Looks up the user by matching their email to the `google.com/email` annotation. Still working out this resolver..... + */ + export const emailMatchingUserEntityAnnotation = createSignInResolverFactory({ + create() { + return async (info: SignInInfo, ctx) => { + const email = info.result.iapToken.email; + + if (!email) { + throw new Error('Google IAP sign-in result is missing email'); + } + + return ctx.signInWithCatalogUser({ + annotations: { + 'google.com/email': email, + }, + }); + }; + }, + }); } diff --git a/plugins/auth-backend-module-oidc-provider/src/types.d.ts b/plugins/auth-backend-module-oidc-provider/src/types.d.ts deleted file mode 100644 index 96535294ee..0000000000 --- a/plugins/auth-backend-module-oidc-provider/src/types.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * 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. - */ - -declare module 'passport-gitlab2' { - import { Request } from 'express'; - import { StrategyCreated } from 'passport'; - - export class Strategy { - constructor(options: any, verify: any); - authenticate(this: StrategyCreated, req: Request, options?: any): any; - } -} diff --git a/yarn.lock b/yarn.lock index 53cd19f848..6e6721b99e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4672,6 +4672,30 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-auth-backend-module-oidc-provider@workspace:plugins/auth-backend-module-oidc-provider": + version: 0.0.0-use.local + resolution: "@backstage/plugin-auth-backend-module-oidc-provider@workspace:plugins/auth-backend-module-oidc-provider" + dependencies: + "@backstage/backend-common": "workspace:^" + "@backstage/backend-defaults": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/plugin-auth-backend": "workspace:^" + "@backstage/plugin-auth-node": "workspace:^" + cookie-parser: ^1.4.6 + express: ^4.18.2 + express-promise-router: ^4.1.1 + express-session: ^1.17.3 + jose: ^4.14.6 + msw: ^1.3.1 + openid-client: ^5.5.0 + passport: ^0.6.0 + supertest: ^6.3.3 + languageName: unknown + linkType: soft + "@backstage/plugin-auth-backend-module-okta-provider@workspace:^, @backstage/plugin-auth-backend-module-okta-provider@workspace:plugins/auth-backend-module-okta-provider": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend-module-okta-provider@workspace:plugins/auth-backend-module-okta-provider" @@ -31245,6 +31269,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^4.14.4": + version: 4.14.6 + resolution: "jose@npm:4.14.6" + checksum: eae81a234e7bf1446b1bd80722b3462b014e3835b155c3a7799c1c5043163a53a0dc28d347004151b031e6b7b863403aabf8814d9cc217ce21f8c2f3ebd4b335 + languageName: node + linkType: hard + "jose@npm:^4.14.6, jose@npm:^4.15.4, jose@npm:^4.6.0": version: 4.15.4 resolution: "jose@npm:4.15.4" @@ -35636,6 +35667,18 @@ __metadata: languageName: node linkType: hard +"openid-client@npm:^5.5.0": + version: 5.5.0 + resolution: "openid-client@npm:5.5.0" + dependencies: + jose: ^4.14.4 + lru-cache: ^6.0.0 + object-hash: ^2.2.0 + oidc-token-hash: ^5.0.3 + checksum: d2617b5bb0d9a0da338aeb7489bcbe3a79df9681189c7b61c2a3284289eee7110dfee2b04b49a9fdd4f064b7e2057ddb0becfedd9c19388e7788ae15b24c8e4c + languageName: node + linkType: hard + "oppa@npm:^0.4.0": version: 0.4.0 resolution: "oppa@npm:0.4.0" @@ -36273,6 +36316,17 @@ __metadata: languageName: node linkType: hard +"passport@npm:^0.6.0": + version: 0.6.0 + resolution: "passport@npm:0.6.0" + dependencies: + passport-strategy: 1.x.x + pause: 0.0.1 + utils-merge: ^1.0.1 + checksum: ef932ad671d50de34765c7a53cd1e058d8331a82a6df09265a9c6c1168911aee4a7b5215803d0101110ab7f317e096b4954ca7e18fb2c33b9929f0bd17dbe159 + languageName: node + linkType: hard + "passport@npm:^0.7.0": version: 0.7.0 resolution: "passport@npm:0.7.0" From 55c639839a42a5d38354267ef6018c1c208ee8b0 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Mon, 18 Sep 2023 15:48:06 -0400 Subject: [PATCH 03/10] Working module tests Signed-off-by: Ruben Vallejo --- .../src/module.test.ts | 60 +++++++------------ 1 file changed, 22 insertions(+), 38 deletions(-) diff --git a/plugins/auth-backend-module-oidc-provider/src/module.test.ts b/plugins/auth-backend-module-oidc-provider/src/module.test.ts index 406cc3229e..f85afec0c3 100644 --- a/plugins/auth-backend-module-oidc-provider/src/module.test.ts +++ b/plugins/auth-backend-module-oidc-provider/src/module.test.ts @@ -14,9 +14,6 @@ * limitations under the License. */ -import { mockServices, startTestBackend } from '@backstage/backend-test-utils'; -import { authPlugin } from '@backstage/plugin-auth-backend'; -import { authModuleOidcProvider } from './module'; import request from 'supertest'; import { AuthProviderRouteHandlers, @@ -24,8 +21,7 @@ import { createOAuthRouteHandlers, decodeOAuthState, } from '@backstage/plugin-auth-node'; -import { setupServer } from 'msw'; -import { ClientMetadata, IssuerMetadata } from 'openid-client'; +import { setupServer } from 'msw/node'; import { rest } from 'msw'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { Server } from 'http'; @@ -52,12 +48,12 @@ describe('authModuleOidcProvider', () => { const issuerMetadata = { issuer: 'https://oidc.test', - authorization_endpoint: 'https://oidc.test/as/authorization.oauth2', - token_endpoint: 'https://oidc.test/as/token.oauth2', - revocation_endpoint: 'https://oidc.test/as/revoke_token.oauth2', + authorization_endpoint: 'https://oidc.test/oauth2/authorize', + token_endpoint: 'https://oidc.test/oauth2/token', + revocation_endpoint: 'https://oidc.test/oauth2/revoke_token', userinfo_endpoint: 'https://oidc.test/idp/userinfo.openid', introspection_endpoint: 'https://oidc.test/as/introspect.oauth2', - jwks_uri: 'https://oidc.test/pf/JWKS', + jwks_uri: 'https://oidc.test/jwks.json', scopes_supported: ['openid'], claims_supported: ['email'], response_types_supported: ['code'], @@ -70,21 +66,6 @@ describe('authModuleOidcProvider', () => { request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], }; - const clientMetadata = { - authHandler: async input => ({ - profile: { - displayName: input.userinfo.email, - }, - }), - resolverContext: {} as AuthResolverContext, - callbackUrl: 'https://oidc.test/callback', - clientId: 'testclientid', - clientSecret: 'testclientsecret', - metadataUrl: 'https://oidc.test/.well-known/openid-configuration', - tokenEndpointAuthMethod: 'none', - tokenSignedResponseAlg: 'none', - }; - beforeAll(async () => { const keyPair = await generateKeyPair('ES256'); const privateKey = await exportJWK(keyPair.privateKey); @@ -93,7 +74,7 @@ describe('authModuleOidcProvider', () => { idToken = await new SignJWT({ sub: 'test', - iss: 'https://pinniped.test', + iss: 'https://oidc.test', iat: Date.now(), aud: 'clientId', exp: Date.now() + 10000, @@ -181,11 +162,11 @@ describe('authModuleOidcProvider', () => { const router = Router(); router .use( - '/api/auth/pinniped/start', + '/api/auth/oidc/start', providerRouteHandler.start.bind(providerRouteHandler), ) .use( - '/api/auth/pinniped/handler/frame', + '/api/auth/oidc/handler/frame', providerRouteHandler.frameHandler.bind(providerRouteHandler), ); app.use(router); @@ -196,15 +177,15 @@ describe('authModuleOidcProvider', () => { }); it('should start', async () => { - const agent = request.agent(''); + const agent = request.agent(backstageServer); const startResponse = await agent.get( - `${appUrl}/api/auth/oidc/start?env=development`, + `/api/auth/oidc/start?env=development`, ); expect(startResponse.status).toEqual(302); const nonceCookie = agent.jar.getCookie('oidc-nonce', { - domain: 'localhost', + domain: '127.0.0.1', path: '/api/auth/oidc/handler', script: false, secure: false, @@ -212,21 +193,24 @@ describe('authModuleOidcProvider', () => { expect(nonceCookie).toBeDefined(); const startUrl = new URL(startResponse.get('location')); - expect(startUrl.origin).toBe('https://oidc.com'); - expect(startUrl.pathname).toBe('/oauth/authorize'); + expect(startUrl.origin).toBe('https://oidc.test'); + expect(startUrl.pathname).toBe('/oauth2/authorize'); expect(Object.fromEntries(startUrl.searchParams)).toEqual({ response_type: 'code', - scope: 'read_user', - client_id: 'my-client-id', - redirect_uri: `http://localhost:${ - (backstageServer.address() as AddressInfo).port - }/api/auth/oidc/handler/frame`, + scope: 'openid profile email', + client_id: 'clientId', + redirect_uri: `${appUrl}/api/auth/oidc/handler/frame`, state: expect.any(String), + prompt: 'none', + code_challenge: expect.any(String), + code_challenge_method: `S256`, }); expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({ env: 'development', nonce: decodeURIComponent(nonceCookie.value), }); - }, 70000); + }); + + // TODO: This seems to be the place for integration testing, so far only have test that hit metadata endpoints along with /start but might be missing responses for handler? }); From a3c911e6365b60e45392b71df1a03e0384d90e24 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Wed, 27 Sep 2023 17:47:22 -0400 Subject: [PATCH 04/10] authenticator alongside passing unit tests Signed-off-by: Ruben Vallejo --- .../config.d.ts | 5 +- .../src/authenticator.test.ts | 394 ++++++++++++++ .../src/authenticator.ts | 124 +++-- .../src/module.test.ts | 69 ++- .../src/resolvers.ts | 51 +- plugins/auth-backend/package.json | 1 + .../src/providers/oidc/provider.test.ts | 189 ------- .../src/providers/oidc/provider.ts | 486 +++++++++--------- yarn.lock | 38 +- 9 files changed, 825 insertions(+), 532 deletions(-) create mode 100644 plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts delete mode 100644 plugins/auth-backend/src/providers/oidc/provider.test.ts diff --git a/plugins/auth-backend-module-oidc-provider/config.d.ts b/plugins/auth-backend-module-oidc-provider/config.d.ts index ba141d9555..803b9add73 100644 --- a/plugins/auth-backend-module-oidc-provider/config.d.ts +++ b/plugins/auth-backend-module-oidc-provider/config.d.ts @@ -25,8 +25,11 @@ export interface Config { * @visibility secret */ clientSecret: string; - audience?: string; + metadataUrl: string; callbackUrl?: string; + tokenSignedResponseAlg?: string; + scope?: string; + prompt?: string; }; }; }; diff --git a/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts b/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts new file mode 100644 index 0000000000..6f22517361 --- /dev/null +++ b/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts @@ -0,0 +1,394 @@ +/* + * 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 { + OAuthAuthenticatorAuthenticateInput, + OAuthAuthenticatorRefreshInput, + OAuthAuthenticatorStartInput, + OAuthState, + decodeOAuthState, + encodeOAuthState, +} from '@backstage/plugin-auth-node'; +import { oidcAuthenticator } from './authenticator'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { ConfigReader } from '@backstage/config'; +import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose'; +import { rest } from 'msw'; +import express from 'express'; + +describe('oidcAuthenticator', () => { + let implementation: any; + let oauthState: OAuthState; + let idToken: string; + let publicKey: JWK; + + const mswServer = setupServer(); + setupRequestMockHandlers(mswServer); + + const issuerMetadata = { + issuer: 'https://oidc.test', + authorization_endpoint: 'https://oidc.test/oauth2/authorize', + token_endpoint: 'https://oidc.test/oauth2/token', + revocation_endpoint: 'https://oidc.test/oauth2/revoke_token', + userinfo_endpoint: 'https://oidc.test/idp/userinfo.openid', + introspection_endpoint: 'https://oidc.test/introspect.oauth2', + jwks_uri: 'https://oidc.test/jwks.json', + scopes_supported: [ + 'openid', + 'offline_access', + 'oidc:request-audience', + 'username', + 'groups', + ], + claims_supported: ['email', 'username', 'groups', 'additionalClaims'], + response_types_supported: ['code'], + id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + token_endpoint_auth_signing_alg_values_supported: [ + 'RS256', + 'RS512', + 'HS256', + ], + request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + }; + + beforeAll(async () => { + const keyPair = await generateKeyPair('RS256'); + const privateKey = await exportJWK(keyPair.privateKey); + publicKey = await exportJWK(keyPair.publicKey); + publicKey.alg = privateKey.alg = 'RS256'; + + idToken = await new SignJWT({ + sub: 'test', + iss: 'https://oidc.test', + iat: Date.now(), + aud: 'clientId', + exp: Date.now() + 10000, + }) + .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .sign(keyPair.privateKey); + }); + + beforeEach(() => { + mswServer.use( + rest.get( + 'https://oidc.test/.well-known/openid-configuration', + (_req, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(issuerMetadata), + ), + ), + rest.get('https://oidc.test/jwks.json', async (_req, res, ctx) => + res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })), + ), + rest.post('https://oidc.test/oauth2/token', async (req, res, ctx) => { + return res( + req.headers.get('Authorization') + ? ctx.json({ + access_token: 'accessToken', + id_token: idToken, + refresh_token: 'refreshToken', + scope: 'testScope', + token_type: '', + }) + : ctx.status(401), + ); + }), + rest.get( + 'https://oidc.test/idp/userinfo.openid', + async (_req, res, ctx) => + res( + ctx.status(200), + ctx.json({ + sub: 'test', + name: 'Alice Adams', + given_name: 'Alice', + family_name: 'Adams', + email: 'alice@test.com', + }), + ), + ), + ); + + implementation = oidcAuthenticator.initialize({ + callbackUrl: 'https://backstage.test/callback', + config: new ConfigReader({ + metadataUrl: 'https://oidc.test', + clientId: 'clientId', + clientSecret: 'clientSecret', + }), + }); + + oauthState = { + nonce: 'nonce', + env: 'env', + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('#start', () => { + let fakeSession: Record; + let startRequest: OAuthAuthenticatorStartInput; + + beforeEach(() => { + fakeSession = {}; + startRequest = { + state: encodeOAuthState(oauthState), + req: { + method: 'GET', + url: 'test', + session: fakeSession, + }, + } as unknown as OAuthAuthenticatorStartInput; + }); + + it('redirects to authorization endpoint returned from OIDC metadata endpoint', async () => { + const startResponse = await oidcAuthenticator.start( + startRequest, + implementation, + ); + const url = new URL(startResponse.url); + + expect(url.protocol).toBe('https:'); + expect(url.hostname).toBe('oidc.test'); + expect(url.pathname).toBe('/oauth2/authorize'); + }); + + it('initiates authorization code grant', async () => { + const startResponse = await oidcAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('response_type')).toBe('code'); + }); + + it('passes client ID from config', async () => { + const startResponse = await oidcAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('client_id')).toBe('clientId'); + }); + + it('passes callback URL from config', async () => { + const startResponse = await oidcAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('redirect_uri')).toBe( + 'https://backstage.test/callback', + ); + }); + + it('generates PKCE challenge', async () => { + const startResponse = await oidcAuthenticator.start( + startRequest, + implementation, + ); + 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 oidcAuthenticator.start(startRequest, implementation); + expect(fakeSession['oidc:oidc.test'].code_verifier).toBeDefined(); + }); + + // without the offline_access scope refresh should not work should we add in the test that is a required scope to test for? curently dont see that by defualt in the provider implementation. + it('requests default scopes if none are provided in config', async () => { + const startResponse = await oidcAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + const scopes = searchParams.get('scope')?.split(' ') ?? []; + + expect(scopes).toEqual( + expect.arrayContaining(['openid', 'profile', 'email']), + ); + }); + + it('encodes OAuth state in query param', async () => { + const startResponse = await oidcAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + const stateParam = searchParams.get('state'); + const decodedState = decodeOAuthState(stateParam!); + + expect(decodedState).toMatchObject(oauthState); + }); + + it('fails when request has no session', async () => { + return expect( + oidcAuthenticator.start( + { + state: encodeOAuthState(oauthState), + req: { + method: 'GET', + url: 'test', + }, + } as unknown as OAuthAuthenticatorStartInput, + implementation, + ), + ).rejects.toThrow('authentication requires session support'); + }); + }); + + describe('#authenticate', () => { + let handlerRequest: OAuthAuthenticatorAuthenticateInput; + + beforeEach(() => { + handlerRequest = { + req: { + method: 'GET', + url: `https://test?code=authorization_code&state=${encodeOAuthState( + oauthState, + )}`, + session: { + 'oidc:oidc.test': { + state: encodeOAuthState(oauthState), + }, + }, + } as unknown as express.Request, + }; + }); + + it('exchanges authorization code for access token', async () => { + const handlerResponse = await oidcAuthenticator.authenticate( + handlerRequest, + implementation, + ); + const accessToken = handlerResponse.session.accessToken; + + expect(accessToken).toEqual('accessToken'); + }); + + it('exchanges authorization code for refresh token', async () => { + const handlerResponse = await oidcAuthenticator.authenticate( + handlerRequest, + implementation, + ); + const refreshToken = handlerResponse.session.refreshToken; + + expect(refreshToken).toEqual('refreshToken'); + }); + + it('returns granted scope', async () => { + const handlerResponse = await oidcAuthenticator.authenticate( + handlerRequest, + implementation, + ); + const responseScope = handlerResponse.session.scope; + + expect(responseScope).toEqual('testScope'); + }); + + it('fails without authorization code', async () => { + handlerRequest.req.url = 'https://test.com'; + return expect( + oidcAuthenticator.authenticate(handlerRequest, implementation), + ).rejects.toThrow('Unexpected redirect'); + }); + + it('fails without oauth state', async () => { + return expect( + oidcAuthenticator.authenticate( + { + req: { + method: 'GET', + url: `https://test?code=authorization_code}`, + session: { + ['oidc:pinniped.test']: { + state: { handle: 'sessionid', code_verifier: 'foo' }, + }, + }, + } as unknown as express.Request, + }, + implementation, + ), + ).rejects.toThrow( + 'Authentication failed, did not find expected authorization request details in session, req.session["oidc:oidc.test"] is undefined', + ); + }); + + it('fails when request has no session', async () => { + return expect( + oidcAuthenticator.authenticate( + { + req: { + method: 'GET', + url: 'https://test.com', + } as unknown as express.Request, + }, + implementation, + ), + ).rejects.toThrow('authentication requires session support'); + }); + }); + + describe('#refresh', () => { + let refreshRequest: OAuthAuthenticatorRefreshInput; + + beforeEach(() => { + refreshRequest = { + scope: '', + refreshToken: 'otherRefreshToken', + req: {} as express.Request, + }; + }); + + it('gets new refresh token', async () => { + const refreshResponse = await oidcAuthenticator.refresh( + refreshRequest, + implementation, + ); + + expect(refreshResponse.session.refreshToken).toBe('refreshToken'); + }); + + it('gets access token', async () => { + const refreshResponse = await oidcAuthenticator.refresh( + refreshRequest, + implementation, + ); + + expect(refreshResponse.session.accessToken).toBe('accessToken'); + }); + + it('gets id token', async () => { + const refreshResponse = await oidcAuthenticator.refresh( + refreshRequest, + implementation, + ); + + expect(refreshResponse.session.idToken).toBe(idToken); + }); + }); +}); diff --git a/plugins/auth-backend-module-oidc-provider/src/authenticator.ts b/plugins/auth-backend-module-oidc-provider/src/authenticator.ts index 7a7d57d7e2..7a7ebafe5c 100644 --- a/plugins/auth-backend-module-oidc-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-oidc-provider/src/authenticator.ts @@ -23,15 +23,10 @@ import { } from 'openid-client'; import { createOAuthAuthenticator, - decodeOAuthState, - encodeOAuthState, - PassportDoneCallback, PassportOAuthAuthenticatorHelper, PassportOAuthDoneCallback, - PassportOAuthPrivateInfo, - PassportProfile, } from '@backstage/plugin-auth-node'; -import { OidcAuthResult } from '@backstage/plugin-auth-backend'; +// import { BACKSTAGE_SESSION_EXPIRATION } from '@backstage/plugin-auth-backend/src/lib/session'; /** @public */ export const oidcAuthenticator = createOAuthAuthenticator({ @@ -40,9 +35,9 @@ export const oidcAuthenticator = createOAuthAuthenticator({ async initialize({ callbackUrl, config }) { const clientId = config.getString('clientId'); const clientSecret = config.getString('clientSecret'); + const metadataUrl = config.getString('metadataUrl'); const customCallbackUrl = config.getOptionalString('callbackUrl'); const callbackUrl2 = customCallbackUrl || callbackUrl; - const metadataUrl = config.getString('metadataUrl'); const tokenEndpointAuthMethod = config.getOptionalString( 'tokenEndpointAuthMethod', ) as ClientAuthMethod; @@ -66,39 +61,54 @@ export const oidcAuthenticator = createOAuthAuthenticator({ scope: initializedScope || '', }); - const helper = PassportOAuthAuthenticatorHelper.from( - new OidcStrategy( - { - client, - passReqToCallback: false, - }, - ( - tokenset: TokenSet, - userinfo: UserinfoResponse, - done: PassportDoneCallback, - ) => { - if (typeof done !== 'function') { - throw new Error( - 'OIDC IdP must provide a userinfo_endpoint in the metadata response', - ); - } - done( - undefined, - { tokenset, userinfo }, - { - refreshToken: tokenset.refresh_token, - }, + const strategy = new OidcStrategy( + { + client, + passReqToCallback: false, + }, + ( + tokenset: TokenSet, + userinfo: UserinfoResponse, + done: PassportOAuthDoneCallback, + ) => { + if (typeof done !== 'function') { + throw new Error( + 'OIDC IdP must provide a userinfo_endpoint in the metadata response', ); - }, - ), + } + + const expiresInSeconds = !tokenset.expires_in + ? 3600 + : Math.min(tokenset.expires_in!, 3600); + + done( + undefined, + { + fullProfile: { + provider: 'oidc', + id: userinfo.sub, + displayName: userinfo.name!, + }, + accessToken: tokenset.access_token!, + params: { + scope: tokenset.scope!, + expires_in: expiresInSeconds, + }, + }, + { + refreshToken: tokenset.refresh_token, + }, + ); + }, ); - return { helper, client, initializedScope, initializedPrompt }; + const helper = PassportOAuthAuthenticatorHelper.from(strategy); + + return { helper, client, initializedScope, initializedPrompt, strategy }; }, - async start(input, implementation) { - const { initializedScope, initializedPrompt, helper } = - await implementation; + async start(input, ctx) { + const { initializedScope, initializedPrompt, helper, strategy } = await ctx; const options: Record = { scope: input.scope || initializedScope || 'openid profile email', state: input.state, @@ -108,16 +118,48 @@ export const oidcAuthenticator = createOAuthAuthenticator({ options.prompt = prompt; } - return helper.start(input, { - ...options, + return new Promise((resolve, reject) => { + strategy.error = reject; + + return helper + .start(input, { + ...options, + }) + .then(resolve); }); }, - async authenticate(input, implementation) { - return (await implementation).helper.authenticate(input); + async authenticate(input, ctx) { + return (await ctx).helper.authenticate(input); }, - async refresh(input, implementation) { - return (await implementation).helper.refresh(input); + async refresh(input, ctx) { + const { client } = await ctx; + const tokenset = await client.refresh(input.refreshToken); + if (!tokenset.access_token) { + throw new Error('Refresh failed'); + } + const userinfo = await client.userinfo(tokenset.access_token); + + return new Promise((resolve, reject) => { + if (!tokenset.access_token) { + reject(new Error('Refresh Failed')); + } + resolve({ + fullProfile: { + provider: 'oidc', + id: userinfo.sub, + displayName: userinfo.name!, + }, + session: { + accessToken: tokenset.access_token!, + tokenType: tokenset.token_type ?? 'bearer', + scope: tokenset.scope!, + expiresInSeconds: tokenset.expires_in, + idToken: tokenset.id_token, + refreshToken: tokenset.refresh_token, + }, + }); + }); }, }); diff --git a/plugins/auth-backend-module-oidc-provider/src/module.test.ts b/plugins/auth-backend-module-oidc-provider/src/module.test.ts index f85afec0c3..5e01ae343f 100644 --- a/plugins/auth-backend-module-oidc-provider/src/module.test.ts +++ b/plugins/auth-backend-module-oidc-provider/src/module.test.ts @@ -67,10 +67,10 @@ describe('authModuleOidcProvider', () => { }; beforeAll(async () => { - const keyPair = await generateKeyPair('ES256'); + const keyPair = await generateKeyPair('RS256'); const privateKey = await exportJWK(keyPair.privateKey); publicKey = await exportJWK(keyPair.publicKey); - publicKey.alg = privateKey.alg = 'ES256'; + publicKey.alg = privateKey.alg = 'RS256'; idToken = await new SignJWT({ sub: 'test', @@ -109,6 +109,36 @@ describe('authModuleOidcProvider', () => { ctx.set('Location', callbackUrl.toString()), ); }), + rest.get('https://oidc.test/jwks.json', async (_req, res, ctx) => + res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })), + ), + rest.post('https://oidc.test/oauth2/token', async (req, res, ctx) => { + return res( + req.headers.get('Authorization') + ? ctx.json({ + access_token: 'accessToken', + id_token: idToken, + refresh_token: 'refreshToken', + scope: 'testScope', + token_type: '', + }) + : ctx.status(401), + ); + }), + rest.get( + 'https://oidc.test/idp/userinfo.openid', + async (_req, res, ctx) => + res( + ctx.status(200), + ctx.json({ + sub: 'test', + name: 'Alice Adams', + given_name: 'Alice', + family_name: 'Adams', + email: 'alice@test.com', + }), + ), + ), ); const secret = 'secret'; @@ -212,5 +242,38 @@ describe('authModuleOidcProvider', () => { }); }); - // TODO: This seems to be the place for integration testing, so far only have test that hit metadata endpoints along with /start but might be missing responses for handler? + it('#authenticate exchanges authorization code for a access_token', async () => { + const agent = request.agent(''); + + // make /start request with audience parameter + const startResponse = await agent.get( + `${appUrl}/api/auth/oidc/start?env=development`, + ); + // follow redirect to authorization endpoint + const authorizationResponse = await agent.get( + startResponse.header.location, + ); + // follow redirect to token_endpoint + const handlerResponse = await agent.get( + authorizationResponse.header.location, + ); + + expect(handlerResponse.text).toContain( + encodeURIComponent( + JSON.stringify({ + type: 'authorization_response', + response: { + profile: { + displayName: 'Alice Adams', + }, + providerInfo: { + accessToken: 'accessToken', + scope: 'testScope', + expiresInSeconds: 3600, + }, + }, + }), + ), + ); + }); }); diff --git a/plugins/auth-backend-module-oidc-provider/src/resolvers.ts b/plugins/auth-backend-module-oidc-provider/src/resolvers.ts index 930ff462f2..5367ab5216 100644 --- a/plugins/auth-backend-module-oidc-provider/src/resolvers.ts +++ b/plugins/auth-backend-module-oidc-provider/src/resolvers.ts @@ -14,12 +14,7 @@ * limitations under the License. */ -import { - createSignInResolverFactory, - OAuthAuthenticatorResult, - PassportProfile, - SignInInfo, -} from '@backstage/plugin-auth-node'; +import { commonSignInResolvers } from '@backstage/plugin-auth-node'; /** * Available sign-in resolvers for the Oidc auth provider. @@ -28,44 +23,16 @@ import { */ export namespace oidcSignInResolvers { /** - * Looks up the user by matching their Oidc username to the entity name. + * A oidc resolver that looks up the user using the local part of + * their email address as the entity name. */ - export const usernameMatchingUserEntityName = createSignInResolverFactory({ - create() { - return async ( - info: SignInInfo>, - ctx, - ) => { - const { result } = info; - - const id = result.fullProfile.username; - if (!id) { - throw new Error(`Oidc user profile does not contain a username`); - } - - return ctx.signInWithCatalogUser({ entityRef: { name: id } }); - }; - }, - }); + export const emailLocalPartMatchingUserEntityName = + commonSignInResolvers.emailLocalPartMatchingUserEntityName; /** - * Looks up the user by matching their email to the `google.com/email` annotation. Still working out this resolver..... + * A oidc resolver that looks up the user using their email address + * as email of the entity. */ - export const emailMatchingUserEntityAnnotation = createSignInResolverFactory({ - create() { - return async (info: SignInInfo, ctx) => { - const email = info.result.iapToken.email; - - if (!email) { - throw new Error('Google IAP sign-in result is missing email'); - } - - return ctx.signInWithCatalogUser({ - annotations: { - 'google.com/email': email, - }, - }); - }; - }, - }); + export const emailMatchingUserEntityProfileEmail = + commonSignInResolvers.emailMatchingUserEntityProfileEmail; } diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index a2b4d01b6c..ca92d4c9dd 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -45,6 +45,7 @@ "@backstage/plugin-auth-backend-module-google-provider": "workspace:^", "@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^", "@backstage/plugin-auth-backend-module-oauth2-proxy-provider": "workspace:^", + "@backstage/plugin-auth-backend-module-oidc-provider": "workspace:^", "@backstage/plugin-auth-backend-module-okta-provider": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-catalog-node": "workspace:^", diff --git a/plugins/auth-backend/src/providers/oidc/provider.test.ts b/plugins/auth-backend/src/providers/oidc/provider.test.ts deleted file mode 100644 index 7161ef1d6b..0000000000 --- a/plugins/auth-backend/src/providers/oidc/provider.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -/* - * 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 { Config, ConfigReader } from '@backstage/config'; -import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; -import express from 'express'; -import { Session } from 'express-session'; -import { UnsecuredJWT } from 'jose'; -import { rest } from 'msw'; -import { setupServer } from 'msw/node'; -import { ClientMetadata, IssuerMetadata } from 'openid-client'; -import { OAuthAdapter } from '../../lib/oauth'; -import { oidc, OidcAuthProvider, Options } from './provider'; -import { AuthResolverContext } from '../types'; - -const issuerMetadata = { - issuer: 'https://oidc.test', - authorization_endpoint: 'https://oidc.test/as/authorization.oauth2', - token_endpoint: 'https://oidc.test/as/token.oauth2', - revocation_endpoint: 'https://oidc.test/as/revoke_token.oauth2', - userinfo_endpoint: 'https://oidc.test/idp/userinfo.openid', - introspection_endpoint: 'https://oidc.test/as/introspect.oauth2', - jwks_uri: 'https://oidc.test/pf/JWKS', - scopes_supported: ['openid'], - claims_supported: ['email'], - response_types_supported: ['code'], - id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], - token_endpoint_auth_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], - request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], -}; - -const clientMetadata: Options = { - authHandler: async input => ({ - profile: { - displayName: input.userinfo.email, - }, - }), - resolverContext: {} as AuthResolverContext, - callbackUrl: 'https://oidc.test/callback', - clientId: 'testclientid', - clientSecret: 'testclientsecret', - metadataUrl: 'https://oidc.test/.well-known/openid-configuration', - tokenEndpointAuthMethod: 'none', - tokenSignedResponseAlg: 'none', -}; - -describe('OidcAuthProvider', () => { - const worker = setupServer(); - setupRequestMockHandlers(worker); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('hit the metadata url', async () => { - const handler = jest.fn((_req, res, ctx) => { - return res( - ctx.status(200), - ctx.set('Content-Type', 'application/json'), - ctx.json(issuerMetadata), - ); - }); - worker.use( - rest.get('https://oidc.test/.well-known/openid-configuration', handler), - ); - const provider = new OidcAuthProvider(clientMetadata); - const { strategy } = (await (provider as any).implementation) as any as { - strategy: { - _client: ClientMetadata; - _issuer: IssuerMetadata; - }; - }; - // Assert that the expected request to the metadaurl was made. - expect(handler).toHaveBeenCalledTimes(1); - const { _client, _issuer } = strategy; - expect(_client.client_id).toBe(clientMetadata.clientId); - expect(_issuer.token_endpoint).toBe(issuerMetadata.token_endpoint); - }); - - it('OidcAuthProvider#handler successfully invokes the oidc endpoints', async () => { - const sub = 'alice'; - const iss = 'https://oidc.test'; - const iat = Date.now(); - const aud = clientMetadata.clientId; - const exp = Date.now() + 10000; - const jwt = await new UnsecuredJWT({ iss, sub, aud, iat, exp }) - .setIssuer(iss) - .setAudience(aud) - .setSubject(sub) - .setIssuedAt(iat) - .setExpirationTime(exp) - .encode(); - const requestSequence: Array = []; - - // The array of expected requests executed by the provider handler - const requests: Array<{ - method: 'get' | 'post'; - url: string; - payload: object; - }> = [ - { - method: 'get', - url: 'https://oidc.test/.well-known/openid-configuration', - payload: issuerMetadata, - }, - { - method: 'post', - url: 'https://oidc.test/as/token.oauth2', - payload: { - id_token: jwt, - access_token: 'test', - authorization_signed_response_alg: 'HS256', - }, - }, - { - method: 'get', - url: 'https://oidc.test/idp/userinfo.openid', - payload: { - sub: 'alice', - email: 'alice@oidc.test', - }, - }, - ]; - worker.use( - ...requests.map(r => { - return rest[r.method](r.url, (_req, res, ctx) => { - requestSequence.push(r.url); - return res( - ctx.status(200), - ctx.set('Content-Type', 'application/json'), - ctx.json(r.payload), - ); - }); - }), - ); - const provider = new OidcAuthProvider(clientMetadata); - const req = { - method: 'GET', - url: 'https://oidc.test/?code=test2', - session: { 'oidc:oidc.test': 'test' } as any as Session, - } as express.Request; - await provider.handler(req); - expect(requestSequence).toEqual([0, 1, 2].map(i => requests[i].url)); - }); - - it('oidc.create', async () => { - const handler = jest.fn((_req, res, ctx) => { - return res( - ctx.status(200), - ctx.set('Content-Type', 'application/json'), - ctx.json(issuerMetadata), - ); - }); - worker.use( - rest.get('https://oidc.test/.well-known/openid-configuration', handler), - ); - const config: Config = new ConfigReader({ - testEnv: { - ...clientMetadata, - metadataUrl: 'https://oidc.test/.well-known/openid-configuration', - }, - } as any); - const provider = oidc.create()({ - globalConfig: { - appUrl: 'https://oidc.test', - baseUrl: 'https://oidc.test', - }, - config, - } as any) as OAuthAdapter; - expect(provider.start).toBeDefined(); - // Cast provider as any here to be able to inspect private members - await (provider as any).handlers.get('testEnv').handlers.implementation; - // Assert that the expected request to the metadaurl was made. - expect(handler).toHaveBeenCalledTimes(1); - }); -}); diff --git a/plugins/auth-backend/src/providers/oidc/provider.ts b/plugins/auth-backend/src/providers/oidc/provider.ts index b6608aebbd..ce8809c55a 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.ts @@ -14,278 +14,282 @@ * limitations under the License. */ -import express from 'express'; -import { - Client, - ClientAuthMethod, - Issuer, - Strategy as OidcStrategy, - TokenSet, - UserinfoResponse, -} from 'openid-client'; -import { - encodeState, - OAuthAdapter, - OAuthEnvironmentHandler, - OAuthHandlers, - OAuthProviderOptions, - OAuthRefreshRequest, - OAuthResponse, - OAuthStartRequest, -} from '../../lib/oauth'; -import { - executeFrameHandlerStrategy, - executeRedirectStrategy, - PassportDoneCallback, -} from '../../lib/passport'; -import { - AuthHandler, - AuthResolverContext, - OAuthStartResponse, - SignInResolver, -} from '../types'; +import { AuthHandler, SignInResolver } from '../types'; +import { OAuthResult } from '../../lib/oauth'; import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; +import { createOAuthProviderFactory } from '@backstage/plugin-auth-node'; import { - commonByEmailLocalPartResolver, - commonByEmailResolver, -} from '../resolvers'; -import { BACKSTAGE_SESSION_EXPIRATION } from '../../lib/session'; + adaptLegacyOAuthHandler, + adaptLegacyOAuthSignInResolver, +} from '../../lib/legacy'; -type PrivateInfo = { - refreshToken?: string; -}; +import { oidcAuthenticator } from '@backstage/plugin-auth-backend-module-oidc-provider'; -type OidcImpl = { - strategy: OidcStrategy; - client: Client; -}; +// type PrivateInfo = { +// refreshToken?: string; +// }; -/** - * authentication result for the OIDC which includes the token set and user information (a profile response sent by OIDC server) - * @public - */ -export type OidcAuthResult = { - tokenset: TokenSet; - userinfo: UserinfoResponse; -}; +// type OidcImpl = { +// strategy: OidcStrategy; +// client: Client; +// }; -export type Options = OAuthProviderOptions & { - metadataUrl: string; - scope?: string; - prompt?: string; - tokenEndpointAuthMethod?: ClientAuthMethod; - tokenSignedResponseAlg?: string; - signInResolver?: SignInResolver; - authHandler: AuthHandler; - resolverContext: AuthResolverContext; -}; +// /** +// * authentication result for the OIDC which includes the token set and user information (a profile response sent by OIDC server) +// * @public +// */ +// export type OidcAuthResult = { +// tokenset: TokenSet; +// userinfo: UserinfoResponse; +// }; -export class OidcAuthProvider implements OAuthHandlers { - private readonly implementation: Promise; - private readonly scope?: string; - private readonly prompt?: string; +// export type Options = OAuthProviderOptions & { +// metadataUrl: string; +// scope?: string; +// prompt?: string; +// tokenEndpointAuthMethod?: ClientAuthMethod; +// tokenSignedResponseAlg?: string; +// signInResolver?: SignInResolver; +// authHandler: AuthHandler; +// resolverContext: AuthResolverContext; +// }; - private readonly signInResolver?: SignInResolver; - private readonly authHandler: AuthHandler; - private readonly resolverContext: AuthResolverContext; +// export class OidcAuthProvider implements OAuthHandlers { +// private readonly implementation: Promise; +// private readonly scope?: string; +// private readonly prompt?: string; - constructor(options: Options) { - this.implementation = this.setupStrategy(options); - this.scope = options.scope; - this.prompt = options.prompt; - this.signInResolver = options.signInResolver; - this.authHandler = options.authHandler; - this.resolverContext = options.resolverContext; - } +// private readonly signInResolver?: SignInResolver; +// private readonly authHandler: AuthHandler; +// private readonly resolverContext: AuthResolverContext; - async start(req: OAuthStartRequest): Promise { - const { strategy } = await this.implementation; - const options: Record = { - scope: req.scope || this.scope || 'openid profile email', - state: encodeState(req.state), - }; - const prompt = this.prompt || 'none'; - if (prompt !== 'auto') { - options.prompt = prompt; - } - return await executeRedirectStrategy(req, strategy, options); - } +// constructor(options: Options) { +// this.implementation = this.setupStrategy(options); +// this.scope = options.scope; +// this.prompt = options.prompt; +// this.signInResolver = options.signInResolver; +// this.authHandler = options.authHandler; +// this.resolverContext = options.resolverContext; +// } - async handler(req: express.Request) { - const { strategy } = await this.implementation; - const { result, privateInfo } = await executeFrameHandlerStrategy< - OidcAuthResult, - PrivateInfo - >(req, strategy); +// async start(req: OAuthStartRequest): Promise { +// const { strategy } = await this.implementation; +// const options: Record = { +// scope: req.scope || this.scope || 'openid profile email', +// state: encodeState(req.state), +// }; +// const prompt = this.prompt || 'none'; +// if (prompt !== 'auto') { +// options.prompt = prompt; +// } +// return await executeRedirectStrategy(req, strategy, options); +// } - return { - response: await this.handleResult(result), - refreshToken: privateInfo.refreshToken, - }; - } +// async handler(req: express.Request) { +// const { strategy } = await this.implementation; +// const { result, privateInfo } = await executeFrameHandlerStrategy< +// OidcAuthResult, +// PrivateInfo +// >(req, strategy); - async refresh(req: OAuthRefreshRequest) { - const { client } = await this.implementation; - const tokenset = await client.refresh(req.refreshToken); - if (!tokenset.access_token) { - throw new Error('Refresh failed'); - } - if (!tokenset.scope) { - tokenset.scope = req.scope; - } - const userinfo = await client.userinfo(tokenset.access_token); + // async refresh(req: OAuthRefreshRequest) { + // const { client } = await this.implementation; + // const tokenset = await client.refresh(req.refreshToken); + // if (!tokenset.access_token) { + // throw new Error('Refresh failed'); + // } + // if (!tokenset.scope) { + // tokenset.scope = req.scope; + // } + // const userinfo = await client.userinfo(tokenset.access_token); +// return { +// response: await this.handleResult(result), +// refreshToken: privateInfo.refreshToken, +// }; +// } - return { - response: await this.handleResult({ tokenset, userinfo }), - refreshToken: tokenset.refresh_token, - }; - } +// async refresh(req: OAuthRefreshRequest) { +// const { client } = await this.implementation; +// const tokenset = await client.refresh(req.refreshToken); +// if (!tokenset.access_token) { +// throw new Error('Refresh failed'); +// } +// const userinfo = await client.userinfo(tokenset.access_token); - private async setupStrategy(options: Options): Promise { - const issuer = await Issuer.discover(options.metadataUrl); - const client = new issuer.Client({ - access_type: 'offline', // this option must be passed to provider to receive a refresh token - client_id: options.clientId, - client_secret: options.clientSecret, - redirect_uris: [options.callbackUrl], - response_types: ['code'], - token_endpoint_auth_method: - options.tokenEndpointAuthMethod || 'client_secret_basic', - id_token_signed_response_alg: options.tokenSignedResponseAlg || 'RS256', - scope: options.scope || '', - }); +// return { +// response: await this.handleResult({ tokenset, userinfo }), +// refreshToken: tokenset.refresh_token, +// }; +// } - const strategy = new OidcStrategy( - { - client, - passReqToCallback: false, - }, - ( - tokenset: TokenSet, - userinfo: UserinfoResponse, - done: PassportDoneCallback, - ) => { - if (typeof done !== 'function') { - throw new Error( - 'OIDC IdP must provide a userinfo_endpoint in the metadata response', - ); - } - done( - undefined, - { tokenset, userinfo }, - { - refreshToken: tokenset.refresh_token, - }, - ); - }, - ); - strategy.error = console.error; - return { strategy, client }; - } +// private async setupStrategy(options: Options): Promise { +// const issuer = await Issuer.discover(options.metadataUrl); +// const client = new issuer.Client({ +// access_type: 'offline', // this option must be passed to provider to receive a refresh token +// client_id: options.clientId, +// client_secret: options.clientSecret, +// redirect_uris: [options.callbackUrl], +// response_types: ['code'], +// token_endpoint_auth_method: +// options.tokenEndpointAuthMethod || 'client_secret_basic', +// id_token_signed_response_alg: options.tokenSignedResponseAlg || 'RS256', +// scope: options.scope || '', +// }); - // Use this function to grab the user profile info from the token - // Then populate the profile with it - private async handleResult(result: OidcAuthResult): Promise { - const { profile } = await this.authHandler(result, this.resolverContext); +// const strategy = new OidcStrategy( +// { +// client, +// passReqToCallback: false, +// }, +// ( +// tokenset: TokenSet, +// userinfo: UserinfoResponse, +// done: PassportDoneCallback, +// ) => { +// if (typeof done !== 'function') { +// throw new Error( +// 'OIDC IdP must provide a userinfo_endpoint in the metadata response', +// ); +// } +// done( +// undefined, +// { tokenset, userinfo }, +// { +// refreshToken: tokenset.refresh_token, +// }, +// ); +// }, +// ); +// strategy.error = console.error; +// return { strategy, client }; +// } - const expiresInSeconds = - result.tokenset.expires_in === undefined - ? BACKSTAGE_SESSION_EXPIRATION - : Math.min(result.tokenset.expires_in, BACKSTAGE_SESSION_EXPIRATION); +// Use this function to grab the user profile info from the token +// Then populate the profile with it +// private async handleResult(result: OidcAuthResult): Promise { +// const { profile } = await this.authHandler(result, this.resolverContext); - let backstageIdentity = undefined; - if (this.signInResolver) { - backstageIdentity = await this.signInResolver( - { - result, - profile, - }, - this.resolverContext, - ); - } +// const expiresInSeconds = +// result.tokenset.expires_in === undefined +// ? BACKSTAGE_SESSION_EXPIRATION +// : Math.min(result.tokenset.expires_in, BACKSTAGE_SESSION_EXPIRATION); - return { - backstageIdentity, - providerInfo: { - idToken: result.tokenset.id_token, - accessToken: result.tokenset.access_token!, - scope: result.tokenset.scope!, - expiresInSeconds, - }, - profile, - }; - } -} +// let backstageIdentity = undefined; +// if (this.signInResolver) { +// backstageIdentity = await this.signInResolver( +// { +// result, +// profile, +// }, +// this.resolverContext, +// ); +// } + +// return { +// backstageIdentity, +// providerInfo: { +// idToken: result.tokenset.id_token, +// accessToken: result.tokenset.access_token!, +// scope: result.tokenset.scope!, +// expiresInSeconds, +// }, +// profile, +// }; +// } +// } /** * Auth provider integration for generic OpenID Connect auth * * @public */ +// export const oidc = createAuthProviderIntegration({ +// create(options?: { +// authHandler?: AuthHandler; + +// signIn?: { +// resolver: SignInResolver; +// }; +// }) { +// return ({ providerId, globalConfig, config, resolverContext }) => +// OAuthEnvironmentHandler.mapConfig(config, envConfig => { +// const clientId = envConfig.getString('clientId'); +// const clientSecret = envConfig.getString('clientSecret'); +// const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); +// const callbackUrl = +// customCallbackUrl || +// `${globalConfig.baseUrl}/${providerId}/handler/frame`; +// const metadataUrl = envConfig.getString('metadataUrl'); +// const tokenEndpointAuthMethod = envConfig.getOptionalString( +// 'tokenEndpointAuthMethod', +// ) as ClientAuthMethod; +// const tokenSignedResponseAlg = envConfig.getOptionalString( +// 'tokenSignedResponseAlg', +// ); +// const scope = envConfig.getOptionalString('scope'); +// const prompt = envConfig.getOptionalString('prompt'); + +// const authHandler: AuthHandler = options?.authHandler +// ? options.authHandler +// : async ({ userinfo }) => ({ +// profile: { +// displayName: userinfo.name, +// email: userinfo.email, +// picture: userinfo.picture, +// }, +// }); + +// const provider = new OidcAuthProvider({ +// clientId, +// clientSecret, +// callbackUrl, +// tokenEndpointAuthMethod, +// tokenSignedResponseAlg, +// metadataUrl, +// scope, +// prompt, +// signInResolver: options?.signIn?.resolver, +// authHandler, +// resolverContext, +// }); + +// return OAuthAdapter.fromConfig(globalConfig, provider, { +// providerId, +// callbackUrl, +// }); +// }); +// }, +// resolvers: { +// /** +// * Looks up the user by matching their email local part to the entity name. +// */ +// emailLocalPartMatchingUserEntityName: () => commonByEmailLocalPartResolver, +// /** +// * Looks up the user by matching their email to the entity email. +// */ +// emailMatchingUserEntityProfileEmail: () => commonByEmailResolver, +// }, +// }); + export const oidc = createAuthProviderIntegration({ create(options?: { - authHandler?: AuthHandler; + /** + * The profile transformation function used to verify and convert the auth response + * into the profile that will be presented to the user. + */ + authHandler?: AuthHandler; + /** + * Configure sign-in for this provider, without it the provider can not be used to sign users in. + */ signIn?: { - resolver: SignInResolver; + resolver: SignInResolver; }; }) { - return ({ providerId, globalConfig, config, resolverContext }) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); - const callbackUrl = - customCallbackUrl || - `${globalConfig.baseUrl}/${providerId}/handler/frame`; - const metadataUrl = envConfig.getString('metadataUrl'); - const tokenEndpointAuthMethod = envConfig.getOptionalString( - 'tokenEndpointAuthMethod', - ) as ClientAuthMethod; - const tokenSignedResponseAlg = envConfig.getOptionalString( - 'tokenSignedResponseAlg', - ); - const scope = envConfig.getOptionalString('scope'); - const prompt = envConfig.getOptionalString('prompt'); - - const authHandler: AuthHandler = options?.authHandler - ? options.authHandler - : async ({ userinfo }) => ({ - profile: { - displayName: userinfo.name, - email: userinfo.email, - picture: userinfo.picture, - }, - }); - - const provider = new OidcAuthProvider({ - clientId, - clientSecret, - callbackUrl, - tokenEndpointAuthMethod, - tokenSignedResponseAlg, - metadataUrl, - scope, - prompt, - signInResolver: options?.signIn?.resolver, - authHandler, - resolverContext, - }); - - return OAuthAdapter.fromConfig(globalConfig, provider, { - providerId, - callbackUrl, - }); - }); - }, - resolvers: { - /** - * Looks up the user by matching their email local part to the entity name. - */ - emailLocalPartMatchingUserEntityName: () => commonByEmailLocalPartResolver, - /** - * Looks up the user by matching their email to the entity email. - */ - emailMatchingUserEntityProfileEmail: () => commonByEmailResolver, + return createOAuthProviderFactory({ + authenticator: oidcAuthenticator, + profileTransform: adaptLegacyOAuthHandler(options?.authHandler), + signInResolver: adaptLegacyOAuthSignInResolver(options?.signIn?.resolver), + }); }, }); diff --git a/yarn.lock b/yarn.lock index 6e6721b99e..16979362ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4672,7 +4672,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-auth-backend-module-oidc-provider@workspace:plugins/auth-backend-module-oidc-provider": +"@backstage/plugin-auth-backend-module-oidc-provider@workspace:^, @backstage/plugin-auth-backend-module-oidc-provider@workspace:plugins/auth-backend-module-oidc-provider": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend-module-oidc-provider@workspace:plugins/auth-backend-module-oidc-provider" dependencies: @@ -4777,6 +4777,7 @@ __metadata: "@backstage/plugin-auth-backend-module-google-provider": "workspace:^" "@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^" "@backstage/plugin-auth-backend-module-oauth2-proxy-provider": "workspace:^" + "@backstage/plugin-auth-backend-module-oidc-provider": "workspace:^" "@backstage/plugin-auth-backend-module-okta-provider": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^" @@ -31276,13 +31277,20 @@ __metadata: languageName: node linkType: hard -"jose@npm:^4.14.6, jose@npm:^4.15.4, jose@npm:^4.6.0": +"jose@npm:^4.14.6, jose@npm:^4.15.1": version: 4.15.4 resolution: "jose@npm:4.15.4" checksum: dccad91cb3357f36423774a0b89ad830dd84b31090de65cd139b85488439f16a00f8c59c0773825e8a1adb0dd9d13ad725ad66e6ea33880ecb3959bb99e1ea5b languageName: node linkType: hard +"jose@npm:^4.6.0": + version: 4.15.2 + resolution: "jose@npm:4.15.2" + checksum: 8f0cab1eef31243abe14a935b2b330cd95f10f9b69808fd642088ae5000e50e566664934537d2c6413ab2f6b54acd8265a5033da05157aa1260c5f1d7e57fab0 + languageName: node + linkType: hard + "joycon@npm:^3.0.1": version: 3.1.0 resolution: "joycon@npm:3.1.0" @@ -35655,19 +35663,7 @@ __metadata: languageName: node linkType: hard -"openid-client@npm:^5.2.1, openid-client@npm:^5.3.0, openid-client@npm:^5.4.3": - version: 5.6.4 - resolution: "openid-client@npm:5.6.4" - dependencies: - jose: ^4.15.4 - lru-cache: ^6.0.0 - object-hash: ^2.2.0 - oidc-token-hash: ^5.0.3 - checksum: 69843f078dacbbc6bad6d65ca6689414ac73f095dfe2f8e606822e6cfc9d9cd7d0dfaf2649352eda604653806f0ea65326ad2d6266da897e4740ec93d26d21f6 - languageName: node - linkType: hard - -"openid-client@npm:^5.5.0": +"openid-client@npm:^5.2.1, openid-client@npm:^5.3.0, openid-client@npm:^5.5.0": version: 5.5.0 resolution: "openid-client@npm:5.5.0" dependencies: @@ -35679,6 +35675,18 @@ __metadata: languageName: node linkType: hard +"openid-client@npm:^5.4.3": + version: 5.6.1 + resolution: "openid-client@npm:5.6.1" + dependencies: + jose: ^4.15.1 + lru-cache: ^6.0.0 + object-hash: ^2.2.0 + oidc-token-hash: ^5.0.3 + checksum: 9d939cec57540e6dd3f67e9a248ec5ecec3b439b7ab5bd2e9fb4481bd03e8d030deedd87a447348194be7f3e93e84085841b0414033caf86479870f526cdbc2f + languageName: node + linkType: hard + "oppa@npm:^0.4.0": version: 0.4.0 resolution: "oppa@npm:0.4.0" From 1964cb7d8805f4b78a75a920c411b68b4cbf9a39 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Thu, 28 Sep 2023 10:59:18 -0400 Subject: [PATCH 05/10] Working oidc authenticator with passing unit and module tests Signed-off-by: Ruben Vallejo --- .../config.d.ts | 1 + .../dev/index.ts | 6 +- .../src/authenticator.test.ts | 53 +++- .../src/authenticator.ts | 32 ++- .../src/index.ts | 2 +- .../src/module.test.ts | 8 +- .../auth-backend/src/providers/oidc/index.ts | 1 - .../src/providers/oidc/provider.ts | 242 ------------------ 8 files changed, 84 insertions(+), 261 deletions(-) diff --git a/plugins/auth-backend-module-oidc-provider/config.d.ts b/plugins/auth-backend-module-oidc-provider/config.d.ts index 803b9add73..16131c0761 100644 --- a/plugins/auth-backend-module-oidc-provider/config.d.ts +++ b/plugins/auth-backend-module-oidc-provider/config.d.ts @@ -27,6 +27,7 @@ export interface Config { clientSecret: string; metadataUrl: string; callbackUrl?: string; + tokenEndpointAuthMethod?: string; tokenSignedResponseAlg?: string; scope?: string; prompt?: string; diff --git a/plugins/auth-backend-module-oidc-provider/dev/index.ts b/plugins/auth-backend-module-oidc-provider/dev/index.ts index 4d027a19c2..d3c18c1d48 100644 --- a/plugins/auth-backend-module-oidc-provider/dev/index.ts +++ b/plugins/auth-backend-module-oidc-provider/dev/index.ts @@ -15,12 +15,10 @@ */ import { createBackend } from '@backstage/backend-defaults'; -import { authPlugin } from '@backstage/plugin-auth-backend'; -import { authModuleOidcProvider } from '../src'; const backend = createBackend(); -backend.add(authPlugin); -backend.add(authModuleOidcProvider); +backend.add(import('@backstage/plugin-auth-backend')); +backend.add(import('../src')); backend.start(); diff --git a/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts b/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts index 6f22517361..4d21db8442 100644 --- a/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts +++ b/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts @@ -103,7 +103,7 @@ describe('oidcAuthenticator', () => { id_token: idToken, refresh_token: 'refreshToken', scope: 'testScope', - token_type: '', + expires_in: 3600, }) : ctx.status(401), ); @@ -119,6 +119,7 @@ describe('oidcAuthenticator', () => { given_name: 'Alice', family_name: 'Adams', email: 'alice@test.com', + picture: 'http://testPictureUrl/photo.jpg', }), ), ), @@ -127,7 +128,7 @@ describe('oidcAuthenticator', () => { implementation = oidcAuthenticator.initialize({ callbackUrl: 'https://backstage.test/callback', config: new ConfigReader({ - metadataUrl: 'https://oidc.test', + metadataUrl: 'https://oidc.test/.well-known/openid-configuration', clientId: 'clientId', clientSecret: 'clientSecret', }), @@ -219,7 +220,6 @@ describe('oidcAuthenticator', () => { expect(fakeSession['oidc:oidc.test'].code_verifier).toBeDefined(); }); - // without the offline_access scope refresh should not work should we add in the test that is a required scope to test for? curently dont see that by defualt in the provider implementation. it('requests default scopes if none are provided in config', async () => { const startResponse = await oidcAuthenticator.start( startRequest, @@ -310,6 +310,53 @@ describe('oidcAuthenticator', () => { expect(responseScope).toEqual('testScope'); }); + it('returns a default session.tokentype field', async () => { + const handlerResponse = await oidcAuthenticator.authenticate( + handlerRequest, + implementation, + ); + const tokenType = handlerResponse.session.tokenType; + + expect(tokenType).toEqual('bearer'); + }); + + it('returns defined fullProfile with picture and email', async () => { + const handlerResponse = await oidcAuthenticator.authenticate( + handlerRequest, + implementation, + ); + const displayName = handlerResponse.fullProfile.displayName; + const email = handlerResponse.fullProfile.emails![0].value; + const picture = handlerResponse.fullProfile.photos![0].value; + + expect(displayName).toEqual('Alice Adams'); + expect(email).toEqual('alice@test.com'); + expect(picture).toEqual('http://testPictureUrl/photo.jpg'); + }); + + it('returns defined response with an idToken', async () => { + const handlerResponse = await oidcAuthenticator.authenticate( + handlerRequest, + implementation, + ); + + expect(handlerResponse).toMatchObject({ + fullProfile: { + displayName: 'Alice Adams', + id: 'test', + provider: 'oidc', + }, + session: { + accessToken: 'accessToken', + expiresInSeconds: 3600, + idToken, + refreshToken: 'refreshToken', + scope: 'testScope', + tokenType: 'bearer', + }, + }); + }); + it('fails without authorization code', async () => { handlerRequest.req.url = 'https://test.com'; return expect( diff --git a/plugins/auth-backend-module-oidc-provider/src/authenticator.ts b/plugins/auth-backend-module-oidc-provider/src/authenticator.ts index 7a7ebafe5c..a95010e353 100644 --- a/plugins/auth-backend-module-oidc-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-oidc-provider/src/authenticator.ts @@ -26,7 +26,6 @@ import { PassportOAuthAuthenticatorHelper, PassportOAuthDoneCallback, } from '@backstage/plugin-auth-node'; -// import { BACKSTAGE_SESSION_EXPIRATION } from '@backstage/plugin-auth-backend/src/lib/session'; /** @public */ export const oidcAuthenticator = createOAuthAuthenticator({ @@ -46,9 +45,8 @@ export const oidcAuthenticator = createOAuthAuthenticator({ ); const initializedScope = config.getOptionalString('scope'); const initializedPrompt = config.getOptionalString('prompt'); - const issuer = await Issuer.discover( - `${metadataUrl}/.well-known/openid-configuration`, - ); + + const issuer = await Issuer.discover(metadataUrl); const client = new issuer.Client({ access_type: 'offline', // this option must be passed to provider to receive a refresh token client_id: clientId, @@ -77,9 +75,18 @@ export const oidcAuthenticator = createOAuthAuthenticator({ ); } - const expiresInSeconds = !tokenset.expires_in - ? 3600 - : Math.min(tokenset.expires_in!, 3600); + const emails = userinfo.email ? [{ value: userinfo.email }] : undefined; + const photos = userinfo.picture + ? [{ value: userinfo.picture }] + : undefined; + const name = + userinfo.family_name && userinfo.given_name + ? { + familyName: userinfo.family_name, + givenName: userinfo.given_name, + middleName: userinfo.middle_name, + } + : undefined; done( undefined, @@ -88,11 +95,17 @@ export const oidcAuthenticator = createOAuthAuthenticator({ provider: 'oidc', id: userinfo.sub, displayName: userinfo.name!, + username: userinfo.preferred_username, + name, + emails, + photos, }, accessToken: tokenset.access_token!, params: { + id_token: tokenset.id_token, scope: tokenset.scope!, - expires_in: expiresInSeconds, + expires_in: tokenset.expires_in!, + token_type: tokenset.token_type, }, }, { @@ -139,6 +152,9 @@ export const oidcAuthenticator = createOAuthAuthenticator({ if (!tokenset.access_token) { throw new Error('Refresh failed'); } + if (!tokenset.scope) { + tokenset.scope = input.scope; + } const userinfo = await client.userinfo(tokenset.access_token); return new Promise((resolve, reject) => { diff --git a/plugins/auth-backend-module-oidc-provider/src/index.ts b/plugins/auth-backend-module-oidc-provider/src/index.ts index 4b5ddc123a..b14d350d4d 100644 --- a/plugins/auth-backend-module-oidc-provider/src/index.ts +++ b/plugins/auth-backend-module-oidc-provider/src/index.ts @@ -21,5 +21,5 @@ */ export { oidcAuthenticator } from './authenticator'; -export { authModuleOidcProvider } from './module'; +export { authModuleOidcProvider as default } from './module'; export { oidcSignInResolvers } from './resolvers'; diff --git a/plugins/auth-backend-module-oidc-provider/src/module.test.ts b/plugins/auth-backend-module-oidc-provider/src/module.test.ts index 5e01ae343f..09ad38b84c 100644 --- a/plugins/auth-backend-module-oidc-provider/src/module.test.ts +++ b/plugins/auth-backend-module-oidc-provider/src/module.test.ts @@ -17,7 +17,6 @@ import request from 'supertest'; import { AuthProviderRouteHandlers, - AuthResolverContext, createOAuthRouteHandlers, decodeOAuthState, } from '@backstage/plugin-auth-node'; @@ -121,6 +120,7 @@ describe('authModuleOidcProvider', () => { refresh_token: 'refreshToken', scope: 'testScope', token_type: '', + expires_in: 3600, }) : ctx.status(401), ); @@ -136,6 +136,7 @@ describe('authModuleOidcProvider', () => { given_name: 'Alice', family_name: 'Adams', email: 'alice@test.com', + picture: 'http://testPictureUrl/photo.jpg', }), ), ), @@ -172,7 +173,7 @@ describe('authModuleOidcProvider', () => { isOriginAllowed: _ => true, providerId: 'oidc', config: new ConfigReader({ - metadataUrl: 'https://oidc.test', + metadataUrl: 'https://oidc.test/.well-known/openid-configuration', clientId: 'clientId', clientSecret: 'clientSecret', }), @@ -264,9 +265,12 @@ describe('authModuleOidcProvider', () => { type: 'authorization_response', response: { profile: { + email: 'alice@test.com', + picture: 'http://testPictureUrl/photo.jpg', displayName: 'Alice Adams', }, providerInfo: { + idToken, accessToken: 'accessToken', scope: 'testScope', expiresInSeconds: 3600, diff --git a/plugins/auth-backend/src/providers/oidc/index.ts b/plugins/auth-backend/src/providers/oidc/index.ts index 6b2282411c..c4343b1547 100644 --- a/plugins/auth-backend/src/providers/oidc/index.ts +++ b/plugins/auth-backend/src/providers/oidc/index.ts @@ -15,4 +15,3 @@ */ export { oidc } from './provider'; -export type { OidcAuthResult } from './provider'; diff --git a/plugins/auth-backend/src/providers/oidc/provider.ts b/plugins/auth-backend/src/providers/oidc/provider.ts index ce8809c55a..18170e104e 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.ts @@ -22,255 +22,13 @@ import { adaptLegacyOAuthHandler, adaptLegacyOAuthSignInResolver, } from '../../lib/legacy'; - import { oidcAuthenticator } from '@backstage/plugin-auth-backend-module-oidc-provider'; -// type PrivateInfo = { -// refreshToken?: string; -// }; - -// type OidcImpl = { -// strategy: OidcStrategy; -// client: Client; -// }; - -// /** -// * authentication result for the OIDC which includes the token set and user information (a profile response sent by OIDC server) -// * @public -// */ -// export type OidcAuthResult = { -// tokenset: TokenSet; -// userinfo: UserinfoResponse; -// }; - -// export type Options = OAuthProviderOptions & { -// metadataUrl: string; -// scope?: string; -// prompt?: string; -// tokenEndpointAuthMethod?: ClientAuthMethod; -// tokenSignedResponseAlg?: string; -// signInResolver?: SignInResolver; -// authHandler: AuthHandler; -// resolverContext: AuthResolverContext; -// }; - -// export class OidcAuthProvider implements OAuthHandlers { -// private readonly implementation: Promise; -// private readonly scope?: string; -// private readonly prompt?: string; - -// private readonly signInResolver?: SignInResolver; -// private readonly authHandler: AuthHandler; -// private readonly resolverContext: AuthResolverContext; - -// constructor(options: Options) { -// this.implementation = this.setupStrategy(options); -// this.scope = options.scope; -// this.prompt = options.prompt; -// this.signInResolver = options.signInResolver; -// this.authHandler = options.authHandler; -// this.resolverContext = options.resolverContext; -// } - -// async start(req: OAuthStartRequest): Promise { -// const { strategy } = await this.implementation; -// const options: Record = { -// scope: req.scope || this.scope || 'openid profile email', -// state: encodeState(req.state), -// }; -// const prompt = this.prompt || 'none'; -// if (prompt !== 'auto') { -// options.prompt = prompt; -// } -// return await executeRedirectStrategy(req, strategy, options); -// } - -// async handler(req: express.Request) { -// const { strategy } = await this.implementation; -// const { result, privateInfo } = await executeFrameHandlerStrategy< -// OidcAuthResult, -// PrivateInfo -// >(req, strategy); - - // async refresh(req: OAuthRefreshRequest) { - // const { client } = await this.implementation; - // const tokenset = await client.refresh(req.refreshToken); - // if (!tokenset.access_token) { - // throw new Error('Refresh failed'); - // } - // if (!tokenset.scope) { - // tokenset.scope = req.scope; - // } - // const userinfo = await client.userinfo(tokenset.access_token); -// return { -// response: await this.handleResult(result), -// refreshToken: privateInfo.refreshToken, -// }; -// } - -// async refresh(req: OAuthRefreshRequest) { -// const { client } = await this.implementation; -// const tokenset = await client.refresh(req.refreshToken); -// if (!tokenset.access_token) { -// throw new Error('Refresh failed'); -// } -// const userinfo = await client.userinfo(tokenset.access_token); - -// return { -// response: await this.handleResult({ tokenset, userinfo }), -// refreshToken: tokenset.refresh_token, -// }; -// } - -// private async setupStrategy(options: Options): Promise { -// const issuer = await Issuer.discover(options.metadataUrl); -// const client = new issuer.Client({ -// access_type: 'offline', // this option must be passed to provider to receive a refresh token -// client_id: options.clientId, -// client_secret: options.clientSecret, -// redirect_uris: [options.callbackUrl], -// response_types: ['code'], -// token_endpoint_auth_method: -// options.tokenEndpointAuthMethod || 'client_secret_basic', -// id_token_signed_response_alg: options.tokenSignedResponseAlg || 'RS256', -// scope: options.scope || '', -// }); - -// const strategy = new OidcStrategy( -// { -// client, -// passReqToCallback: false, -// }, -// ( -// tokenset: TokenSet, -// userinfo: UserinfoResponse, -// done: PassportDoneCallback, -// ) => { -// if (typeof done !== 'function') { -// throw new Error( -// 'OIDC IdP must provide a userinfo_endpoint in the metadata response', -// ); -// } -// done( -// undefined, -// { tokenset, userinfo }, -// { -// refreshToken: tokenset.refresh_token, -// }, -// ); -// }, -// ); -// strategy.error = console.error; -// return { strategy, client }; -// } - -// Use this function to grab the user profile info from the token -// Then populate the profile with it -// private async handleResult(result: OidcAuthResult): Promise { -// const { profile } = await this.authHandler(result, this.resolverContext); - -// const expiresInSeconds = -// result.tokenset.expires_in === undefined -// ? BACKSTAGE_SESSION_EXPIRATION -// : Math.min(result.tokenset.expires_in, BACKSTAGE_SESSION_EXPIRATION); - -// let backstageIdentity = undefined; -// if (this.signInResolver) { -// backstageIdentity = await this.signInResolver( -// { -// result, -// profile, -// }, -// this.resolverContext, -// ); -// } - -// return { -// backstageIdentity, -// providerInfo: { -// idToken: result.tokenset.id_token, -// accessToken: result.tokenset.access_token!, -// scope: result.tokenset.scope!, -// expiresInSeconds, -// }, -// profile, -// }; -// } -// } - /** * Auth provider integration for generic OpenID Connect auth * * @public */ -// export const oidc = createAuthProviderIntegration({ -// create(options?: { -// authHandler?: AuthHandler; - -// signIn?: { -// resolver: SignInResolver; -// }; -// }) { -// return ({ providerId, globalConfig, config, resolverContext }) => -// OAuthEnvironmentHandler.mapConfig(config, envConfig => { -// const clientId = envConfig.getString('clientId'); -// const clientSecret = envConfig.getString('clientSecret'); -// const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); -// const callbackUrl = -// customCallbackUrl || -// `${globalConfig.baseUrl}/${providerId}/handler/frame`; -// const metadataUrl = envConfig.getString('metadataUrl'); -// const tokenEndpointAuthMethod = envConfig.getOptionalString( -// 'tokenEndpointAuthMethod', -// ) as ClientAuthMethod; -// const tokenSignedResponseAlg = envConfig.getOptionalString( -// 'tokenSignedResponseAlg', -// ); -// const scope = envConfig.getOptionalString('scope'); -// const prompt = envConfig.getOptionalString('prompt'); - -// const authHandler: AuthHandler = options?.authHandler -// ? options.authHandler -// : async ({ userinfo }) => ({ -// profile: { -// displayName: userinfo.name, -// email: userinfo.email, -// picture: userinfo.picture, -// }, -// }); - -// const provider = new OidcAuthProvider({ -// clientId, -// clientSecret, -// callbackUrl, -// tokenEndpointAuthMethod, -// tokenSignedResponseAlg, -// metadataUrl, -// scope, -// prompt, -// signInResolver: options?.signIn?.resolver, -// authHandler, -// resolverContext, -// }); - -// return OAuthAdapter.fromConfig(globalConfig, provider, { -// providerId, -// callbackUrl, -// }); -// }); -// }, -// resolvers: { -// /** -// * Looks up the user by matching their email local part to the entity name. -// */ -// emailLocalPartMatchingUserEntityName: () => commonByEmailLocalPartResolver, -// /** -// * Looks up the user by matching their email to the entity email. -// */ -// emailMatchingUserEntityProfileEmail: () => commonByEmailResolver, -// }, -// }); - export const oidc = createAuthProviderIntegration({ create(options?: { /** From 5d2fcba0646b95ad4e81fdd5e46e0f6c3448dd5d Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Fri, 29 Sep 2023 12:30:50 -0400 Subject: [PATCH 06/10] PR chores, changeset,apireport Signed-off-by: Ruben Vallejo --- .changeset/lemon-cameras-remember.md | 5 +++ .changeset/old-students-smoke.md | 5 +++ .../api-report.md | 42 +++++++++++++++++++ .../package.json | 2 +- .../src/authenticator.test.ts | 4 +- .../src/authenticator.ts | 4 +- .../src/module.test.ts | 23 +--------- plugins/auth-backend/api-report.md | 6 +-- plugins/auth-backend/config.d.ts | 16 ------- .../auth-backend/src/providers/oidc/index.ts | 1 + .../src/providers/oidc/provider.ts | 25 +++++++++++ yarn.lock | 36 +++------------- 12 files changed, 92 insertions(+), 77 deletions(-) create mode 100644 .changeset/lemon-cameras-remember.md create mode 100644 .changeset/old-students-smoke.md create mode 100644 plugins/auth-backend-module-oidc-provider/api-report.md diff --git a/.changeset/lemon-cameras-remember.md b/.changeset/lemon-cameras-remember.md new file mode 100644 index 0000000000..29cd88602f --- /dev/null +++ b/.changeset/lemon-cameras-remember.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Migrated oidc auth provider to new `@backstage/plugin-auth-backend-module-oidc-provider` module package. diff --git a/.changeset/old-students-smoke.md b/.changeset/old-students-smoke.md new file mode 100644 index 0000000000..8e072c5203 --- /dev/null +++ b/.changeset/old-students-smoke.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-oidc-provider': minor +--- + +Created new `@backstage/plugin-auth-backend-module-oidc-provider` module package to house oidc auth provider migration. diff --git a/plugins/auth-backend-module-oidc-provider/api-report.md b/plugins/auth-backend-module-oidc-provider/api-report.md new file mode 100644 index 0000000000..cf3335b13b --- /dev/null +++ b/plugins/auth-backend-module-oidc-provider/api-report.md @@ -0,0 +1,42 @@ +## API Report File for "@backstage/plugin-auth-backend-module-oidc-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 { BaseClient } from 'openid-client'; +import { OAuthAuthenticator } from '@backstage/plugin-auth-node'; +import { PassportOAuthAuthenticatorHelper } from '@backstage/plugin-auth-node'; +import { PassportOAuthResult } from '@backstage/plugin-auth-node'; +import { PassportProfile } from '@backstage/plugin-auth-node'; +import { SignInResolverFactory } from '@backstage/plugin-auth-node'; +import { Strategy } from 'openid-client'; + +// @public (undocumented) +const authModuleOidcProvider: () => BackendFeature; +export default authModuleOidcProvider; + +// @public (undocumented) +export const oidcAuthenticator: OAuthAuthenticator< + Promise<{ + helper: PassportOAuthAuthenticatorHelper; + client: BaseClient; + initializedScope: string | undefined; + initializedPrompt: string | undefined; + strategy: Strategy; + }>, + PassportProfile +>; + +// @public +export namespace oidcSignInResolvers { + const emailLocalPartMatchingUserEntityName: SignInResolverFactory< + unknown, + unknown + >; + const emailMatchingUserEntityProfileEmail: SignInResolverFactory< + unknown, + unknown + >; +} +``` diff --git a/plugins/auth-backend-module-oidc-provider/package.json b/plugins/auth-backend-module-oidc-provider/package.json index 09bd1033a0..6962c1f340 100644 --- a/plugins/auth-backend-module-oidc-provider/package.json +++ b/plugins/auth-backend-module-oidc-provider/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-auth-backend-module-oidc-provider", "description": "The oidc-provider backend module for the auth plugin.", - "version": "0.1.0-next.1", + "version": "0.0.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", diff --git a/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts b/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts index 4d21db8442..b44f89e7c7 100644 --- a/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts +++ b/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts @@ -348,13 +348,15 @@ describe('oidcAuthenticator', () => { }, session: { accessToken: 'accessToken', - expiresInSeconds: 3600, idToken, refreshToken: 'refreshToken', scope: 'testScope', tokenType: 'bearer', }, }); + expect( + Math.abs(handlerResponse.session.expiresInSeconds! - 3600), + ).toBeLessThan(5); }); it('fails without authorization code', async () => { diff --git a/plugins/auth-backend-module-oidc-provider/src/authenticator.ts b/plugins/auth-backend-module-oidc-provider/src/authenticator.ts index a95010e353..97b0af8d7f 100644 --- a/plugins/auth-backend-module-oidc-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-oidc-provider/src/authenticator.ts @@ -36,7 +36,6 @@ export const oidcAuthenticator = createOAuthAuthenticator({ const clientSecret = config.getString('clientSecret'); const metadataUrl = config.getString('metadataUrl'); const customCallbackUrl = config.getOptionalString('callbackUrl'); - const callbackUrl2 = customCallbackUrl || callbackUrl; const tokenEndpointAuthMethod = config.getOptionalString( 'tokenEndpointAuthMethod', ) as ClientAuthMethod; @@ -51,7 +50,7 @@ export const oidcAuthenticator = createOAuthAuthenticator({ access_type: 'offline', // this option must be passed to provider to receive a refresh token client_id: clientId, client_secret: clientSecret, - redirect_uris: [callbackUrl2], + redirect_uris: [customCallbackUrl || callbackUrl], response_types: ['code'], token_endpoint_auth_method: tokenEndpointAuthMethod || 'client_secret_basic', @@ -84,7 +83,6 @@ export const oidcAuthenticator = createOAuthAuthenticator({ ? { familyName: userinfo.family_name, givenName: userinfo.given_name, - middleName: userinfo.middle_name, } : undefined; diff --git a/plugins/auth-backend-module-oidc-provider/src/module.test.ts b/plugins/auth-backend-module-oidc-provider/src/module.test.ts index 09ad38b84c..d2487da450 100644 --- a/plugins/auth-backend-module-oidc-provider/src/module.test.ts +++ b/plugins/auth-backend-module-oidc-provider/src/module.test.ts @@ -245,39 +245,18 @@ describe('authModuleOidcProvider', () => { it('#authenticate exchanges authorization code for a access_token', async () => { const agent = request.agent(''); - - // make /start request with audience parameter const startResponse = await agent.get( `${appUrl}/api/auth/oidc/start?env=development`, ); - // follow redirect to authorization endpoint const authorizationResponse = await agent.get( startResponse.header.location, ); - // follow redirect to token_endpoint const handlerResponse = await agent.get( authorizationResponse.header.location, ); expect(handlerResponse.text).toContain( - encodeURIComponent( - JSON.stringify({ - type: 'authorization_response', - response: { - profile: { - email: 'alice@test.com', - picture: 'http://testPictureUrl/photo.jpg', - displayName: 'Alice Adams', - }, - providerInfo: { - idToken, - accessToken: 'accessToken', - scope: 'testScope', - expiresInSeconds: 3600, - }, - }, - }), - ), + encodeURIComponent(`"accessToken":"accessToken"`), ); }); }); diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index d80648a10f..d097ae2881 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -340,7 +340,7 @@ export type OAuthStartResponse = { // @public @deprecated (undocumented) export type OAuthState = OAuthState_2; -// @public +// @public @deprecated export type OidcAuthResult = { tokenset: TokenSet; userinfo: UserinfoResponse; @@ -564,10 +564,10 @@ export const providers: Readonly<{ create: ( options?: | { - authHandler?: AuthHandler | undefined; + authHandler?: AuthHandler | undefined; signIn?: | { - resolver: SignInResolver; + resolver: SignInResolver; } | undefined; } diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index 34139593d3..2b80324fcd 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -149,22 +149,6 @@ export interface Config { }; }; /** @visibility frontend */ - oidc?: { - [authEnv: string]: { - clientId: string; - /** - * @visibility secret - */ - clientSecret: string; - callbackUrl?: string; - metadataUrl: string; - tokenEndpointAuthMethod?: string; - tokenSignedResponseAlg?: string; - scope?: string; - prompt?: string; - }; - }; - /** @visibility frontend */ auth0?: { [authEnv: string]: { clientId: string; diff --git a/plugins/auth-backend/src/providers/oidc/index.ts b/plugins/auth-backend/src/providers/oidc/index.ts index c4343b1547..6b2282411c 100644 --- a/plugins/auth-backend/src/providers/oidc/index.ts +++ b/plugins/auth-backend/src/providers/oidc/index.ts @@ -15,3 +15,4 @@ */ export { oidc } from './provider'; +export type { OidcAuthResult } from './provider'; diff --git a/plugins/auth-backend/src/providers/oidc/provider.ts b/plugins/auth-backend/src/providers/oidc/provider.ts index 18170e104e..f6804265cd 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.ts @@ -23,6 +23,21 @@ import { adaptLegacyOAuthSignInResolver, } from '../../lib/legacy'; import { oidcAuthenticator } from '@backstage/plugin-auth-backend-module-oidc-provider'; +import { TokenSet, UserinfoResponse } from 'openid-client'; +import { + commonByEmailLocalPartResolver, + commonByEmailResolver, +} from '../resolvers'; + +/** + * authentication result for the OIDC which includes the token set and user information (a profile response sent by OIDC server) + * @public + * @deprecated No longer used + */ +export type OidcAuthResult = { + tokenset: TokenSet; + userinfo: UserinfoResponse; +}; /** * Auth provider integration for generic OpenID Connect auth @@ -50,4 +65,14 @@ export const oidc = createAuthProviderIntegration({ signInResolver: adaptLegacyOAuthSignInResolver(options?.signIn?.resolver), }); }, + resolvers: { + /** + * Looks up the user by matching their email local part to the entity name. + */ + emailLocalPartMatchingUserEntityName: () => commonByEmailLocalPartResolver, + /** + * Looks up the user by matching their email to the entity email. + */ + emailMatchingUserEntityProfileEmail: () => commonByEmailResolver, + }, }); diff --git a/yarn.lock b/yarn.lock index 16979362ca..1e41fd83c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -31270,24 +31270,10 @@ __metadata: languageName: node linkType: hard -"jose@npm:^4.14.4": - version: 4.14.6 - resolution: "jose@npm:4.14.6" - checksum: eae81a234e7bf1446b1bd80722b3462b014e3835b155c3a7799c1c5043163a53a0dc28d347004151b031e6b7b863403aabf8814d9cc217ce21f8c2f3ebd4b335 - languageName: node - linkType: hard - -"jose@npm:^4.14.6, jose@npm:^4.15.1": - version: 4.15.4 - resolution: "jose@npm:4.15.4" - checksum: dccad91cb3357f36423774a0b89ad830dd84b31090de65cd139b85488439f16a00f8c59c0773825e8a1adb0dd9d13ad725ad66e6ea33880ecb3959bb99e1ea5b - languageName: node - linkType: hard - -"jose@npm:^4.6.0": - version: 4.15.2 - resolution: "jose@npm:4.15.2" - checksum: 8f0cab1eef31243abe14a935b2b330cd95f10f9b69808fd642088ae5000e50e566664934537d2c6413ab2f6b54acd8265a5033da05157aa1260c5f1d7e57fab0 +"jose@npm:^4.14.6, jose@npm:^4.15.1, jose@npm:^4.6.0": + version: 4.15.3 + resolution: "jose@npm:4.15.3" + checksum: b76eeccc1d40d0eaf26dfaadc0f88fc15802c9105ab66a24ee223bd84369f7cb217f4a2cb852f5080ff6996170b3a73db2b2d26878b8905d99c36ca432628134 languageName: node linkType: hard @@ -35663,19 +35649,7 @@ __metadata: languageName: node linkType: hard -"openid-client@npm:^5.2.1, openid-client@npm:^5.3.0, openid-client@npm:^5.5.0": - version: 5.5.0 - resolution: "openid-client@npm:5.5.0" - dependencies: - jose: ^4.14.4 - lru-cache: ^6.0.0 - object-hash: ^2.2.0 - oidc-token-hash: ^5.0.3 - checksum: d2617b5bb0d9a0da338aeb7489bcbe3a79df9681189c7b61c2a3284289eee7110dfee2b04b49a9fdd4f064b7e2057ddb0becfedd9c19388e7788ae15b24c8e4c - languageName: node - linkType: hard - -"openid-client@npm:^5.4.3": +"openid-client@npm:^5.2.1, openid-client@npm:^5.3.0, openid-client@npm:^5.4.3, openid-client@npm:^5.5.0": version: 5.6.1 resolution: "openid-client@npm:5.6.1" dependencies: From 002010c8f60e9cbbea4c3297c86c9c668cd5e9f5 Mon Sep 17 00:00:00 2001 From: Jamie Klassen Date: Fri, 5 Jan 2024 18:56:32 -0500 Subject: [PATCH 07/10] authHandler/signInResolver backwards compatibility Signed-off-by: Jamie Klassen --- .../api-report.md | 24 ++- .../src/authenticator.test.ts | 52 +++--- .../src/authenticator.ts | 172 +++++++++--------- .../src/index.ts | 1 + plugins/auth-backend/api-report.md | 13 +- plugins/auth-backend/package.json | 1 - plugins/auth-backend/src/providers/index.ts | 1 - .../auth-backend/src/providers/oidc/index.ts | 1 - .../src/providers/oidc/provider.test.ts | 169 +++++++++++++++++ .../src/providers/oidc/provider.ts | 57 +++--- yarn.lock | 1 - 11 files changed, 337 insertions(+), 155 deletions(-) create mode 100644 plugins/auth-backend/src/providers/oidc/provider.test.ts diff --git a/plugins/auth-backend-module-oidc-provider/api-report.md b/plugins/auth-backend-module-oidc-provider/api-report.md index cf3335b13b..d96d9952a0 100644 --- a/plugins/auth-backend-module-oidc-provider/api-report.md +++ b/plugins/auth-backend-module-oidc-provider/api-report.md @@ -7,10 +7,10 @@ import { BackendFeature } from '@backstage/backend-plugin-api'; import { BaseClient } from 'openid-client'; import { OAuthAuthenticator } from '@backstage/plugin-auth-node'; import { PassportOAuthAuthenticatorHelper } from '@backstage/plugin-auth-node'; -import { PassportOAuthResult } from '@backstage/plugin-auth-node'; -import { PassportProfile } from '@backstage/plugin-auth-node'; import { SignInResolverFactory } from '@backstage/plugin-auth-node'; import { Strategy } from 'openid-client'; +import { TokenSet } from 'openid-client'; +import { UserinfoResponse } from 'openid-client'; // @public (undocumented) const authModuleOidcProvider: () => BackendFeature; @@ -18,16 +18,24 @@ export default authModuleOidcProvider; // @public (undocumented) export const oidcAuthenticator: OAuthAuthenticator< - Promise<{ - helper: PassportOAuthAuthenticatorHelper; - client: BaseClient; + { initializedScope: string | undefined; initializedPrompt: string | undefined; - strategy: Strategy; - }>, - PassportProfile + promise: Promise<{ + helper: PassportOAuthAuthenticatorHelper; + client: BaseClient; + strategy: Strategy; + }>; + }, + OidcAuthResult >; +// @public +export type OidcAuthResult = { + tokenset: TokenSet; + userinfo: UserinfoResponse; +}; + // @public export namespace oidcSignInResolvers { const emailLocalPartMatchingUserEntityName: SignInResolverFactory< diff --git a/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts b/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts index b44f89e7c7..717083562c 100644 --- a/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts +++ b/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts @@ -281,81 +281,75 @@ describe('oidcAuthenticator', () => { }); it('exchanges authorization code for access token', async () => { - const handlerResponse = await oidcAuthenticator.authenticate( + const authenticatorResult = await oidcAuthenticator.authenticate( handlerRequest, implementation, ); - const accessToken = handlerResponse.session.accessToken; + const accessToken = authenticatorResult.session.accessToken; expect(accessToken).toEqual('accessToken'); }); it('exchanges authorization code for refresh token', async () => { - const handlerResponse = await oidcAuthenticator.authenticate( + const authenticatorResult = await oidcAuthenticator.authenticate( handlerRequest, implementation, ); - const refreshToken = handlerResponse.session.refreshToken; + const refreshToken = authenticatorResult.session.refreshToken; expect(refreshToken).toEqual('refreshToken'); }); it('returns granted scope', async () => { - const handlerResponse = await oidcAuthenticator.authenticate( + const authenticatorResult = await oidcAuthenticator.authenticate( handlerRequest, implementation, ); - const responseScope = handlerResponse.session.scope; + const responseScope = authenticatorResult.session.scope; expect(responseScope).toEqual('testScope'); }); it('returns a default session.tokentype field', async () => { - const handlerResponse = await oidcAuthenticator.authenticate( + const authenticatorResult = await oidcAuthenticator.authenticate( handlerRequest, implementation, ); - const tokenType = handlerResponse.session.tokenType; + const tokenType = authenticatorResult.session.tokenType; expect(tokenType).toEqual('bearer'); }); - it('returns defined fullProfile with picture and email', async () => { - const handlerResponse = await oidcAuthenticator.authenticate( + it('returns picture and email', async () => { + const authenticatorResult = await oidcAuthenticator.authenticate( handlerRequest, implementation, ); - const displayName = handlerResponse.fullProfile.displayName; - const email = handlerResponse.fullProfile.emails![0].value; - const picture = handlerResponse.fullProfile.photos![0].value; - expect(displayName).toEqual('Alice Adams'); - expect(email).toEqual('alice@test.com'); - expect(picture).toEqual('http://testPictureUrl/photo.jpg'); + expect(authenticatorResult).toMatchObject({ + fullProfile: { + userinfo: { + email: 'alice@test.com', + picture: 'http://testPictureUrl/photo.jpg', + name: 'Alice Adams', + }, + }, + }); }); - it('returns defined response with an idToken', async () => { - const handlerResponse = await oidcAuthenticator.authenticate( + it('returns idToken', async () => { + const authenticatorResult = await oidcAuthenticator.authenticate( handlerRequest, implementation, ); - expect(handlerResponse).toMatchObject({ - fullProfile: { - displayName: 'Alice Adams', - id: 'test', - provider: 'oidc', - }, + expect(authenticatorResult).toMatchObject({ session: { - accessToken: 'accessToken', idToken, - refreshToken: 'refreshToken', - scope: 'testScope', - tokenType: 'bearer', }, }); expect( - Math.abs(handlerResponse.session.expiresInSeconds! - 3600), + Math.abs(authenticatorResult.session.expiresInSeconds! - 3600), ).toBeLessThan(5); }); diff --git a/plugins/auth-backend-module-oidc-provider/src/authenticator.ts b/plugins/auth-backend-module-oidc-provider/src/authenticator.ts index 97b0af8d7f..ba6bc9d142 100644 --- a/plugins/auth-backend-module-oidc-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-oidc-provider/src/authenticator.ts @@ -23,15 +23,35 @@ import { } from 'openid-client'; import { createOAuthAuthenticator, + OAuthAuthenticatorResult, + PassportDoneCallback, + PassportHelpers, PassportOAuthAuthenticatorHelper, - PassportOAuthDoneCallback, + PassportOAuthPrivateInfo, } from '@backstage/plugin-auth-node'; +/** + * authentication result for the OIDC which includes the token set and user + * profile response + * @public + */ +export type OidcAuthResult = { + tokenset: TokenSet; + userinfo: UserinfoResponse; +}; + /** @public */ export const oidcAuthenticator = createOAuthAuthenticator({ - defaultProfileTransform: - PassportOAuthAuthenticatorHelper.defaultProfileTransform, - async initialize({ callbackUrl, config }) { + defaultProfileTransform: async ( + input: OAuthAuthenticatorResult, + ) => ({ + profile: { + email: input.fullProfile.userinfo.email, + picture: input.fullProfile.userinfo.picture, + displayName: input.fullProfile.userinfo.name, + }, + }), + initialize({ callbackUrl, config }) { const clientId = config.getString('clientId'); const clientSecret = config.getString('clientSecret'); const metadataUrl = config.getString('metadataUrl'); @@ -45,81 +65,53 @@ export const oidcAuthenticator = createOAuthAuthenticator({ const initializedScope = config.getOptionalString('scope'); const initializedPrompt = config.getOptionalString('prompt'); - const issuer = await Issuer.discover(metadataUrl); - const client = new issuer.Client({ - access_type: 'offline', // this option must be passed to provider to receive a refresh token - client_id: clientId, - client_secret: clientSecret, - redirect_uris: [customCallbackUrl || callbackUrl], - response_types: ['code'], - token_endpoint_auth_method: - tokenEndpointAuthMethod || 'client_secret_basic', - id_token_signed_response_alg: tokenSignedResponseAlg || 'RS256', - scope: initializedScope || '', + const promise = Issuer.discover(metadataUrl).then(issuer => { + const client = new issuer.Client({ + access_type: 'offline', // this option must be passed to provider to receive a refresh token + client_id: clientId, + client_secret: clientSecret, + redirect_uris: [customCallbackUrl || callbackUrl], + response_types: ['code'], + token_endpoint_auth_method: + tokenEndpointAuthMethod || 'client_secret_basic', + id_token_signed_response_alg: tokenSignedResponseAlg || 'RS256', + scope: initializedScope || '', + }); + + const strategy = new OidcStrategy( + { + client, + passReqToCallback: false, + }, + ( + tokenset: TokenSet, + userinfo: UserinfoResponse, + done: PassportDoneCallback, + ) => { + if (typeof done !== 'function') { + throw new Error( + 'OIDC IdP must provide a userinfo_endpoint in the metadata response', + ); + } + + done( + undefined, + { tokenset, userinfo }, + { refreshToken: tokenset.refresh_token }, + ); + }, + ); + + const helper = PassportOAuthAuthenticatorHelper.from(strategy); + return { helper, client, strategy }; }); - const strategy = new OidcStrategy( - { - client, - passReqToCallback: false, - }, - ( - tokenset: TokenSet, - userinfo: UserinfoResponse, - done: PassportOAuthDoneCallback, - ) => { - if (typeof done !== 'function') { - throw new Error( - 'OIDC IdP must provide a userinfo_endpoint in the metadata response', - ); - } - - const emails = userinfo.email ? [{ value: userinfo.email }] : undefined; - const photos = userinfo.picture - ? [{ value: userinfo.picture }] - : undefined; - const name = - userinfo.family_name && userinfo.given_name - ? { - familyName: userinfo.family_name, - givenName: userinfo.given_name, - } - : undefined; - - done( - undefined, - { - fullProfile: { - provider: 'oidc', - id: userinfo.sub, - displayName: userinfo.name!, - username: userinfo.preferred_username, - name, - emails, - photos, - }, - accessToken: tokenset.access_token!, - params: { - id_token: tokenset.id_token, - scope: tokenset.scope!, - expires_in: tokenset.expires_in!, - token_type: tokenset.token_type, - }, - }, - { - refreshToken: tokenset.refresh_token, - }, - ); - }, - ); - - const helper = PassportOAuthAuthenticatorHelper.from(strategy); - - return { helper, client, initializedScope, initializedPrompt, strategy }; + return { initializedScope, initializedPrompt, promise }; }, async start(input, ctx) { - const { initializedScope, initializedPrompt, helper, strategy } = await ctx; + const { initializedScope, initializedPrompt, promise } = ctx; + const { helper, strategy } = await promise; const options: Record = { scope: input.scope || initializedScope || 'openid profile email', state: input.state, @@ -140,12 +132,32 @@ export const oidcAuthenticator = createOAuthAuthenticator({ }); }, - async authenticate(input, ctx) { - return (await ctx).helper.authenticate(input); + async authenticate( + input, + ctx, + ): Promise> { + const { strategy } = await ctx.promise; + const { result, privateInfo } = + await PassportHelpers.executeFrameHandlerStrategy< + OidcAuthResult, + PassportOAuthPrivateInfo + >(input.req, strategy); + + return { + fullProfile: result, + session: { + accessToken: result.tokenset.access_token!, + tokenType: result.tokenset.token_type ?? 'bearer', + scope: result.tokenset.scope!, + expiresInSeconds: result.tokenset.expires_in, + idToken: result.tokenset.id_token, + refreshToken: privateInfo.refreshToken, + }, + }; }, async refresh(input, ctx) { - const { client } = await ctx; + const { client } = await ctx.promise; const tokenset = await client.refresh(input.refreshToken); if (!tokenset.access_token) { throw new Error('Refresh failed'); @@ -160,11 +172,7 @@ export const oidcAuthenticator = createOAuthAuthenticator({ reject(new Error('Refresh Failed')); } resolve({ - fullProfile: { - provider: 'oidc', - id: userinfo.sub, - displayName: userinfo.name!, - }, + fullProfile: { userinfo, tokenset }, session: { accessToken: tokenset.access_token!, tokenType: tokenset.token_type ?? 'bearer', diff --git a/plugins/auth-backend-module-oidc-provider/src/index.ts b/plugins/auth-backend-module-oidc-provider/src/index.ts index b14d350d4d..4e951382bb 100644 --- a/plugins/auth-backend-module-oidc-provider/src/index.ts +++ b/plugins/auth-backend-module-oidc-provider/src/index.ts @@ -21,5 +21,6 @@ */ export { oidcAuthenticator } from './authenticator'; +export type { OidcAuthResult } from './authenticator'; export { authModuleOidcProvider as default } from './module'; export { oidcSignInResolvers } from './resolvers'; diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index d097ae2881..16f257d17d 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -25,6 +25,7 @@ import { LoggerService } from '@backstage/backend-plugin-api'; import { OAuth2ProxyResult as OAuth2ProxyResult_2 } from '@backstage/plugin-auth-backend-module-oauth2-proxy-provider'; import { OAuthEnvironmentHandler as OAuthEnvironmentHandler_2 } from '@backstage/plugin-auth-node'; import { OAuthState as OAuthState_2 } from '@backstage/plugin-auth-node'; +import { OidcAuthResult } from '@backstage/plugin-auth-backend-module-oidc-provider'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { prepareBackstageIdentityResponse as prepareBackstageIdentityResponse_2 } from '@backstage/plugin-auth-node'; @@ -34,9 +35,7 @@ import { SignInInfo as SignInInfo_2 } from '@backstage/plugin-auth-node'; import { SignInResolver as SignInResolver_2 } from '@backstage/plugin-auth-node'; import { TokenManager } from '@backstage/backend-common'; import { TokenParams as TokenParams_2 } from '@backstage/plugin-auth-node'; -import { TokenSet } from 'openid-client'; import { UserEntity } from '@backstage/catalog-model'; -import { UserinfoResponse } from 'openid-client'; import { WebMessageResponse as WebMessageResponse_2 } from '@backstage/plugin-auth-node'; // @public @deprecated @@ -340,12 +339,6 @@ export type OAuthStartResponse = { // @public @deprecated (undocumented) export type OAuthState = OAuthState_2; -// @public @deprecated -export type OidcAuthResult = { - tokenset: TokenSet; - userinfo: UserinfoResponse; -}; - // @public @deprecated (undocumented) export const postMessageResponse: ( res: express.Response, @@ -564,10 +557,10 @@ export const providers: Readonly<{ create: ( options?: | { - authHandler?: AuthHandler | undefined; + authHandler?: AuthHandler | undefined; signIn?: | { - resolver: SignInResolver; + resolver: SignInResolver; } | undefined; } diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index ca92d4c9dd..918e359610 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -75,7 +75,6 @@ "passport-auth0": "^1.4.3", "passport-bitbucket-oauth2": "^0.1.2", "passport-github2": "^0.1.12", - "passport-gitlab2": "^5.0.0", "passport-google-oauth20": "^2.0.0", "passport-microsoft": "^1.0.0", "passport-oauth2": "^1.6.1", diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 8173a7fbc3..c8140f864e 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -29,7 +29,6 @@ export type { } from './cloudflare-access'; export type { GithubOAuthResult } from './github'; export type { OAuth2ProxyResult } from './oauth2-proxy'; -export type { OidcAuthResult } from './oidc'; export type { SamlAuthResult } from './saml'; export type { GcpIapResult, GcpIapTokenInfo } from './gcp-iap'; diff --git a/plugins/auth-backend/src/providers/oidc/index.ts b/plugins/auth-backend/src/providers/oidc/index.ts index 6b2282411c..c4343b1547 100644 --- a/plugins/auth-backend/src/providers/oidc/index.ts +++ b/plugins/auth-backend/src/providers/oidc/index.ts @@ -15,4 +15,3 @@ */ export { oidc } from './provider'; -export type { OidcAuthResult } from './provider'; diff --git a/plugins/auth-backend/src/providers/oidc/provider.test.ts b/plugins/auth-backend/src/providers/oidc/provider.test.ts new file mode 100644 index 0000000000..f2172a796d --- /dev/null +++ b/plugins/auth-backend/src/providers/oidc/provider.test.ts @@ -0,0 +1,169 @@ +/* + * 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 { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { getVoidLogger } from '@backstage/backend-common'; +import { LoggerService } from '@backstage/backend-plugin-api'; +import { Config, ConfigReader } from '@backstage/config'; +import { + AuthProviderConfig, + AuthResolverContext, + CookieConfigurer, +} from '@backstage/plugin-auth-node'; +import express from 'express'; +import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { oidc } from './provider'; + +describe('oidc.create', () => { + const userinfo = { + sub: 'test', + iss: 'https://oidc.test', + aud: 'clientId', + nonce: 'foo', + }; + const server = setupServer(); + setupRequestMockHandlers(server); + + let publicKey: JWK; + let tokenset: object; + let providerFactoryOptions: { + providerId: string; + globalConfig: AuthProviderConfig; + config: Config; + logger: LoggerService; + resolverContext: AuthResolverContext; + baseUrl: string; + appUrl: string; + isOriginAllowed: (origin: string) => boolean; + cookieConfigurer?: CookieConfigurer; + }; + + beforeAll(async () => { + const keyPair = await generateKeyPair('RS256'); + const privateKey = await exportJWK(keyPair.privateKey); + publicKey = await exportJWK(keyPair.publicKey); + publicKey.alg = privateKey.alg = 'RS256'; + + tokenset = { + id_token: await new SignJWT({ + iat: Date.now(), + exp: Date.now() + 10000, + ...userinfo, + }) + .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .sign(keyPair.privateKey), + access_token: 'accessToken', + }; + }); + + beforeEach(() => { + server.use( + rest.get( + 'https://oidc.test/.well-known/openid-configuration', + (_req, res, ctx) => + res( + ctx.json({ + issuer: 'https://oidc.test', + token_endpoint: 'https://oidc.test/oauth2/token', + userinfo_endpoint: 'https://oidc.test/idp/userinfo.openid', + jwks_uri: 'https://oidc.test/jwks.json', + }), + ), + ), + rest.post('https://oidc.test/oauth2/token', (_req, res, ctx) => + res(ctx.json(tokenset)), + ), + rest.get('https://oidc.test/jwks.json', async (_req, res, ctx) => + res(ctx.json({ keys: [{ ...publicKey }] })), + ), + rest.get( + 'https://oidc.test/idp/userinfo.openid', + async (_req, res, ctx) => res(ctx.json(userinfo)), + ), + ); + providerFactoryOptions = { + providerId: 'myoidc', + baseUrl: 'http://backstage.test/api/auth', + appUrl: 'http://backstage.test', + isOriginAllowed: _ => true, + globalConfig: { + baseUrl: 'http://backstage.test/api/auth', + appUrl: 'http://backstage.test', + isOriginAllowed: _ => true, + }, + config: new ConfigReader({ + development: { + metadataUrl: 'https://oidc.test/.well-known/openid-configuration', + clientId: 'clientId', + clientSecret: 'clientSecret', + }, + }), + logger: getVoidLogger(), + resolverContext: { + issueToken: jest.fn(), + findCatalogUser: jest.fn(), + signInWithCatalogUser: jest.fn(), + }, + }; + }); + + it('invokes authHandler with tokenset and userinfo response', async () => { + const authHandler = jest.fn(); + const provider = oidc.create({ authHandler })(providerFactoryOptions); + const state = Buffer.from('nonce=foo&env=development').toString('hex'); + + await provider.frameHandler( + { + method: 'GET', + url: `http://backstage.test/api/auth/myoidc/handler/frame?code=blahblah&state=${state}`, + query: { state }, + cookies: { 'myoidc-nonce': 'foo' }, + session: { 'oidc:oidc.test': { state, nonce: 'foo' } }, + } as unknown as express.Request, + { setHeader: jest.fn(), end: jest.fn() } as unknown as express.Response, + ); + + expect(authHandler).toHaveBeenCalledWith( + { tokenset, userinfo }, + providerFactoryOptions.resolverContext, + ); + }); + + it('invokes sign-in resolver with tokenset and userinfo response', async () => { + const resolver = jest.fn(); + const provider = oidc.create({ signIn: { resolver } })( + providerFactoryOptions, + ); + const state = Buffer.from('nonce=foo&env=development').toString('hex'); + + await provider.frameHandler( + { + method: 'GET', + url: `http://backstage.test/api/auth/myoidc/handler/frame?code=blahblah&state=${state}`, + query: { state }, + cookies: { 'myoidc-nonce': 'foo' }, + session: { 'oidc:oidc.test': { state, nonce: 'foo' } }, + } as unknown as express.Request, + { setHeader: jest.fn(), end: jest.fn() } as unknown as express.Response, + ); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ result: { tokenset, userinfo } }), + providerFactoryOptions.resolverContext, + ); + }); +}); diff --git a/plugins/auth-backend/src/providers/oidc/provider.ts b/plugins/auth-backend/src/providers/oidc/provider.ts index f6804265cd..9bc78c48d2 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.ts @@ -15,30 +15,23 @@ */ import { AuthHandler, SignInResolver } from '../types'; -import { OAuthResult } from '../../lib/oauth'; import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; -import { createOAuthProviderFactory } from '@backstage/plugin-auth-node'; import { - adaptLegacyOAuthHandler, - adaptLegacyOAuthSignInResolver, -} from '../../lib/legacy'; -import { oidcAuthenticator } from '@backstage/plugin-auth-backend-module-oidc-provider'; -import { TokenSet, UserinfoResponse } from 'openid-client'; + createOAuthProviderFactory, + AuthResolverContext, + BackstageSignInResult, + OAuthAuthenticatorResult, + SignInInfo, +} from '@backstage/plugin-auth-node'; +import { + oidcAuthenticator, + OidcAuthResult, +} from '@backstage/plugin-auth-backend-module-oidc-provider'; import { commonByEmailLocalPartResolver, commonByEmailResolver, } from '../resolvers'; -/** - * authentication result for the OIDC which includes the token set and user information (a profile response sent by OIDC server) - * @public - * @deprecated No longer used - */ -export type OidcAuthResult = { - tokenset: TokenSet; - userinfo: UserinfoResponse; -}; - /** * Auth provider integration for generic OpenID Connect auth * @@ -50,19 +43,39 @@ export const oidc = createAuthProviderIntegration({ * The profile transformation function used to verify and convert the auth response * into the profile that will be presented to the user. */ - authHandler?: AuthHandler; + authHandler?: AuthHandler; /** - * Configure sign-in for this provider, without it the provider can not be used to sign users in. + * Configure sign-in for this provider; convert user profile respones into + * Backstage identities. */ signIn?: { - resolver: SignInResolver; + resolver: SignInResolver; }; }) { + const authHandler = options?.authHandler; + const signInResolver = options?.signIn?.resolver; return createOAuthProviderFactory({ authenticator: oidcAuthenticator, - profileTransform: adaptLegacyOAuthHandler(options?.authHandler), - signInResolver: adaptLegacyOAuthSignInResolver(options?.signIn?.resolver), + profileTransform: + authHandler && + (( + result: OAuthAuthenticatorResult, + context: AuthResolverContext, + ) => authHandler(result.fullProfile, context)), + signInResolver: + signInResolver && + (( + info: SignInInfo>, + context: AuthResolverContext, + ): Promise => + signInResolver( + { + result: info.result.fullProfile, + profile: info.profile, + }, + context, + )), }); }, resolvers: { diff --git a/yarn.lock b/yarn.lock index 1e41fd83c7..eb6ade926f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4819,7 +4819,6 @@ __metadata: passport-auth0: ^1.4.3 passport-bitbucket-oauth2: ^0.1.2 passport-github2: ^0.1.12 - passport-gitlab2: ^5.0.0 passport-google-oauth20: ^2.0.0 passport-microsoft: ^1.0.0 passport-oauth2: ^1.6.1 From 7e85338c1f31a846ad4b3f5e5d4607cb7ae78c0c Mon Sep 17 00:00:00 2001 From: Jamie Klassen Date: Mon, 8 Jan 2024 14:52:19 -0500 Subject: [PATCH 08/10] return to using startTestBackend following the example of #21445 Signed-off-by: Jamie Klassen --- .../src/module.test.ts | 105 ++++++------------ 1 file changed, 33 insertions(+), 72 deletions(-) diff --git a/plugins/auth-backend-module-oidc-provider/src/module.test.ts b/plugins/auth-backend-module-oidc-provider/src/module.test.ts index d2487da450..216c101049 100644 --- a/plugins/auth-backend-module-oidc-provider/src/module.test.ts +++ b/plugins/auth-backend-module-oidc-provider/src/module.test.ts @@ -15,30 +15,21 @@ */ import request from 'supertest'; -import { - AuthProviderRouteHandlers, - createOAuthRouteHandlers, - decodeOAuthState, -} from '@backstage/plugin-auth-node'; +import { decodeOAuthState } from '@backstage/plugin-auth-node'; import { setupServer } from 'msw/node'; import { rest } from 'msw'; -import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { + mockServices, + setupRequestMockHandlers, + startTestBackend, +} from '@backstage/backend-test-utils'; import { Server } from 'http'; -import { AddressInfo } from 'net'; -import express from 'express'; import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose'; -import cookieParser from 'cookie-parser'; -import passport from 'passport'; -import session from 'express-session'; -import { oidcAuthenticator } from './authenticator'; -import { ConfigReader } from '@backstage/config'; -import Router from 'express-promise-router'; +import { authModuleOidcProvider } from './module'; describe('authModuleOidcProvider', () => { - let app: express.Express; let backstageServer: Server; let appUrl: string; - let providerRouteHandler: AuthProviderRouteHandlers; let idToken: string; let publicKey: JWK; @@ -142,65 +133,35 @@ describe('authModuleOidcProvider', () => { ), ); - const secret = 'secret'; - app = express() - .use(cookieParser(secret)) - .use( - session({ - secret, - saveUninitialized: false, - resave: false, - cookie: { secure: false }, - }), - ) - .use(passport.initialize()) - .use(passport.session()); - await new Promise(resolve => { - backstageServer = app.listen(0, '0.0.0.0', () => { - appUrl = `http://127.0.0.1:${ - (backstageServer.address() as AddressInfo).port - }`; - resolve(null); - }); - }); - - mswServer.use(rest.all(`${appUrl}/*`, req => req.passthrough())); - - providerRouteHandler = createOAuthRouteHandlers({ - authenticator: oidcAuthenticator, - appUrl, - baseUrl: `${appUrl}/api/auth`, - isOriginAllowed: _ => true, - providerId: 'oidc', - config: new ConfigReader({ - metadataUrl: 'https://oidc.test/.well-known/openid-configuration', - clientId: 'clientId', - clientSecret: 'clientSecret', - }), - resolverContext: { - issueToken: async _ => ({ token: '' }), - findCatalogUser: async _ => ({ - entity: { - apiVersion: '', - kind: '', - metadata: { name: '' }, + const backend = await startTestBackend({ + features: [ + authModuleOidcProvider, + import('@backstage/plugin-auth-backend'), + mockServices.rootConfig.factory({ + data: { + app: { baseUrl: 'http://localhost' }, + auth: { + session: { secret: 'test' }, + providers: { + oidc: { + development: { + metadataUrl: + 'https://oidc.test/.well-known/openid-configuration', + clientId: 'clientId', + clientSecret: 'clientSecret', + }, + }, + }, + }, }, }), - signInWithCatalogUser: async _ => ({ token: '' }), - }, + ], }); - const router = Router(); - router - .use( - '/api/auth/oidc/start', - providerRouteHandler.start.bind(providerRouteHandler), - ) - .use( - '/api/auth/oidc/handler/frame', - providerRouteHandler.frameHandler.bind(providerRouteHandler), - ); - app.use(router); + backstageServer = backend.server; + const port = backend.server.port(); + appUrl = `http://localhost:${port}`; + mswServer.use(rest.all(`http://*:${port}/*`, req => req.passthrough())); }); afterEach(() => { @@ -216,7 +177,7 @@ describe('authModuleOidcProvider', () => { expect(startResponse.status).toEqual(302); const nonceCookie = agent.jar.getCookie('oidc-nonce', { - domain: '127.0.0.1', + domain: 'localhost', path: '/api/auth/oidc/handler', script: false, secure: false, From 01515668d2d957d17f64c382460416d301df22a4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 21 Jan 2024 14:50:35 +0100 Subject: [PATCH 09/10] yarn.lock: restore jose and openid-client versions Signed-off-by: Patrik Oldsberg --- yarn.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/yarn.lock b/yarn.lock index eb6ade926f..fcb1ece0b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -31269,10 +31269,10 @@ __metadata: languageName: node linkType: hard -"jose@npm:^4.14.6, jose@npm:^4.15.1, jose@npm:^4.6.0": - version: 4.15.3 - resolution: "jose@npm:4.15.3" - checksum: b76eeccc1d40d0eaf26dfaadc0f88fc15802c9105ab66a24ee223bd84369f7cb217f4a2cb852f5080ff6996170b3a73db2b2d26878b8905d99c36ca432628134 +"jose@npm:^4.14.6, jose@npm:^4.15.4, jose@npm:^4.6.0": + version: 4.15.4 + resolution: "jose@npm:4.15.4" + checksum: dccad91cb3357f36423774a0b89ad830dd84b31090de65cd139b85488439f16a00f8c59c0773825e8a1adb0dd9d13ad725ad66e6ea33880ecb3959bb99e1ea5b languageName: node linkType: hard @@ -35649,14 +35649,14 @@ __metadata: linkType: hard "openid-client@npm:^5.2.1, openid-client@npm:^5.3.0, openid-client@npm:^5.4.3, openid-client@npm:^5.5.0": - version: 5.6.1 - resolution: "openid-client@npm:5.6.1" + version: 5.6.4 + resolution: "openid-client@npm:5.6.4" dependencies: - jose: ^4.15.1 + jose: ^4.15.4 lru-cache: ^6.0.0 object-hash: ^2.2.0 oidc-token-hash: ^5.0.3 - checksum: 9d939cec57540e6dd3f67e9a248ec5ecec3b439b7ab5bd2e9fb4481bd03e8d030deedd87a447348194be7f3e93e84085841b0414033caf86479870f526cdbc2f + checksum: 69843f078dacbbc6bad6d65ca6689414ac73f095dfe2f8e606822e6cfc9d9cd7d0dfaf2649352eda604653806f0ea65326ad2d6266da897e4740ec93d26d21f6 languageName: node linkType: hard From b5d4c7b882271abb905c6564e5b7baddeb9c2c86 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 21 Jan 2024 14:57:40 +0100 Subject: [PATCH 10/10] auth-backend: add back deprecated `OidcAuthResult` Signed-off-by: Patrik Oldsberg --- plugins/auth-backend/api-report.md | 9 ++++++--- plugins/auth-backend/src/providers/index.ts | 1 + plugins/auth-backend/src/providers/oidc/index.ts | 8 ++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 16f257d17d..9f68d8085c 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -25,7 +25,7 @@ import { LoggerService } from '@backstage/backend-plugin-api'; import { OAuth2ProxyResult as OAuth2ProxyResult_2 } from '@backstage/plugin-auth-backend-module-oauth2-proxy-provider'; import { OAuthEnvironmentHandler as OAuthEnvironmentHandler_2 } from '@backstage/plugin-auth-node'; import { OAuthState as OAuthState_2 } from '@backstage/plugin-auth-node'; -import { OidcAuthResult } from '@backstage/plugin-auth-backend-module-oidc-provider'; +import { OidcAuthResult as OidcAuthResult_2 } from '@backstage/plugin-auth-backend-module-oidc-provider'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { prepareBackstageIdentityResponse as prepareBackstageIdentityResponse_2 } from '@backstage/plugin-auth-node'; @@ -339,6 +339,9 @@ export type OAuthStartResponse = { // @public @deprecated (undocumented) export type OAuthState = OAuthState_2; +// @public @deprecated (undocumented) +export type OidcAuthResult = OidcAuthResult_2; + // @public @deprecated (undocumented) export const postMessageResponse: ( res: express.Response, @@ -557,10 +560,10 @@ export const providers: Readonly<{ create: ( options?: | { - authHandler?: AuthHandler | undefined; + authHandler?: AuthHandler | undefined; signIn?: | { - resolver: SignInResolver; + resolver: SignInResolver; } | undefined; } diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index c8140f864e..8173a7fbc3 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -29,6 +29,7 @@ export type { } from './cloudflare-access'; export type { GithubOAuthResult } from './github'; export type { OAuth2ProxyResult } from './oauth2-proxy'; +export type { OidcAuthResult } from './oidc'; export type { SamlAuthResult } from './saml'; export type { GcpIapResult, GcpIapTokenInfo } from './gcp-iap'; diff --git a/plugins/auth-backend/src/providers/oidc/index.ts b/plugins/auth-backend/src/providers/oidc/index.ts index c4343b1547..501f223fb3 100644 --- a/plugins/auth-backend/src/providers/oidc/index.ts +++ b/plugins/auth-backend/src/providers/oidc/index.ts @@ -15,3 +15,11 @@ */ export { oidc } from './provider'; + +import { OidcAuthResult as OidcAuthResult_ } from '@backstage/plugin-auth-backend-module-oidc-provider'; + +/** + * @public + * @deprecated Use OidcAuthResult from `@backstage/plugin-auth-backend-module-oidc-provider` instead + */ +export type OidcAuthResult = OidcAuthResult_;