From 5f472493d9a93e0368a6015d76dd0bd35c495db3 Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Wed, 22 May 2024 17:43:40 +0700 Subject: [PATCH] Fix dbAuth middleware and update tests --- .../__tests__/initDbAuthMiddleware.test.ts | 232 ++++++++++-------- .../dbAuth/middleware/src/index.ts | 127 ++++++---- 2 files changed, 205 insertions(+), 154 deletions(-) diff --git a/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts b/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts index 6cae357b3e3a..856aafe4ecb7 100644 --- a/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts +++ b/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts @@ -2,6 +2,7 @@ import path from 'node:path' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { dbAuthSession } from '@redwoodjs/auth-dbauth-api' import { MiddlewareRequest as MWRequest, MiddlewareRequest, @@ -18,13 +19,36 @@ const FIXTURE_PATH = path.resolve( beforeAll(() => { process.env.RWJS_CWD = FIXTURE_PATH + + // Mock the session decryption + vi.mock('@redwoodjs/auth-dbauth-api', async (importOriginal) => { + const original = (await importOriginal()) as any + return { + ...original, + dbAuthSession: vi.fn().mockImplementation((req, cookieName) => { + if ( + req.headers + .get('Cookie') + .includes(`${cookieName}=this_is_the_only_correct_session`) + ) { + return { + currentUser: { + email: 'user-1@example.com', + id: 'mocked-current-user-1', + }, + mockedSession: 'this_is_the_only_correct_session', + } + } + }), + } + }) }) afterAll(() => { delete process.env.RWJS_CWD }) -describe('initDbAuthMiddleware()', () => { +describe('dbAuthMiddleware', () => { it('When no cookie headers, pass through the response', async () => { const options: DbAuthMiddlewareOptions = { cookieName: '8911', @@ -53,15 +77,14 @@ describe('initDbAuthMiddleware()', () => { }) it('When it has a cookie header, decrypts and sets server auth context', async () => { - const cookieHeader = - 'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w==' + const cookieHeader = 'session=this_is_the_only_correct_session' const options: DbAuthMiddlewareOptions = { - cookieName: '8911', getCurrentUser: vi.fn(async () => { return { id: 'mocked-current-user-1', email: 'user-1@example.com' } }), dbAuthHandler: vi.fn(), + extractRoles: vi.fn(() => ['f1driver']), } const [middleware] = initDbAuthMiddleware(options) @@ -77,8 +100,59 @@ describe('initDbAuthMiddleware()', () => { const res = await middleware(mwReq, MiddlewareResponse.next()) expect(mwReq.serverAuthState.get()).toEqual({ - cookieHeader: - 'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w==', + cookieHeader: 'session=this_is_the_only_correct_session', + currentUser: { + email: 'user-1@example.com', + id: 'mocked-current-user-1', + }, + hasError: false, + isAuthenticated: true, + loading: false, + userMetadata: { + email: 'user-1@example.com', + id: 'mocked-current-user-1', + }, + roles: ['f1driver'], + }) + + expect(options.extractRoles).toHaveBeenCalledWith({ + currentUser: { + email: 'user-1@example.com', + id: 'mocked-current-user-1', + }, + mockedSession: 'this_is_the_only_correct_session', + }) + + // Allow react render, because body is not defined, and status code not redirect + expect(res).toHaveProperty('body', undefined) + expect(res).toHaveProperty('status', 200) + }) + + it('Will use the cookie name option correctly', async () => { + const cookieHeader = 'bazinga_8911=this_is_the_only_correct_session' + + const options: DbAuthMiddlewareOptions = { + getCurrentUser: vi.fn(async () => { + return { id: 'mocked-current-user-1', email: 'user-1@example.com' } + }), + dbAuthHandler: vi.fn(), + cookieName: 'bazinga_%port%', + } + const [middleware] = initDbAuthMiddleware(options) + + const mwReq = new MiddlewareRequest( + new Request('http://bazinga.new/kittens', { + method: 'GET', + headers: { + Cookie: cookieHeader, + }, + }), + ) + + const res = await middleware(mwReq, MiddlewareResponse.next()) + + expect(mwReq.serverAuthState.get()).toEqual({ + cookieHeader: 'bazinga_8911=this_is_the_only_correct_session', currentUser: { email: 'user-1@example.com', id: 'mocked-current-user-1', @@ -90,6 +164,7 @@ describe('initDbAuthMiddleware()', () => { email: 'user-1@example.com', id: 'mocked-current-user-1', }, + // No extract roles function, so it should be empty roles: [], }) @@ -98,6 +173,39 @@ describe('initDbAuthMiddleware()', () => { expect(res).toHaveProperty('status', 200) }) + it('handles a currentUser request', async () => { + const cookieHeader = 'session=this_is_the_only_correct_session' + const request = new Request( + 'http://localhost:8910/middleware/dbauth/currentUser', + { + method: 'GET', + headers: { + Cookie: cookieHeader, + }, + }, + ) + + const req = new MWRequest(request) + const cookie = req.headers.get('Cookie') + + expect(cookie).toBe(cookieHeader) + + const currentUser = { user: { id: 100, email: 'currentUser@example.com' } } + + const options: DbAuthMiddlewareOptions = { + getCurrentUser: async () => { + return currentUser + }, + dbAuthHandler: vi.fn(), + } + const [middleware] = initDbAuthMiddleware(options) + + const res = await middleware(req, MiddlewareResponse.next()) + + expect(res).toBeDefined() + expect(res?.body).toBe(JSON.stringify({ currentUser })) + }) + describe('handle all supported dbAuth verbs (aka methods) and their HTTP methods', async () => { /** * Supported verbs and their corresponding HTTP methods: @@ -300,13 +408,12 @@ describe('initDbAuthMiddleware()', () => { const req = new MWRequest(request) const options: DbAuthMiddlewareOptions = { - cookieName: 'session_8911', getCurrentUser: async () => { return { user: { id: 100, email: 'tolkienUser@example.com' } } }, dbAuthHandler: async () => { return { - body: '', + body: 'getTokenResponse', headers: {}, statusCode: 200, } @@ -316,14 +423,9 @@ describe('initDbAuthMiddleware()', () => { const res = await middleware(req, MiddlewareResponse.next()) expect(res).toBeDefined() - - const serverAuthState = req.serverAuthState.get() - expect(serverAuthState.isAuthenticated).toBe(true) - expect(serverAuthState.currentUser).toEqual({ - user: { id: 100, email: 'tolkienUser@example.com' }, - }) - expect(serverAuthState.cookieHeader).toBe(cookieHeader) + expect(res?.body).toBe('getTokenResponse') }) + it('handles a validateResetToken request', async () => { const request = new Request( 'http://localhost:8911/middleware/dbauth/auth?method=validateResetToken', @@ -361,8 +463,9 @@ describe('initDbAuthMiddleware()', () => { const serverAuthState = req.serverAuthState.get() expect(serverAuthState.isAuthenticated).toBe(false) }) + it('handles a webAuthnRegOptions request', async () => { - const body = JSON.stringify({ + const regOptionsBody = JSON.stringify({ r: { id: 1 }, user: { user: { id: 100, email: 'user@example.com' } }, challenge: 'challenge', @@ -387,7 +490,7 @@ describe('initDbAuthMiddleware()', () => { }, dbAuthHandler: async () => { return { - body, + body: regOptionsBody, headers: {}, statusCode: 200, } @@ -396,10 +499,7 @@ describe('initDbAuthMiddleware()', () => { const [middleware] = initDbAuthMiddleware(options) const res = await middleware(req, MiddlewareResponse.next()) - expect(res).toBeDefined() - // should the body be the webAuth reg options? - // but get requests need a cookie to be set? - // expect(res?.body).toBeDefined() + expect(res?.body).toBe(regOptionsBody) }) // @todo: implement the following tests when try out webAuth // it('handles a webAuthnRegister', async () => { @@ -412,49 +512,6 @@ describe('initDbAuthMiddleware()', () => { // //: 'POST', // }) }) - it('handles a currentUser request', async () => { - // encrypted session taken fom dbAuth tests - // I cannot figure out why the header here has to be session - // but the cookieName session_8911 to work properly - const cookieHeader = - 'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w==' - const request = new Request( - 'http://localhost:8911/middleware/dbauth/currentUser', - { - method: 'GET', - headers: { - Cookie: cookieHeader, - }, - }, - ) - - const req = new MWRequest(request) - const cookie = req.headers.get('Cookie') - - expect(cookie).toBe(cookieHeader) - - const currentUser = { user: { id: 100, email: 'currentUser@example.com' } } - - const options: DbAuthMiddlewareOptions = { - cookieName: 'session_8911', - getCurrentUser: async () => { - return currentUser - }, - dbAuthHandler: async () => { - return { - body: '', - headers: {}, - statusCode: 200, - } - }, - } - const [middleware] = initDbAuthMiddleware(options) - - const res = await middleware(req, MiddlewareResponse.next()) - - expect(res).toBeDefined() - expect(res?.body).toBe(JSON.stringify({ currentUser })) - }) describe('handle exception cases', async () => { const unauthenticatedServerAuthState = { @@ -463,6 +520,11 @@ describe('initDbAuthMiddleware()', () => { roles: [], } + beforeAll(() => { + // So that we don't see errors in console when running negative cases + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + it('handles a POST that is not one of the supported dbAuth verbs and still build headers when passing along the request', async () => { const request = new Request( 'http://localhost:8911/middleware/dbauth/unsupportedVerb', @@ -502,48 +564,6 @@ describe('initDbAuthMiddleware()', () => { const serverAuthState = req.serverAuthState.get() expect(serverAuthState).toHaveProperty('isAuthenticated', false) }) - it('handles a GET request with correct cookies', async () => { - // encrypted session taken fom dbAuth tests - // I cannot figure out why the header here has to be session - // but the cookieName session_8911 to work properly - const cookieHeader = - 'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w==' - const request = new Request('http://localhost:8911/functions/hello', { - method: 'GET', - headers: { - Cookie: cookieHeader, - }, - }) - - const req = new MWRequest(request) - const cookie = req.headers.get('Cookie') - - expect(cookie).toBe(cookieHeader) - - const options: DbAuthMiddlewareOptions = { - cookieName: 'session_8911', - getCurrentUser: async () => { - return { user: { id: 100, email: 'hello@example.com' } } - }, - dbAuthHandler: async () => { - return { - body: '', - headers: {}, - statusCode: 200, - } - }, - } - const [middleware] = initDbAuthMiddleware(options) - - const res = await middleware(req, MiddlewareResponse.next()) - const serverAuthState = req.serverAuthState.get() - - expect(res).toBeDefined() - expect(serverAuthState.isAuthenticated).toBe(true) - expect(serverAuthState.currentUser).toEqual({ - user: { id: 100, email: 'hello@example.com' }, - }) - }) it('handles a GET request with incorrect cookies (bad decrypt)', async () => { const request = new Request( diff --git a/packages/auth-providers/dbAuth/middleware/src/index.ts b/packages/auth-providers/dbAuth/middleware/src/index.ts index 2ec8f31c716c..e54c642b6092 100644 --- a/packages/auth-providers/dbAuth/middleware/src/index.ts +++ b/packages/auth-providers/dbAuth/middleware/src/index.ts @@ -6,11 +6,11 @@ import { dbAuthSession, } from '@redwoodjs/auth-dbauth-api' import type { GetCurrentUser } from '@redwoodjs/graphql-server' -import type { Middleware } from '@redwoodjs/vite/middleware' +import type { Middleware, MiddlewareRequest } from '@redwoodjs/vite/middleware' import { MiddlewareResponse } from '@redwoodjs/vite/middleware' export interface DbAuthMiddlewareOptions { - cookieName: string + cookieName?: string dbAuthUrl?: string // @NOTE: we never pass lambda event or contexts, when using middleware // this is because in existing projects have it typed api/src/functions/auth.ts @@ -23,36 +23,43 @@ export interface DbAuthMiddlewareOptions { } export const initDbAuthMiddleware = ({ - cookieName, dbAuthHandler, getCurrentUser, extractRoles, + cookieName, dbAuthUrl = '/middleware/dbauth', }: DbAuthMiddlewareOptions): [Middleware, '*'] => { const mw: Middleware = async (req, res = MiddlewareResponse.next()) => { - // Handoff POST requests to the dbAuthHandler. The url is configurable on the dbAuth client side. - // This is where we handle login, logout, and signup, etc., but we don't want to intercept - if (req.method === 'POST') { - if (!req.url.includes(dbAuthUrl)) { - // Bail if the POST request is not for the dbAuthHandler - return res - } - - const output = await dbAuthHandler(req) - const finalHeaders = new Headers() - - Object.entries(output.headers).forEach(([key, value]) => { - if (Array.isArray(value)) { - value.forEach((mvhHeader) => finalHeaders.append(key, mvhHeader)) - } else { - finalHeaders.append(key, value) - } - }) + // Handoff POST and some GET requests to the dbAuthHandler. The url is configurable on the dbAuth client side. + // This is where we handle login, logout, and signup, etc., no need to enrich the context + if (req.url.includes(dbAuthUrl)) { + // Short circuit here ... + // if the call came from packages/auth-providers/dbAuth/web/src/getCurrentUserFromMiddleware.ts + if (req.url.includes(`${dbAuthUrl}/currentUser`)) { + const { currentUser } = await validateSession({ + req, + cookieName, + getCurrentUser, + }) - return new MiddlewareResponse(output.body, { - headers: finalHeaders, - status: output.statusCode, - }) + return new MiddlewareResponse(JSON.stringify({ currentUser })) + } else { + const output = await dbAuthHandler(req) + const finalHeaders = new Headers() + + Object.entries(output.headers).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach((mvhHeader) => finalHeaders.append(key, mvhHeader)) + } else { + finalHeaders.append(key, value) + } + }) + + return new MiddlewareResponse(output.body, { + headers: finalHeaders, + status: output.statusCode, + }) + } } const cookieHeader = req.headers.get('Cookie') @@ -65,28 +72,12 @@ export const initDbAuthMiddleware = ({ // 👇 Authenticated request try { // Call the dbAuth auth decoder. For dbAuth we have direct access to the `dbAuthSession` function. - // Other providers may be slightly different. - const decryptedSession = dbAuthSession(req as Request, cookieName) - - const currentUser = await getCurrentUser( - decryptedSession, - { - type: 'dbAuth', - schema: 'cookie', - // @MARK: We pass the entire cookie header as a token. This isn't actually the token! - token: cookieHeader, - }, - { - // MWRequest is a superset of Request - event: req as Request, - }, - ) - - // Short circuit here ... - // if the call came from packages/auth-providers/dbAuth/web/src/getCurrentUserFromMiddleware.ts - if (req.url.includes(`${dbAuthUrl}/currentUser`)) { - return new MiddlewareResponse(JSON.stringify({ currentUser })) - } + // Other providers will be slightly different. + const { currentUser, decryptedSession } = await validateSession({ + req, + cookieName, + getCurrentUser, + }) req.serverAuthState.set({ currentUser, @@ -99,7 +90,7 @@ export const initDbAuthMiddleware = ({ }) } catch (e) { // Clear server auth context - console.error(e, 'Error decrypting dbAuth cookie') + console.error('Error decrypting dbAuth cookie \n', e) req.serverAuthState.clear() // Note we have to use ".unset" and not ".clear" @@ -116,4 +107,44 @@ export const initDbAuthMiddleware = ({ return [mw, '*'] } +interface ValidateParams { + req: MiddlewareRequest + getCurrentUser: GetCurrentUser + cookieName?: string +} + +async function validateSession({ + req, + cookieName, + getCurrentUser, +}: ValidateParams) { + const decryptedSession = dbAuthSession( + req as Request, + cookieNameCreator(cookieName), + ) + + // So that it goes into the catch block + if (!decryptedSession) { + throw new Error( + `No decrypted session found. Check passed in cookie name options to middleware, looking for "${cookieName}"`, + ) + } + + const currentUser = await getCurrentUser( + decryptedSession, + { + type: 'dbAuth', + schema: 'cookie', + // @MARK: We pass the entire cookie header as a token. This isn't actually the token! + // At this point the Cookie header is guaranteed, because otherwise a decryptionError would be thrown + token: req.headers.get('Cookie') as string, + }, + { + // MWRequest is a superset of Request + event: req as Request, + }, + ) + return { currentUser, decryptedSession } +} + export default initDbAuthMiddleware