From 295dae8ab535966f7d269c19192c9a60218ae457 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Mon, 24 Jul 2023 11:52:18 -0400 Subject: [PATCH] passing pinniped authprovider #handler responds with Id token test Signed-off-by: Ruben Vallejo --- plugins/auth-backend/package.json | 3 +- .../src/providers/pinniped/provider.test.ts | 155 +++++++++++++++++- .../src/providers/pinniped/provider.ts | 103 +++++++++++- yarn.lock | 20 ++- 4 files changed, 269 insertions(+), 12 deletions(-) diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 85cfc81d01..0d9fc96ce8 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -66,12 +66,13 @@ "fs-extra": "10.1.0", "google-auth-library": "^8.0.0", "jose": "^4.6.0", - "jwt-decode": "^3.1.0", + "jwt-decode": "^3.1.2", "knex": "^2.0.0", "lodash": "^4.17.21", "luxon": "^3.0.0", "minimatch": "^5.0.0", "morgan": "^1.10.0", + "njwt": "^2.0.0", "node-cache": "^5.1.2", "node-fetch": "^2.6.7", "openid-client": "^5.2.1", diff --git a/plugins/auth-backend/src/providers/pinniped/provider.test.ts b/plugins/auth-backend/src/providers/pinniped/provider.test.ts index 5ce30df717..ef409e78c4 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.test.ts @@ -14,17 +14,20 @@ * limitations under the License. */ import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; -import { OAuthStartRequest } from '../../lib/oauth'; +import { OAuthStartRequest, OAuthState, encodeState } from '../../lib/oauth'; import { AuthResolverContext } from '../types'; import { PinnipedAuthProvider, PinnipedOptions } from './provider'; import { setupServer } from 'msw/node'; import { rest } from 'msw'; import { ClientMetadata, IssuerMetadata } from 'openid-client'; +import express from 'express'; +import nJwt from 'njwt'; +import { UnsecuredJWT } from 'jose'; describe('PinnipedAuthProvider', () => { + let provider: PinnipedAuthProvider; let startRequest: OAuthStartRequest; let fakeSession: Record; - let provider: PinnipedAuthProvider; const worker = setupServer(); setupRequestMockHandlers(worker); @@ -61,20 +64,62 @@ describe('PinnipedAuthProvider', () => { clientSecret: 'secret.test', callbackUrl: 'https://federationDomain.test/callback', resolverContext: {} as AuthResolverContext, + tokenSignedResponseAlg: 'none', authHandler: async () => ({ profile: {}, }), }; + // const idToken: string = nJwt + // .create( + // { + // iss: 'https://pinniped.test', + // sub: 'test', + // aud: clientMetadata.clientId, + // claims: { + // given_name: 'Givenname', + // family_name: 'Familyname', + // email: 'user@example.com', + // }, + // }, + // Buffer.from('signing key'), + // ) + // .compact(); + + const sub = 'test'; + const iss = 'https://pinniped.test'; + const iat = Date.now(); + const aud = clientMetadata.clientId; + const exp = Date.now() + 10000; + const idToken = new UnsecuredJWT({ iss, sub, aud, iat, exp }) + .setIssuer(iss) + .setAudience(aud) + .setSubject(sub) + .setIssuedAt(iat) + .setExpirationTime(exp) + .encode(); + beforeEach(() => { jest.clearAllMocks(); + worker.use( + rest.post('https://pinniped.test/oauth2/token', (req, res, ctx) => + res( + req.headers.get('Authorization') + ? ctx.json({ + access_token: 'accessToken', + refresh_token: 'refreshToken', + id_token: idToken, + }) + : ctx.status(401), + ), + ), + ); fakeSession = {}; startRequest = { session: fakeSession, method: 'GET', url: 'test', } as unknown as OAuthStartRequest; - const handler = jest.fn((_req, res, ctx) => { return res( ctx.status(200), @@ -82,14 +127,12 @@ describe('PinnipedAuthProvider', () => { ctx.json(issuerMetadata), ); }); - worker.use( rest.all( 'https://federationDomain.test/.well-known/openid-configuration', handler, ), ); - provider = new PinnipedAuthProvider(clientMetadata); }); @@ -123,7 +166,7 @@ describe('PinnipedAuthProvider', () => { expect(strategy._issuer.token_endpoint).toBe(issuerMetadata.token_endpoint); }); - describe('/start', () => { + describe('#start', () => { it('redirects to authorization endpoint returned from federationDomain config value', async () => { const startResponse = await provider.start(startRequest); const url = new URL(startResponse.url); @@ -170,5 +213,105 @@ describe('PinnipedAuthProvider', () => { } as unknown as OAuthStartRequest), ).rejects.toThrow('authentication requires session support'); }); + // false passing test: passes because we compare two falsy values undefined and undefined + // need to add the logic that makes this true + it.skip('adds session ID handle to state param', async () => { + const startResponse = await provider.start(startRequest); + // stateParam is empty string + const stateParam = new URL(startResponse.url).searchParams.get('state'); + const state = Object.fromEntries( + new URLSearchParams(Buffer.from(stateParam!, 'hex').toString('utf-8')), + ); + // handle is currently undefined + const { handle } = fakeSession['oidc:pinniped.test'].state; + console.log(`This is the param:`, stateParam); + // state.handle = undefined + expect(state.handle ?? '').toEqual(handle); + }); + }); + + describe('#handler', () => { + let handlerRequest: express.Request; + + beforeEach(() => { + provider = new PinnipedAuthProvider(clientMetadata); + + const testState = encodeState({ + nonce: 'nonce', + env: 'development', + origin: 'undefined', + }); + + handlerRequest = { + method: 'GET', + url: `https://test?code=authorization_code&state=${testState}`, + session: { + 'oidc:pinniped.test': { + state: testState, + }, + }, + } as unknown as express.Request; + + worker.use( + rest.post('https://pinniped.test/oauth2/token', (req, res, ctx) => + res( + req.headers.get('Authorization') + ? ctx.json({ + access_token: 'accessToken', + refresh_token: 'refreshToken', + id_token: idToken, + }) + : ctx.status(401), + ), + ), + rest.get( + 'https://pinniped.test/idp/userinfo.openid', + (_req, res, ctx) => + res( + ctx.json({ + iss: 'https://pinniped.test', + sub: 'test', + aud: clientMetadata.clientId, + claims: { + given_name: 'Givenname', + family_name: 'Familyname', + email: 'user@example.com', + }, + }), + ctx.status(200), + ), + ), + ); + }); + + it('responds with ID token', async () => { + const { response } = await provider.handler(handlerRequest); + expect(response.providerInfo.idToken).toBe(idToken); + }); + + it.only('decodes profile from ID token', async () => { + const { response } = await provider.handler(handlerRequest); + + expect(response.profile).toStrictEqual({ + displayName: 'Givenname Familyname', + email: 'user@example.com', + }); + }); + + it('fails when request has no 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', + ); + }); }); }); diff --git a/plugins/auth-backend/src/providers/pinniped/provider.ts b/plugins/auth-backend/src/providers/pinniped/provider.ts index 9ed184abc3..d74004cb10 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.ts @@ -23,13 +23,13 @@ import { import { OAuthHandlers, OAuthProviderOptions, + OAuthRefreshRequest, OAuthResponse, OAuthStartRequest, encodeState, } from '../../lib/oauth'; import { executeFrameHandlerStrategy, - executeRedirectStrategy, PassportDoneCallback, } from '../../lib/passport'; import { AuthResolverContext, OAuthStartResponse } from '../types'; @@ -38,6 +38,9 @@ import { OidcAuthResult } from '../oidc'; import { OAuthAdapter, OAuthEnvironmentHandler } from '../../lib/oauth'; import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; import { AuthHandler, SignInResolver } from '../types'; +import { BACKSTAGE_SESSION_EXPIRATION } from '../../lib/session'; +import { InternalOAuthError } from 'passport-oauth2'; +import jwtDecoder from 'jwt-decode'; type OidcImpl = { strategy: OidcStrategy; @@ -72,6 +75,7 @@ export class PinnipedAuthProvider implements OAuthHandlers { private readonly signInResolver?: SignInResolver; private readonly authHandler: AuthHandler; private readonly resolverContext: AuthResolverContext; + // private readonly state?; constructor(options: PinnipedOptions) { this.implementation = this.setupStrategy(options); @@ -92,6 +96,7 @@ export class PinnipedAuthProvider implements OAuthHandlers { scope: req.scope || this.scope || 'openid profile email', state: encodeState(req.state), }; + // this.state = options.state return new Promise((resolve, reject) => { strategy.redirect = (url: string, status?: number) => { resolve({ url, status: status ?? undefined }); @@ -103,6 +108,102 @@ export class PinnipedAuthProvider implements OAuthHandlers { }); } + async handler( + req: express.Request, + ): Promise<{ response: OAuthResponse; refreshToken?: string }> { + const { strategy } = await this.implementation; + + // we are passed a state inside of a session object + // const options: Record = { + // state: encodeState(req.state), + // }; + + console.log(req); + // return { + // response: { + // profile: {}, + // providerInfo: { accessToken: '', scope: '' }, + // }, + // }; + + // const stateParam = new URL(startResponse.url).searchParams.get('state'); + // const state = Object.fromEntries( + // new URLSearchParams(Buffer.from(stateParam!, 'hex').toString('utf-8')), + // ); + return new Promise((resolve, reject) => { + strategy.success = ( + user: { + tokenset: { + id_token: string; + }; + }, + info: { refreshToken: string }, + ) => { + // const identity: Record = jwtDecoder( + // user.tokenset.id_token, + // ); + // const identity2 = + // console.log(identity); + resolve({ + response: { + profile: {}, + providerInfo: { + idToken: user.tokenset.id_token, + accessToken: '', + scope: '', + }, + }, + refreshToken: info.refreshToken, + }); + }; + + strategy.fail = info => { + if (info.message) { + reject(new Error(`Authentication rejected, ${info.message ?? ''}`)); + } else { + console.log('what the heckhappened'); + } + }; + + strategy.error = (error: InternalOAuthError) => { + let message = `Authentication failed, ${error.message}`; + if (error.oauthError?.data) { + try { + const errorData = JSON.parse(error.oauthError.data); + + if (errorData.message) { + message += ` - ${errorData.message}`; + } + } catch (parseError) { + message += ` - ${error.oauthError}`; + } + } + reject(new Error(message)); + }; + + strategy.redirect = () => { + reject(new Error('Unexpected redirect')); + }; + + strategy.authenticate(req); + }); + } + // 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 = client.issuer.userinfo_endpoint + // ? await client.userinfo(tokenset.access_token) + // : { sub: '' }; + + // return { + // response: await this.handleResult({ tokenset, userinfo }), + // refreshToken: tokenset.refresh_token, + // }; + // } + private async setupStrategy(options: PinnipedOptions): Promise { const issuer = await Issuer.discover( `${options.federationDomain}/.well-known/openid-configuration`, diff --git a/yarn.lock b/yarn.lock index 9f64dacf56..cdbbe10ced 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5031,13 +5031,14 @@ __metadata: fs-extra: 10.1.0 google-auth-library: ^8.0.0 jose: ^4.6.0 - jwt-decode: ^3.1.0 + jwt-decode: ^3.1.2 knex: ^2.0.0 lodash: ^4.17.21 luxon: ^3.0.0 minimatch: ^5.0.0 morgan: ^1.10.0 msw: ^1.0.0 + njwt: ^2.0.0 node-cache: ^5.1.2 node-fetch: ^2.6.7 openid-client: ^5.2.1 @@ -18284,7 +18285,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^15.6.1": +"@types/node@npm:^15.0.1, @types/node@npm:^15.6.1": version: 15.14.9 resolution: "@types/node@npm:15.14.9" checksum: 49f7f0522a3af4b8389aee660e88426490cd54b86356672a1fedb49919a8797c00d090ec2dcc4a5df34edc2099d57fc2203d796c4e7fbd382f2022ccd789eee7 @@ -24426,7 +24427,7 @@ __metadata: languageName: node linkType: hard -"ecdsa-sig-formatter@npm:1.0.11, ecdsa-sig-formatter@npm:^1.0.11": +"ecdsa-sig-formatter@npm:1.0.11, ecdsa-sig-formatter@npm:^1.0.11, ecdsa-sig-formatter@npm:^1.0.5": version: 1.0.11 resolution: "ecdsa-sig-formatter@npm:1.0.11" dependencies: @@ -31177,7 +31178,7 @@ __metadata: languageName: node linkType: hard -"jwt-decode@npm:*, jwt-decode@npm:^3.1.0": +"jwt-decode@npm:*, jwt-decode@npm:^3.1.0, jwt-decode@npm:^3.1.2": version: 3.1.2 resolution: "jwt-decode@npm:3.1.2" checksum: 20a4b072d44ce3479f42d0d2c8d3dabeb353081ba4982e40b83a779f2459a70be26441be6c160bfc8c3c6eadf9f6380a036fbb06ac5406b5674e35d8c4205eeb @@ -33704,6 +33705,17 @@ __metadata: languageName: node linkType: hard +"njwt@npm:^2.0.0": + version: 2.0.0 + resolution: "njwt@npm:2.0.0" + dependencies: + "@types/node": ^15.0.1 + ecdsa-sig-formatter: ^1.0.5 + uuid: ^8.3.2 + checksum: 3c6c33b2fd044bca7468171f5dca064f5a4f59ce0e63b567df62c1a8d720e3c3d65921d5e99ae72eb22fde3285ef42b6009b4c4469f06e3a0e66d88a6f393373 + languageName: node + linkType: hard + "no-case@npm:^3.0.4": version: 3.0.4 resolution: "no-case@npm:3.0.4"