diff --git a/.changesets/10457.md b/.changesets/10457.md new file mode 100644 index 000000000000..daa8f21d0e87 --- /dev/null +++ b/.changesets/10457.md @@ -0,0 +1,28 @@ +- feat(server-auth): dbAuth 3/3 - handle login, logout, signup, etc. requests if forwarded from middleware (#10457) by @dac09 + +This PR updates the DbAuthHandler class to handle requests forwarded from middleware, so it can generate responses for login, logout, signup, etc. These are POST requests - it used to be to the `/auth` function, but now they will be captured by dbAuth middleware and forwarded onto DbAuthHandler. + +**High level changes:** +- use the `Headers` class in each of the "method" responses. This allows us to set multi-value headers like Set-Cookie. A simple object would not. See type `AuthMethodOutput` +- extracts `buildResponse` into a testable function and adds test. For `Set-Cookie` headers we return an array of strings. + +In the middleware here's the code I had for the final conversion: +```ts + if (AUTHHANDLER_REQUEST) { + const output = await dbAuthHandler(req) + + const finalHeaders = new Headers() + Object.entries(output.headers).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach((v) => finalHeaders.append(key, v)) + } else { + finalHeaders.append(key, value) + } + }) + + return new MiddlewareResponse(output.body, { + headers: finalHeaders, + status: output.statusCode, + }) + } +``` diff --git a/packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts b/packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts index d2f3e563ca53..80553c7cb0cd 100644 --- a/packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts +++ b/packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts @@ -16,12 +16,7 @@ import base64url from 'base64url' import md5 from 'md5' import { v4 as uuidv4 } from 'uuid' -import type { - CorsConfig, - CorsContext, - CorsHeaders, - PartialRequest, -} from '@redwoodjs/api' +import type { CorsConfig, CorsContext, PartialRequest } from '@redwoodjs/api' import { createCorsContext, isFetchApiRequest, @@ -30,22 +25,20 @@ import { import * as DbAuthError from './errors' import { + buildDbAuthResponse, cookieName, decryptSession, encryptSession, extractCookie, + extractHashingOptions, getSession, hashPassword, - legacyHashPassword, hashToken, - webAuthnSession, - extractHashingOptions, isLegacySession, + legacyHashPassword, + webAuthnSession, } from './shared' -type SetCookieHeader = { 'set-cookie': string } -type CsrfTokenHeader = { 'csrf-token': string } - interface SignupFlowOptions> { /** * Allow users to sign up. Defaults to true. @@ -168,6 +161,12 @@ interface WebAuthnFlowOptions { export type UserType = Record +type AuthMethodOutput = [ + string | Record | boolean | undefined, // body + Headers?, + { statusCode: number }?, +] + export interface DbAuthHandlerOptions< TUser = UserType, TUserAttributes = Record, @@ -278,11 +277,11 @@ export type AuthMethodNames = | 'logout' | 'resetPassword' | 'signup' - | 'validateResetToken' + | 'webAuthnAuthenticate' + | 'webAuthnAuthOptions' | 'webAuthnRegOptions' | 'webAuthnRegister' - | 'webAuthnAuthOptions' - | 'webAuthnAuthenticate' + | 'validateResetToken' type Params = AuthenticationResponseJSON & RegistrationResponseJSON & { @@ -295,7 +294,7 @@ type Params = AuthenticationResponseJSON & transports?: string // used by webAuthN for something } -type DbAuthSession = Record +type DbAuthSession = Record const DEFAULT_ALLOWED_USER_FIELDS = ['id', 'email'] @@ -387,15 +386,25 @@ export class DbAuthHandler< * * @see: https://www.rfc-editor.org/rfc/rfc7540#section-8.1.2 */ - get _deleteSessionHeader() { - return { - 'set-cookie': [ + get _deleteSessionHeader(): Headers { + const deleteHeaders = new Headers() + + deleteHeaders.append( + 'set-cookie', + [ `${cookieName(this.options.cookie?.name)}=`, ...this._cookieAttributes({ expires: 'now' }), - // `auth-provider=`, - // ...this._cookieAttributes({ expires: 'now' }), ].join(';'), - } + ) + + deleteHeaders.append( + 'set-cookie', + [`auth-provider=`, ...this._cookieAttributes({ expires: 'now' })].join( + ';', + ), + ) + + return deleteHeaders } constructor( @@ -477,17 +486,14 @@ export class DbAuthHandler< corsHeaders = this.corsContext.getRequestHeaders(this.normalizedRequest) // Return CORS headers for OPTIONS requests if (this.corsContext.shouldHandleCors(this.normalizedRequest)) { - return this._buildResponseWithCorsHeaders( - { body: '', statusCode: 200 }, - corsHeaders, - ) + return buildDbAuthResponse({ body: '', statusCode: 200 }, corsHeaders) } } // if there was a problem decryption the session, just return the logout // response immediately if (this.hasInvalidSession) { - return this._buildResponseWithCorsHeaders( + return buildDbAuthResponse( this._ok(...this._logoutResponse()), corsHeaders, ) @@ -498,27 +504,24 @@ export class DbAuthHandler< // get the auth method the incoming request is trying to call if (!DbAuthHandler.METHODS.includes(method)) { - return this._buildResponseWithCorsHeaders(this._notFound(), corsHeaders) + return buildDbAuthResponse(this._notFound(), corsHeaders) } // make sure it's using the correct verb, GET vs POST if (this.httpMethod !== DbAuthHandler.VERBS[method]) { - return this._buildResponseWithCorsHeaders(this._notFound(), corsHeaders) + return buildDbAuthResponse(this._notFound(), corsHeaders) } // call whatever auth method was requested and return the body and headers const [body, headers, options = { statusCode: 200 }] = await this[method]() - return this._buildResponseWithCorsHeaders( - this._ok(body, headers, options), - corsHeaders, - ) + return buildDbAuthResponse(this._ok(body, headers, options), corsHeaders) } catch (e: any) { if (e instanceof DbAuthError.WrongVerbError) { - return this._buildResponseWithCorsHeaders(this._notFound(), corsHeaders) + return buildDbAuthResponse(this._notFound(), corsHeaders) } else { - return this._buildResponseWithCorsHeaders( + return buildDbAuthResponse( this._badRequest(e.message || e), corsHeaders, ) @@ -526,7 +529,7 @@ export class DbAuthHandler< } } - async forgotPassword() { + async forgotPassword(): Promise { const { enabled = true } = this.options.forgotPassword if (!enabled) { @@ -591,9 +594,7 @@ export class DbAuthHandler< return [ response ? JSON.stringify(response) : '', - { - ...this._deleteSessionHeader, - }, + this._deleteSessionHeader, ] } else { throw new DbAuthError.UsernameNotFoundError( @@ -603,10 +604,10 @@ export class DbAuthHandler< } } - async getToken() { + async getToken(): Promise { try { const user = await this._getCurrentUser() - let headers = {} + let headers = new Headers() // if the session was encrypted with the old algorithm, re-encrypt it // with the new one @@ -624,7 +625,7 @@ export class DbAuthHandler< } } - async login() { + async login(): Promise { const { enabled = true } = this.options.login if (!enabled) { @@ -650,11 +651,11 @@ export class DbAuthHandler< return this._loginResponse(handlerUser) } - logout() { + logout(): AuthMethodOutput { return this._logoutResponse() } - async resetPassword() { + async resetPassword(): Promise { const { enabled = true } = this.options.resetPassword if (!enabled) { throw new DbAuthError.FlowNotEnabledError( @@ -727,7 +728,7 @@ export class DbAuthHandler< } } - async signup() { + async signup(): Promise { const { enabled = true } = this.options.signup if (!enabled) { throw new DbAuthError.FlowNotEnabledError( @@ -752,11 +753,11 @@ export class DbAuthHandler< return this._loginResponse(user, 201) } else { const message = userOrMessage - return [JSON.stringify({ message }), {}, { statusCode: 201 }] + return [JSON.stringify({ message }), new Headers(), { statusCode: 201 }] } } - async validateResetToken() { + async validateResetToken(): Promise { const { resetToken } = this.normalizedRequest.jsonBody || {} // is token present at all? if (!resetToken || String(resetToken).trim() === '') { @@ -769,17 +770,14 @@ export class DbAuthHandler< const user = await this._findUserByToken(resetToken) - return [ - JSON.stringify(this._sanitizeUser(user)), - { - ...this._deleteSessionHeader, - }, - ] + return [JSON.stringify(this._sanitizeUser(user)), this._deleteSessionHeader] } // browser submits WebAuthn credentials - async webAuthnAuthenticate() { - const { verifyAuthenticationResponse } = require('@simplewebauthn/server') + async webAuthnAuthenticate(): Promise { + const { verifyAuthenticationResponse } = await import( + '@simplewebauthn/server' + ) const webAuthnOptions = this.options.webAuthn const { rawId } = this.normalizedRequest.jsonBody || {} @@ -857,18 +855,22 @@ export class DbAuthHandler< } // get the regular `login` cookies - const [, loginHeaders] = this._loginResponse(user) - const cookies = [ + const [, headers] = this._loginResponse(user) + + // Now add the webAuthN cookies + headers.append( + 'set-cookie', this._webAuthnCookie(rawId, this.webAuthnExpiresDate), - loginHeaders['set-cookie'], - ].flat() + ) - return [verified, { 'set-cookie': cookies }] + return [verified, headers] } // get options for a WebAuthn authentication - async webAuthnAuthOptions() { - const { generateAuthenticationOptions } = require('@simplewebauthn/server') + async webAuthnAuthOptions(): Promise { + const { generateAuthenticationOptions } = await import( + '@simplewebauthn/server' + ) if (this.options.webAuthn === undefined || !this.options.webAuthn.enabled) { throw new DbAuthError.WebAuthnError('WebAuthn is not enabled') @@ -897,7 +899,7 @@ export class DbAuthHandler< if (!user) { return [ { error: 'Log in with username and password to enable WebAuthn' }, - { 'set-cookie': this._webAuthnCookie('', 'now') }, + new Headers([['set-cookie', this._webAuthnCookie('', 'now')]]), { statusCode: 400 }, ] } @@ -933,8 +935,10 @@ export class DbAuthHandler< } // get options for WebAuthn registration - async webAuthnRegOptions() { - const { generateRegistrationOptions } = require('@simplewebauthn/server') + async webAuthnRegOptions(): Promise { + const { generateRegistrationOptions } = await import( + '@simplewebauthn/server' + ) if (!this.options?.webAuthn?.enabled) { throw new DbAuthError.WebAuthnError('WebAuthn is not enabled') @@ -977,8 +981,10 @@ export class DbAuthHandler< } // browser submits WebAuthn credentials for the first time on a new device - async webAuthnRegister() { - const { verifyRegistrationResponse } = require('@simplewebauthn/server') + async webAuthnRegister(): Promise { + const { verifyRegistrationResponse } = await import( + '@simplewebauthn/server' + ) if (this.options.webAuthn === undefined || !this.options.webAuthn.enabled) { throw new DbAuthError.WebAuthnError('WebAuthn is not enabled') @@ -1038,15 +1044,14 @@ export class DbAuthHandler< // clear challenge await this._saveChallenge(user[this.options.authFields.id], null) - return [ - verified, - { - 'set-cookie': this._webAuthnCookie( - plainCredentialId, - this.webAuthnExpiresDate, - ), - }, - ] + const headers = new Headers([ + [ + 'set-cookie', + this._webAuthnCookie(plainCredentialId, this.webAuthnExpiresDate), + ], + ]) + + return [verified, headers] } // validates that we have all the ENV and options we need to login/signup @@ -1202,22 +1207,27 @@ export class DbAuthHandler< return meta } + _createAuthProviderCookieString(): string { + return [ + `auth-provider=dbAuth`, + ...this._cookieAttributes({ expires: this.sessionExpiresDate }), + ].join(';') + } + // returns the set-cookie header to be returned in the request (effectively // creates the session) - _createSessionHeader( - data: DbAuthSession, + _createSessionCookieString( + data: DbAuthSession, csrfToken: string, - ): SetCookieHeader { + ): string { const session = JSON.stringify(data) + ';' + csrfToken const encrypted = encryptSession(session) - const cookie = [ + const sessionCookieString = [ `${cookieName(this.options.cookie?.name)}=${encrypted}`, ...this._cookieAttributes({ expires: this.sessionExpiresDate }), - // 'auth-provider=dbAuth', - // ...this._cookieAttributes({ expires: this.sessionExpiresDate }), // TODO need this to be not http-only ].join(';') - return { 'set-cookie': cookie } + return sessionCookieString } // checks the CSRF token in the header against the CSRF token in the session @@ -1489,45 +1499,39 @@ export class DbAuthHandler< _loginResponse( user: Record, statusCode = 200, - ): [ - { id: string }, - SetCookieHeader & CsrfTokenHeader, - { statusCode: number }, - ] { + ): [{ id: string }, Headers, { statusCode: number }] { const sessionData = this._sanitizeUser(user) // TODO: this needs to go into graphql somewhere so that each request makes a new CSRF token and sets it in both the encrypted session and the csrf-token header const csrfToken = DbAuthHandler.CSRF_TOKEN - return [ - sessionData, - { - 'csrf-token': csrfToken, - // @TODO We need to have multiple Set-Cookie headers - // Not sure how to do this yet! - ...this._createSessionHeader(sessionData, csrfToken), - }, - { statusCode }, - ] + const headers = new Headers() + + headers.append('csrf-token', csrfToken) + headers.append('set-cookie', this._createAuthProviderCookieString()) + headers.append( + 'set-cookie', + this._createSessionCookieString(sessionData, csrfToken), + ) + + return [sessionData, headers, { statusCode }] } - _logoutResponse( - response?: Record, - ): [string, SetCookieHeader] { - return [ - response ? JSON.stringify(response) : '', - { - ...this._deleteSessionHeader, - }, - ] + _logoutResponse(response?: Record): AuthMethodOutput { + return [response ? JSON.stringify(response) : '', this._deleteSessionHeader] } - _ok(body: string, headers = {}, options = { statusCode: 200 }) { + _ok( + body: string | boolean | undefined | Record, + headers = new Headers(), + options = { statusCode: 200 }, + ) { + headers.append('content-type', 'application/json') + return { statusCode: options.statusCode, - // @TODO should we do a null check in body?! body: typeof body === 'string' ? body : JSON.stringify(body), - headers: { 'Content-Type': 'application/json', ...headers }, + headers, } } @@ -1541,24 +1545,7 @@ export class DbAuthHandler< return { statusCode: 400, body: JSON.stringify({ error: message }), - headers: { 'Content-Type': 'application/json' }, - } - } - - _buildResponseWithCorsHeaders( - response: { - body?: string - statusCode: number - headers?: Record - }, - corsHeaders: CorsHeaders, - ) { - return { - ...response, - headers: { - ...(response.headers || {}), - ...corsHeaders, - }, + headers: new Headers({ 'content-type': 'application/json' }), } } diff --git a/packages/auth-providers/dbAuth/api/src/__tests__/DbAuthHandler.fetch.test.js b/packages/auth-providers/dbAuth/api/src/__tests__/DbAuthHandler.fetch.test.js index 4004327419bc..17f17ffda554 100644 --- a/packages/auth-providers/dbAuth/api/src/__tests__/DbAuthHandler.fetch.test.js +++ b/packages/auth-providers/dbAuth/api/src/__tests__/DbAuthHandler.fetch.test.js @@ -122,11 +122,33 @@ const createDbUser = async (attributes = {}) => { } const expectLoggedOutResponse = (response) => { - expect(response[1]['set-cookie']).toEqual(LOGOUT_COOKIE) + const setCookie = response[1].getSetCookie() + + const deleteSession = setCookie.some((cookie) => { + return cookie === LOGOUT_COOKIE + }) + + const authProviderPresent = setCookie.some((cookie) => { + return cookie.match('auth-provider=') + }) + + expect(deleteSession).toBe(true) + expect(authProviderPresent).toBe(true) } const expectLoggedInResponse = (response) => { - expect(response[1]['set-cookie']).toMatch(SET_SESSION_REGEX) + const setCookie = response[1].getSetCookie() + + const sessionPresent = setCookie.some((cookie) => { + return cookie.match(SET_SESSION_REGEX) + }) + + const authProviderPresent = setCookie.some((cookie) => { + return cookie.match('auth-provider=') + }) + + expect(sessionPresent).toBe(true) + expect(authProviderPresent).toBe(true) } const encryptToCookie = (data) => { @@ -303,10 +325,17 @@ describe('dbAuth', () => { const dbAuth = new DbAuthHandler(req, context, options) await dbAuth.init() const headers = dbAuth._deleteSessionHeader + const headersObj = Object.fromEntries( + dbAuth._deleteSessionHeader.entries(), + ) - expect(Object.keys(headers).length).toEqual(1) - expect(Object.keys(headers)).toContain('set-cookie') - expect(headers['set-cookie']).toEqual(LOGOUT_COOKIE) + expect(Object.keys(headersObj).length).toEqual(1) + + // Get setSetCookie returns an array of set-cookie headers + expect(headers.getSetCookie()).toContainEqual(LOGOUT_COOKIE) + expect(headers.getSetCookie()).toContainEqual( + 'auth-provider=;Expires=Thu, 01 Jan 1970 00:00:00 GMT', + ) }) }) @@ -627,7 +656,10 @@ describe('dbAuth', () => { const dbAuth = new DbAuthHandler(myEvent, context, options) const response = await dbAuth.invoke() - expect(response.headers['set-cookie']).toEqual(LOGOUT_COOKIE) + expect(response.headers['set-cookie']).toContain(LOGOUT_COOKIE) + expect(response.headers['set-cookie']).toContain( + 'auth-provider=;Expires=Thu, 01 Jan 1970 00:00:00 GMT', + ) }) it('returns a 404 if using the wrong HTTP verb', async () => { @@ -722,14 +754,14 @@ describe('dbAuth', () => { const dbAuth = new DbAuthHandler(fetchEvent, context, options) await dbAuth.init() - dbAuth.logout = vi.fn(() => ['body', { foo: 'bar' }]) + dbAuth.logout = vi.fn(() => ['body', new Headers([['foo', 'bar']])]) const response = await dbAuth.invoke() expect(dbAuth.logout).toHaveBeenCalled() expect(response.statusCode).toEqual(200) expect(response.body).toEqual('body') expect(response.headers).toEqual({ - 'Content-Type': 'application/json', + 'content-type': 'application/json', foo: 'bar', }) }) @@ -1215,8 +1247,9 @@ describe('dbAuth', () => { const dbAuth = new DbAuthHandler(req, context, options) await dbAuth.init() - const response = await dbAuth.login() - expect(response[1]['csrf-token']).toMatch(UUID_REGEX) + const [_, headers] = await dbAuth.login() + const csrfHeader = headers.get('csrf-token') + expect(csrfHeader).toMatch(UUID_REGEX) }) it('returns a set-cookie header to create session', async () => { @@ -1232,9 +1265,9 @@ describe('dbAuth', () => { const dbAuth = new DbAuthHandler(req, context, options) await dbAuth.init() - const response = await dbAuth.login() + const [_, headers] = await dbAuth.login() - expect(response[1]['csrf-token']).toMatch(UUID_REGEX) + expect(headers.get('csrf-token')).toMatch(UUID_REGEX) }) it('returns a CSRF token in the header', async () => { @@ -1892,7 +1925,7 @@ describe('dbAuth', () => { }) it('returns a message if a string is returned and does not log in', async () => { - const body = JSON.stringify({ + const reqBody = JSON.stringify({ username: 'rob@redwoodjs.com', password: 'password', name: 'Rob', @@ -1902,20 +1935,22 @@ describe('dbAuth', () => { } const req = new Request('http://localhost:8910/_rw_mw', { method: 'POST', - body, + body: reqBody, }) const dbAuth = new DbAuthHandler(req, context, options) await dbAuth.init() - const response = await dbAuth.signup() + const [body, headers, other] = await dbAuth.signup() // returns message - expect(response[0]).toEqual('{"message":"Hello, world"}') - // does not log them in - expect(response[1]['set-cookie']).toBeUndefined() + expect(body).toEqual('{"message":"Hello, world"}') + + const headersValues = Object.fromEntries(headers.values()) + // no login headers + expect(headersValues).toEqual({}) // 201 Created - expect(response[2].statusCode).toEqual(201) + expect(other.statusCode).toEqual(201) }) }) @@ -1934,8 +1969,6 @@ describe('dbAuth', () => { headers, }) - req.headers.get('cookie') - const dbAuth = new DbAuthHandler(req, context, options) await dbAuth.init() const response = await dbAuth.getToken() @@ -1983,7 +2016,7 @@ describe('dbAuth', () => { const [userId, headers] = await dbAuth.getToken() expect(userId).toEqual(7) - expect(headers['set-cookie']).toMatch(SET_SESSION_REGEX) + expect(headers.get('set-cookie')).toMatch(SET_SESSION_REGEX) // set session back to default process.env.SESSION_SECRET = SESSION_SECRET @@ -2223,7 +2256,8 @@ describe('dbAuth', () => { const [body, headers] = await dbAuth.webAuthnAuthenticate() expect(body).toEqual(false) - expect(headers['set-cookie'][0]).toMatch( + + expect(headers.get('set-cookie')).toMatch( 'webAuthn=CxMJqILwYufSaEQsJX6rKHw_LkMXAGU64PaKU55l6ejZ4FNO5kBLiA', ) }) @@ -2604,22 +2638,23 @@ describe('dbAuth', () => { }) }) - describe('_createSessionHeader()', () => { + describe('_createSessionCookieString()', () => { it('returns a Set-Cookie header', async () => { const dbAuth = new DbAuthHandler(req, context, options) await dbAuth.init() - const headers = dbAuth._createSessionHeader({ foo: 'bar' }, 'abcd') - expect(Object.keys(headers).length).toEqual(1) - expect(headers['set-cookie']).toMatch( - `Expires=${dbAuth.sessionExpiresDate}`, + const cookieString = dbAuth._createSessionCookieString( + { foo: 'bar' }, + 'abcd', ) + + expect(cookieString).toMatch(`Expires=${dbAuth.sessionExpiresDate}`) // can't really match on the session value since it will change on every render, // due to CSRF token generation but we can check that it contains only the // characters that would be returned by the encrypt function - expect(headers['set-cookie']).toMatch(SET_SESSION_REGEX) + expect(cookieString).toMatch(SET_SESSION_REGEX) // and we can check that it's a certain number of characters - expect(headers['set-cookie'].split(';')[0].length).toEqual(77) + expect(cookieString.split(';')[0].length).toEqual(77) }) }) @@ -3304,10 +3339,10 @@ describe('dbAuth', () => { it('returns the response array necessary to log user out', async () => { const dbAuth = new DbAuthHandler(req, context, options) await dbAuth.init() - const [body, headers] = dbAuth._logoutResponse() + const response = dbAuth._logoutResponse() - expect(body).toEqual('') - expect(headers['set-cookie']).toMatch(/^session=;/) + expect(response[0]).toEqual('') + expectLoggedOutResponse(response) }) it('can accept an object to return in the body', async () => { @@ -3325,7 +3360,7 @@ describe('dbAuth', () => { it('returns a 200 response by default', async () => { const dbAuth = new DbAuthHandler(req, context, options) await dbAuth.init() - const response = dbAuth._ok('', {}) + const response = dbAuth._ok('') expect(response.statusCode).toEqual(200) }) @@ -3333,7 +3368,7 @@ describe('dbAuth', () => { it('can return other status codes', async () => { const dbAuth = new DbAuthHandler(req, context, options) await dbAuth.init() - const response = dbAuth._ok('', {}, { statusCode: 201 }) + const response = dbAuth._ok('', new Headers(), { statusCode: 201 }) expect(response.statusCode).toEqual(201) }) @@ -3341,7 +3376,9 @@ describe('dbAuth', () => { it('stringifies a JSON body', async () => { const dbAuth = new DbAuthHandler(req, context, options) await dbAuth.init() - const response = dbAuth._ok({ foo: 'bar' }, {}, { statusCode: 201 }) + const response = dbAuth._ok({ foo: 'bar' }, new Headers(), { + statusCode: 201, + }) expect(response.body).toEqual('{"foo":"bar"}') }) @@ -3349,7 +3386,9 @@ describe('dbAuth', () => { it('does not stringify a body that is a string already', async () => { const dbAuth = new DbAuthHandler(req, context, options) await dbAuth.init() - const response = dbAuth._ok('{"foo":"bar"}', {}, { statusCode: 201 }) + const response = dbAuth._ok('{"foo":"bar"}', new Headers(), { + statusCode: 201, + }) expect(response.body).toEqual('{"foo":"bar"}') }) diff --git a/packages/auth-providers/dbAuth/api/src/__tests__/DbAuthHandler.test.js b/packages/auth-providers/dbAuth/api/src/__tests__/DbAuthHandler.test.js index 79806fb8703a..c30d2d77193f 100644 --- a/packages/auth-providers/dbAuth/api/src/__tests__/DbAuthHandler.test.js +++ b/packages/auth-providers/dbAuth/api/src/__tests__/DbAuthHandler.test.js @@ -122,11 +122,33 @@ const createDbUser = async (attributes = {}) => { } const expectLoggedOutResponse = (response) => { - expect(response[1]['set-cookie']).toEqual(LOGOUT_COOKIE) + const setCookie = response[1].getSetCookie() + + const deleteSession = setCookie.some((cookie) => { + return cookie === LOGOUT_COOKIE + }) + + const authProviderPresent = setCookie.some((cookie) => { + return cookie.match('auth-provider=') + }) + + expect(deleteSession).toBe(true) + expect(authProviderPresent).toBe(true) } const expectLoggedInResponse = (response) => { - expect(response[1]['set-cookie']).toMatch(SET_SESSION_REGEX) + const setCookie = response[1].getSetCookie() + + const sessionPresent = setCookie.some((cookie) => { + return cookie.match(SET_SESSION_REGEX) + }) + + const authProviderPresent = setCookie.some((cookie) => { + return cookie.match('auth-provider=') + }) + + expect(sessionPresent).toBe(true) + expect(authProviderPresent).toBe(true) } const encryptToCookie = (data) => { @@ -299,14 +321,21 @@ describe('dbAuth', () => { }) describe('_deleteSessionHeader', () => { - it('returns a Set-Cookie header to delete the session cookie', async () => { + it('returns Set-Cookie headers to delete the session cookie', async () => { const dbAuth = new DbAuthHandler(event, context, options) await dbAuth.init() const headers = dbAuth._deleteSessionHeader + const headersObj = Object.fromEntries( + dbAuth._deleteSessionHeader.entries(), + ) + + expect(Object.keys(headersObj).length).toEqual(1) - expect(Object.keys(headers).length).toEqual(1) - expect(Object.keys(headers)).toContain('set-cookie') - expect(headers['set-cookie']).toEqual(LOGOUT_COOKIE) + // Get setSetCookie returns an array of set-cookie headers + expect(headers.getSetCookie()).toContainEqual(LOGOUT_COOKIE) + expect(headers.getSetCookie()).toContainEqual( + 'auth-provider=;Expires=Thu, 01 Jan 1970 00:00:00 GMT', + ) }) }) @@ -657,7 +686,11 @@ describe('dbAuth', () => { await dbAuth.init() const response = await dbAuth.invoke() - expect(response.headers['set-cookie']).toEqual(LOGOUT_COOKIE) + // @NOTE: this is an array of set-cookie headers + expect(response.headers['set-cookie']).toContain(LOGOUT_COOKIE) + expect(response.headers['set-cookie']).toContain( + 'auth-provider=;Expires=Thu, 01 Jan 1970 00:00:00 GMT', + ) }) it('returns a 404 if using the wrong HTTP verb', async () => { @@ -732,14 +765,14 @@ describe('dbAuth', () => { 'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w==' const dbAuth = new DbAuthHandler(event, context, options) await dbAuth.init() - dbAuth.logout = vi.fn(() => ['body', { foo: 'bar' }]) + dbAuth.logout = vi.fn(() => ['body', new Headers([['foo', 'bar']])]) const response = await dbAuth.invoke() expect(dbAuth.logout).toHaveBeenCalled() expect(response.statusCode).toEqual(200) expect(response.body).toEqual('body') expect(response.headers).toEqual({ - 'Content-Type': 'application/json', + 'content-type': 'application/json', foo: 'bar', }) }) @@ -1117,8 +1150,9 @@ describe('dbAuth', () => { const dbAuth = new DbAuthHandler(event, context, options) await dbAuth.init() - const response = await dbAuth.login() - expect(response[1]['csrf-token']).toMatch(UUID_REGEX) + const [_, headers] = await dbAuth.login() + const csrfHeader = headers.get('csrf-token') + expect(csrfHeader).toMatch(UUID_REGEX) }) it('returns a set-cookie header to create session', async () => { @@ -1130,9 +1164,9 @@ describe('dbAuth', () => { const dbAuth = new DbAuthHandler(event, context, options) await dbAuth.init() - const response = await dbAuth.login() + const [_, headers] = await dbAuth.login() - expect(response[1]['csrf-token']).toMatch(UUID_REGEX) + expect(headers.get('csrf-token')).toMatch(UUID_REGEX) }) it('returns a CSRF token in the header', async () => { @@ -1675,14 +1709,16 @@ describe('dbAuth', () => { const dbAuth = new DbAuthHandler(event, context, options) await dbAuth.init() - const response = await dbAuth.signup() + const [body, headers, other] = await dbAuth.signup() // returns message - expect(response[0]).toEqual('{"message":"Hello, world"}') - // does not log them in - expect(response[1]['set-cookie']).toBeUndefined() + expect(body).toEqual('{"message":"Hello, world"}') + + const headersValues = Object.fromEntries(headers.values()) + // no login headers + expect(headersValues).toEqual({}) // 201 Created - expect(response[2].statusCode).toEqual(201) + expect(other.statusCode).toEqual(201) }) }) @@ -1745,7 +1781,7 @@ describe('dbAuth', () => { const [userId, headers] = await dbAuth.getToken() expect(userId).toEqual(7) - expect(headers['set-cookie']).toMatch(SET_SESSION_REGEX) + expect(headers.get('set-cookie')).toMatch(SET_SESSION_REGEX) // set session back to default process.env.SESSION_SECRET = SESSION_SECRET @@ -1968,7 +2004,7 @@ describe('dbAuth', () => { const [body, headers] = await dbAuth.webAuthnAuthenticate() expect(body).toEqual(false) - expect(headers['set-cookie'][0]).toMatch( + expect(headers.get('set-cookie')).toMatch( 'webAuthn=CxMJqILwYufSaEQsJX6rKHw_LkMXAGU64PaKU55l6ejZ4FNO5kBLiA', ) }) @@ -2335,22 +2371,23 @@ describe('dbAuth', () => { }) }) - describe('_createSessionHeader()', () => { + describe('_createSessionCookieString()', () => { it('returns a Set-Cookie header', async () => { const dbAuth = new DbAuthHandler(event, context, options) await dbAuth.init() - const headers = dbAuth._createSessionHeader({ foo: 'bar' }, 'abcd') - expect(Object.keys(headers).length).toEqual(1) - expect(headers['set-cookie']).toMatch( - `Expires=${dbAuth.sessionExpiresDate}`, + const cookieString = dbAuth._createSessionCookieString( + { foo: 'bar' }, + 'abcd', ) + + expect(cookieString).toMatch(`Expires=${dbAuth.sessionExpiresDate}`) // can't really match on the session value since it will change on every render, // due to CSRF token generation but we can check that it contains only the // characters that would be returned by the encrypt function - expect(headers['set-cookie']).toMatch(SET_SESSION_REGEX) + expect(cookieString).toMatch(SET_SESSION_REGEX) // and we can check that it's a certain number of characters - expect(headers['set-cookie'].split(';')[0].length).toEqual(77) + expect(cookieString.split(';')[0].length).toEqual(77) }) }) @@ -2961,10 +2998,10 @@ describe('dbAuth', () => { it('returns the response array necessary to log user out', async () => { const dbAuth = new DbAuthHandler(event, context, options) await dbAuth.init() - const [body, headers] = dbAuth._logoutResponse() + const response = dbAuth._logoutResponse() - expect(body).toEqual('') - expect(headers['set-cookie']).toMatch(/^session=;/) + expect(response[0]).toEqual('') + expectLoggedOutResponse(response) }) it('can accept an object to return in the body', async () => { @@ -2982,7 +3019,7 @@ describe('dbAuth', () => { it('returns a 200 response by default', async () => { const dbAuth = new DbAuthHandler(event, context, options) await dbAuth.init() - const response = dbAuth._ok('', {}) + const response = dbAuth._ok('', new Headers()) expect(response.statusCode).toEqual(200) }) @@ -2990,7 +3027,7 @@ describe('dbAuth', () => { it('can return other status codes', async () => { const dbAuth = new DbAuthHandler(event, context, options) await dbAuth.init() - const response = dbAuth._ok('', {}, { statusCode: 201 }) + const response = dbAuth._ok('', new Headers(), { statusCode: 201 }) expect(response.statusCode).toEqual(201) }) @@ -2998,7 +3035,9 @@ describe('dbAuth', () => { it('stringifies a JSON body', async () => { const dbAuth = new DbAuthHandler(event, context, options) await dbAuth.init() - const response = dbAuth._ok({ foo: 'bar' }, {}, { statusCode: 201 }) + const response = dbAuth._ok({ foo: 'bar' }, new Headers(), { + statusCode: 201, + }) expect(response.body).toEqual('{"foo":"bar"}') }) @@ -3006,7 +3045,9 @@ describe('dbAuth', () => { it('does not stringify a body that is a string already', async () => { const dbAuth = new DbAuthHandler(event, context, options) await dbAuth.init() - const response = dbAuth._ok('{"foo":"bar"}', {}, { statusCode: 201 }) + const response = dbAuth._ok('{"foo":"bar"}', new Headers(), { + statusCode: 201, + }) expect(response.body).toEqual('{"foo":"bar"}') }) diff --git a/packages/auth-providers/dbAuth/api/src/__tests__/buildDbAuthResponse.test.ts b/packages/auth-providers/dbAuth/api/src/__tests__/buildDbAuthResponse.test.ts new file mode 100644 index 000000000000..08e63ce921a1 --- /dev/null +++ b/packages/auth-providers/dbAuth/api/src/__tests__/buildDbAuthResponse.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest' + +import { buildDbAuthResponse } from '../shared' + +describe('buildDbAuthResponse', () => { + it('should add cors headers and set-cookie as array to the response', () => { + const resHeaders = new Headers({ + header1: 'value1', + header2: 'value2', + }) + + resHeaders.append('set-cookie', 'cookie1=value1') + resHeaders.append('set-cookie', 'cookie2=value2') + + const response = { + statusCode: 200, + headers: resHeaders, + } + + const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE', + } + + const expectedResponse = { + statusCode: 200, + headers: { + header1: 'value1', + header2: 'value2', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE', + 'set-cookie': ['cookie1=value1', 'cookie2=value2'], + }, + } + + const result = buildDbAuthResponse(response, corsHeaders) + + expect(result).toEqual(expectedResponse) + }) + + it('should handle empty set-cookie headers', () => { + const response = { + statusCode: 200, + headers: new Headers({ + header1: 'value1', + header2: 'value2', + }), + } + + const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST', + } + + const expectedResponse = { + statusCode: 200, + headers: { + header1: 'value1', + header2: 'value2', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST', + }, + } + + const result = buildDbAuthResponse(response, corsHeaders) + + expect(result).toEqual(expectedResponse) + }) +}) diff --git a/packages/auth-providers/dbAuth/api/src/shared.ts b/packages/auth-providers/dbAuth/api/src/shared.ts index e2ce9634073b..4a3a0dc24735 100644 --- a/packages/auth-providers/dbAuth/api/src/shared.ts +++ b/packages/auth-providers/dbAuth/api/src/shared.ts @@ -2,6 +2,7 @@ import crypto from 'node:crypto' import type { APIGatewayProxyEvent } from 'aws-lambda' +import type { CorsHeaders } from '@redwoodjs/api' import { getEventHeader, isFetchApiRequest } from '@redwoodjs/api' import { getConfig, getConfigPath } from '@redwoodjs/project-config' @@ -257,6 +258,37 @@ export const cookieName = (name: string | undefined) => { return cookieName } +/** + * Returns a lambda response! + * + * This is used as the final call to return a response from the handler. + * + * Converts "Set-Cookie" headers to an array of strings. + */ +export const buildDbAuthResponse = ( + response: { + body?: string + statusCode: number + headers?: Headers + }, + corsHeaders: CorsHeaders, +) => { + const setCookieHeaders = response.headers?.getSetCookie() || [] + + return { + ...response, + headers: { + ...Object.fromEntries(response.headers?.entries() || []), + ...(setCookieHeaders.length > 0 + ? { + 'set-cookie': setCookieHeaders, + } + : {}), + ...corsHeaders, + }, + } +} + export const extractHashingOptions = (text: string): ScryptOptions => { const [_hash, ...options] = text.split('|')