diff --git a/.changesets/10498.md b/.changesets/10498.md new file mode 100644 index 000000000000..611d82c6371c --- /dev/null +++ b/.changesets/10498.md @@ -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. + + \ No newline at end of file diff --git a/packages/auth-providers/dbAuth/web/src/webAuthn.ts b/packages/auth-providers/dbAuth/web/src/webAuthn.ts index 5c4d3606b94e..29feb8bd5d68 100644 --- a/packages/auth-providers/dbAuth/web/src/webAuthn.ts +++ b/packages/auth-providers/dbAuth/web/src/webAuthn.ts @@ -1,9 +1,3 @@ -import { - startRegistration, - startAuthentication, - browserSupportsWebAuthn, -} from '@simplewebauthn/browser' - class WebAuthnRegistrationError extends Error { constructor(message: string) { super(message) @@ -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) } @@ -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) @@ -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) { diff --git a/packages/auth/src/AuthProvider/AuthProvider.tsx b/packages/auth/src/AuthProvider/AuthProvider.tsx index f88d00333183..e9870147b4a3 100644 --- a/packages/auth/src/AuthProvider/AuthProvider.tsx +++ b/packages/auth/src/AuthProvider/AuthProvider.tsx @@ -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' @@ -83,7 +83,7 @@ export function createAuthProvider< const [authProviderState, setAuthProviderState] = useState< AuthProviderState - >(serverAuthState || defaultAuthProviderState) + >(serverAuthState || spaDefaultAuthProviderState) const getToken = useToken(authImplementation) diff --git a/packages/auth/src/AuthProvider/AuthProviderState.ts b/packages/auth/src/AuthProvider/AuthProviderState.ts index 3bcaa6ed6035..b8898c0b9d3b 100644 --- a/packages/auth/src/AuthProvider/AuthProviderState.ts +++ b/packages/auth/src/AuthProvider/AuthProviderState.ts @@ -10,10 +10,18 @@ export type AuthProviderState = { client?: TClient } -export const defaultAuthProviderState: AuthProviderState = { +export const spaDefaultAuthProviderState: AuthProviderState = { loading: true, isAuthenticated: false, userMetadata: null, currentUser: null, hasError: false, } + +export const middlewareDefaultAuthProviderState: AuthProviderState = { + loading: false, + isAuthenticated: false, + userMetadata: null, + currentUser: null, + hasError: false, +} diff --git a/packages/auth/src/AuthProvider/ServerAuthProvider.tsx b/packages/auth/src/AuthProvider/ServerAuthProvider.tsx index c90f568fab15..c69bccb15d74 100644 --- a/packages/auth/src/AuthProvider/ServerAuthProvider.tsx +++ b/packages/auth/src/AuthProvider/ServerAuthProvider.tsx @@ -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 & { cookieHeader?: string @@ -11,7 +11,7 @@ export type ServerAuthState = AuthProviderState & { const getAuthInitialStateFromServer = () => { if (globalThis?.__REDWOOD__SERVER__AUTH_STATE__) { const initialState = { - ...defaultAuthProviderState, + ...middlewareDefaultAuthProviderState, encryptedSession: null, ...(globalThis?.__REDWOOD__SERVER__AUTH_STATE__ || {}), } @@ -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 */ diff --git a/packages/auth/src/AuthProvider/useLogIn.ts b/packages/auth/src/AuthProvider/useLogIn.ts index efbc0b763efc..dd405fe5f848 100644 --- a/packages/auth/src/AuthProvider/useLogIn.ts +++ b/packages/auth/src/AuthProvider/useLogIn.ts @@ -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' @@ -50,7 +50,7 @@ export const useLogIn = < return useCallback( async (options?: TLogInOptions) => { - setAuthProviderState(defaultAuthProviderState) + setAuthProviderState(spaDefaultAuthProviderState) const loginResult = await authImplementation.login(options) await reauthenticate() diff --git a/packages/vite/src/middleware/MiddlewareRequest.ts b/packages/vite/src/middleware/MiddlewareRequest.ts index b5a717360300..570a60d48a16 100644 --- a/packages/vite/src/middleware/MiddlewareRequest.ts +++ b/packages/vite/src/middleware/MiddlewareRequest.ts @@ -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' @@ -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) } } diff --git a/packages/vite/src/middleware/invokeMiddleware.test.ts b/packages/vite/src/middleware/invokeMiddleware.test.ts index cf043e746793..22d33d5cf5ff 100644 --- a/packages/vite/src/middleware/invokeMiddleware.test.ts +++ b/packages/vite/src/middleware/invokeMiddleware.test.ts @@ -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' @@ -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 () => { @@ -55,7 +55,7 @@ describe('Invoke middleware', () => { ) expect(mwRes).toBeInstanceOf(MiddlewareResponse) - expect(authState).toEqual(defaultAuthProviderState) + expect(authState).toEqual(middlewareDefaultAuthProviderState) }) }) }) diff --git a/packages/vite/src/middleware/invokeMiddleware.ts b/packages/vite/src/middleware/invokeMiddleware.ts index 96279c4abaf0..685b6636f344 100644 --- a/packages/vite/src/middleware/invokeMiddleware.ts +++ b/packages/vite/src/middleware/invokeMiddleware.ts @@ -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' @@ -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) diff --git a/packages/vite/src/streaming/createReactStreamingHandler.ts b/packages/vite/src/streaming/createReactStreamingHandler.ts index 5bea07f6eaac..174820ec7c84 100644 --- a/packages/vite/src/streaming/createReactStreamingHandler.ts +++ b/packages/vite/src/streaming/createReactStreamingHandler.ts @@ -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' @@ -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 = {} @@ -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.