From 362a5e293fec50c3f772e55eac89cd88419a49a8 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Wed, 6 Sep 2023 15:57:45 -0400 Subject: [PATCH] Completed Pinniped Authenticator refactor with passing unit tests Signed-off-by: Ruben Vallejo --- packages/backend/package.json | 1 - .../README.md | 8 +- .../package.json | 17 +- .../src/authenticator.test.ts | 361 +++++++++++-- .../src/authenticator.ts | 118 +++-- .../src/module.test.ts | 174 +++++- plugins/auth-backend/package.json | 1 + plugins/auth-backend/src/lib/oauth/types.ts | 17 +- .../src/providers/oidc/provider.ts | 24 +- .../src/providers/pinniped/provider.test.ts | 498 +----------------- .../src/providers/pinniped/provider.ts | 200 +------ yarn.lock | 30 +- 12 files changed, 613 insertions(+), 836 deletions(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index 4d10ab1507..cd0191f7fa 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -35,7 +35,6 @@ "@backstage/plugin-adr-backend": "workspace:^", "@backstage/plugin-app-backend": "workspace:^", "@backstage/plugin-auth-backend": "workspace:^", - "@backstage/plugin-auth-backend-module-pinniped-provider": "^0.0.0", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-azure-devops-backend": "workspace:^", "@backstage/plugin-azure-sites-backend": "workspace:^", diff --git a/plugins/auth-backend-module-pinniped-provider/README.md b/plugins/auth-backend-module-pinniped-provider/README.md index f173b9b151..bdb340d426 100644 --- a/plugins/auth-backend-module-pinniped-provider/README.md +++ b/plugins/auth-backend-module-pinniped-provider/README.md @@ -1,5 +1,7 @@ -# @backstage/plugin-auth-backend-module-pinniped-provider +# Auth Module: Pinniped Provider -The pinniped-provider backend module for the auth plugin. +This module provides an Pinniped auth provider implementation for `@backstage/plugin-auth-backend`. -_This plugin was created through the Backstage CLI_ +## Links + +- [Backstage](https://backstage.io) diff --git a/plugins/auth-backend-module-pinniped-provider/package.json b/plugins/auth-backend-module-pinniped-provider/package.json index a0e6a51a9c..3d7dcf97d9 100644 --- a/plugins/auth-backend-module-pinniped-provider/package.json +++ b/plugins/auth-backend-module-pinniped-provider/package.json @@ -24,11 +24,24 @@ }, "dependencies": { "@backstage/backend-common": "workspace:^", - "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/plugin-auth-node": "workspace:^", + "openid-client": "^5.4.3" }, "devDependencies": { + "@backstage/backend-defaults": "workspace:^", "@backstage/backend-test-utils": "workspace:^", - "@backstage/cli": "workspace:^" + "@backstage/cli": "workspace:^", + "@backstage/config": "workspace:^", + "@backstage/plugin-auth-backend": "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.0", + "passport": "^0.6.0", + "supertest": "^6.3.3" }, "files": [ "dist" diff --git a/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts b/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts index de92253f97..87d443a11a 100644 --- a/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts +++ b/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts @@ -1,9 +1,23 @@ +/* + * 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 { - OAuthAuthenticator, + OAuthAuthenticatorAuthenticateInput, + OAuthAuthenticatorRefreshInput, OAuthAuthenticatorStartInput, OAuthState, - PassportOAuthAuthenticatorHelper, - PassportProfile, decodeOAuthState, encodeOAuthState, } from '@backstage/plugin-auth-node'; @@ -53,10 +67,10 @@ describe('pinnipedAuthenticator', () => { const clusterScopedIdToken = 'dummy-token'; beforeAll(async () => { - const keyPair = await generateKeyPair('RS256'); + const keyPair = await generateKeyPair('ES256'); const privateKey = await exportJWK(keyPair.privateKey); publicKey = await exportJWK(keyPair.publicKey); - publicKey.alg = privateKey.alg = 'RS256'; + publicKey.alg = privateKey.alg = 'ES256'; idToken = await new SignJWT({ sub: 'test', @@ -69,8 +83,6 @@ describe('pinnipedAuthenticator', () => { .sign(keyPair.privateKey); }); - - beforeEach(() => { jest.clearAllMocks(); @@ -84,20 +96,51 @@ describe('pinnipedAuthenticator', () => { ctx.json(issuerMetadata), ), ), - ) - implementation = pinnipedAuthenticator.initialize({ + rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) => + res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })), + ), + rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => { + const formBody = new URLSearchParams(await req.text()); + const isGrantTypeTokenExchange = + formBody.get('grant_type') === + 'urn:ietf:params:oauth:grant-type:token-exchange'; + const hasValidTokenExchangeParams = + formBody.get('subject_token') === 'accessToken' && + formBody.get('audience') === 'test_cluster' && + formBody.get('subject_token_type') === + 'urn:ietf:params:oauth:token-type:access_token' && + formBody.get('requested_token_type') === + 'urn:ietf:params:oauth:token-type:jwt'; + + return res( + req.headers.get('Authorization') && + (!isGrantTypeTokenExchange || hasValidTokenExchangeParams) + ? ctx.json({ + access_token: isGrantTypeTokenExchange + ? clusterScopedIdToken + : 'accessToken', + refresh_token: 'refreshToken', + ...(!isGrantTypeTokenExchange && { id_token: idToken }), + scope: 'testScope', + }) + : ctx.status(401), + ); + }), + ); + + implementation = pinnipedAuthenticator.initialize({ callbackUrl: 'https://backstage.test/callback', config: new ConfigReader({ federationDomain: 'https://federationDomain.test', clientId: 'clientId', clientSecret: 'clientSecret', - }) - }) + }), + }); oauthState = { nonce: 'nonce', env: 'env', - } + }; }); describe('#start', () => { @@ -108,7 +151,7 @@ describe('pinnipedAuthenticator', () => { fakeSession = {}; startRequest = { state: encodeOAuthState(oauthState), - req: { + req: { method: 'GET', url: 'test', session: fakeSession, @@ -117,16 +160,22 @@ describe('pinnipedAuthenticator', () => { }); it('redirects to authorization endpoint returned from OIDC metadata endpoint', async () => { - const startResponse = await pinnipedAuthenticator.start(startRequest, implementation); + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); const url = new URL(startResponse.url); - + expect(url.protocol).toBe('https:'); expect(url.hostname).toBe('pinniped.test'); expect(url.pathname).toBe('/oauth2/authorize'); }); it('initiates authorization code grant', async () => { - const startResponse = await pinnipedAuthenticator.start(startRequest, implementation); + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); const { searchParams } = new URL(startResponse.url); expect(searchParams.get('response_type')).toBe('code'); @@ -134,7 +183,10 @@ describe('pinnipedAuthenticator', () => { it('persists audience parameter in oauth state', async () => { startRequest.req.query = { audience: 'test-cluster' }; - const startResponse = await pinnipedAuthenticator.start(startRequest, implementation); + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); const { searchParams } = new URL(startResponse.url); const stateParam = searchParams.get('state'); const decodedState = decodeOAuthState(stateParam!); @@ -147,14 +199,20 @@ describe('pinnipedAuthenticator', () => { }); it('passes client ID from config', async () => { - const startResponse = await pinnipedAuthenticator.start(startRequest, implementation); + const startResponse = await pinnipedAuthenticator.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 pinnipedAuthenticator.start(startRequest, implementation); + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); const { searchParams } = new URL(startResponse.url); expect(searchParams.get('redirect_uri')).toBe( @@ -163,7 +221,10 @@ describe('pinnipedAuthenticator', () => { }); it('generates PKCE challenge', async () => { - const startResponse = await pinnipedAuthenticator.start(startRequest, implementation); + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); const { searchParams } = new URL(startResponse.url); expect(searchParams.get('code_challenge_method')).toBe('S256'); @@ -176,7 +237,10 @@ describe('pinnipedAuthenticator', () => { }); it('requests sufficient scopes for token exchange by default', async () => { - const startResponse = await pinnipedAuthenticator.start(startRequest, implementation); + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); const { searchParams } = new URL(startResponse.url); const scopes = searchParams.get('scope')?.split(' ') ?? []; @@ -191,7 +255,10 @@ describe('pinnipedAuthenticator', () => { }); it('encodes OAuth state in query param', async () => { - const startResponse = await pinnipedAuthenticator.start(startRequest, implementation); + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); const { searchParams } = new URL(startResponse.url); const stateParam = searchParams.get('state'); const decodedState = decodeOAuthState(stateParam!); @@ -201,32 +268,236 @@ describe('pinnipedAuthenticator', () => { it('fails when request has no session', async () => { return expect( - pinnipedAuthenticator.start({state: encodeOAuthState(oauthState),req: { - method: 'GET', - url: 'test', - }} as unknown as OAuthAuthenticatorStartInput, - implementation) + pinnipedAuthenticator.start( + { + state: encodeOAuthState(oauthState), + req: { + method: 'GET', + url: 'test', + }, + } as unknown as OAuthAuthenticatorStartInput, + implementation, + ), ).rejects.toThrow('authentication requires session support'); }); - }); - // describe('#authenticate', () => { - // let handlerRequest: express.Request; + describe('#authenticate', () => { + let handlerRequest: OAuthAuthenticatorAuthenticateInput; - // beforeEach(() => { - // handlerRequest = { - // method: 'GET', - // url: `https://test?code=authorization_code&state=${encodeOAuthState( - // oauthState, - // )}`, - // session: { - // 'oidc:pinniped.test': { - // state: encodeOAuthState(oauthState), - // }, - // }, - // } as unknown as express.Request; - // }); + beforeEach(() => { + handlerRequest = { + req: { + method: 'GET', + url: `https://test?code=authorization_code&state=${encodeOAuthState( + oauthState, + )}`, + session: { + 'oidc:pinniped.test': { + state: encodeOAuthState(oauthState), + }, + }, + } as unknown as express.Request, + }; + }); - // }) -}) + it('exchanges authorization code for access token', async () => { + const handlerResponse = await pinnipedAuthenticator.authenticate( + handlerRequest, + implementation, + ); + const accessToken = handlerResponse.session.accessToken; + + expect(accessToken).toEqual('accessToken'); + }); + + it('exchanges authorization code for refresh token', async () => { + const handlerResponse = await pinnipedAuthenticator.authenticate( + handlerRequest, + implementation, + ); + const refreshToken = handlerResponse.session.refreshToken; + + expect(refreshToken).toEqual('refreshToken'); + }); + + it('returns granted scope', async () => { + const handlerResponse = await pinnipedAuthenticator.authenticate( + handlerRequest, + implementation, + ); + const responseScope = handlerResponse.session.scope; + + expect(responseScope).toEqual('testScope'); + }); + + it('returns cluster-scoped ID token when audience is specified', async () => { + oauthState.audience = 'test_cluster'; + handlerRequest = { + req: { + method: 'GET', + url: `https://test?code=authorization_code&state=${encodeOAuthState( + oauthState, + )}`, + session: { + 'oidc:pinniped.test': { + state: encodeOAuthState(oauthState), + }, + }, + } as unknown as express.Request, + }; + + const handlerResponse = await pinnipedAuthenticator.authenticate( + handlerRequest, + implementation, + ); + + expect(handlerResponse.session.idToken).toEqual(clusterScopedIdToken); + }, 70000); + + it('fails on network error during token exchange', async () => { + mswServer.use( + rest.post( + 'https://pinniped.test/oauth2/token', + async (req, res, ctx) => { + const formBody = new URLSearchParams(await req.text()); + const isGrantTypeTokenExchange = + formBody.get('grant_type') === + 'urn:ietf:params:oauth:grant-type:token-exchange'; + const hasValidTokenExchangeParams = + formBody.get('subject_token') === 'accessToken' && + formBody.get('audience') === 'test_cluster' && + formBody.get('subject_token_type') === + 'urn:ietf:params:oauth:token-type:access_token' && + formBody.get('requested_token_type') === + 'urn:ietf:params:oauth:token-type:jwt'; + + mswServer.use( + rest.post( + 'https://pinniped.test/oauth2/token', + async (_req, response, _ctx) => + response.networkError('Connection timed out'), + ), + ); + + return res( + req.headers.get('Authorization') && + (!isGrantTypeTokenExchange || hasValidTokenExchangeParams) + ? ctx.json({ + access_token: isGrantTypeTokenExchange + ? clusterScopedIdToken + : 'accessToken', + refresh_token: 'refreshToken', + ...(!isGrantTypeTokenExchange && { id_token: idToken }), + scope: 'testScope', + }) + : ctx.status(401), + ); + }, + ), + ); + + oauthState.audience = 'test_cluster'; + handlerRequest = { + req: { + method: 'GET', + url: `https://test?code=authorization_code&state=${encodeOAuthState( + oauthState, + )}`, + session: { + 'oidc:pinniped.test': { + state: encodeOAuthState(oauthState), + }, + }, + } as unknown as express.Request, + }; + + await expect( + pinnipedAuthenticator.authenticate(handlerRequest, implementation), + ).rejects.toThrow( + `Failed to get cluster specific ID token for "test_cluster", RFC8693 token exchange failed with error: NetworkError: Connection timed out`, + ); + }); + + it('fails without authorization code', async () => { + handlerRequest.req.url = 'https://test.com'; + return expect( + pinnipedAuthenticator.authenticate(handlerRequest, implementation), + ).rejects.toThrow('Unexpected redirect'); + }); + + it('fails without oauth state', async () => { + return expect( + pinnipedAuthenticator.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 rejected, state missing from the response', + ); + }); + + it('fails when request has no session', async () => { + return expect( + pinnipedAuthenticator.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 pinnipedAuthenticator.refresh( + refreshRequest, + implementation, + ); + + expect(refreshResponse.session.refreshToken).toBe('refreshToken'); + }); + + it('gets access token', async () => { + const refreshResponse = await pinnipedAuthenticator.refresh( + refreshRequest, + implementation, + ); + + expect(refreshResponse.session.accessToken).toBe('accessToken'); + }); + + it('gets id token', async () => { + const refreshResponse = await pinnipedAuthenticator.refresh( + refreshRequest, + implementation, + ); + + expect(refreshResponse.session.idToken).toBe(idToken); + }); + }); +}); diff --git a/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts b/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts index e6e9ca4a76..0194ed03de 100644 --- a/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts @@ -1,18 +1,34 @@ -//bunch of new authenticator logic for our provider goes in here - -import { PassportDoneCallback } from "@backstage/plugin-auth-backend/src/lib/passport"; -import { PassportOAuthAuthenticatorHelper, createOAuthAuthenticator, decodeOAuthState, encodeOAuthState } from "@backstage/plugin-auth-node"; -import { Issuer, TokenSet, Strategy as OidcStrategy } from 'openid-client' +/* + * 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 { PassportDoneCallback } from '@backstage/plugin-auth-node'; +import { + createOAuthAuthenticator, + decodeOAuthState, + encodeOAuthState, +} from '@backstage/plugin-auth-node'; +import { Issuer, TokenSet, Strategy as OidcStrategy } from 'openid-client'; export const pinnipedAuthenticator = createOAuthAuthenticator({ - defaultProfileTransform: - PassportOAuthAuthenticatorHelper.defaultProfileTransform, + defaultProfileTransform: async (_r, _c) => ({ profile: {} }), async initialize({ callbackUrl, config }) { - - const issuer = await Issuer.discover( - `${config.getString('federationDomain')}/.well-known/openid-configuration`, - ) - + const issuer = await Issuer.discover( + `${config.getString( + 'federationDomain', + )}/.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: config.getString('clientId'), @@ -20,34 +36,38 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({ redirect_uris: [callbackUrl], response_types: ['code'], scope: config.getOptionalString('scope') || '', + id_token_signed_response_alg: 'ES256', }); + const strategy = new OidcStrategy( + { + client, + passReqToCallback: false, + }, + ( + tokenset: TokenSet, + done: PassportDoneCallback< + { tokenset: TokenSet }, + { + refreshToken?: string; + } + >, + ) => { + done(undefined, { tokenset }, {}); + }, + ); - const strategy = new OidcStrategy({ - client, - passReqToCallback: false, - },( - tokenset: TokenSet, - done: PassportDoneCallback<{ tokenset: TokenSet }, { - refreshToken?: string; - }>, - ) => { - done(undefined, { tokenset }, {}); - },) - - return ({ strategy, client }) - - + return { strategy, client }; }, - //how does this helper get defined in other providers an what is the reason i get void type for it in my implementation async start(input, implementation) { - const { strategy } = await implementation + const { strategy } = await implementation; const stringifiedAudience = input.req.query?.audience as string; - const decodedState = decodeOAuthState(input.state) - const state = { ...decodedState, audience: stringifiedAudience } + const decodedState = decodeOAuthState(input.state); + const state = { ...decodedState, audience: stringifiedAudience }; const options: Record = { scope: - input.scope || 'openid pinniped:request-audience username offline_access', + input.scope || + 'openid pinniped:request-audience username offline_access', state: encodeOAuthState(state), }; @@ -64,10 +84,12 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({ async authenticate(input, implementation) { const { strategy, client } = await implementation; - const { req } = input + const { req } = input; const { searchParams } = new URL(req.url, 'https://pinniped.com'); const stateParam = searchParams.get('state'); - const audience = stateParam ? decodeOAuthState(stateParam).audience : undefined; + const audience = stateParam + ? decodeOAuthState(stateParam).audience + : undefined; return new Promise((resolve, reject) => { strategy.success = user => { @@ -79,20 +101,27 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({ audience, subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', - inputuested_token_type: 'urn:ietf:params:oauth:token-type:jwt', + requested_token_type: 'urn:ietf:params:oauth:token-type:jwt', }) .then(tokenset => tokenset.access_token) + .catch(err => + reject( + new Error( + `Failed to get cluster specific ID token for "${audience}", RFC8693 token exchange failed with error: ${err}`, + ), + ), + ) : Promise.resolve(user.tokenset.id_token) ).then(idToken => { resolve({ - fullProfile: {provider: " ",id: " ",displayName: " "}, + fullProfile: { provider: ' ', id: ' ', displayName: ' ' }, session: { accessToken: user.tokenset.access_token!, - tokenType: "random", + tokenType: user.tokenset.token_type ?? 'bearer', scope: user.tokenset.scope!, idToken, - refreshToken: user.tokenset.refresh_token - } + refreshToken: user.tokenset.refresh_token, + }, }); }); }; @@ -123,16 +152,15 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({ } resolve({ - fullProfile: {provider: " ",id: " ",displayName: " "}, + fullProfile: { provider: ' ', id: ' ', displayName: ' ' }, session: { accessToken: tokenset.access_token!, - tokenType: "random", + tokenType: tokenset.token_type ?? 'bearer', scope: tokenset.scope!, idToken: tokenset.id_token, - refreshToken: tokenset.refresh_token - } + refreshToken: tokenset.refresh_token, + }, }); }); }, - -}) \ No newline at end of file +}); diff --git a/plugins/auth-backend-module-pinniped-provider/src/module.test.ts b/plugins/auth-backend-module-pinniped-provider/src/module.test.ts index e4dd80b9f9..ed285cff86 100644 --- a/plugins/auth-backend-module-pinniped-provider/src/module.test.ts +++ b/plugins/auth-backend-module-pinniped-provider/src/module.test.ts @@ -1,24 +1,44 @@ -import { setupRequestMockHandlers } from "@backstage/backend-test-utils" -import { authModulePinnipedProvider } from "./module" +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import request from 'supertest'; import { setupServer } from 'msw/node'; import { rest } from 'msw'; -import { Server } from "http"; +import { Server } from 'http'; import express from 'express'; import cookieParser from 'cookie-parser'; import session from 'express-session'; import passport from 'passport'; import { AddressInfo } from 'net'; -import { AuthProviderRouteHandlers, createOAuthRouteHandlers } from "@backstage/plugin-auth-node"; +import { + AuthProviderRouteHandlers, + createOAuthRouteHandlers, +} from '@backstage/plugin-auth-node'; import Router from 'express-promise-router'; -import { pinnipedAuthenticator } from "./authenticator"; -import { ConfigReader } from "@backstage/config"; +import { pinnipedAuthenticator } from './authenticator'; +import { ConfigReader } from '@backstage/config'; +import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose'; describe('authModulePinnipedProvider', () => { let app: express.Express; let backstageServer: Server; let appUrl: string; - let providerRouteHandler: AuthProviderRouteHandlers + let providerRouteHandler: AuthProviderRouteHandlers; + let idToken: string; + let publicKey: JWK; const mswServer = setupServer(); setupRequestMockHandlers(mswServer); @@ -49,8 +69,27 @@ describe('authModulePinnipedProvider', () => { request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], }; + const clusterScopedIdToken = 'dummy-token'; + + 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(); + jest.clearAllMocks(); mswServer.use( rest.get( @@ -62,7 +101,55 @@ describe('authModulePinnipedProvider', () => { ctx.json(issuerMetadata), ), ), - ) + rest.get( + 'https://pinniped.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()), + ); + }, + ), + rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) => + res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })), + ), + rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => { + const formBody = new URLSearchParams(await req.text()); + const isGrantTypeTokenExchange = + formBody.get('grant_type') === + 'urn:ietf:params:oauth:grant-type:token-exchange'; + const hasValidTokenExchangeParams = + formBody.get('subject_token') === 'accessToken' && + formBody.get('audience') === 'test_cluster' && + formBody.get('subject_token_type') === + 'urn:ietf:params:oauth:token-type:access_token' && + formBody.get('requested_token_type') === + 'urn:ietf:params:oauth:token-type:jwt'; + + return res( + req.headers.get('Authorization') && + (!isGrantTypeTokenExchange || hasValidTokenExchangeParams) + ? ctx.json({ + access_token: isGrantTypeTokenExchange + ? clusterScopedIdToken + : 'accessToken', + refresh_token: 'refreshToken', + ...(!isGrantTypeTokenExchange && { id_token: idToken }), + scope: 'testScope', + }) + : ctx.status(401), + ); + }), + ); const secret = 'secret'; app = express() @@ -110,33 +197,64 @@ describe('authModulePinnipedProvider', () => { }), signInWithCatalogUser: async _ => ({ token: '' }), }, - }) + }); 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); - }) + 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(); }); - //TODO: are we actually testing the auth module here since our setup makes use of creating the Oauthfactory directly and not through the module, how can we attach it using the module instead???? - it('should start', async () => { + const agent = request.agent(backstageServer); + const startResponse = await agent.get( + `/api/auth/pinniped/start?env=development&audience=test_cluster`, + ); - const agent = request.agent(backstageServer) + expect(startResponse.status).toBe(302); + }); - const startResponse = await agent.get(`/api/auth/pinniped/start?env=development&audience=test_cluster`); + it('/handler/frame exchanges authorization code from #start for Cluster Specific ID token', async () => { + const agent = request.agent(''); - expect(startResponse.status).toBe(302) - }, 70000) -}) \ No newline at end of file + // make /start request with audience parameter + const startResponse = await agent.get( + `${appUrl}/api/auth/pinniped/start?env=development&audience=test_cluster`, + ); + // 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: {}, + providerInfo: { + idToken: clusterScopedIdToken, + accessToken: 'accessToken', + scope: 'testScope', + }, + }, + }), + ), + ); + }); +}); diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 0d9fc96ce8..dab3e6d883 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-microsoft-provider": "workspace:^", "@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^", + "@backstage/plugin-auth-backend-module-pinniped-provider": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-catalog-node": "workspace:^", "@backstage/types": "workspace:^", diff --git a/plugins/auth-backend/src/lib/oauth/types.ts b/plugins/auth-backend/src/lib/oauth/types.ts index 49398ab279..b7205c9b85 100644 --- a/plugins/auth-backend/src/lib/oauth/types.ts +++ b/plugins/auth-backend/src/lib/oauth/types.ts @@ -92,18 +92,11 @@ export type OAuthProviderInfo = { scope: string; }; -/** @public */ -export type OAuthState = { - /* A type for the serialized value in the `state` parameter of the OAuth authorization flow - */ - nonce: string; - env: string; - origin?: string; - scope?: string; - redirectUrl?: string; - flow?: string; - audience? : string; -}; +/** + * @public + * @deprecated import from `@backstage/plugin-auth-node` instead + */ +export type OAuthState = _OAuthState; /** * @public diff --git a/plugins/auth-backend/src/providers/oidc/provider.ts b/plugins/auth-backend/src/providers/oidc/provider.ts index 96bcbf6e00..7638027b01 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.ts @@ -130,9 +130,7 @@ export class OidcAuthProvider implements OAuthHandlers { if (!tokenset.access_token) { throw new Error('Refresh failed'); } - const userinfo = client.issuer.userinfo_endpoint - ? await client.userinfo(tokenset.access_token) - : { sub: '' }; + const userinfo = await client.userinfo(tokenset.access_token); return { response: await this.handleResult({ tokenset, userinfo }), @@ -161,23 +159,17 @@ export class OidcAuthProvider implements OAuthHandlers { }, ( tokenset: TokenSet, - userinfo: - | UserinfoResponse - | PassportDoneCallback, - done?: PassportDoneCallback, + userinfo: UserinfoResponse, + done: PassportDoneCallback, ) => { - if (typeof userinfo === 'function') { - userinfo( - undefined, - { tokenset, userinfo: { sub: '' } }, - { - refreshToken: tokenset.refresh_token, - }, + if (typeof done !== 'function') { + throw new Error( + 'OIDC IdP must provide a userinfo_endpoint in the metadata response', ); } - done!( + done( undefined, - { tokenset, userinfo: userinfo as UserinfoResponse }, + { tokenset, userinfo }, { refreshToken: tokenset.refresh_token, }, diff --git a/plugins/auth-backend/src/providers/pinniped/provider.test.ts b/plugins/auth-backend/src/providers/pinniped/provider.test.ts index cd467df1e9..60458e187a 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.test.ts @@ -13,496 +13,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; -import { - OAuthRefreshRequest, - OAuthStartRequest, - encodeState, - readState, -} from '../../lib/oauth'; -import { PinnipedAuthProvider, PinnipedProviderOptions } from './provider'; -import { setupServer } from 'msw/node'; -import { rest } from 'msw'; -import express from 'express'; -import { OAuthState } from '../../lib/oauth'; -import { Server } from 'http'; -import cookieParser from 'cookie-parser'; -import session from 'express-session'; -import passport from 'passport'; -import { ConfigReader } from '@backstage/config'; -import Router from 'express-promise-router'; -import { pinniped } from '.'; -import { AuthProviderRouteHandlers } from '../types'; -import { getVoidLogger } from '@backstage/backend-common'; -import { AddressInfo } from 'net'; -import request from 'supertest'; -import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose'; -describe('PinnipedAuthProvider', () => { - let provider: PinnipedAuthProvider; - let idToken: string; - let publicKey: JWK; - let oauthState: OAuthState; +import { pinnipedAuthenticator } from '@backstage/plugin-auth-backend-module-pinniped-provider'; +import { createOAuthProviderFactory } from '@backstage/plugin-auth-node'; +import { pinniped } from './provider'; - const mswServer = setupServer(); - setupRequestMockHandlers(mswServer); +jest.mock('@backstage/plugin-auth-node', () => ({ + ...jest.requireActual('@backstage/plugin-auth-node'), + createOAuthProviderFactory: jest.fn(() => 'provider-factory'), +})); - const issuerMetadata = { - issuer: 'https://pinniped.test', - authorization_endpoint: 'https://pinniped.test/oauth2/authorize', - token_endpoint: 'https://pinniped.test/oauth2/token', - revocation_endpoint: 'https://pinniped.test/oauth2/revoke_token', - userinfo_endpoint: 'https://pinniped.test/idp/userinfo.openid', - introspection_endpoint: 'https://pinniped.test/introspect.oauth2', - jwks_uri: 'https://pinniped.test/jwks.json', - scopes_supported: [ - 'openid', - 'offline_access', - 'pinniped: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'], - }; +describe('createPinnipedAuthProvider', () => { + afterEach(() => jest.clearAllMocks()); - const pinnipedProviderOptions: PinnipedProviderOptions = { - federationDomain: 'https://federationDomain.test', - clientId: 'clientId', - clientSecret: 'secret', - callbackUrl: 'https://backstage.test/callback', - }; + it('should be created', async () => { + expect(pinniped.create()).toBe('provider-factory'); - const clusterScopedIdToken = 'dummy-token'; - - 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://pinniped.test', - iat: Date.now(), - aud: pinnipedProviderOptions.clientId, - exp: Date.now() + 10000, - }) - .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) - .sign(keyPair.privateKey); - }); - - beforeEach(async () => { - jest.clearAllMocks(); - - mswServer.use( - rest.get( - 'https://federationDomain.test/.well-known/openid-configuration', - (_req, res, ctx) => - res( - ctx.status(200), - ctx.set('Content-Type', 'application/json'), - ctx.json(issuerMetadata), - ), - ), - rest.get( - 'https://pinniped.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()), - ); - }, - ), - rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) => - res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })), - ), - rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => { - const formBody = new URLSearchParams(await req.text()); - const isGrantTypeTokenExchange = - formBody.get('grant_type') === - 'urn:ietf:params:oauth:grant-type:token-exchange'; - const hasValidTokenExchangeParams = - formBody.get('subject_token') === 'accessToken' && - formBody.get('audience') === 'test_cluster' && - formBody.get('subject_token_type') === - 'urn:ietf:params:oauth:token-type:access_token' && - formBody.get('requested_token_type') === - 'urn:ietf:params:oauth:token-type:jwt'; - - return res( - req.headers.get('Authorization') && - (!isGrantTypeTokenExchange || hasValidTokenExchangeParams) - ? ctx.json({ - access_token: isGrantTypeTokenExchange - ? clusterScopedIdToken - : 'accessToken', - refresh_token: 'refreshToken', - ...(!isGrantTypeTokenExchange && { id_token: idToken }), - scope: 'testScope', - }) - : ctx.status(401), - ); - }), - ); - - oauthState = { - nonce: 'nonce', - env: 'env', - }; - - provider = new PinnipedAuthProvider(pinnipedProviderOptions); - }); - - describe('#start', () => { - let fakeSession: Record; - let startRequest: OAuthStartRequest; - - beforeEach(() => { - fakeSession = {}; - startRequest = { - session: fakeSession, - method: 'GET', - url: 'test', - state: oauthState, - } as unknown as OAuthStartRequest; - }); - - it('redirects to authorization endpoint returned from OIDC metadata endpoint', async () => { - const startResponse = await provider.start(startRequest); - const url = new URL(startResponse.url); - - expect(url.protocol).toBe('https:'); - expect(url.hostname).toBe('pinniped.test'); - expect(url.pathname).toBe('/oauth2/authorize'); - }); - - it('initiates authorization code grant', async () => { - const startResponse = await provider.start(startRequest); - const { searchParams } = new URL(startResponse.url); - - expect(searchParams.get('response_type')).toBe('code'); - }); - - it('persists audience parameter in oauth state', async () => { - startRequest.query = { audience: 'test-cluster' }; - const startResponse = await provider.start(startRequest); - const { searchParams } = new URL(startResponse.url); - const stateParam = searchParams.get('state'); - const decodedState = readState(stateParam!); - - expect(decodedState).toMatchObject({ - nonce: 'nonce', - env: 'env', - audience: 'test-cluster', - }); - }); - - it('passes client ID from config', async () => { - const startResponse = await provider.start(startRequest); - const { searchParams } = new URL(startResponse.url); - - expect(searchParams.get('client_id')).toBe('clientId'); - }); - - it('passes callback URL from config', async () => { - const startResponse = await provider.start(startRequest); - const { searchParams } = new URL(startResponse.url); - - expect(searchParams.get('redirect_uri')).toBe( - 'https://backstage.test/callback', - ); - }); - - it('generates PKCE challenge', async () => { - const startResponse = await provider.start(startRequest); - 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 provider.start(startRequest); - expect(fakeSession['oidc:pinniped.test'].code_verifier).toBeDefined(); - }); - - it('requests sufficient scopes for token exchange', async () => { - const startResponse = await provider.start(startRequest); - const { searchParams } = new URL(startResponse.url); - const scopes = searchParams.get('scope')?.split(' ') ?? []; - - expect(scopes).toEqual( - expect.arrayContaining([ - 'openid', - 'pinniped:request-audience', - 'username', - 'offline_access', - ]), - ); - }); - - it('encodes OAuth state in query param', async () => { - const startResponse = await provider.start(startRequest); - const { searchParams } = new URL(startResponse.url); - const stateParam = searchParams.get('state'); - const decodedState = readState(stateParam!); - - expect(decodedState).toMatchObject(oauthState); - }); - - it('fails when request has no session', async () => { - return expect( - provider.start({ - method: 'GET', - url: 'test', - } as unknown as OAuthStartRequest), - ).rejects.toThrow('authentication requires session support'); - }); - }); - - describe('#handler', () => { - let handlerRequest: express.Request; - - beforeEach(() => { - handlerRequest = { - method: 'GET', - url: `https://test?code=authorization_code&state=${encodeState( - oauthState, - )}`, - session: { - 'oidc:pinniped.test': { - state: encodeState(oauthState), - }, - }, - } as unknown as express.Request; - }); - - it('exchanges authorization code for access token', async () => { - const handlerResponse = await provider.handler(handlerRequest); - const accessToken = handlerResponse.response.providerInfo.accessToken; - - expect(accessToken).toEqual('accessToken'); - }); - - it('exchanges authorization code for refresh token', async () => { - const handlerResponse = await provider.handler(handlerRequest); - const refreshToken = handlerResponse.refreshToken; - - expect(refreshToken).toEqual('refreshToken'); - }); - - it('returns granted scope', async () => { - const handlerResponse = await provider.handler(handlerRequest); - const responseScope = handlerResponse.response.providerInfo.scope; - - expect(responseScope).toEqual('testScope'); - }); - - it('returns cluster-scoped ID token when audience is specified', async () => { - oauthState.audience = 'test_cluster'; - handlerRequest = { - method: 'GET', - url: `https://test?code=authorization_code&state=${encodeState( - oauthState, - )}`, - session: { - 'oidc:pinniped.test': { - state: encodeState(oauthState), - }, - }, - } as unknown as express.Request; - - const handlerResponse = await provider.handler(handlerRequest); - const responseIdToken = handlerResponse.response.providerInfo.idToken; - - expect(responseIdToken).toEqual(clusterScopedIdToken); - }); - - it('fails without authorization code', async () => { - handlerRequest.url = 'https://test.com'; - return expect(provider.handler(handlerRequest)).rejects.toThrow( - 'Unexpected redirect', - ); - }); - - it('fails without oauth state', async () => { - return expect( - provider.handler({ - method: 'GET', - url: `https://test?code=authorization_code}`, - session: { - ['oidc:pinniped.test']: { - state: { handle: 'sessionid', code_verifier: 'foo' }, - }, - }, - } as unknown as express.Request), - ).rejects.toThrow( - 'Authentication rejected, state missing from the response', - ); - }); - - it('fails when request has no session', async () => { - return expect( - provider.handler({ - method: 'GET', - url: 'https://test.com', - } as unknown as OAuthStartRequest), - ).rejects.toThrow('authentication requires session support'); - }); - }); - - describe('#refresh', () => { - let refreshRequest: OAuthRefreshRequest; - - beforeEach(() => { - refreshRequest = { - refreshToken: 'otherRefreshToken', - } as unknown as OAuthRefreshRequest; - }); - - it('gets new refresh token', async () => { - const { refreshToken } = await provider.refresh(refreshRequest); - - expect(refreshToken).toBe('refreshToken'); - }); - - it('gets access token', async () => { - const { response } = await provider.refresh(refreshRequest); - - expect(response.providerInfo.accessToken).toBe('accessToken'); - }); - - it('gets id token', async () => { - const { response } = await provider.refresh(refreshRequest); - - expect(response.providerInfo.idToken).toBe(idToken); - }); - }); - - describe('integration', () => { - let app: express.Express; - let providerRouteHandler: AuthProviderRouteHandlers; - let backstageServer: Server; - let appUrl: string; - - beforeEach(async () => { - 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 = pinniped.create()({ - providerId: 'pinniped', - globalConfig: { - baseUrl: `${appUrl}/api/auth`, - appUrl, - isOriginAllowed: _ => true, - }, - config: new ConfigReader({ - development: { - federationDomain: 'https://federationDomain.test', - clientId: 'clientId', - clientSecret: 'clientSecret', - }, - }), - logger: getVoidLogger(), - resolverContext: { - issueToken: async _ => ({ token: '' }), - findCatalogUser: async _ => ({ - entity: { - apiVersion: '', - kind: '', - metadata: { name: '' }, - }, - }), - signInWithCatalogUser: async _ => ({ token: '' }), - }, - baseUrl: `${appUrl}/api/auth`, - appUrl, - isOriginAllowed: _ => true, - }); - 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('/handler/frame exchanges authorization code from #start for Cluster Specific ID token', async () => { - const agent = request.agent(''); - - // make /start request with audience parameter - const startResponse = await agent.get( - `${appUrl}/api/auth/pinniped/start?env=development&audience=test_cluster`, - ); - // 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: { - providerInfo: { - accessToken: 'accessToken', - scope: 'testScope', - idToken: clusterScopedIdToken, - }, - profile: {}, - }, - }), - ), - ); + expect(createOAuthProviderFactory).toHaveBeenCalledWith({ + authenticator: pinnipedAuthenticator, }); }); }); diff --git a/plugins/auth-backend/src/providers/pinniped/provider.ts b/plugins/auth-backend/src/providers/pinniped/provider.ts index 221a526fe0..433c49a6cd 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.ts @@ -13,179 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - Client, - Issuer, - Strategy as OidcStrategy, - TokenSet, -} from 'openid-client'; -import { - OAuthHandlers, - OAuthProviderOptions, - OAuthRefreshRequest, - OAuthResponse, - OAuthStartRequest, - encodeState, - readState, -} from '../../lib/oauth'; -import { PassportDoneCallback } from '../../lib/passport'; -import { OAuthStartResponse } from '../types'; -import express from 'express'; -import { OAuthAdapter, OAuthEnvironmentHandler } from '../../lib/oauth'; + +import { pinnipedAuthenticator } from '@backstage/plugin-auth-backend-module-pinniped-provider'; import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; - -type OidcImpl = { - strategy: OidcStrategy; - client: Client; -}; - -type PrivateInfo = { - refreshToken?: string; -}; - -export type PinnipedProviderOptions = OAuthProviderOptions & { - federationDomain: string; - clientId: string; - clientSecret: string; - callbackUrl: string; - scope?: string; -}; - -export class PinnipedAuthProvider implements OAuthHandlers { - private readonly implementation: Promise; - - constructor(options: PinnipedProviderOptions) { - this.implementation = this.setupStrategy(options); - } - - async start(req: OAuthStartRequest): Promise { - const { strategy } = await this.implementation; - const stringifiedAudience = req.query?.audience as string; - const state = { ...req.state, audience: stringifiedAudience }; - const options: Record = { - scope: - req.scope || 'openid pinniped:request-audience username offline_access', - state: encodeState(state), - }; - return new Promise((resolve, reject) => { - strategy.redirect = (url: string) => { - resolve({ url }); - }; - strategy.error = (error: Error) => { - reject(error); - }; - strategy.authenticate(req, { ...options }); - }); - } - - async handler( - req: express.Request, - ): Promise<{ response: OAuthResponse; refreshToken?: string }> { - const { strategy, client } = await this.implementation; - const { searchParams } = new URL(req.url, 'https://pinniped.com'); - const stateParam = searchParams.get('state'); - const audience = stateParam ? readState(stateParam).audience : undefined; - - return new Promise((resolve, reject) => { - strategy.success = user => { - (audience - ? client - .grant({ - grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', - subject_token: user.tokenset.access_token, - audience, - subject_token_type: - 'urn:ietf:params:oauth:token-type:access_token', - requested_token_type: 'urn:ietf:params:oauth:token-type:jwt', - }) - .then(tokenset => tokenset.access_token) - : Promise.resolve(user.tokenset.id_token) - ).then(idToken => { - resolve({ - response: { - providerInfo: { - accessToken: user.tokenset.access_token, - scope: user.tokenset.scope, - idToken, - }, - profile: {}, - }, - refreshToken: user.tokenset.refresh_token, - }); - }); - }; - - strategy.fail = info => { - reject(new Error(`Authentication rejected, ${info.message || ''}`)); - }; - - strategy.error = (error: Error) => { - reject(error); - }; - - strategy.redirect = () => { - reject(new Error('Unexpected redirect')); - }; - - strategy.authenticate(req); - }); - } - - async refresh( - req: OAuthRefreshRequest, - ): Promise<{ response: OAuthResponse; refreshToken?: string }> { - const { client } = await this.implementation; - const tokenset = await client.refresh(req.refreshToken); - - return new Promise((resolve, reject) => { - if (!tokenset.access_token) { - reject(new Error('Refresh Failed')); - } - - resolve({ - response: { - providerInfo: { - accessToken: tokenset.access_token!, - scope: tokenset.scope!, - idToken: tokenset.id_token, - }, - profile: {}, - }, - refreshToken: tokenset.refresh_token, - }); - }); - } - - private async setupStrategy( - options: PinnipedProviderOptions, - ): Promise { - const issuer = await Issuer.discover( - `${options.federationDomain}/.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: options.clientId, - client_secret: options.clientSecret, - redirect_uris: [options.callbackUrl], - response_types: ['code'], - scope: options.scope || '', - }); - - const strategy = new OidcStrategy( - { - client, - passReqToCallback: false, - }, - ( - tokenset: TokenSet, - done: PassportDoneCallback<{ tokenset: TokenSet }, PrivateInfo>, - ) => { - done(undefined, { tokenset }, {}); - }, - ); - return { strategy, client }; - } -} +import { createOAuthProviderFactory } from '@backstage/plugin-auth-node'; /** * Auth provider integration for Pinniped auth @@ -194,27 +25,8 @@ export class PinnipedAuthProvider implements OAuthHandlers { */ export const pinniped = createAuthProviderIntegration({ create() { - return ({ providerId, globalConfig, config }) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const federationDomain = envConfig.getString('federationDomain'); - const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); - const callbackUrl = - customCallbackUrl || - `${globalConfig.baseUrl}/${providerId}/handler/frame`; - - const provider = new PinnipedAuthProvider({ - federationDomain, - clientId, - clientSecret, - callbackUrl, - }); - - return OAuthAdapter.fromConfig(globalConfig, provider, { - providerId, - callbackUrl, - }); - }); + return createOAuthProviderFactory({ + authenticator: pinnipedAuthenticator, + }); }, }); diff --git a/yarn.lock b/yarn.lock index bab1a52aad..c68ddea50e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4979,14 +4979,27 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-auth-backend-module-pinniped-provider@^0.0.0, @backstage/plugin-auth-backend-module-pinniped-provider@workspace:plugins/auth-backend-module-pinniped-provider": +"@backstage/plugin-auth-backend-module-pinniped-provider@workspace:^, @backstage/plugin-auth-backend-module-pinniped-provider@workspace:plugins/auth-backend-module-pinniped-provider": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend-module-pinniped-provider@workspace:plugins/auth-backend-module-pinniped-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.0 + openid-client: ^5.4.3 + passport: ^0.6.0 + supertest: ^6.3.3 languageName: unknown linkType: soft @@ -5010,6 +5023,7 @@ __metadata: "@backstage/plugin-auth-backend-module-google-provider": "workspace:^" "@backstage/plugin-auth-backend-module-microsoft-provider": "workspace:^" "@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^" + "@backstage/plugin-auth-backend-module-pinniped-provider": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^" "@backstage/types": "workspace:^" @@ -25908,7 +25922,6 @@ __metadata: "@backstage/plugin-adr-backend": "workspace:^" "@backstage/plugin-app-backend": "workspace:^" "@backstage/plugin-auth-backend": "workspace:^" - "@backstage/plugin-auth-backend-module-pinniped-provider": ^0.0.0 "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-azure-devops-backend": "workspace:^" "@backstage/plugin-azure-sites-backend": "workspace:^" @@ -26117,7 +26130,7 @@ __metadata: languageName: node linkType: hard -"express-session@npm:^1.17.1": +"express-session@npm:^1.17.1, express-session@npm:^1.17.3": version: 1.17.3 resolution: "express-session@npm:1.17.3" dependencies: @@ -30330,6 +30343,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^4.14.6": + version: 4.14.6 + resolution: "jose@npm:4.14.6" + checksum: eae81a234e7bf1446b1bd80722b3462b014e3835b155c3a7799c1c5043163a53a0dc28d347004151b031e6b7b863403aabf8814d9cc217ce21f8c2f3ebd4b335 + languageName: node + linkType: hard + "jose@npm:^4.15.1, jose@npm:^4.6.0": version: 4.15.3 resolution: "jose@npm:4.15.3" @@ -33457,7 +33477,7 @@ __metadata: languageName: node linkType: hard -"msw@npm:^1.0.0, msw@npm:^1.0.1, msw@npm:^1.2.1, msw@npm:^1.2.3, msw@npm:^1.3.1": +"msw@npm:^1.0.0, msw@npm:^1.0.1, msw@npm:^1.2.1, msw@npm:^1.2.3, msw@npm:^1.3.0, msw@npm:^1.3.1": version: 1.3.2 resolution: "msw@npm:1.3.2" dependencies: @@ -34531,7 +34551,7 @@ __metadata: languageName: node linkType: hard -"openid-client@npm:^5.2.1, openid-client@npm:^5.3.0": +"openid-client@npm:^5.2.1, openid-client@npm:^5.3.0, openid-client@npm:^5.4.3": version: 5.6.1 resolution: "openid-client@npm:5.6.1" dependencies: