From 67c4ace57ef4745f03ff31838a95f1f29fcff3c2 Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Mon, 11 Dec 2023 14:46:57 +0700 Subject: [PATCH] I think I have it working with failing tests --- .../src/__tests__/normalizeRequest.test.ts | 97 ++++++++++----- packages/api/src/cors.ts | 6 +- packages/api/src/transforms.ts | 52 ++++++-- .../dbAuth/api/src/DbAuthHandler.ts | 116 ++++++++++-------- .../auth-providers/dbAuth/api/src/shared.ts | 49 ++++---- packages/web/src/apollo/suspense.tsx | 3 +- 6 files changed, 202 insertions(+), 121 deletions(-) diff --git a/packages/api/src/__tests__/normalizeRequest.test.ts b/packages/api/src/__tests__/normalizeRequest.test.ts index b1938636c897..9393bb5a695e 100644 --- a/packages/api/src/__tests__/normalizeRequest.test.ts +++ b/packages/api/src/__tests__/normalizeRequest.test.ts @@ -3,7 +3,7 @@ import type { APIGatewayProxyEvent } from 'aws-lambda' import { normalizeRequest } from '../transforms' -export const createMockedEvent = ( +export const createMockedLambdaEvent = ( httpMethod = 'POST', body: any = undefined, isBase64Encoded = false @@ -53,41 +53,78 @@ export const createMockedEvent = ( } } -test('Normalizes an aws event with base64', () => { - const corsEventB64 = createMockedEvent( - 'POST', - Buffer.from(JSON.stringify({ bazinga: 'hello_world' }), 'utf8').toString( - 'base64' - ), - true - ) +describe('Lambda Request', () => { + it('Normalizes an aws event with base64', async () => { + const corsEventB64 = createMockedLambdaEvent( + 'POST', + Buffer.from(JSON.stringify({ bazinga: 'hello_world' }), 'utf8').toString( + 'base64' + ), + true + ) - expect(normalizeRequest(corsEventB64)).toEqual({ - headers: new Headers(corsEventB64.headers), - method: 'POST', - query: null, - body: { - bazinga: 'hello_world', - }, + expect(await normalizeRequest(corsEventB64)).toEqual({ + headers: new Headers(corsEventB64.headers as Record), + method: 'POST', + query: null, + jsonBody: { + bazinga: 'hello_world', + }, + }) }) -}) -test('Handles CORS requests with and without b64 encoded', () => { - const corsEventB64 = createMockedEvent('OPTIONS', undefined, true) + it('Handles CORS requests with and without b64 encoded', async () => { + const corsEventB64 = createMockedLambdaEvent('OPTIONS', undefined, true) + + expect(await normalizeRequest(corsEventB64)).toEqual({ + headers: new Headers(corsEventB64.headers as Record), // headers returned as symbol + method: 'OPTIONS', + query: null, + jsonBody: undefined, + }) + + const corsEventWithoutB64 = createMockedLambdaEvent( + 'OPTIONS', + undefined, + false + ) - expect(normalizeRequest(corsEventB64)).toEqual({ - headers: new Headers(corsEventB64.headers), // headers returned as symbol - method: 'OPTIONS', - query: null, - body: undefined, + expect(await normalizeRequest(corsEventWithoutB64)).toEqual({ + headers: new Headers(corsEventB64.headers as Record), // headers returned as symbol + method: 'OPTIONS', + query: null, + jsonBody: undefined, + }) }) +}) + +describe('Fetch API Request', () => { + it('Normalizes a fetch event', async () => { + const fetchEvent = new Request( + 'http://localhost:9210/graphql?whatsup=doc&its=bugs', + { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ bazinga: 'kittens_purr_purr' }), + } + ) + + const partial = await normalizeRequest(fetchEvent) - const corsEventWithoutB64 = createMockedEvent('OPTIONS', undefined, false) + expect(partial).toMatchObject({ + // headers: fetchEvent.headers, + method: 'POST', + query: { + whatsup: 'doc', + its: 'bugs', + }, + jsonBody: { + bazinga: 'kittens_purr_purr', + }, + }) - expect(normalizeRequest(corsEventWithoutB64)).toEqual({ - headers: new Headers(corsEventB64.headers), // headers returned as symbol - method: 'OPTIONS', - query: null, - body: undefined, + expect(partial.headers.get('content-type')).toEqual('application/json') }) }) diff --git a/packages/api/src/cors.ts b/packages/api/src/cors.ts index 47953651c871..7a33d55e0c14 100644 --- a/packages/api/src/cors.ts +++ b/packages/api/src/cors.ts @@ -1,6 +1,6 @@ import { Headers } from '@whatwg-node/fetch' -import type { Request } from './transforms' +import type { PartialRequest } from './transforms' export type CorsConfig = { origin?: boolean | string | string[] @@ -59,10 +59,10 @@ export function createCorsContext(cors: CorsConfig | undefined) { } return { - shouldHandleCors(request: Request) { + shouldHandleCors(request: PartialRequest) { return request.method === 'OPTIONS' }, - getRequestHeaders(request: Request): CorsHeaders { + getRequestHeaders(request: PartialRequest): CorsHeaders { const eventHeaders = new Headers(request.headers as HeadersInit) const requestCorsHeaders = new Headers(corsHeaders) diff --git a/packages/api/src/transforms.ts b/packages/api/src/transforms.ts index ec784f1c1859..290ea172f0ab 100644 --- a/packages/api/src/transforms.ts +++ b/packages/api/src/transforms.ts @@ -1,10 +1,11 @@ -import { Headers } from '@whatwg-node/fetch' +import { Headers, Request as PonyFillRequest } from '@whatwg-node/fetch' import type { APIGatewayProxyEvent } from 'aws-lambda' -// This is the same interface used by GraphQL Yoga -// But not importing here to avoid adding a dependency -export interface Request { - body?: any +// This is part of the request, dreived either from a LambdaEvent or FetchAPI Request +// We do this to keep the API consistent between the two +// When we support only the FetchAPI request, we should remove this +export interface PartialRequest> { + jsonBody?: TBody headers: Headers method: string query: any @@ -13,7 +14,7 @@ export interface Request { /** * Extracts and parses body payload from event with base64 encoding check */ -export const parseEventBody = (event: APIGatewayProxyEvent) => { +export const parseLambdaEventBody = (event: APIGatewayProxyEvent) => { if (!event.body) { return } @@ -25,14 +26,47 @@ export const parseEventBody = (event: APIGatewayProxyEvent) => { } } -export function normalizeRequest(event: APIGatewayProxyEvent): Request { - const body = parseEventBody(event) +export const isFetchApiRequest = (event: any): event is Request => { + return event instanceof Request || event instanceof PonyFillRequest +} + +function getQueryStringParams(reqUrl: string) { + const url = new URL(reqUrl) + const params = new URLSearchParams(url.search) + + const paramObject: Record = {} + for (const entry of params.entries()) { + paramObject[entry[0]] = entry[1] // each 'entry' is a [key, value] tuple + } + return paramObject +} + +/** + * + * This function returns a an object that lets you access _some_ of the request properties in a consistent way + * You can give it either a LambdaEvent or a Fetch API Request + * + * NOTE: It does NOT return a full Request object! + */ +export async function normalizeRequest( + event: APIGatewayProxyEvent | Request +): Promise { + if (isFetchApiRequest(event)) { + return { + headers: event.headers, + method: event.method, + query: getQueryStringParams(event.url), + jsonBody: await event.json(), + } + } + + const jsonBody = parseLambdaEventBody(event) return { headers: new Headers(event.headers as Record), method: event.httpMethod, query: event.queryStringParameters, - body, + jsonBody, } } diff --git a/packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts b/packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts index 7fbf5281795e..003796a380af 100644 --- a/packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts +++ b/packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts @@ -16,8 +16,17 @@ import base64url from 'base64url' import md5 from 'md5' import { v4 as uuidv4 } from 'uuid' -import type { CorsConfig, CorsContext, CorsHeaders } from '@redwoodjs/api' -import { createCorsContext, normalizeRequest } from '@redwoodjs/api' +import type { + CorsConfig, + CorsContext, + CorsHeaders, + PartialRequest, +} from '@redwoodjs/api' +import { + createCorsContext, + isFetchApiRequest, + normalizeRequest, +} from '@redwoodjs/api' import * as DbAuthError from './errors' import { @@ -267,8 +276,11 @@ type Params = AuthenticationResponseJSON & RegistrationResponseJSON & { username?: string password?: string + resetToken?: string method: AuthMethodNames [key: string]: any + } & { + transports?: string // used by webAuthN for something } interface DbAuthSession { @@ -279,15 +291,15 @@ export class DbAuthHandler< TUser extends Record, TIdType = any > { - event: APIGatewayProxyEvent + event: Request | APIGatewayProxyEvent + normalizedRequest: PartialRequest | undefined + httpMethod: string context: LambdaContext options: DbAuthHandlerOptions - cookie: string | undefined - params: Params + cookie: string db: PrismaClient dbAccessor: any dbCredentialAccessor: any - headerCsrfToken: string | undefined hasInvalidSession: boolean session: DbAuthSession | undefined sessionCsrfToken: string | undefined @@ -363,24 +375,24 @@ export class DbAuthHandler< } constructor( - event: APIGatewayProxyEvent, + event: APIGatewayProxyEvent | Request, context: LambdaContext, options: DbAuthHandlerOptions ) { - this.event = event this.context = context this.options = options - this.cookie = extractCookie(this.event) + this.event = event + this.httpMethod = isFetchApiRequest(event) ? event.method : event.httpMethod + + this.cookie = extractCookie(event) || '' this._validateOptions() - this.params = this._parseBody() this.db = this.options.db this.dbAccessor = this.db[this.options.authModelAccessor] this.dbCredentialAccessor = this.options.credentialModelAccessor ? this.db[this.options.credentialModelAccessor] : null - this.headerCsrfToken = this.event.headers['csrf-token'] this.hasInvalidSession = false const sessionExpiresAt = new Date() @@ -424,12 +436,14 @@ export class DbAuthHandler< // Actual function that triggers everything else to happen: `login`, `signup`, // etc. is called from here, after some checks to make sure the request is good async invoke() { - const request = normalizeRequest(this.event) + this.normalizedRequest = (await normalizeRequest( + this.event + )) as PartialRequest let corsHeaders = {} if (this.corsContext) { - corsHeaders = this.corsContext.getRequestHeaders(request) + corsHeaders = this.corsContext.getRequestHeaders(this.normalizedRequest) // Return CORS headers for OPTIONS requests - if (this.corsContext.shouldHandleCors(request)) { + if (this.corsContext.shouldHandleCors(this.normalizedRequest)) { return this._buildResponseWithCorsHeaders( { body: '', statusCode: 200 }, corsHeaders @@ -455,7 +469,7 @@ export class DbAuthHandler< } // make sure it's using the correct verb, GET vs POST - if (this.event.httpMethod !== DbAuthHandler.VERBS[method]) { + if (this.httpMethod !== DbAuthHandler.VERBS[method]) { return this._buildResponseWithCorsHeaders(this._notFound(), corsHeaders) } @@ -488,7 +502,7 @@ export class DbAuthHandler< ?.flowNotEnabled || `Forgot password flow is not enabled` ) } - const { username } = this.params + const { username } = this.normalizedRequest?.jsonBody || {} // was the username sent in at all? if (!username || username.trim() === '') { @@ -590,7 +604,7 @@ export class DbAuthHandler< `Login flow is not enabled` ) } - const { username, password } = this.params + const { username, password } = this.normalizedRequest?.jsonBody || {} const dbUser = await this._verifyUser(username, password) const handlerUser = await (this.options.login as LoginFlowOptions).handler( dbUser @@ -618,7 +632,7 @@ export class DbAuthHandler< ?.flowNotEnabled || `Reset password flow is not enabled` ) } - const { password, resetToken } = this.params + const { password, resetToken } = this.normalizedRequest?.jsonBody || {} // is the resetToken present? if (resetToken == null || String(resetToken).trim() === '') { @@ -692,7 +706,7 @@ export class DbAuthHandler< } // check if password is valid - const { password } = this.params + const { password } = this.normalizedRequest?.jsonBody || {} ;(this.options.signup as SignupFlowOptions).passwordValidation?.( password as string ) @@ -712,11 +726,9 @@ export class DbAuthHandler< } async validateResetToken() { + const { resetToken } = this.normalizedRequest?.jsonBody || {} // is token present at all? - if ( - this.params.resetToken == null || - String(this.params.resetToken).trim() === '' - ) { + if (!resetToken || String(resetToken).trim() === '') { throw new DbAuthError.ResetTokenRequiredError( ( this.options.resetPassword as ResetPasswordFlowOptions @@ -724,7 +736,7 @@ export class DbAuthHandler< ) } - const user = await this._findUserByToken(this.params.resetToken as string) + const user = await this._findUserByToken(resetToken) return [ JSON.stringify(this._sanitizeUser(user)), @@ -739,12 +751,18 @@ export class DbAuthHandler< const { verifyAuthenticationResponse } = require('@simplewebauthn/server') const webAuthnOptions = this.options.webAuthn + const { rawId } = this.normalizedRequest?.jsonBody || {} + + if (!rawId) { + throw new DbAuthError.WebAuthnError('Missing Id in request') + } + if (!webAuthnOptions || !webAuthnOptions.enabled) { throw new DbAuthError.WebAuthnError('WebAuthn is not enabled') } const credential = await this.dbCredentialAccessor.findFirst({ - where: { id: this.params.rawId }, + where: { id: rawId }, }) if (!credential) { @@ -761,7 +779,8 @@ export class DbAuthHandler< let verification: VerifiedAuthenticationResponse try { const opts: VerifyAuthenticationResponseOpts = { - response: this.params, + response: this.normalizedRequest + ?.jsonBody as AuthenticationResponseJSON, // by this point jsonBody has been validated expectedChallenge: user[this.options.authFields.challenge as string], expectedOrigin: webAuthnOptions.origin, expectedRPID: webAuthnOptions.domain, @@ -809,7 +828,7 @@ export class DbAuthHandler< // get the regular `login` cookies const [, loginHeaders] = this._loginResponse(user) const cookies = [ - this._webAuthnCookie(this.params.rawId, this.webAuthnExpiresDate), + this._webAuthnCookie(rawId, this.webAuthnExpiresDate), loginHeaders['set-cookie'], ].flat() @@ -825,7 +844,7 @@ export class DbAuthHandler< } const webAuthnOptions = this.options.webAuthn - const credentialId = webAuthnSession(this.event) + const credentialId = webAuthnSession(this.cookie) let user @@ -938,7 +957,7 @@ export class DbAuthHandler< let verification: VerifiedRegistrationResponse try { const options: VerifyRegistrationResponseOpts = { - response: this.params, + response: this.normalizedRequest?.jsonBody as RegistrationResponseJSON, // by this point jsonBody has been validated expectedChallenge: user[this.options.authFields.challenge as string], expectedOrigin: this.options.webAuthn.origin, expectedRPID: this.options.webAuthn.domain, @@ -964,6 +983,7 @@ export class DbAuthHandler< }) if (!existingDevice) { + const { transports } = this.normalizedRequest?.jsonBody || {} await this.dbCredentialAccessor.create({ data: { [this.options.webAuthn.credentialFields.id]: plainCredentialId, @@ -971,9 +991,8 @@ export class DbAuthHandler< user[this.options.authFields.id], [this.options.webAuthn.credentialFields.publicKey]: Buffer.from(credentialPublicKey), - [this.options.webAuthn.credentialFields.transports]: this.params - .transports - ? JSON.stringify(this.params.transports) + [this.options.webAuthn.credentialFields.transports]: transports + ? JSON.stringify(transports) : null, [this.options.webAuthn.credentialFields.counter]: counter, }, @@ -1095,20 +1114,8 @@ export class DbAuthHandler< return sanitized } - // parses the event body into JSON, whether it's base64 encoded or not - _parseBody() { - if (this.event.body) { - if (this.event.isBase64Encoded) { - return JSON.parse( - Buffer.from(this.event.body || '', 'base64').toString('utf-8') - ) - } else { - return JSON.parse(this.event.body) - } - } else { - return {} - } - } + // Converts LambdaEvent or FetchRequest to + _decodeEvent() {} // returns all the cookie attributes in an array with the proper expiration date // @@ -1176,7 +1183,10 @@ export class DbAuthHandler< // checks the CSRF token in the header against the CSRF token in the session // and throw an error if they are not the same (not used yet) _validateCsrf() { - if (this.sessionCsrfToken !== this.headerCsrfToken) { + if ( + this.sessionCsrfToken !== + this.normalizedRequest?.headers.get('csrf-token') + ) { throw new DbAuthError.CsrfTokenMismatchError() } return true @@ -1365,7 +1375,8 @@ export class DbAuthHandler< // creates and returns a user, first checking that the username/password // values pass validation async _createUser() { - const { username, password, ...userAttributes } = this.params + const { username, password, ...userAttributes } = + this.normalizedRequest?.jsonBody || {} if ( this._validateField('username', username) && this._validateField('password', password) @@ -1402,12 +1413,15 @@ export class DbAuthHandler< // figure out which auth method we're trying to call _getAuthMethod() { // try getting it from the query string, /.redwood/functions/auth?method=[methodName] - let methodName = this.event.queryStringParameters?.method as AuthMethodNames + let methodName = this.normalizedRequest?.query?.method as AuthMethodNames - if (!DbAuthHandler.METHODS.includes(methodName) && this.params) { + if ( + !DbAuthHandler.METHODS.includes(methodName) && + this.normalizedRequest?.jsonBody + ) { // try getting it from the body in JSON: { method: [methodName] } try { - methodName = this.params.method + methodName = this.normalizedRequest.jsonBody.method } catch (e) { // there's no body, or it's not JSON, `handler` will return a 404 } diff --git a/packages/auth-providers/dbAuth/api/src/shared.ts b/packages/auth-providers/dbAuth/api/src/shared.ts index ea9e1ce6db6e..02ee49196295 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 { isFetchApiRequest } from '@redwoodjs/api' import { getConfig, getConfigPath } from '@redwoodjs/project-config' import * as DbAuthError from './errors' @@ -22,9 +23,16 @@ const DEFAULT_SCRYPT_OPTIONS: ScryptOptions = { parallelization: 1, } -// Extracts the cookie from an event, handling lower and upper case header names. -const eventHeadersCookie = (event: APIGatewayProxyEvent) => { - return event.headers.cookie || event.headers.Cookie +// Extracts the header from an event, handling lower and upper case header names. +const eventGetHeader = ( + event: APIGatewayProxyEvent | Request, + headerName: string +) => { + if (isFetchApiRequest(event)) { + return event.headers.get(headerName) + } + + return event.headers[headerName] || event.headers[headerName.toLowerCase()] } const getPort = () => { @@ -40,24 +48,9 @@ const getPort = () => { return getConfig(configPath).api.port } -// When in development environment, check for cookie in the request extension headers -// if user has generated graphiql headers -const eventGraphiQLHeadersCookie = (event: APIGatewayProxyEvent) => { - if (process.env.NODE_ENV === 'development') { - try { - const jsonBody = JSON.parse(event.body ?? '{}') - return ( - jsonBody?.extensions?.headers?.cookie || - jsonBody?.extensions?.headers?.Cookie - ) - } catch { - // sometimes the event body isn't json - return - } - } - - return -} +// @TODO: reimplement eventGraphiQLHeadersCookie +// Needs a re-implementation on the studio side, because using +// body to send Auth headers requires this function to be async // decrypts session text using old CryptoJS algorithm (using node:crypto library) const legacyDecryptSession = (encryptedText: string) => { @@ -83,8 +76,12 @@ const legacyDecryptSession = (encryptedText: string) => { // Extracts the session cookie from an event, handling both // development environment GraphiQL headers and production environment headers. -export const extractCookie = (event: APIGatewayProxyEvent) => { - return eventGraphiQLHeadersCookie(event) || eventHeadersCookie(event) +export const extractCookie = (event: APIGatewayProxyEvent | Request) => { + // @TODO Disabling Studio Auth impersonation: it uses body instead of headers + // this feels a bit off, but also requires the parsing to become async + + // return eventGraphiQLHeadersCookie(event) || eventHeadersCookie(event) + return eventGetHeader(event, 'Cookie') } function extractEncryptedSessionFromHeader(event: APIGatewayProxyEvent) { @@ -198,12 +195,12 @@ export const dbAuthSession = ( } } -export const webAuthnSession = (event: APIGatewayProxyEvent) => { - if (!event.headers.cookie) { +export const webAuthnSession = (cookieHeader: string) => { + if (!cookieHeader) { return null } - const webAuthnCookie = event.headers.cookie.split(';').find((cook) => { + const webAuthnCookie = cookieHeader.split(';').find((cook) => { return cook.split('=')[0].trim() === 'webAuthn' }) diff --git a/packages/web/src/apollo/suspense.tsx b/packages/web/src/apollo/suspense.tsx index b69f4951fb2d..3f96f49f67b1 100644 --- a/packages/web/src/apollo/suspense.tsx +++ b/packages/web/src/apollo/suspense.tsx @@ -115,8 +115,7 @@ const ApolloProviderWithFetchConfig: React.FunctionComponent<{ useAuth?: UseAuth logLevel: ReturnType children: React.ReactNode -}> = ({ config, children, useAuth = useNoAuth, logLevel }) => { - console.log(`👉 \n ~ file: suspense.tsx:119 ~ useAuth:`, useAuth) +}> = ({ config, children, logLevel }) => { // Should they run into it, this helps users with the "Cannot render cell; GraphQL success but data is null" error. // See https://github.com/redwoodjs/redwood/issues/2473. apolloSetLogVerbosity(logLevel)