Skip to content

Commit

Permalink
feat(server-auth): WebAuthN support during SSR (#10498)
Browse files Browse the repository at this point in the history
  • Loading branch information
dac09 committed Apr 23, 2024
1 parent 1dc7179 commit 3f57976
Show file tree
Hide file tree
Showing 10 changed files with 61 additions and 34 deletions.
10 changes: 10 additions & 0 deletions .changesets/10498.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
- feat(server-auth): WebAuthN support during SSR (#10498) by @dac09

**This PR changes the following:**
**1. Moves webAuthN imports to be dynamic imports**
This is because the dbauth-provider-web packages are still CJS only. When importing in an ESM environment (such as SSR/RSC server) - it complains that about ESM imports

**2. Updates the default auth provider state for middleware auth**
Middleware auth default state is _almost_ the same as SPA default auth state. Except that loading is always false! Otherwise you can get stuck in a loading state forever.


14 changes: 8 additions & 6 deletions packages/auth-providers/dbAuth/web/src/webAuthn.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
import {
startRegistration,
startAuthentication,
browserSupportsWebAuthn,
} from '@simplewebauthn/browser'

class WebAuthnRegistrationError extends Error {
constructor(message: string) {
super(message)
Expand Down Expand Up @@ -55,10 +49,15 @@ export default class WebAuthnClient {
}

async isSupported() {
const { browserSupportsWebAuthn } = await import('@simplewebauthn/browser')
return await browserSupportsWebAuthn()
}

isEnabled() {
if (typeof window === 'undefined') {
return false
}

return !!/\bwebAuthn\b/.test(document.cookie)
}

Expand Down Expand Up @@ -99,6 +98,7 @@ export default class WebAuthnClient {

async authenticate() {
const authOptions = await this.authenticationOptions()
const { startAuthentication } = await import('@simplewebauthn/browser')

try {
const browserResponse = await startAuthentication(authOptions)
Expand Down Expand Up @@ -173,6 +173,8 @@ export default class WebAuthnClient {
const options = await this.registrationOptions()
let regResponse

const { startRegistration } = await import('@simplewebauthn/browser')

try {
regResponse = await startRegistration(options)
} catch (e: any) {
Expand Down
4 changes: 2 additions & 2 deletions packages/auth/src/AuthProvider/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { AuthContextInterface, CurrentUser } from '../AuthContext.js'
import type { AuthImplementation } from '../AuthImplementation.js'

import type { AuthProviderState } from './AuthProviderState.js'
import { defaultAuthProviderState } from './AuthProviderState.js'
import { spaDefaultAuthProviderState } from './AuthProviderState.js'
import { ServerAuthContext } from './ServerAuthProvider.js'
import { useCurrentUser } from './useCurrentUser.js'
import { useForgotPassword } from './useForgotPassword.js'
Expand Down Expand Up @@ -83,7 +83,7 @@ export function createAuthProvider<

const [authProviderState, setAuthProviderState] = useState<
AuthProviderState<TUser>
>(serverAuthState || defaultAuthProviderState)
>(serverAuthState || spaDefaultAuthProviderState)

const getToken = useToken(authImplementation)

Expand Down
10 changes: 9 additions & 1 deletion packages/auth/src/AuthProvider/AuthProviderState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,18 @@ export type AuthProviderState<TUser, TClient = unknown> = {
client?: TClient
}

export const defaultAuthProviderState: AuthProviderState<never> = {
export const spaDefaultAuthProviderState: AuthProviderState<never> = {
loading: true,
isAuthenticated: false,
userMetadata: null,
currentUser: null,
hasError: false,
}

export const middlewareDefaultAuthProviderState: AuthProviderState<never> = {
loading: false,
isAuthenticated: false,
userMetadata: null,
currentUser: null,
hasError: false,
}
6 changes: 3 additions & 3 deletions packages/auth/src/AuthProvider/ServerAuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
import React from 'react'

import type { AuthProviderState } from './AuthProviderState.js'
import { defaultAuthProviderState } from './AuthProviderState.js'
import { middlewareDefaultAuthProviderState } from './AuthProviderState.js'

export type ServerAuthState = AuthProviderState<never> & {
cookieHeader?: string
Expand All @@ -11,7 +11,7 @@ export type ServerAuthState = AuthProviderState<never> & {
const getAuthInitialStateFromServer = () => {
if (globalThis?.__REDWOOD__SERVER__AUTH_STATE__) {
const initialState = {
...defaultAuthProviderState,
...middlewareDefaultAuthProviderState,
encryptedSession: null,
...(globalThis?.__REDWOOD__SERVER__AUTH_STATE__ || {}),
}
Expand All @@ -25,7 +25,7 @@ const getAuthInitialStateFromServer = () => {
}

/**
* On the server, it resolves to the defaultAuthProviderState first.
* On the server, it resolves to the middlewareDefaultAuthProviderState first.
*
* On the client it restores from the initial server state injected in the ServerAuthProvider
*/
Expand Down
4 changes: 2 additions & 2 deletions packages/auth/src/AuthProvider/useLogIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useCallback } from 'react'
import type { AuthImplementation } from '../AuthImplementation.js'

import type { AuthProviderState } from './AuthProviderState.js'
import { defaultAuthProviderState } from './AuthProviderState.js'
import { spaDefaultAuthProviderState } from './AuthProviderState.js'
import type { useCurrentUser } from './useCurrentUser.js'
import { useReauthenticate } from './useReauthenticate.js'

Expand Down Expand Up @@ -50,7 +50,7 @@ export const useLogIn = <

return useCallback(
async (options?: TLogInOptions) => {
setAuthProviderState(defaultAuthProviderState)
setAuthProviderState(spaDefaultAuthProviderState)
const loginResult = await authImplementation.login(options)
await reauthenticate()

Expand Down
7 changes: 5 additions & 2 deletions packages/vite/src/middleware/MiddlewareRequest.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Request as WhatWgRequest } from '@whatwg-node/fetch'

import { defaultAuthProviderState, type ServerAuthState } from '@redwoodjs/auth'
import {
middlewareDefaultAuthProviderState,
type ServerAuthState,
} from '@redwoodjs/auth'

import { CookieJar } from './CookieJar.js'

Expand All @@ -27,7 +30,7 @@ export class MiddlewareRequest extends WhatWgRequest {
constructor(input: Request) {
super(input)
this.cookies = new CookieJar(input.headers.get('Cookie'))
this.serverAuthContext = new ContextJar(defaultAuthProviderState)
this.serverAuthContext = new ContextJar(middlewareDefaultAuthProviderState)
}
}

Expand Down
6 changes: 3 additions & 3 deletions packages/vite/src/middleware/invokeMiddleware.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { MockInstance } from 'vitest'
import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest'

import { defaultAuthProviderState } from '@redwoodjs/auth'
import { middlewareDefaultAuthProviderState } from '@redwoodjs/auth'

import { invoke } from './invokeMiddleware'
import type { MiddlewareRequest } from './MiddlewareRequest'
Expand All @@ -11,7 +11,7 @@ describe('Invoke middleware', () => {
test('returns a MiddlewareResponse, even if no middleware defined', async () => {
const [mwRes, authState] = await invoke(new Request('https://example.com'))
expect(mwRes).toBeInstanceOf(MiddlewareResponse)
expect(authState).toEqual(defaultAuthProviderState)
expect(authState).toEqual(middlewareDefaultAuthProviderState)
})

test('extracts auth state correctly, and always returns a MWResponse', async () => {
Expand Down Expand Up @@ -55,7 +55,7 @@ describe('Invoke middleware', () => {
)

expect(mwRes).toBeInstanceOf(MiddlewareResponse)
expect(authState).toEqual(defaultAuthProviderState)
expect(authState).toEqual(middlewareDefaultAuthProviderState)
})
})
})
7 changes: 5 additions & 2 deletions packages/vite/src/middleware/invokeMiddleware.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { defaultAuthProviderState, type ServerAuthState } from '@redwoodjs/auth'
import {
middlewareDefaultAuthProviderState,
type ServerAuthState,
} from '@redwoodjs/auth'

import { MiddlewareRequest } from './MiddlewareRequest.js'
import { MiddlewareResponse } from './MiddlewareResponse.js'
Expand All @@ -18,7 +21,7 @@ export const invoke = async (
options?: MiddlewareInvokeOptions,
): Promise<[MiddlewareResponse, ServerAuthState]> => {
if (typeof middleware !== 'function') {
return [MiddlewareResponse.next(), defaultAuthProviderState]
return [MiddlewareResponse.next(), middlewareDefaultAuthProviderState]
}

const mwReq = new MiddlewareRequest(req)
Expand Down
27 changes: 14 additions & 13 deletions packages/vite/src/streaming/createReactStreamingHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { HTTPMethod } from 'find-my-way'
import isbot from 'isbot'
import type { ViteDevServer } from 'vite'

import { defaultAuthProviderState } from '@redwoodjs/auth'
import { middlewareDefaultAuthProviderState } from '@redwoodjs/auth'
import type { RouteSpec, RWRouteManifestItem } from '@redwoodjs/internal'
import { getAppRouteHook, getConfig, getPaths } from '@redwoodjs/project-config'
import { matchPath } from '@redwoodjs/router'
Expand Down Expand Up @@ -69,7 +69,7 @@ export const createReactStreamingHandler = async (
// @NOTE: we are returning a FetchAPI handler
return async (req: Request) => {
let mwResponse = MiddlewareResponse.next()
let decodedAuthState = defaultAuthProviderState
let decodedAuthState = middlewareDefaultAuthProviderState
// @TODO: Make the currentRoute 404?
let currentRoute: RWRouteManifestItem | undefined
let parsedParams: any = {}
Expand All @@ -92,17 +92,18 @@ export const createReactStreamingHandler = async (
// ~~~ Middleware Handling ~~~
if (middlewareRouter) {
const matchedMw = middlewareRouter.find(req.method as HTTPMethod, req.url)
;[mwResponse, decodedAuthState = defaultAuthProviderState] = await invoke(
req,
matchedMw?.handler as Middleware | undefined,
currentRoute
? {
route: currentRoute,
cssPaths: getStylesheetLinks(currentRoute),
params: matchedMw?.params,
}
: {},
)
;[mwResponse, decodedAuthState = middlewareDefaultAuthProviderState] =
await invoke(
req,
matchedMw?.handler as Middleware | undefined,
currentRoute
? {
route: currentRoute,
cssPaths: getStylesheetLinks(currentRoute),
params: matchedMw?.params,
}
: {},
)

// If mwResponse is a redirect, short-circuit here, and skip React rendering
// If the response has a body, no need to render react.
Expand Down

0 comments on commit 3f57976

Please sign in to comment.