Skip to content

Commit

Permalink
Implement dbAuth CORS support + Add cookie options to auth handler (r…
Browse files Browse the repository at this point in the history
…edwoodjs#4150)

* Update cors support + Cookie config

* Remove typescript ignore comments

* Adds `config` prop on <AuthProvider> for dbAuth fetch options

* Typo in error message

* Only set response if it isn't already set

* Cookie options are all optional

* Include config in all auth calls

* Adds tests for missing/empty cookie options

* Add @ts-ignore for now

* Include credentials in call to current user, mark with TODO

* Updates template to set `Secure: true` in all cases (seems to be an exception for localhost)

* Fix syntax error in test

* Fix constraints err

* Change dbAuth invoke to return with short circuits

* Set dbAuth options as { fetchConfig: { credentials }}

* Remove @ts-ignore

* Fix british spelling of 'Headders" ;)

* Only allow config to be passed for dbAuth

* config is optional

* Adds tests for passing config through to createAuthClient and dbAuth client

* Fix ts error in dbAuth client

* Fix dbAuth credentials test

* Add test for CORS options request to dbauth handler

* Update normalizeRequest test in graphql-server

Co-authored-by: Rob Cameron <[email protected]>
  • Loading branch information
dac09 and cannikin authored Mar 4, 2022
1 parent fd8ac2f commit 25860dc
Show file tree
Hide file tree
Showing 20 changed files with 685 additions and 53 deletions.
1 change: 1 addition & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"jsonwebtoken": "8.5.1",
"jwks-rsa": "2.0.5",
"md5": "2.3.0",
"node-fetch": "2.6.7",
"pascalcase": "1.0.0",
"pino": "7.8.0",
"uuid": "8.3.2"
Expand Down
93 changes: 93 additions & 0 deletions packages/api/src/__tests__/normalizeRequest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { APIGatewayProxyEvent } from 'aws-lambda'
import { Headers } from 'node-fetch'

import { normalizeRequest } from '../transforms'

export const createMockedEvent = (
httpMethod = 'POST',
body: any = undefined,
isBase64Encoded = false
): APIGatewayProxyEvent => {
return {
body,
headers: {},
multiValueHeaders: {},
httpMethod,
isBase64Encoded,
path: '/MOCK_PATH',
pathParameters: null,
queryStringParameters: null,
multiValueQueryStringParameters: null,
stageVariables: null,
requestContext: {
accountId: 'MOCKED_ACCOUNT',
apiId: 'MOCKED_API_ID',
authorizer: { name: 'MOCKED_AUTHORIZER' },
protocol: 'HTTP',
identity: {
accessKey: null,
accountId: null,
apiKey: null,
apiKeyId: null,
caller: null,
clientCert: null,
cognitoAuthenticationProvider: null,
cognitoAuthenticationType: null,
cognitoIdentityId: null,
cognitoIdentityPoolId: null,
principalOrgId: null,
sourceIp: '123.123.123.123',
user: null,
userAgent: null,
userArn: null,
},
httpMethod: 'POST',
path: '/MOCK_PATH',
stage: 'MOCK_STAGE',
requestId: 'MOCKED_REQUEST_ID',
requestTimeEpoch: 1,
resourceId: 'MOCKED_RESOURCE_ID',
resourcePath: 'MOCKED_RESOURCE_PATH',
},
resource: 'MOCKED_RESOURCE',
}
}

test('Normalizes an aws event with base64', () => {
const corsEventB64 = createMockedEvent(
'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',
},
})
})

test('Handles CORS requests with and without b64 encoded', () => {
const corsEventB64 = createMockedEvent('OPTIONS', undefined, true)

expect(normalizeRequest(corsEventB64)).toEqual({
headers: new Headers(corsEventB64.headers), // headers returned as symbol
method: 'OPTIONS',
query: null,
body: undefined,
})

const corsEventWithoutB64 = createMockedEvent('OPTIONS', undefined, false)

expect(normalizeRequest(corsEventWithoutB64)).toEqual({
headers: new Headers(corsEventB64.headers), // headers returned as symbol
method: 'OPTIONS',
query: null,
body: undefined,
})
})
98 changes: 98 additions & 0 deletions packages/api/src/cors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { Request } from 'graphql-helix'
import { Headers } from 'node-fetch'

