From c541201b1b2856b1cde74a079d90aad7bcad51a4 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Mon, 18 Sep 2023 11:38:10 -0400 Subject: [PATCH] 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"