export type CorsConfig = {
origin?: boolean | string | string[]
methods?: string | string[]
allowedHeaders?: string | string[]
exposedHeaders?: string | string[]
credentials?: boolean
maxAge?: number
}

export type CorsHeaders = Record<string, string>
export type CorsContext = ReturnType<typeof createCorsContext>

export function createCorsContext(cors: CorsConfig | undefined) {
// Taken from apollo-server-env
// @see: https://github.com/apollographql/apollo-server/blob/9267a79b974e397e87ad9ee408b65c46751e4565/packages/apollo-server-env/src/polyfills/fetch.js#L1
const corsHeaders = new Headers()

if (cors) {
if (cors.methods) {
if (typeof cors.methods === 'string') {
corsHeaders.set('access-control-allow-methods', cors.methods)
} else if (Array.isArray(cors.methods)) {
corsHeaders.set('access-control-allow-methods', cors.methods.join(','))
}
}

if (cors.allowedHeaders) {
if (typeof cors.allowedHeaders === 'string') {
corsHeaders.set('access-control-allow-headers', cors.allowedHeaders)
} else if (Array.isArray(cors.allowedHeaders)) {
corsHeaders.set(
'access-control-allow-headers',
cors.allowedHeaders.join(',')
)
}
}

if (cors.exposedHeaders) {
if (typeof cors.exposedHeaders === 'string') {
corsHeaders.set('access-control-expose-headers', cors.exposedHeaders)
} else if (Array.isArray(cors.exposedHeaders)) {
corsHeaders.set(
'access-control-expose-headers',
cors.exposedHeaders.join(',')
)
}
}

if (cors.credentials) {
corsHeaders.set('access-control-allow-credentials', 'true')
}
if (typeof cors.maxAge === 'number') {
corsHeaders.set('access-control-max-age', cors.maxAge.toString())
}
}

return {
shouldHandleCors(request: Request) {
return request.method === 'OPTIONS'
},
getRequestHeaders(request: Request): CorsHeaders {
const eventHeaders = new Headers(
request.headers as Record<string, string>
)
const requestCorsHeaders = new Headers(corsHeaders)

if (cors && cors.origin) {
const requestOrigin = eventHeaders.get('origin')
if (typeof cors.origin === 'string') {
requestCorsHeaders.set('access-control-allow-origin', cors.origin)
} else if (
requestOrigin &&
(typeof cors.origin === 'boolean' ||
(Array.isArray(cors.origin) &&
requestOrigin &&
cors.origin.includes(requestOrigin)))
) {
requestCorsHeaders.set('access-control-allow-origin', requestOrigin)
}

const requestAccessControlRequestHeaders = eventHeaders.get(
'access-control-request-headers'
)
if (!cors.allowedHeaders && requestAccessControlRequestHeaders) {
requestCorsHeaders.set(
'access-control-allow-headers',
requestAccessControlRequestHeaders
)
}
}

return Object.fromEntries(requestCorsHeaders.entries())
},
}
}
111 changes: 102 additions & 9 deletions packages/api/src/functions/dbAuth/DbAuthHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ import CryptoJS from 'crypto-js'
import md5 from 'md5'
import { v4 as uuidv4 } from 'uuid'

import {
CorsConfig,
CorsContext,
CorsHeaders,
createCorsContext,
} from '../../cors'
import { normalizeRequest } from '../../transforms'

import * as DbAuthError from './errors'
import { decryptSession, getSession } from './shared'

Expand All @@ -30,6 +38,16 @@ interface DbAuthHandlerOptions {
resetToken: string
resetTokenExpiresAt: string
}
/**
* Object containing cookie config options
*/
cookie?: {
Path?: string
HttpOnly?: boolean
Secure?: boolean
SameSite?: string
Domain?: string
}
/**
* Object containing forgot password options
*/
Expand Down Expand Up @@ -100,6 +118,11 @@ interface DbAuthHandlerOptions {
usernameTaken?: string
}
}

/**
* CORS settings, same as in createGraphqlHandler
*/
cors?: CorsConfig
}

interface SignupHandlerOptions {
Expand Down Expand Up @@ -140,6 +163,7 @@ export class DbAuthHandler {
hasInvalidSession: boolean
session: SessionRecord | undefined
sessionCsrfToken: string | undefined
corsContext: CorsContext | undefined

// class constant: list of auth methods that are supported
static get METHODS(): AuthMethodNames[] {
Expand Down Expand Up @@ -168,6 +192,7 @@ export class DbAuthHandler {
}

// class constant: all the attributes of the cookie other than the value itself
// DEPRECATED: Remove once deprecation warning is removed from _cookieAttributes()
static get COOKIE_META() {
const meta = [`Path=/`, 'HttpOnly', 'SameSite=Strict']

Expand Down Expand Up @@ -223,6 +248,10 @@ export class DbAuthHandler {
this.headerCsrfToken = this.event.headers['csrf-token']
this.hasInvalidSession = false

if (options.cors) {
this.corsContext = createCorsContext(options.cors)
}

try {
const [session, csrfToken] = decryptSession(
getSession(this.event.headers['cookie'])
Expand All @@ -243,36 +272,58 @@ 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)
let corsHeaders = {}
if (this.corsContext) {
corsHeaders = this.corsContext.getRequestHeaders(request)
// Return CORS headers for OPTIONS requests
if (this.corsContext.shouldHandleCors(request)) {
return this._buildResponseWithCorsHeaders(
{ body: '', statusCode: 200 },
corsHeaders
)
}
}

// if there was a problem decryption the session, just return the logout
// response immediately
if (this.hasInvalidSession) {
return this._ok(...this._logoutResponse())
return this._buildResponseWithCorsHeaders(
this._ok(...this._logoutResponse()),
corsHeaders
)
}

try {
const method = this._getAuthMethod()

// get the auth method the incoming request is trying to call
if (!DbAuthHandler.METHODS.includes(method)) {
return this._notFound()
return this._buildResponseWithCorsHeaders(this._notFound(), corsHeaders)
}

// make sure it's using the correct verb, GET vs POST
if (this.event.httpMethod !== DbAuthHandler.VERBS[method]) {
return this._notFound()
return this._buildResponseWithCorsHeaders(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._ok(body, headers, options)
return this._buildResponseWithCorsHeaders(
this._ok(body, headers, options),
corsHeaders
)
} catch (e: any) {
if (e instanceof DbAuthError.WrongVerbError) {
return this._notFound()
return this._buildResponseWithCorsHeaders(this._notFound(), corsHeaders)
} else {
return this._badRequest(e.message || e)
return this._buildResponseWithCorsHeaders(
this._badRequest(e.message || e),
corsHeaders
)
}
}
}
Expand Down Expand Up @@ -517,10 +568,35 @@ export class DbAuthHandler {
// pass the argument `expires` set to "now" to get the attributes needed to expire
// the session, or "future" (or left out completely) to set to `_futureExpiresDate`
_cookieAttributes({ expires = 'future' }: { expires?: 'now' | 'future' }) {
const meta = JSON.parse(JSON.stringify(DbAuthHandler.COOKIE_META))
let meta

if (process.env.NODE_ENV !== 'development') {
meta.push('Secure')
// DEPRECATED: Remove deprecation logic after a few releases, assume this.options.cookie contains config
if (!this.options.cookie) {
console.warn(
`\n[Deprecation Notice] dbAuth cookie config has moved to\n api/src/function/auth.js for better customization.\n See https://redwoodjs.com/docs/authentication#cookie-config\n`
)
meta = JSON.parse(JSON.stringify(DbAuthHandler.COOKIE_META))

if (process.env.NODE_ENV !== 'development') {
meta.push('Secure')
}
} else {
const cookieOptions = this.options.cookie || {}
meta = Object.keys(cookieOptions)
.map((key) => {
const optionValue =
cookieOptions[key as keyof DbAuthHandlerOptions['cookie']]

// Convert the options to valid cookie string
if (optionValue === true) {
return key
} else if (optionValue === false) {
return null
} else {
return `${key}=${optionValue}`
}
})
.filter((v) => v)
}

const expiresAt =
Expand Down Expand Up @@ -787,4 +863,21 @@ export class DbAuthHandler {
headers: { 'Content-Type': 'application/json' },
}
}

_buildResponseWithCorsHeaders(
response: {
body?: string
statusCode: number
headers?: Record<string, string>
},
corsHeaders: CorsHeaders
) {
return {
...response,
headers: {
...(response.headers || {}),
...corsHeaders,
},
}
}
}
Loading

0 comments on commit 25860dc

Please sign in to comment.