From a9a9772a71da3a116563122ec6775aeb57df4bc2 Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Tue, 21 May 2024 19:52:39 +0700 Subject: [PATCH 01/20] feat(rsc-auth): Implement extractRoles function in auth mw Update default serverAuthState to contain roles Make cookieHeader a required attribute --- .../dbAuth/middleware/src/index.ts | 7 ++++-- .../supabase/middleware/src/index.ts | 4 +++ .../supabase/middleware/src/util.ts | 2 +- .../src/AuthProvider/ServerAuthProvider.tsx | 11 ++++---- packages/graphql-server/src/types.ts | 2 +- .../vite/src/middleware/MiddlewareRequest.ts | 25 +++++++++++++------ .../vite/src/middleware/invokeMiddleware.ts | 19 ++++++++++---- .../streaming/createReactStreamingHandler.ts | 6 ++++- packages/web/src/apollo/links.tsx | 2 +- 9 files changed, 55 insertions(+), 23 deletions(-) diff --git a/packages/auth-providers/dbAuth/middleware/src/index.ts b/packages/auth-providers/dbAuth/middleware/src/index.ts index 780deda07918..0475b85e23c3 100644 --- a/packages/auth-providers/dbAuth/middleware/src/index.ts +++ b/packages/auth-providers/dbAuth/middleware/src/index.ts @@ -18,6 +18,7 @@ export interface DbAuthMiddlewareOptions { req: Request | APIGatewayProxyEvent, context?: Context, ) => DbAuthResponse + extractRoles?: (decoded: any) => string[] getCurrentUser: GetCurrentUser } @@ -25,6 +26,7 @@ export const createDbAuthMiddleware = ({ cookieName, dbAuthHandler, getCurrentUser, + extractRoles, dbAuthUrl = '/middleware/dbauth', }: DbAuthMiddlewareOptions) => { return async ( @@ -94,13 +96,14 @@ export const createDbAuthMiddleware = ({ loading: false, isAuthenticated: !!currentUser, hasError: false, - userMetadata: currentUser, // Not sure! + userMetadata: currentUser, // dbAuth doesn't have userMetadata cookieHeader, + roles: extractRoles ? extractRoles(decryptedSession) : [], }) } catch (e) { // Clear server auth context console.error(e, 'Error decrypting dbAuth cookie') - req.serverAuthState.set(null) + req.serverAuthState.clear() // Note we have to use ".unset" and not ".clear" // because we want to remove these cookies from the browser diff --git a/packages/auth-providers/supabase/middleware/src/index.ts b/packages/auth-providers/supabase/middleware/src/index.ts index 05bc259450c8..457cb7ae629c 100644 --- a/packages/auth-providers/supabase/middleware/src/index.ts +++ b/packages/auth-providers/supabase/middleware/src/index.ts @@ -10,6 +10,7 @@ import { clearAuthState } from './util' export interface SupabaseAuthMiddlewareOptions { getCurrentUser: GetCurrentUser + extractRoles?: (decoded: any) => string[] } /** @@ -17,6 +18,7 @@ export interface SupabaseAuthMiddlewareOptions { */ const createSupabaseAuthMiddleware = ({ getCurrentUser, + extractRoles, }: SupabaseAuthMiddlewareOptions) => { return async (req: MiddlewareRequest, res: MiddlewareResponse) => { const type = 'supabase' @@ -68,6 +70,8 @@ const createSupabaseAuthMiddleware = ({ isAuthenticated: !!currentUser, hasError: false, userMetadata: userMetadata || currentUser, + cookieHeader, + roles: extractRoles ? extractRoles(decoded) : [], }) } catch (e) { console.error(e, 'Error in Supabase Auth Middleware') diff --git a/packages/auth-providers/supabase/middleware/src/util.ts b/packages/auth-providers/supabase/middleware/src/util.ts index edc65dcb14c5..b67d5b47850c 100644 --- a/packages/auth-providers/supabase/middleware/src/util.ts +++ b/packages/auth-providers/supabase/middleware/src/util.ts @@ -61,7 +61,7 @@ export const clearAuthState = ( res: MiddlewareResponse, ) => { // Clear server auth context - req.serverAuthState.set(null) + req.serverAuthState.clear() // clear supabase cookies // We can't call .signOut() because that revokes all refresh tokens, diff --git a/packages/auth/src/AuthProvider/ServerAuthProvider.tsx b/packages/auth/src/AuthProvider/ServerAuthProvider.tsx index c69bccb15d74..5aa852295207 100644 --- a/packages/auth/src/AuthProvider/ServerAuthProvider.tsx +++ b/packages/auth/src/AuthProvider/ServerAuthProvider.tsx @@ -4,8 +4,11 @@ import React from 'react' import type { AuthProviderState } from './AuthProviderState.js' import { middlewareDefaultAuthProviderState } from './AuthProviderState.js' -export type ServerAuthState = AuthProviderState & { - cookieHeader?: string +export type ServerAuthState = AuthProviderState & { + // Intentionally making these keys non-optional (even if nullable) to make sure + // they are set correctly in middleware + cookieHeader: string | null | undefined + roles: string[] } const getAuthInitialStateFromServer = () => { @@ -45,9 +48,6 @@ export const ServerAuthProvider = ({ value: ServerAuthState children?: ReactNode[] }) => { - // @NOTE: we "Sanitize" to remove cookieHeader - // not totally necessary, but it's nice to not have them in the DOM - // @MARK: needs discussion! const stringifiedAuthState = `__REDWOOD__SERVER__AUTH_STATE__ = ${JSON.stringify( sanitizeServerAuthState(value), )};` @@ -67,6 +67,7 @@ export const ServerAuthProvider = ({ ) } + function sanitizeServerAuthState(value: ServerAuthState) { const sanitizedState = { ...value } // Remove the cookie from being printed onto the DOM diff --git a/packages/graphql-server/src/types.ts b/packages/graphql-server/src/types.ts index e130df8b9bef..752017d23734 100644 --- a/packages/graphql-server/src/types.ts +++ b/packages/graphql-server/src/types.ts @@ -58,7 +58,7 @@ export type GetCurrentUser = ( decoded: AuthContextPayload[0], raw: AuthContextPayload[1], req?: AuthContextPayload[2], -) => Promise | string> +) => Promise> export type GenerateGraphiQLHeader = () => string diff --git a/packages/vite/src/middleware/MiddlewareRequest.ts b/packages/vite/src/middleware/MiddlewareRequest.ts index 1b9ebda81e02..50223cfde8c1 100644 --- a/packages/vite/src/middleware/MiddlewareRequest.ts +++ b/packages/vite/src/middleware/MiddlewareRequest.ts @@ -7,30 +7,41 @@ import { import { CookieJar } from './CookieJar.js' -class AuthStateJar { - private _data: T +class AuthStateJar { + private _data: ServerAuthState | null - constructor(data?: T) { - this._data = data as T + constructor(data?: ServerAuthState) { + this._data = data || null } get() { return this._data } - set(value: any) { + set(value: ServerAuthState) { this._data = value } + + clear() { + this._data = null + } } export class MiddlewareRequest extends WhatWgRequest { cookies: CookieJar - serverAuthState: AuthStateJar + serverAuthState: AuthStateJar constructor(input: Request) { super(input) + + const defaultServerAuthState = { + ...middlewareDefaultAuthProviderState, + cookieHeader: input.headers.get('Cookie'), + roles: [], + } + this.cookies = new CookieJar(input.headers.get('Cookie')) - this.serverAuthState = new AuthStateJar(middlewareDefaultAuthProviderState) + this.serverAuthState = new AuthStateJar(defaultServerAuthState) } } diff --git a/packages/vite/src/middleware/invokeMiddleware.ts b/packages/vite/src/middleware/invokeMiddleware.ts index 87660be974ca..a91164c82651 100644 --- a/packages/vite/src/middleware/invokeMiddleware.ts +++ b/packages/vite/src/middleware/invokeMiddleware.ts @@ -25,10 +25,16 @@ export const invoke = async ( middleware?: Middleware, options?: MiddlewareInvokeOptions, ): Promise<[MiddlewareResponse, ServerAuthState]> => { + const defaultServerAuthState = { + ...middlewareDefaultAuthProviderState, + cookieHeader: req.headers.get('Cookie'), + roles: [], + } + if (typeof middleware !== 'function') { - setupServerStore(req, middlewareDefaultAuthProviderState) + setupServerStore(req, defaultServerAuthState) - return [MiddlewareResponse.next(), middlewareDefaultAuthProviderState] + return [MiddlewareResponse.next(), defaultServerAuthState] } const mwReq = new MiddlewareRequest(req) @@ -55,7 +61,10 @@ export const invoke = async ( // @TODO catch the error here, and see if its a short-circuit // A shortcircuit will prevent execution of all other middleware down the chain, and prevent react rendering if (e instanceof MiddlewareShortCircuit) { - return [e.mwResponse, mwReq.serverAuthState.get()] + return [ + e.mwResponse, + mwReq.serverAuthState.get() || defaultServerAuthState, + ] } console.error('Error executing middleware > \n') @@ -64,10 +73,10 @@ export const invoke = async ( console.error('~'.repeat(80)) } finally { // This one is for the server. The worker serverStore is initialized in the worker itself! - setupServerStore(req, mwReq.serverAuthState.get()) + setupServerStore(req, mwReq.serverAuthState.get() || defaultServerAuthState) } - return [mwRes, mwReq.serverAuthState.get()] + return [mwRes, mwReq.serverAuthState.get() || defaultServerAuthState] } const setupServerStore = (_req: Request, serverAuthState: ServerAuthState) => { diff --git a/packages/vite/src/streaming/createReactStreamingHandler.ts b/packages/vite/src/streaming/createReactStreamingHandler.ts index fbb03313a7a2..b6a18b8fb064 100644 --- a/packages/vite/src/streaming/createReactStreamingHandler.ts +++ b/packages/vite/src/streaming/createReactStreamingHandler.ts @@ -185,7 +185,11 @@ export const createReactStreamingHandler = async ( cssLinks, isProd, jsBundles, - authState: decodedAuthState, + authState: { + roles: [], + cookieHeader: req.headers.get('cookie'), + ...decodedAuthState, + }, }, { waitForAllReady: isSeoCrawler, diff --git a/packages/web/src/apollo/links.tsx b/packages/web/src/apollo/links.tsx index 3484e3c2f529..6394b23df832 100644 --- a/packages/web/src/apollo/links.tsx +++ b/packages/web/src/apollo/links.tsx @@ -6,7 +6,7 @@ import { print } from 'graphql/language/printer' export function createHttpLink( uri: string, httpLinkConfig: HttpOptions | undefined, - cookieHeader?: string, + cookieHeader?: string | null, ) { const headers: Record = {} From 34cec37baa82bbb0cece60558579a48cf1b2a5b4 Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Tue, 21 May 2024 20:00:18 +0700 Subject: [PATCH 02/20] Bit more cleanup --- packages/vite/src/middleware/invokeMiddleware.ts | 11 +++-------- .../vite/src/streaming/createReactStreamingHandler.ts | 9 ++++++--- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/vite/src/middleware/invokeMiddleware.ts b/packages/vite/src/middleware/invokeMiddleware.ts index a91164c82651..c11237d0fee1 100644 --- a/packages/vite/src/middleware/invokeMiddleware.ts +++ b/packages/vite/src/middleware/invokeMiddleware.ts @@ -19,6 +19,7 @@ import type { Middleware, MiddlewareInvokeOptions } from './types.js' * * Returns promise that will resolve to a tuple of * [MiddlewareResponse, ServerAuthState] + * and will always make sure there is a ServerAuthState set */ export const invoke = async ( req: Request, @@ -32,7 +33,7 @@ export const invoke = async ( } if (typeof middleware !== 'function') { - setupServerStore(req, defaultServerAuthState) + setServerAuthState(defaultServerAuthState) return [MiddlewareResponse.next(), defaultServerAuthState] } @@ -73,14 +74,8 @@ export const invoke = async ( console.error('~'.repeat(80)) } finally { // This one is for the server. The worker serverStore is initialized in the worker itself! - setupServerStore(req, mwReq.serverAuthState.get() || defaultServerAuthState) + setServerAuthState(mwReq.serverAuthState.get() || defaultServerAuthState) } return [mwRes, mwReq.serverAuthState.get() || defaultServerAuthState] } - -const setupServerStore = (_req: Request, serverAuthState: ServerAuthState) => { - // Init happens in app.use('*') - - setServerAuthState(serverAuthState) -} diff --git a/packages/vite/src/streaming/createReactStreamingHandler.ts b/packages/vite/src/streaming/createReactStreamingHandler.ts index b6a18b8fb064..096cd93d2723 100644 --- a/packages/vite/src/streaming/createReactStreamingHandler.ts +++ b/packages/vite/src/streaming/createReactStreamingHandler.ts @@ -94,13 +94,16 @@ export const createReactStreamingHandler = async ( // ~~~ Middleware Handling ~~~ if (middlewareRouter) { const matchedMw = middlewareRouter.find(req.method as HTTPMethod, req.url) - ;[mwResponse, decodedAuthState = middlewareDefaultAuthProviderState] = - await invoke(req, matchedMw?.handler as Middleware | undefined, { + ;[mwResponse, decodedAuthState] = await invoke( + req, + matchedMw?.handler as Middleware | undefined, + { route: currentRoute, cssPaths: getStylesheetLinks(currentRoute), params: matchedMw?.params, viteDevServer, - }) + }, + ) // If mwResponse is a redirect, short-circuit here, and skip React rendering // If the response has a body, no need to render react. From 940fa6373ab435ea544d3829d609fef88e48fd98 Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Tue, 21 May 2024 20:12:15 +0700 Subject: [PATCH 03/20] Bit more cleanup --- .../src/middleware/MiddlewareRequest.test.ts | 43 +++++++++++++++++-- .../vite/src/middleware/MiddlewareRequest.ts | 10 +++-- .../vite/src/middleware/invokeMiddleware.ts | 13 +++--- 3 files changed, 51 insertions(+), 15 deletions(-) diff --git a/packages/vite/src/middleware/MiddlewareRequest.test.ts b/packages/vite/src/middleware/MiddlewareRequest.test.ts index 37c0ae0fce91..0b41d8ea416e 100644 --- a/packages/vite/src/middleware/MiddlewareRequest.test.ts +++ b/packages/vite/src/middleware/MiddlewareRequest.test.ts @@ -1,7 +1,9 @@ import { Request as ArdaRequest } from '@whatwg-node/fetch' import { describe, expect, test } from 'vitest' -import { createMiddlewareRequest } from './MiddlewareRequest' +import type { ServerAuthState } from '@redwoodjs/auth' + +import { MiddlewareRequest, createMiddlewareRequest } from './MiddlewareRequest' describe('MiddlewareRequest', () => { test('Converts a Web API Request object correctly', () => { @@ -49,18 +51,53 @@ describe('MiddlewareRequest', () => { expect(mReq.headers.get('x-custom-header')).toStrictEqual('beatdrop') }) - test('Can attach and retrieve server auth context', () => { + test('Has a default server auth state', () => { + const mwReq = new MiddlewareRequest( + new Request('http://redwoodjs.com', { + headers: { + Cookie: 'foo=bar', + }, + }), + ) + + const authState = mwReq.serverAuthState.get() + expect(authState?.cookieHeader).toStrictEqual('foo=bar') + expect(authState?.isAuthenticated).toBe(false) + }) + + test('Can attach and retrieve server auth state', () => { const req = new Request('http://redwoodjs.com') const FAKE_AUTH_CONTEXT = { currentUser: { name: 'Danny', }, isAuthenticated: true, - } + } as unknown as ServerAuthState const mReq = createMiddlewareRequest(req) mReq.serverAuthState.set(FAKE_AUTH_CONTEXT) expect(mReq.serverAuthState.get()).toStrictEqual(FAKE_AUTH_CONTEXT) }) + + test('Can clear auth state', () => { + const mwReq = new MiddlewareRequest( + new Request('http://redwoodjs.com', { + headers: { + Cookie: 'foo=bar', + }, + }), + ) + const FAKE_AUTH_CONTEXT = { + isAuthenticated: true, + } as unknown as ServerAuthState + + mwReq.serverAuthState.set(FAKE_AUTH_CONTEXT) + + expect(mwReq.serverAuthState.get()?.isAuthenticated).toBe(true) + + mwReq.serverAuthState.clear() + + expect(mwReq.serverAuthState.get().isAuthenticated).toBe(false) + }) }) diff --git a/packages/vite/src/middleware/MiddlewareRequest.ts b/packages/vite/src/middleware/MiddlewareRequest.ts index 50223cfde8c1..348ad12e8c5e 100644 --- a/packages/vite/src/middleware/MiddlewareRequest.ts +++ b/packages/vite/src/middleware/MiddlewareRequest.ts @@ -8,10 +8,12 @@ import { import { CookieJar } from './CookieJar.js' class AuthStateJar { - private _data: ServerAuthState | null + private _data: ServerAuthState + private _initialState: ServerAuthState - constructor(data?: ServerAuthState) { - this._data = data || null + constructor(initialState: ServerAuthState) { + this._data = initialState + this._initialState = initialState } get() { @@ -23,7 +25,7 @@ class AuthStateJar { } clear() { - this._data = null + this._data = this._initialState } } diff --git a/packages/vite/src/middleware/invokeMiddleware.ts b/packages/vite/src/middleware/invokeMiddleware.ts index c11237d0fee1..6d6c8fa623c7 100644 --- a/packages/vite/src/middleware/invokeMiddleware.ts +++ b/packages/vite/src/middleware/invokeMiddleware.ts @@ -59,13 +59,10 @@ export const invoke = async ( ) } } catch (e) { - // @TODO catch the error here, and see if its a short-circuit - // A shortcircuit will prevent execution of all other middleware down the chain, and prevent react rendering + // A short-circuit will prevent execution of all other middleware down the chain, + // and prevent react rendering if (e instanceof MiddlewareShortCircuit) { - return [ - e.mwResponse, - mwReq.serverAuthState.get() || defaultServerAuthState, - ] + return [e.mwResponse, mwReq.serverAuthState.get()] } console.error('Error executing middleware > \n') @@ -74,8 +71,8 @@ export const invoke = async ( console.error('~'.repeat(80)) } finally { // This one is for the server. The worker serverStore is initialized in the worker itself! - setServerAuthState(mwReq.serverAuthState.get() || defaultServerAuthState) + setServerAuthState(mwReq.serverAuthState.get()) } - return [mwRes, mwReq.serverAuthState.get() || defaultServerAuthState] + return [mwRes, mwReq.serverAuthState.get()] } From b88b1692b1278c55184f9e32b6e52ecc168eaf2a Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Tue, 21 May 2024 20:18:24 +0700 Subject: [PATCH 04/20] Update invokeMiddleware tests --- .../src/middleware/invokeMiddleware.test.ts | 17 ++++++++++++----- .../vite/src/middleware/invokeMiddleware.ts | 16 ++++------------ 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/packages/vite/src/middleware/invokeMiddleware.test.ts b/packages/vite/src/middleware/invokeMiddleware.test.ts index d6af46aac24c..98051fbbd1c0 100644 --- a/packages/vite/src/middleware/invokeMiddleware.test.ts +++ b/packages/vite/src/middleware/invokeMiddleware.test.ts @@ -1,6 +1,7 @@ import type { MockInstance } from 'vitest' import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest' +import type { ServerAuthState } from '@redwoodjs/auth' import { middlewareDefaultAuthProviderState } from '@redwoodjs/auth' import { createServerStorage } from '../serverStore' @@ -15,10 +16,16 @@ describe('Invoke middleware', () => { createServerStorage() }) + const unauthenticatedServerAuthState = { + ...middlewareDefaultAuthProviderState, + roles: [], + cookieHeader: null, + } + 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(middlewareDefaultAuthProviderState) + expect(authState).toEqual(unauthenticatedServerAuthState) }) test('extracts auth state correctly, and always returns a MWResponse', async () => { @@ -26,7 +33,7 @@ describe('Invoke middleware', () => { const fakeMiddleware = (req: MiddlewareRequest) => { req.serverAuthState.set({ user: BOB, - }) + } as unknown as ServerAuthState) } const [mwRes, authState] = await invoke( @@ -63,7 +70,7 @@ describe('Invoke middleware', () => { ) expect(mwRes).toBeInstanceOf(MiddlewareResponse) - expect(authState).toEqual(middlewareDefaultAuthProviderState) + expect(authState).toEqual(unauthenticatedServerAuthState) }) // A short-circuit is a way to stop the middleware chain immediately, and return a response @@ -84,7 +91,7 @@ describe('Invoke middleware', () => { expect(mwRes.body).toEqual('Zap') expect(mwRes.status).toEqual(999) expect(mwRes.statusText).toEqual('Ouch') - expect(authState).toEqual(middlewareDefaultAuthProviderState) + expect(authState).toEqual(unauthenticatedServerAuthState) }) test('can set extra properties in the shortcircuit response', async () => { @@ -115,7 +122,7 @@ describe('Invoke middleware', () => { 'is awesome', ) - expect(authState).toEqual(middlewareDefaultAuthProviderState) + expect(authState).toEqual(unauthenticatedServerAuthState) }) }) }) diff --git a/packages/vite/src/middleware/invokeMiddleware.ts b/packages/vite/src/middleware/invokeMiddleware.ts index 6d6c8fa623c7..ef46bea4e80a 100644 --- a/packages/vite/src/middleware/invokeMiddleware.ts +++ b/packages/vite/src/middleware/invokeMiddleware.ts @@ -1,7 +1,4 @@ -import { - middlewareDefaultAuthProviderState, - type ServerAuthState, -} from '@redwoodjs/auth' +import { type ServerAuthState } from '@redwoodjs/auth' import { setServerAuthState } from '../serverStore.js' @@ -26,19 +23,14 @@ export const invoke = async ( middleware?: Middleware, options?: MiddlewareInvokeOptions, ): Promise<[MiddlewareResponse, ServerAuthState]> => { - const defaultServerAuthState = { - ...middlewareDefaultAuthProviderState, - cookieHeader: req.headers.get('Cookie'), - roles: [], - } + const mwReq = new MiddlewareRequest(req) if (typeof middleware !== 'function') { - setServerAuthState(defaultServerAuthState) + setServerAuthState(mwReq.serverAuthState.get()) - return [MiddlewareResponse.next(), defaultServerAuthState] + return [MiddlewareResponse.next(), mwReq.serverAuthState.get()] } - const mwReq = new MiddlewareRequest(req) let mwRes: MiddlewareResponse = MiddlewareResponse.next() try { From 910e9ad17bd6bcd12d867f313bbacc12833dcb0e Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Tue, 21 May 2024 20:27:08 +0700 Subject: [PATCH 05/20] Update supabase middleware tests --- .../createSupabaseAuthMiddleware.test.ts | 32 +++++++++++++++---- .../vite/src/middleware/MiddlewareRequest.ts | 12 ++++--- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/packages/auth-providers/supabase/middleware/src/__tests__/createSupabaseAuthMiddleware.test.ts b/packages/auth-providers/supabase/middleware/src/__tests__/createSupabaseAuthMiddleware.test.ts index 4456e60c2544..c8d3c765c073 100644 --- a/packages/auth-providers/supabase/middleware/src/__tests__/createSupabaseAuthMiddleware.test.ts +++ b/packages/auth-providers/supabase/middleware/src/__tests__/createSupabaseAuthMiddleware.test.ts @@ -5,6 +5,7 @@ import { vi } from 'vitest' import { middlewareDefaultAuthProviderState, + unauthenticatedServerAuthState, // type ServerAuthState, } from '@redwoodjs/auth' import { authDecoder } from '@redwoodjs/auth-supabase-api' @@ -74,6 +75,12 @@ const options: SupabaseAuthMiddlewareOptions = { } describe('createSupabaseAuthMiddleware()', () => { + const unauthenticatedServerAuthState = { + ...middlewareDefaultAuthProviderState, + roles: [], + cookieHeader: null, + } + it('creates middleware for Supabase SSR auth', async () => { const middleware = createSupabaseAuthMiddleware(options) const request = new Request('http://localhost:8911', { @@ -90,7 +97,7 @@ describe('createSupabaseAuthMiddleware()', () => { expect(result).toHaveProperty('status', 200) const serverAuthState = req.serverAuthState.get() - expect(serverAuthState).toEqual(middlewareDefaultAuthProviderState) + expect(serverAuthState).toEqual(unauthenticatedServerAuthState) }) it('passes through non-authenticated requests', async () => { @@ -107,7 +114,7 @@ describe('createSupabaseAuthMiddleware()', () => { expect(result.body).toEqual('original response body') const serverAuthState = req.serverAuthState.get() - expect(serverAuthState).toEqual(middlewareDefaultAuthProviderState) + expect(serverAuthState).toEqual(unauthenticatedServerAuthState) }) it('passes through when no auth-provider cookie', async () => { const middleware = createSupabaseAuthMiddleware(options) @@ -127,7 +134,10 @@ describe('createSupabaseAuthMiddleware()', () => { expect(result.body).toEqual('original response body when no auth provider') const serverAuthState = req.serverAuthState.get() - expect(serverAuthState).toEqual(middlewareDefaultAuthProviderState) + expect(serverAuthState).toEqual({ + ...unauthenticatedServerAuthState, + cookieHeader: 'missing-the-auth-provider-cookie-header-name=supabase', + }) }) it('passes through when unsupported auth-provider', async () => { @@ -147,7 +157,10 @@ describe('createSupabaseAuthMiddleware()', () => { 'original response body for unsupported provider', ) const serverAuthState = req.serverAuthState.get() - expect(serverAuthState).toEqual(middlewareDefaultAuthProviderState) + expect(serverAuthState).toEqual({ + ...unauthenticatedServerAuthState, + cookieHeader: 'auth-provider=unsupported', + }) }) it('handles current user GETs', async () => { @@ -171,7 +184,10 @@ describe('createSupabaseAuthMiddleware()', () => { expect(req.url).toContain('/middleware/supabase/currentUser') const serverAuthState = req.serverAuthState.get() - expect(serverAuthState).toEqual(middlewareDefaultAuthProviderState) + expect(serverAuthState).toEqual({ + ...unauthenticatedServerAuthState, + cookieHeader: 'auth-provider=supabase', + }) }) it('authenticated request sets currentUser', async () => { @@ -268,7 +284,11 @@ describe('createSupabaseAuthMiddleware()', () => { // when an exception is thrown, such as when tampering with the cookie, //the serverAuthState should be cleared const serverAuthState = req.serverAuthState.get() - expect(serverAuthState).toBeNull() + expect(serverAuthState).toEqual({ + ...unauthenticatedServerAuthState, + cookieHeader: + 'auth-provider=supabase;sb-example-auth-token=dummy_access_token', + }) // the auth-provider cookie should be cleared from the response const authProviderCookie = res.cookies.get('auth-provider') diff --git a/packages/vite/src/middleware/MiddlewareRequest.ts b/packages/vite/src/middleware/MiddlewareRequest.ts index 348ad12e8c5e..ae45626e961c 100644 --- a/packages/vite/src/middleware/MiddlewareRequest.ts +++ b/packages/vite/src/middleware/MiddlewareRequest.ts @@ -8,7 +8,7 @@ import { import { CookieJar } from './CookieJar.js' class AuthStateJar { - private _data: ServerAuthState + private _data: ServerAuthState | null private _initialState: ServerAuthState constructor(initialState: ServerAuthState) { @@ -16,16 +16,20 @@ class AuthStateJar { this._initialState = initialState } + /** + * Always returns the server auth state, even if its set to null, + * it'll fall back to the initial state (created when mwReq is initialised) + */ get() { - return this._data + return this._data || this._initialState } - set(value: ServerAuthState) { + set(value: ServerAuthState | null) { this._data = value } clear() { - this._data = this._initialState + this._data = null } } From 9dc27b19957f4ae6c33d0dd7055493d5127ee47a Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Tue, 21 May 2024 20:32:13 +0700 Subject: [PATCH 06/20] Update dbAuth middleware tests --- .../src/__tests__/createDbAuthMiddleware.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/auth-providers/dbAuth/middleware/src/__tests__/createDbAuthMiddleware.test.ts b/packages/auth-providers/dbAuth/middleware/src/__tests__/createDbAuthMiddleware.test.ts index 2559a00ef4df..a93be7835552 100644 --- a/packages/auth-providers/dbAuth/middleware/src/__tests__/createDbAuthMiddleware.test.ts +++ b/packages/auth-providers/dbAuth/middleware/src/__tests__/createDbAuthMiddleware.test.ts @@ -7,6 +7,7 @@ import { MiddlewareRequest, } from '@redwoodjs/vite/middleware' +import { middlewareDefaultAuthProviderState } from '../../../../../auth/dist/AuthProvider/AuthProviderState' import type { DbAuthMiddlewareOptions } from '../index' import { createDbAuthMiddleware } from '../index' const FIXTURE_PATH = path.resolve( @@ -88,6 +89,7 @@ describe('createDbAuthMiddleware()', () => { email: 'user-1@example.com', id: 'mocked-current-user-1', }, + roles: [], }) // Allow react render, because body is not defined, and status code not redirect @@ -454,6 +456,12 @@ describe('createDbAuthMiddleware()', () => { }) describe('handle exception cases', async () => { + const unauthenticatedServerAuthState = { + ...middlewareDefaultAuthProviderState, + cookieHeader: null, + roles: [], + } + it('handles a POST that is not one of the supported dbAuth verbs and still build headers when passing along the request', async () => { const request = new Request( 'http://localhost:8911/middleware/dbauth/unsupportedVerb', @@ -569,7 +577,11 @@ describe('createDbAuthMiddleware()', () => { expect(res).toBeDefined() const serverAuthState = mwReq.serverAuthState.get() - expect(serverAuthState).toBeNull() + expect(serverAuthState).toEqual({ + ...unauthenticatedServerAuthState, + cookieHeader: + 'session_8911=some-bad-encrypted-cookie;auth-provider=dbAuth', + }) expect(res.toResponse().headers.getSetCookie()).toEqual([ // Expired cookies, will be removed by browser From 3e22b822f1e25f04865a45ffb1ae4d66e8f627ef Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Tue, 21 May 2024 20:47:17 +0700 Subject: [PATCH 07/20] Cleanup red lines in dbAuthMw tests --- .../__tests__/initDbAuthMiddleware.test.ts | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts b/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts index 3f22fb2f014a..6cae357b3e3a 100644 --- a/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts +++ b/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts @@ -5,6 +5,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { MiddlewareRequest as MWRequest, MiddlewareRequest, + MiddlewareResponse, } from '@redwoodjs/vite/middleware' import { middlewareDefaultAuthProviderState } from '../../../../../auth/dist/AuthProvider/AuthProviderState' @@ -73,7 +74,7 @@ describe('initDbAuthMiddleware()', () => { }), ) - const res = await middleware(mwReq) + const res = await middleware(mwReq, MiddlewareResponse.next()) expect(mwReq.serverAuthState.get()).toEqual({ cookieHeader: @@ -144,7 +145,7 @@ describe('initDbAuthMiddleware()', () => { } const [middleware] = initDbAuthMiddleware(options) - const res = await middleware(req) + const res = await middleware(req, MiddlewareResponse.next()) // Forwards the request on expect(options.dbAuthHandler).toHaveBeenCalledWith(req) @@ -182,12 +183,12 @@ describe('initDbAuthMiddleware()', () => { } const [middleware] = initDbAuthMiddleware(options) - const res = await middleware(req) + const res = await middleware(req, MiddlewareResponse.next()) expect(res).toBeDefined() expect(res).toHaveProperty('body', '') expect(res).toHaveProperty('status', 200) - expect(res.headers.getSetCookie()).toContain( + expect(res?.headers.getSetCookie()).toContain( 'session=cookie-value; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; HttpOnly; SameSite=Lax; Secure', ) }) @@ -236,7 +237,7 @@ describe('initDbAuthMiddleware()', () => { } const [middleware] = initDbAuthMiddleware(options) - const res = await middleware(req) + const res = await middleware(req, MiddlewareResponse.next()) expect(res).toBeDefined() expect(res).toHaveProperty( @@ -247,7 +248,7 @@ describe('initDbAuthMiddleware()', () => { ) expect(res).toHaveProperty('status', 200) - expect(res.headers.getSetCookie()).toContain( + expect(res?.headers.getSetCookie()).toContain( 'session_8911=some-encrypted-cookie', ) }) @@ -279,8 +280,8 @@ describe('initDbAuthMiddleware()', () => { } const [middleware] = initDbAuthMiddleware(options) - const res = await middleware(req) - expect(res.body).toEqual(resetToken) + const res = await middleware(req, MiddlewareResponse.next()) + expect(res?.body).toEqual(resetToken) }) it('handles a getToken request', async () => { const cookieHeader = @@ -313,7 +314,7 @@ describe('initDbAuthMiddleware()', () => { } const [middleware] = initDbAuthMiddleware(options) - const res = await middleware(req) + const res = await middleware(req, MiddlewareResponse.next()) expect(res).toBeDefined() const serverAuthState = req.serverAuthState.get() @@ -351,9 +352,9 @@ describe('initDbAuthMiddleware()', () => { } const [middleware] = initDbAuthMiddleware(options) - const res = await middleware(req) + const res = await middleware(req, MiddlewareResponse.next()) expect(res).toBeDefined() - expect(res.body).toBe( + expect(res?.body).toBe( JSON.stringify({ user: { id: 100, email: 'reset@example.com' } }), ) @@ -394,11 +395,11 @@ describe('initDbAuthMiddleware()', () => { } const [middleware] = initDbAuthMiddleware(options) - const res = await middleware(req) + const res = await middleware(req, MiddlewareResponse.next()) expect(res).toBeDefined() // should the body be the webAuth reg options? // but get requests need a cookie to be set? - // expect(res.body).toBeDefined() + // expect(res?.body).toBeDefined() }) // @todo: implement the following tests when try out webAuth // it('handles a webAuthnRegister', async () => { @@ -449,10 +450,10 @@ describe('initDbAuthMiddleware()', () => { } const [middleware] = initDbAuthMiddleware(options) - const res = await middleware(req) + const res = await middleware(req, MiddlewareResponse.next()) expect(res).toBeDefined() - expect(res.body).toBe(JSON.stringify({ currentUser })) + expect(res?.body).toBe(JSON.stringify({ currentUser })) }) describe('handle exception cases', async () => { @@ -492,11 +493,11 @@ describe('initDbAuthMiddleware()', () => { } const [middleware] = initDbAuthMiddleware(options) - const res = await middleware(req) + const res = await middleware(req, MiddlewareResponse.next()) expect(res).toBeDefined() - expect(res.headers.get('one')).toBe('header-one') - expect(res.headers.get('two')).toBe('header-two') + expect(res?.headers.get('one')).toBe('header-one') + expect(res?.headers.get('two')).toBe('header-two') const serverAuthState = req.serverAuthState.get() expect(serverAuthState).toHaveProperty('isAuthenticated', false) @@ -534,7 +535,7 @@ describe('initDbAuthMiddleware()', () => { } const [middleware] = initDbAuthMiddleware(options) - const res = await middleware(req) + const res = await middleware(req, MiddlewareResponse.next()) const serverAuthState = req.serverAuthState.get() expect(res).toBeDefined() @@ -573,7 +574,7 @@ describe('initDbAuthMiddleware()', () => { } const [middleware] = initDbAuthMiddleware(options) - const res = await middleware(mwReq) + const res = await middleware(mwReq, MiddlewareResponse.next()) expect(res).toBeDefined() const serverAuthState = mwReq.serverAuthState.get() @@ -583,7 +584,7 @@ describe('initDbAuthMiddleware()', () => { 'session_8911=some-bad-encrypted-cookie;auth-provider=dbAuth', }) - expect(res.toResponse().headers.getSetCookie()).toEqual([ + expect(res?.toResponse().headers.getSetCookie()).toEqual([ // Expired cookies, will be removed by browser 'session_8911=; Expires=Thu, 01 Jan 1970 00:00:00 GMT', 'auth-provider=; Expires=Thu, 01 Jan 1970 00:00:00 GMT', @@ -613,7 +614,7 @@ describe('initDbAuthMiddleware()', () => { } const [middleware] = initDbAuthMiddleware(options) - const res = await middleware(req) + const res = await middleware(req, MiddlewareResponse.next()) expect(res).toBeDefined() const serverAuthState = req.serverAuthState.get() From 12e4eea888300e168840a0d1ee0a59d672006106 Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Tue, 21 May 2024 21:03:36 +0700 Subject: [PATCH 08/20] Add test for extractRoles for supa mw --- .../createSupabaseAuthMiddleware.test.ts | 48 ++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/auth-providers/supabase/middleware/src/__tests__/createSupabaseAuthMiddleware.test.ts b/packages/auth-providers/supabase/middleware/src/__tests__/createSupabaseAuthMiddleware.test.ts index c8d3c765c073..e390180b37a2 100644 --- a/packages/auth-providers/supabase/middleware/src/__tests__/createSupabaseAuthMiddleware.test.ts +++ b/packages/auth-providers/supabase/middleware/src/__tests__/createSupabaseAuthMiddleware.test.ts @@ -34,8 +34,6 @@ vi.mock('jsonwebtoken', () => { } }) -// }) - vi.mock('@redwoodjs/auth-supabase-api', () => { return { authDecoder: vi.fn(() => { @@ -215,6 +213,52 @@ describe('createSupabaseAuthMiddleware()', () => { }) }) + it('authenticated request sets userMetadata', async () => { + const optionsWithExtractRole: SupabaseAuthMiddlewareOptions = { + getCurrentUser: async () => { + return { + id: 1, + email: 'user-1@example.com', + user_metadata: { favoriteColor: 'yellow' }, + } + }, + extractRoles: vi.fn().mockReturnValue(['admin', 'editor']), + } + + const middleware = createSupabaseAuthMiddleware(optionsWithExtractRole) + const request = new Request('http://localhost:8911/authenticated-request', { + method: 'GET', + headers: new Headers({ + cookie: 'auth-provider=supabase;sb_access_token=dummy_access_token', + }), + }) + const req = new MiddlewareRequest(request) + + const result = await middleware(req, MiddlewareResponse.next()) + expect(result).toBeDefined() + expect(req).toBeDefined() + + expect(authDecoder).toHaveBeenCalledWith( + 'auth-provider=supabase;sb_access_token=dummy_access_token', + 'supabase', + expect.anything(), + ) + + const serverAuthState = req.serverAuthState.get() + expect(serverAuthState).toBeDefined() + expect(serverAuthState).toHaveProperty('currentUser') + expect(serverAuthState.isAuthenticated).toEqual(true) + expect(serverAuthState.userMetadata).toEqual({ + favoriteColor: 'yellow', + }) + expect(serverAuthState.roles).toEqual(['admin', 'editor']) + + // Called with result of decoding the token + expect(optionsWithExtractRole.extractRoles).toHaveBeenCalledWith({ + sub: 'abc123', + }) + }) + it('authenticated request sets userMetadata', async () => { const optionsWithUserMetadata: SupabaseAuthMiddlewareOptions = { getCurrentUser: async () => { From 71fd7155242b1629afc13be3cd5c90155c53a2ab Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Tue, 21 May 2024 21:21:58 +0700 Subject: [PATCH 09/20] Cleanup createStreamingHandler --- .../src/streaming/createReactStreamingHandler.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/vite/src/streaming/createReactStreamingHandler.ts b/packages/vite/src/streaming/createReactStreamingHandler.ts index 096cd93d2723..40eeefa57417 100644 --- a/packages/vite/src/streaming/createReactStreamingHandler.ts +++ b/packages/vite/src/streaming/createReactStreamingHandler.ts @@ -6,6 +6,7 @@ import type { HTTPMethod } from 'find-my-way' import { createIsbotFromList, list as isbotList } from 'isbot' import type { ViteDevServer } from 'vite' +import type { ServerAuthState } from '@redwoodjs/auth' import { middlewareDefaultAuthProviderState } from '@redwoodjs/auth' import type { RouteSpec, RWRouteManifestItem } from '@redwoodjs/internal' import { getAppRouteHook, getConfig, getPaths } from '@redwoodjs/project-config' @@ -71,7 +72,14 @@ export const createReactStreamingHandler = async ( // @NOTE: we are returning a FetchAPI handler return async (req: Request) => { let mwResponse = MiddlewareResponse.next() - let decodedAuthState = middlewareDefaultAuthProviderState + + // Default auth state + let decodedAuthState: ServerAuthState = { + ...middlewareDefaultAuthProviderState, + cookieHeader: req.headers.get('cookie'), + roles: [], + } + // @TODO: Make the currentRoute 404? let currentRoute: RWRouteManifestItem | undefined let parsedParams: any = {} @@ -188,11 +196,7 @@ export const createReactStreamingHandler = async ( cssLinks, isProd, jsBundles, - authState: { - roles: [], - cookieHeader: req.headers.get('cookie'), - ...decodedAuthState, - }, + authState: decodedAuthState, }, { waitForAllReady: isSeoCrawler, From 5f472493d9a93e0368a6015d76dd0bd35c495db3 Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Wed, 22 May 2024 17:43:40 +0700 Subject: [PATCH 10/20] Fix dbAuth middleware and update tests --- .../__tests__/initDbAuthMiddleware.test.ts | 232 ++++++++++-------- .../dbAuth/middleware/src/index.ts | 127 ++++++---- 2 files changed, 205 insertions(+), 154 deletions(-) diff --git a/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts b/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts index 6cae357b3e3a..856aafe4ecb7 100644 --- a/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts +++ b/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts @@ -2,6 +2,7 @@ import path from 'node:path' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { dbAuthSession } from '@redwoodjs/auth-dbauth-api' import { MiddlewareRequest as MWRequest, MiddlewareRequest, @@ -18,13 +19,36 @@ const FIXTURE_PATH = path.resolve( beforeAll(() => { process.env.RWJS_CWD = FIXTURE_PATH + + // Mock the session decryption + vi.mock('@redwoodjs/auth-dbauth-api', async (importOriginal) => { + const original = (await importOriginal()) as any + return { + ...original, + dbAuthSession: vi.fn().mockImplementation((req, cookieName) => { + if ( + req.headers + .get('Cookie') + .includes(`${cookieName}=this_is_the_only_correct_session`) + ) { + return { + currentUser: { + email: 'user-1@example.com', + id: 'mocked-current-user-1', + }, + mockedSession: 'this_is_the_only_correct_session', + } + } + }), + } + }) }) afterAll(() => { delete process.env.RWJS_CWD }) -describe('initDbAuthMiddleware()', () => { +describe('dbAuthMiddleware', () => { it('When no cookie headers, pass through the response', async () => { const options: DbAuthMiddlewareOptions = { cookieName: '8911', @@ -53,15 +77,14 @@ describe('initDbAuthMiddleware()', () => { }) it('When it has a cookie header, decrypts and sets server auth context', async () => { - const cookieHeader = - 'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w==' + const cookieHeader = 'session=this_is_the_only_correct_session' const options: DbAuthMiddlewareOptions = { - cookieName: '8911', getCurrentUser: vi.fn(async () => { return { id: 'mocked-current-user-1', email: 'user-1@example.com' } }), dbAuthHandler: vi.fn(), + extractRoles: vi.fn(() => ['f1driver']), } const [middleware] = initDbAuthMiddleware(options) @@ -77,8 +100,59 @@ describe('initDbAuthMiddleware()', () => { const res = await middleware(mwReq, MiddlewareResponse.next()) expect(mwReq.serverAuthState.get()).toEqual({ - cookieHeader: - 'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w==', + cookieHeader: 'session=this_is_the_only_correct_session', + currentUser: { + email: 'user-1@example.com', + id: 'mocked-current-user-1', + }, + hasError: false, + isAuthenticated: true, + loading: false, + userMetadata: { + email: 'user-1@example.com', + id: 'mocked-current-user-1', + }, + roles: ['f1driver'], + }) + + expect(options.extractRoles).toHaveBeenCalledWith({ + currentUser: { + email: 'user-1@example.com', + id: 'mocked-current-user-1', + }, + mockedSession: 'this_is_the_only_correct_session', + }) + + // Allow react render, because body is not defined, and status code not redirect + expect(res).toHaveProperty('body', undefined) + expect(res).toHaveProperty('status', 200) + }) + + it('Will use the cookie name option correctly', async () => { + const cookieHeader = 'bazinga_8911=this_is_the_only_correct_session' + + const options: DbAuthMiddlewareOptions = { + getCurrentUser: vi.fn(async () => { + return { id: 'mocked-current-user-1', email: 'user-1@example.com' } + }), + dbAuthHandler: vi.fn(), + cookieName: 'bazinga_%port%', + } + const [middleware] = initDbAuthMiddleware(options) + + const mwReq = new MiddlewareRequest( + new Request('http://bazinga.new/kittens', { + method: 'GET', + headers: { + Cookie: cookieHeader, + }, + }), + ) + + const res = await middleware(mwReq, MiddlewareResponse.next()) + + expect(mwReq.serverAuthState.get()).toEqual({ + cookieHeader: 'bazinga_8911=this_is_the_only_correct_session', currentUser: { email: 'user-1@example.com', id: 'mocked-current-user-1', @@ -90,6 +164,7 @@ describe('initDbAuthMiddleware()', () => { email: 'user-1@example.com', id: 'mocked-current-user-1', }, + // No extract roles function, so it should be empty roles: [], }) @@ -98,6 +173,39 @@ describe('initDbAuthMiddleware()', () => { expect(res).toHaveProperty('status', 200) }) + it('handles a currentUser request', async () => { + const cookieHeader = 'session=this_is_the_only_correct_session' + const request = new Request( + 'http://localhost:8910/middleware/dbauth/currentUser', + { + method: 'GET', + headers: { + Cookie: cookieHeader, + }, + }, + ) + + const req = new MWRequest(request) + const cookie = req.headers.get('Cookie') + + expect(cookie).toBe(cookieHeader) + + const currentUser = { user: { id: 100, email: 'currentUser@example.com' } } + + const options: DbAuthMiddlewareOptions = { + getCurrentUser: async () => { + return currentUser + }, + dbAuthHandler: vi.fn(), + } + const [middleware] = initDbAuthMiddleware(options) + + const res = await middleware(req, MiddlewareResponse.next()) + + expect(res).toBeDefined() + expect(res?.body).toBe(JSON.stringify({ currentUser })) + }) + describe('handle all supported dbAuth verbs (aka methods) and their HTTP methods', async () => { /** * Supported verbs and their corresponding HTTP methods: @@ -300,13 +408,12 @@ describe('initDbAuthMiddleware()', () => { const req = new MWRequest(request) const options: DbAuthMiddlewareOptions = { - cookieName: 'session_8911', getCurrentUser: async () => { return { user: { id: 100, email: 'tolkienUser@example.com' } } }, dbAuthHandler: async () => { return { - body: '', + body: 'getTokenResponse', headers: {}, statusCode: 200, } @@ -316,14 +423,9 @@ describe('initDbAuthMiddleware()', () => { const res = await middleware(req, MiddlewareResponse.next()) expect(res).toBeDefined() - - const serverAuthState = req.serverAuthState.get() - expect(serverAuthState.isAuthenticated).toBe(true) - expect(serverAuthState.currentUser).toEqual({ - user: { id: 100, email: 'tolkienUser@example.com' }, - }) - expect(serverAuthState.cookieHeader).toBe(cookieHeader) + expect(res?.body).toBe('getTokenResponse') }) + it('handles a validateResetToken request', async () => { const request = new Request( 'http://localhost:8911/middleware/dbauth/auth?method=validateResetToken', @@ -361,8 +463,9 @@ describe('initDbAuthMiddleware()', () => { const serverAuthState = req.serverAuthState.get() expect(serverAuthState.isAuthenticated).toBe(false) }) + it('handles a webAuthnRegOptions request', async () => { - const body = JSON.stringify({ + const regOptionsBody = JSON.stringify({ r: { id: 1 }, user: { user: { id: 100, email: 'user@example.com' } }, challenge: 'challenge', @@ -387,7 +490,7 @@ describe('initDbAuthMiddleware()', () => { }, dbAuthHandler: async () => { return { - body, + body: regOptionsBody, headers: {}, statusCode: 200, } @@ -396,10 +499,7 @@ describe('initDbAuthMiddleware()', () => { const [middleware] = initDbAuthMiddleware(options) const res = await middleware(req, MiddlewareResponse.next()) - expect(res).toBeDefined() - // should the body be the webAuth reg options? - // but get requests need a cookie to be set? - // expect(res?.body).toBeDefined() + expect(res?.body).toBe(regOptionsBody) }) // @todo: implement the following tests when try out webAuth // it('handles a webAuthnRegister', async () => { @@ -412,49 +512,6 @@ describe('initDbAuthMiddleware()', () => { // //: 'POST', // }) }) - it('handles a currentUser request', async () => { - // encrypted session taken fom dbAuth tests - // I cannot figure out why the header here has to be session - // but the cookieName session_8911 to work properly - const cookieHeader = - 'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w==' - const request = new Request( - 'http://localhost:8911/middleware/dbauth/currentUser', - { - method: 'GET', - headers: { - Cookie: cookieHeader, - }, - }, - ) - - const req = new MWRequest(request) - const cookie = req.headers.get('Cookie') - - expect(cookie).toBe(cookieHeader) - - const currentUser = { user: { id: 100, email: 'currentUser@example.com' } } - - const options: DbAuthMiddlewareOptions = { - cookieName: 'session_8911', - getCurrentUser: async () => { - return currentUser - }, - dbAuthHandler: async () => { - return { - body: '', - headers: {}, - statusCode: 200, - } - }, - } - const [middleware] = initDbAuthMiddleware(options) - - const res = await middleware(req, MiddlewareResponse.next()) - - expect(res).toBeDefined() - expect(res?.body).toBe(JSON.stringify({ currentUser })) - }) describe('handle exception cases', async () => { const unauthenticatedServerAuthState = { @@ -463,6 +520,11 @@ describe('initDbAuthMiddleware()', () => { roles: [], } + beforeAll(() => { + // So that we don't see errors in console when running negative cases + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + it('handles a POST that is not one of the supported dbAuth verbs and still build headers when passing along the request', async () => { const request = new Request( 'http://localhost:8911/middleware/dbauth/unsupportedVerb', @@ -502,48 +564,6 @@ describe('initDbAuthMiddleware()', () => { const serverAuthState = req.serverAuthState.get() expect(serverAuthState).toHaveProperty('isAuthenticated', false) }) - it('handles a GET request with correct cookies', async () => { - // encrypted session taken fom dbAuth tests - // I cannot figure out why the header here has to be session - // but the cookieName session_8911 to work properly - const cookieHeader = - 'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w==' - const request = new Request('http://localhost:8911/functions/hello', { - method: 'GET', - headers: { - Cookie: cookieHeader, - }, - }) - - const req = new MWRequest(request) - const cookie = req.headers.get('Cookie') - - expect(cookie).toBe(cookieHeader) - - const options: DbAuthMiddlewareOptions = { - cookieName: 'session_8911', - getCurrentUser: async () => { - return { user: { id: 100, email: 'hello@example.com' } } - }, - dbAuthHandler: async () => { - return { - body: '', - headers: {}, - statusCode: 200, - } - }, - } - const [middleware] = initDbAuthMiddleware(options) - - const res = await middleware(req, MiddlewareResponse.next()) - const serverAuthState = req.serverAuthState.get() - - expect(res).toBeDefined() - expect(serverAuthState.isAuthenticated).toBe(true) - expect(serverAuthState.currentUser).toEqual({ - user: { id: 100, email: 'hello@example.com' }, - }) - }) it('handles a GET request with incorrect cookies (bad decrypt)', async () => { const request = new Request( diff --git a/packages/auth-providers/dbAuth/middleware/src/index.ts b/packages/auth-providers/dbAuth/middleware/src/index.ts index 2ec8f31c716c..e54c642b6092 100644 --- a/packages/auth-providers/dbAuth/middleware/src/index.ts +++ b/packages/auth-providers/dbAuth/middleware/src/index.ts @@ -6,11 +6,11 @@ import { dbAuthSession, } from '@redwoodjs/auth-dbauth-api' import type { GetCurrentUser } from '@redwoodjs/graphql-server' -import type { Middleware } from '@redwoodjs/vite/middleware' +import type { Middleware, MiddlewareRequest } from '@redwoodjs/vite/middleware' import { MiddlewareResponse } from '@redwoodjs/vite/middleware' export interface DbAuthMiddlewareOptions { - cookieName: string + cookieName?: string dbAuthUrl?: string // @NOTE: we never pass lambda event or contexts, when using middleware // this is because in existing projects have it typed api/src/functions/auth.ts @@ -23,36 +23,43 @@ export interface DbAuthMiddlewareOptions { } export const initDbAuthMiddleware = ({ - cookieName, dbAuthHandler, getCurrentUser, extractRoles, + cookieName, dbAuthUrl = '/middleware/dbauth', }: DbAuthMiddlewareOptions): [Middleware, '*'] => { const mw: Middleware = async (req, res = MiddlewareResponse.next()) => { - // Handoff POST requests to the dbAuthHandler. The url is configurable on the dbAuth client side. - // This is where we handle login, logout, and signup, etc., but we don't want to intercept - if (req.method === 'POST') { - if (!req.url.includes(dbAuthUrl)) { - // Bail if the POST request is not for the dbAuthHandler - return res - } - - const output = await dbAuthHandler(req) - const finalHeaders = new Headers() - - Object.entries(output.headers).forEach(([key, value]) => { - if (Array.isArray(value)) { - value.forEach((mvhHeader) => finalHeaders.append(key, mvhHeader)) - } else { - finalHeaders.append(key, value) - } - }) + // Handoff POST and some GET requests to the dbAuthHandler. The url is configurable on the dbAuth client side. + // This is where we handle login, logout, and signup, etc., no need to enrich the context + if (req.url.includes(dbAuthUrl)) { + // Short circuit here ... + // if the call came from packages/auth-providers/dbAuth/web/src/getCurrentUserFromMiddleware.ts + if (req.url.includes(`${dbAuthUrl}/currentUser`)) { + const { currentUser } = await validateSession({ + req, + cookieName, + getCurrentUser, + }) - return new MiddlewareResponse(output.body, { - headers: finalHeaders, - status: output.statusCode, - }) + return new MiddlewareResponse(JSON.stringify({ currentUser })) + } else { + const output = await dbAuthHandler(req) + const finalHeaders = new Headers() + + Object.entries(output.headers).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach((mvhHeader) => finalHeaders.append(key, mvhHeader)) + } else { + finalHeaders.append(key, value) + } + }) + + return new MiddlewareResponse(output.body, { + headers: finalHeaders, + status: output.statusCode, + }) + } } const cookieHeader = req.headers.get('Cookie') @@ -65,28 +72,12 @@ export const initDbAuthMiddleware = ({ // 👇 Authenticated request try { // Call the dbAuth auth decoder. For dbAuth we have direct access to the `dbAuthSession` function. - // Other providers may be slightly different. - const decryptedSession = dbAuthSession(req as Request, cookieName) - - const currentUser = await getCurrentUser( - decryptedSession, - { - type: 'dbAuth', - schema: 'cookie', - // @MARK: We pass the entire cookie header as a token. This isn't actually the token! - token: cookieHeader, - }, - { - // MWRequest is a superset of Request - event: req as Request, - }, - ) - - // Short circuit here ... - // if the call came from packages/auth-providers/dbAuth/web/src/getCurrentUserFromMiddleware.ts - if (req.url.includes(`${dbAuthUrl}/currentUser`)) { - return new MiddlewareResponse(JSON.stringify({ currentUser })) - } + // Other providers will be slightly different. + const { currentUser, decryptedSession } = await validateSession({ + req, + cookieName, + getCurrentUser, + }) req.serverAuthState.set({ currentUser, @@ -99,7 +90,7 @@ export const initDbAuthMiddleware = ({ }) } catch (e) { // Clear server auth context - console.error(e, 'Error decrypting dbAuth cookie') + console.error('Error decrypting dbAuth cookie \n', e) req.serverAuthState.clear() // Note we have to use ".unset" and not ".clear" @@ -116,4 +107,44 @@ export const initDbAuthMiddleware = ({ return [mw, '*'] } +interface ValidateParams { + req: MiddlewareRequest + getCurrentUser: GetCurrentUser + cookieName?: string +} + +async function validateSession({ + req, + cookieName, + getCurrentUser, +}: ValidateParams) { + const decryptedSession = dbAuthSession( + req as Request, + cookieNameCreator(cookieName), + ) + + // So that it goes into the catch block + if (!decryptedSession) { + throw new Error( + `No decrypted session found. Check passed in cookie name options to middleware, looking for "${cookieName}"`, + ) + } + + const currentUser = await getCurrentUser( + decryptedSession, + { + type: 'dbAuth', + schema: 'cookie', + // @MARK: We pass the entire cookie header as a token. This isn't actually the token! + // At this point the Cookie header is guaranteed, because otherwise a decryptionError would be thrown + token: req.headers.get('Cookie') as string, + }, + { + // MWRequest is a superset of Request + event: req as Request, + }, + ) + return { currentUser, decryptedSession } +} + export default initDbAuthMiddleware From a730f93d41619742ee852652671bdfa941c7f563 Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Wed, 22 May 2024 23:16:22 +0700 Subject: [PATCH 11/20] Add changeset, update dbAuthReadme --- .changesets/10656.md | 23 +++++++++++++++++++ .../dbAuth/middleware/README.md | 3 ++- 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 .changesets/10656.md diff --git a/.changesets/10656.md b/.changesets/10656.md new file mode 100644 index 000000000000..31bd874cb0e2 --- /dev/null +++ b/.changesets/10656.md @@ -0,0 +1,23 @@ +- feat(rsc-auth): Implement extractRoles function in auth mw & update default ServerAuthState (#10656) by @dac09 + +- Implement extractRoles function in supabase and dbAuth middleware +- Updates default serverAuthState to contain roles +- Make cookieHeader a required attribute +- Introduces new `clear()` function to remove auth state - just syntax sugar + +## Example usage +```tsx +// In entry.server.tsx +export const registerMiddleware = () => { + // This actually returns [dbAuthMiddleware, '*'] + const authMw = initDbAuthMiddleware({ + dbAuthHandler, + getCurrentUser, + extractRoles: (decoded) => { + return decoded.currentUser.roles || [] + } + }) + + return [authMw] +} +``` diff --git a/packages/auth-providers/dbAuth/middleware/README.md b/packages/auth-providers/dbAuth/middleware/README.md index 687095432622..b55351e29faf 100644 --- a/packages/auth-providers/dbAuth/middleware/README.md +++ b/packages/auth-providers/dbAuth/middleware/README.md @@ -18,9 +18,10 @@ interface Props { export const registerMiddleware = () => { // This actually returns [dbAuthMiddleware, '*'] const authMw = initDbAuthMiddleware({ - cookieName, dbAuthHandler, getCurrentUser, + // cookieName optional + // extractRoles optional // dbAuthUrl? optional }) From 21df5376431dd96696fad1b569549e31eefd18b8 Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Sat, 25 May 2024 12:30:21 +0700 Subject: [PATCH 12/20] Rename extractRoles to getRoles --- .changesets/10656.md | 6 +++--- packages/auth-providers/dbAuth/middleware/README.md | 2 +- .../middleware/src/__tests__/initDbAuthMiddleware.test.ts | 4 ++-- packages/auth-providers/dbAuth/middleware/src/index.ts | 6 +++--- .../src/__tests__/createSupabaseAuthMiddleware.test.ts | 4 ++-- packages/auth-providers/supabase/middleware/src/index.ts | 6 +++--- packages/ogimage-gen/empty.js | 1 + packages/ogimage-gen/package.json | 1 + packages/router/src/location.tsx | 1 + packages/vite/src/lib/getMergedConfig.ts | 5 +++-- 10 files changed, 20 insertions(+), 16 deletions(-) create mode 100644 packages/ogimage-gen/empty.js diff --git a/.changesets/10656.md b/.changesets/10656.md index 31bd874cb0e2..8307e1eda502 100644 --- a/.changesets/10656.md +++ b/.changesets/10656.md @@ -1,6 +1,6 @@ -- feat(rsc-auth): Implement extractRoles function in auth mw & update default ServerAuthState (#10656) by @dac09 +- feat(rsc-auth): Implement getRoles function in auth mw & update default ServerAuthState (#10656) by @dac09 -- Implement extractRoles function in supabase and dbAuth middleware +- Implement getRoles function in supabase and dbAuth middleware - Updates default serverAuthState to contain roles - Make cookieHeader a required attribute - Introduces new `clear()` function to remove auth state - just syntax sugar @@ -13,7 +13,7 @@ export const registerMiddleware = () => { const authMw = initDbAuthMiddleware({ dbAuthHandler, getCurrentUser, - extractRoles: (decoded) => { + getRoles: (decoded) => { return decoded.currentUser.roles || [] } }) diff --git a/packages/auth-providers/dbAuth/middleware/README.md b/packages/auth-providers/dbAuth/middleware/README.md index b55351e29faf..b5c92d061a64 100644 --- a/packages/auth-providers/dbAuth/middleware/README.md +++ b/packages/auth-providers/dbAuth/middleware/README.md @@ -21,7 +21,7 @@ export const registerMiddleware = () => { dbAuthHandler, getCurrentUser, // cookieName optional - // extractRoles optional + // getRoles optional // dbAuthUrl? optional }) diff --git a/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts b/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts index 27e94d5efbaf..95ef32e84cc1 100644 --- a/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts +++ b/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts @@ -83,7 +83,7 @@ describe('dbAuthMiddleware', () => { return { id: 'mocked-current-user-1', email: 'user-1@example.com' } }), dbAuthHandler: vi.fn(), - extractRoles: vi.fn(() => ['f1driver']), + getRoles: vi.fn(() => ['f1driver']), } const [middleware] = initDbAuthMiddleware(options) @@ -114,7 +114,7 @@ describe('dbAuthMiddleware', () => { roles: ['f1driver'], }) - expect(options.extractRoles).toHaveBeenCalledWith({ + expect(options.getRoles).toHaveBeenCalledWith({ currentUser: { email: 'user-1@example.com', id: 'mocked-current-user-1', diff --git a/packages/auth-providers/dbAuth/middleware/src/index.ts b/packages/auth-providers/dbAuth/middleware/src/index.ts index e54c642b6092..790adc9827fc 100644 --- a/packages/auth-providers/dbAuth/middleware/src/index.ts +++ b/packages/auth-providers/dbAuth/middleware/src/index.ts @@ -18,14 +18,14 @@ export interface DbAuthMiddlewareOptions { req: Request | APIGatewayProxyEvent, context?: Context, ) => DbAuthResponse - extractRoles?: (decoded: any) => string[] + getRoles?: (decoded: any) => string[] getCurrentUser: GetCurrentUser } export const initDbAuthMiddleware = ({ dbAuthHandler, getCurrentUser, - extractRoles, + getRoles, cookieName, dbAuthUrl = '/middleware/dbauth', }: DbAuthMiddlewareOptions): [Middleware, '*'] => { @@ -86,7 +86,7 @@ export const initDbAuthMiddleware = ({ hasError: false, userMetadata: currentUser, // dbAuth doesn't have userMetadata cookieHeader, - roles: extractRoles ? extractRoles(decryptedSession) : [], + roles: getRoles ? getRoles(decryptedSession) : [], }) } catch (e) { // Clear server auth context diff --git a/packages/auth-providers/supabase/middleware/src/__tests__/createSupabaseAuthMiddleware.test.ts b/packages/auth-providers/supabase/middleware/src/__tests__/createSupabaseAuthMiddleware.test.ts index e390180b37a2..1254d75ee413 100644 --- a/packages/auth-providers/supabase/middleware/src/__tests__/createSupabaseAuthMiddleware.test.ts +++ b/packages/auth-providers/supabase/middleware/src/__tests__/createSupabaseAuthMiddleware.test.ts @@ -222,7 +222,7 @@ describe('createSupabaseAuthMiddleware()', () => { user_metadata: { favoriteColor: 'yellow' }, } }, - extractRoles: vi.fn().mockReturnValue(['admin', 'editor']), + getRoles: vi.fn().mockReturnValue(['admin', 'editor']), } const middleware = createSupabaseAuthMiddleware(optionsWithExtractRole) @@ -254,7 +254,7 @@ describe('createSupabaseAuthMiddleware()', () => { expect(serverAuthState.roles).toEqual(['admin', 'editor']) // Called with result of decoding the token - expect(optionsWithExtractRole.extractRoles).toHaveBeenCalledWith({ + expect(optionsWithExtractRole.getRoles).toHaveBeenCalledWith({ sub: 'abc123', }) }) diff --git a/packages/auth-providers/supabase/middleware/src/index.ts b/packages/auth-providers/supabase/middleware/src/index.ts index 457cb7ae629c..f414917f23ec 100644 --- a/packages/auth-providers/supabase/middleware/src/index.ts +++ b/packages/auth-providers/supabase/middleware/src/index.ts @@ -10,7 +10,7 @@ import { clearAuthState } from './util' export interface SupabaseAuthMiddlewareOptions { getCurrentUser: GetCurrentUser - extractRoles?: (decoded: any) => string[] + getRoles?: (decoded: any) => string[] } /** @@ -18,7 +18,7 @@ export interface SupabaseAuthMiddlewareOptions { */ const createSupabaseAuthMiddleware = ({ getCurrentUser, - extractRoles, + getRoles, }: SupabaseAuthMiddlewareOptions) => { return async (req: MiddlewareRequest, res: MiddlewareResponse) => { const type = 'supabase' @@ -71,7 +71,7 @@ const createSupabaseAuthMiddleware = ({ hasError: false, userMetadata: userMetadata || currentUser, cookieHeader, - roles: extractRoles ? extractRoles(decoded) : [], + roles: getRoles ? getRoles(decoded) : [], }) } catch (e) { console.error(e, 'Error in Supabase Auth Middleware') diff --git a/packages/ogimage-gen/empty.js b/packages/ogimage-gen/empty.js new file mode 100644 index 000000000000..8def92b13538 --- /dev/null +++ b/packages/ogimage-gen/empty.js @@ -0,0 +1 @@ +// FIND ME ROB! diff --git a/packages/ogimage-gen/package.json b/packages/ogimage-gen/package.json index 84617e16b84d..42a5d6e60622 100644 --- a/packages/ogimage-gen/package.json +++ b/packages/ogimage-gen/package.json @@ -16,6 +16,7 @@ "./middleware": { "import": "./dist/OgImageMiddleware.js", "default": "./cjsWrappers/middleware.js", + "react-server": "./empty.js", "types": "./dist/OgImageMiddleware.d.ts" }, "./hooks": { diff --git a/packages/router/src/location.tsx b/packages/router/src/location.tsx index b3a3e3e1286b..b893767877f6 100644 --- a/packages/router/src/location.tsx +++ b/packages/router/src/location.tsx @@ -1,3 +1,4 @@ +'use client' import React from 'react' import { createNamedContext } from './createNamedContext' diff --git a/packages/vite/src/lib/getMergedConfig.ts b/packages/vite/src/lib/getMergedConfig.ts index 9c91e5dd68d6..30fcb1166029 100644 --- a/packages/vite/src/lib/getMergedConfig.ts +++ b/packages/vite/src/lib/getMergedConfig.ts @@ -171,16 +171,17 @@ function getRollupInput(ssr: boolean): InputOption | undefined { throw new Error('entryClient not defined') } - const ssrEnabled = rwConfig.experimental?.streamingSsr?.enabled + const streamingEnabled = rwConfig.experimental?.streamingSsr?.enabled const rscEnabled = rwConfig.experimental?.rsc?.enabled // @NOTE once streaming ssr is out of experimental, this will become the // default - if (ssrEnabled) { + if (streamingEnabled) { if (ssr) { if (rscEnabled) { return { Document: rwPaths.web.document, + 'entry.server': rwPaths.web.entryServer!, } } From 87283462e71bc5d50ea1540a61056720f966917a Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Mon, 27 May 2024 15:22:30 +0700 Subject: [PATCH 13/20] Implement default getRoles for dbAuth --- .../src/__tests__/defaultGetRoles.test.ts | 28 +++++++++++++++++++ .../__tests__/initDbAuthMiddleware.test.ts | 19 ++++--------- .../dbAuth/middleware/src/defaultGetRoles.ts | 13 +++++++++ .../dbAuth/middleware/src/index.ts | 6 ++-- .../createSupabaseAuthMiddleware.test.ts | 6 +--- 5 files changed, 52 insertions(+), 20 deletions(-) create mode 100644 packages/auth-providers/dbAuth/middleware/src/__tests__/defaultGetRoles.test.ts create mode 100644 packages/auth-providers/dbAuth/middleware/src/defaultGetRoles.ts diff --git a/packages/auth-providers/dbAuth/middleware/src/__tests__/defaultGetRoles.test.ts b/packages/auth-providers/dbAuth/middleware/src/__tests__/defaultGetRoles.test.ts new file mode 100644 index 000000000000..04e0d2ca2d3c --- /dev/null +++ b/packages/auth-providers/dbAuth/middleware/src/__tests__/defaultGetRoles.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' + +import { defaultGetRoles } from '../defaultGetRoles' + +describe('dbAuth: defaultGetRoles', () => { + it('returns an empty array if no roles are present', () => { + const decoded = { + currentUser: { + id: 1, + email: 'ba@zin.ga', + }, + } + const roles = defaultGetRoles(decoded) + expect(roles).toEqual([]) + }) + + it('always returns an array of roles, even when currentUser has a string', () => { + const decoded = { currentUser: { roles: 'admin' } } + const roles = defaultGetRoles(decoded) + expect(roles).toEqual(['admin']) + }) + + it('falls back to an empty array if the decoded object is null', () => { + const decoded = null + const roles = defaultGetRoles(decoded) + expect(roles).toEqual([]) + }) +}) diff --git a/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts b/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts index 95ef32e84cc1..f1bc6b2a5eb4 100644 --- a/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts +++ b/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts @@ -2,6 +2,7 @@ import path from 'node:path' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { dbAuthSession } from '@redwoodjs/auth-dbauth-api' import { MiddlewareRequest as MWRequest, MiddlewareRequest, @@ -51,17 +52,10 @@ describe('dbAuthMiddleware', () => { it('When no cookie headers, pass through the response', async () => { const options: DbAuthMiddlewareOptions = { cookieName: '8911', - getCurrentUser: async () => { - return { id: 1, email: 'user-1@example.com' } - }, - dbAuthHandler: async () => { - return { - body: 'body', - headers: {}, - statusCode: 200, - } - }, + getCurrentUser: vi.fn(), + dbAuthHandler: vi.fn(), } + const [middleware] = initDbAuthMiddleware(options) const req = { method: 'GET', @@ -69,7 +63,6 @@ describe('dbAuthMiddleware', () => { url: 'http://localhost:8911', } as MiddlewareRequest - // Typecase for the test const res = await middleware(req, { passthrough: true } as any) expect(res).toEqual({ passthrough: true }) @@ -111,7 +104,7 @@ describe('dbAuthMiddleware', () => { email: 'user-1@example.com', id: 'mocked-current-user-1', }, - roles: ['f1driver'], + roles: ['f1driver'], // Because we override the getRoles function }) expect(options.getRoles).toHaveBeenCalledWith({ @@ -163,7 +156,7 @@ describe('dbAuthMiddleware', () => { email: 'user-1@example.com', id: 'mocked-current-user-1', }, - // No extract roles function, so it should be empty + // No get roles function, so it should be empty roles: [], }) diff --git a/packages/auth-providers/dbAuth/middleware/src/defaultGetRoles.ts b/packages/auth-providers/dbAuth/middleware/src/defaultGetRoles.ts new file mode 100644 index 000000000000..ce45d0567a94 --- /dev/null +++ b/packages/auth-providers/dbAuth/middleware/src/defaultGetRoles.ts @@ -0,0 +1,13 @@ +export const defaultGetRoles = (decoded: Record): string[] => { + try { + const roles = decoded?.currentUser?.roles + + if (Array.isArray(roles)) { + return roles + } else { + return roles ? [roles] : [] + } + } catch (e) { + return [] + } +} diff --git a/packages/auth-providers/dbAuth/middleware/src/index.ts b/packages/auth-providers/dbAuth/middleware/src/index.ts index 790adc9827fc..7cf01c9c8804 100644 --- a/packages/auth-providers/dbAuth/middleware/src/index.ts +++ b/packages/auth-providers/dbAuth/middleware/src/index.ts @@ -9,6 +9,8 @@ import type { GetCurrentUser } from '@redwoodjs/graphql-server' import type { Middleware, MiddlewareRequest } from '@redwoodjs/vite/middleware' import { MiddlewareResponse } from '@redwoodjs/vite/middleware' +import { defaultGetRoles } from './defaultGetRoles' + export interface DbAuthMiddlewareOptions { cookieName?: string dbAuthUrl?: string @@ -25,7 +27,7 @@ export interface DbAuthMiddlewareOptions { export const initDbAuthMiddleware = ({ dbAuthHandler, getCurrentUser, - getRoles, + getRoles = defaultGetRoles, cookieName, dbAuthUrl = '/middleware/dbauth', }: DbAuthMiddlewareOptions): [Middleware, '*'] => { @@ -86,7 +88,7 @@ export const initDbAuthMiddleware = ({ hasError: false, userMetadata: currentUser, // dbAuth doesn't have userMetadata cookieHeader, - roles: getRoles ? getRoles(decryptedSession) : [], + roles: getRoles(decryptedSession), }) } catch (e) { // Clear server auth context diff --git a/packages/auth-providers/supabase/middleware/src/__tests__/createSupabaseAuthMiddleware.test.ts b/packages/auth-providers/supabase/middleware/src/__tests__/createSupabaseAuthMiddleware.test.ts index 1254d75ee413..668878054dc9 100644 --- a/packages/auth-providers/supabase/middleware/src/__tests__/createSupabaseAuthMiddleware.test.ts +++ b/packages/auth-providers/supabase/middleware/src/__tests__/createSupabaseAuthMiddleware.test.ts @@ -3,11 +3,7 @@ import path from 'node:path' import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { vi } from 'vitest' -import { - middlewareDefaultAuthProviderState, - unauthenticatedServerAuthState, - // type ServerAuthState, -} from '@redwoodjs/auth' +import { middlewareDefaultAuthProviderState } from '@redwoodjs/auth' import { authDecoder } from '@redwoodjs/auth-supabase-api' import { MiddlewareRequest, From 85700eff78d5ed1b3c3b1ba51479e0adde2db225 Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Mon, 27 May 2024 15:30:05 +0700 Subject: [PATCH 14/20] Update readme --- .../dbAuth/middleware/README.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/auth-providers/dbAuth/middleware/README.md b/packages/auth-providers/dbAuth/middleware/README.md index b5c92d061a64..65ff1e3087a5 100644 --- a/packages/auth-providers/dbAuth/middleware/README.md +++ b/packages/auth-providers/dbAuth/middleware/README.md @@ -1,5 +1,7 @@ # DbAuth Middleware +### Example instantiation + ```tsx filename='entry.server.tsx' import type { TagDescriptor } from '@redwoodjs/web' @@ -36,3 +38,50 @@ export const ServerEntry: React.FC = ({ css, meta }) => { ) } ``` + +### Roles handling +By default the middleware assumes your roles will be in `currentUser.roles` - either as a string or an array of strings. + +For example +```js + +// If this is your current user: +{ + email: 'user-1@example.com', + id: 'mocked-current-user-1', + roles: 'admin' +} + +// In the ServerAuthState +{ + cookieHeader: 'session=session_cookie', + currentUser: { + email: 'user-1@example.com', + id: 'mocked-current-user-1', + roles: 'admin' // <-- you sent back 'admin' as string + }, + hasError: false, + isAuthenticated: true, + loading: false, + userMetadata: /*..*/ + roles: ['admin'] // <-- converted to array +} +``` + +You can customise this by passing a custom `getRoles` function into `initDbAuthMiddleware`. For example: + +```ts + const authMw = initDbAuthMiddleware({ + dbAuthHandler, + getCurrentUser, + getRoles: (decoded) => { + // Assuming you want to get roles from a property called org + if (decoded.currentUser.org) { + return [decoded.currentUser.org] + } else { + return [] + } + } + }) + +``` From 854fbe0855580c1265228fa560519b2585c1b4d4 Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Mon, 27 May 2024 17:00:59 +0700 Subject: [PATCH 15/20] Add default get roles for supabase --- .../src/__tests__/defaultGetRoles.test.ts | 76 +++++++++++++++++++ .../middleware/src/defaultGetRoles.ts | 52 +++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 packages/auth-providers/supabase/middleware/src/__tests__/defaultGetRoles.test.ts create mode 100644 packages/auth-providers/supabase/middleware/src/defaultGetRoles.ts diff --git a/packages/auth-providers/supabase/middleware/src/__tests__/defaultGetRoles.test.ts b/packages/auth-providers/supabase/middleware/src/__tests__/defaultGetRoles.test.ts new file mode 100644 index 000000000000..5621e5d6ee61 --- /dev/null +++ b/packages/auth-providers/supabase/middleware/src/__tests__/defaultGetRoles.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' + +import { defaultGetRoles } from '../defaultGetRoles' + +describe('dbAuth: defaultGetRoles', () => { + it('returns an empty array if no roles are present', () => { + const decoded = { + aud: 'authenticated', + exp: 1716806712, + iat: 1716803112, + iss: 'https://bubnfbrfzfdryapcuybr.supabase.co/auth/v1', + sub: '75fd8091-e0a7-4e7d-8a8d-138d0eb3ca5a', + email: 'dannychoudhury+1@gmail.com', + phone: '', + app_metadata: { + provider: 'email', + providers: ['email'], + }, + user_metadata: { + 'full-name': 'Danny Choudhury 1', + }, + role: 'authenticated', // <-- ⭐ this refers to supabase role, not app role + aal: 'aal1', + amr: [ + { + method: 'password', + timestamp: 1716803107, + }, + ], + session_id: '39b4ae31-c57a-4ac1-8f01-e9d6ccbd9365', + is_anonymous: false, + } + + const roles = defaultGetRoles(decoded) + expect(roles).toEqual([]) + }) + + it('always returns an array of roles, even when currentUser has a string', () => { + const decoded = { + aud: 'authenticated', + exp: 1716806712, + iat: 1716803112, + iss: 'https://bubnfbrfzfdryapcuybr.supabase.co/auth/v1', + sub: '75fd8091-e0a7-4e7d-8a8d-138d0eb3ca5a', + email: 'dannychoudhury+1@gmail.com', + phone: '', + app_metadata: { + provider: 'email', + providers: ['email'], + roles: 'admin', // <-- ⭐ this is the role we are looking for, set by the app + }, + user_metadata: { + 'full-name': 'Danny Choudhury 1', + }, + role: 'IGNORE_ME', // <-- ⭐ not this one + aal: 'aal1', + amr: [ + { + method: 'password', + timestamp: 1716803107, + }, + ], + session_id: '39b4ae31-c57a-4ac1-8f01-e9d6ccbd9365', + is_anonymous: false, + } + + const roles = defaultGetRoles(decoded) + expect(roles).toEqual(['admin']) + }) + + it('falls back to an empty array if the decoded object is null', () => { + const decoded = null + const roles = defaultGetRoles(decoded as any) + expect(roles).toEqual([]) + }) +}) diff --git a/packages/auth-providers/supabase/middleware/src/defaultGetRoles.ts b/packages/auth-providers/supabase/middleware/src/defaultGetRoles.ts new file mode 100644 index 000000000000..6a37d3a350e0 --- /dev/null +++ b/packages/auth-providers/supabase/middleware/src/defaultGetRoles.ts @@ -0,0 +1,52 @@ +/** + * Currently the supabase auth decoder returns something like this: +{ + "aud": "authenticated", + "exp": 1716806712, + "iat": 1716803112, + "iss": "https://bubnfbrfzfdryapcuybr.supabase.co/auth/v1", + "sub": "75fd8091-e0a7-4e7d-8a8d-138d0eb3ca5a", + "email": "dannychoudhury+1@gmail.com", + "phone": "", + "app_metadata": { + "provider": "email", + "providers": [ + "email" + ], + "roles": "admin" <-- this the role we're looking for + }, + "user_metadata": { + "full-name": "Danny Choudhury 1" + }, + "role": "authenticated", <-- this is the role supabase sets + "aal": "aal1", + "amr": [ + { + "method": "password", + "timestamp": 1716803107 + } + ], + "session_id": "39b4ae31-c57a-4ac1-8f01-e9d6ccbd9365", + "is_anonymous": false +} + */ + +interface PartialSupabaseDecoded { + app_metadata: { + roles?: string + } +} + +export const defaultGetRoles = (decoded: PartialSupabaseDecoded): string[] => { + try { + const roles = decoded?.app_metadata?.roles + + if (Array.isArray(roles)) { + return roles + } else { + return roles ? [roles] : [] + } + } catch (e) { + return [] + } +} From 01544bca32b717e882d690d5ab62575dd0f63707 Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Mon, 27 May 2024 17:09:39 +0700 Subject: [PATCH 16/20] Lint --- .../dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts b/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts index f1bc6b2a5eb4..512b30de7475 100644 --- a/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts +++ b/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts @@ -2,7 +2,6 @@ import path from 'node:path' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import { dbAuthSession } from '@redwoodjs/auth-dbauth-api' import { MiddlewareRequest as MWRequest, MiddlewareRequest, From 9618e8eec6b9307d34a1783ec0ae1f73bb27fe6c Mon Sep 17 00:00:00 2001 From: David Thyresson Date: Tue, 28 May 2024 09:50:31 -0400 Subject: [PATCH 17/20] Fix tests to init supabase middleware --- ...ware.test.ts => initSupabaseAuthMiddleware.test.ts} | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) rename packages/auth-providers/supabase/middleware/src/__tests__/{createSupabaseAuthMiddleware.test.ts => initSupabaseAuthMiddleware.test.ts} (97%) diff --git a/packages/auth-providers/supabase/middleware/src/__tests__/createSupabaseAuthMiddleware.test.ts b/packages/auth-providers/supabase/middleware/src/__tests__/initSupabaseAuthMiddleware.test.ts similarity index 97% rename from packages/auth-providers/supabase/middleware/src/__tests__/createSupabaseAuthMiddleware.test.ts rename to packages/auth-providers/supabase/middleware/src/__tests__/initSupabaseAuthMiddleware.test.ts index f54242110e6c..08adf13a5cbe 100644 --- a/packages/auth-providers/supabase/middleware/src/__tests__/createSupabaseAuthMiddleware.test.ts +++ b/packages/auth-providers/supabase/middleware/src/__tests__/initSupabaseAuthMiddleware.test.ts @@ -68,14 +68,14 @@ const options: SupabaseAuthMiddlewareOptions = { }, } -describe('createSupabaseAuthMiddleware()', () => { +describe('initSupabaseAuthMiddleware()', () => { const unauthenticatedServerAuthState = { ...middlewareDefaultAuthProviderState, roles: [], cookieHeader: null, } - it('creates middleware for Supabase SSR auth', async () => { + it('initializes middleware for Supabase SSR auth', async () => { const [middleware] = initSupabaseAuthMiddleware(options) const request = new Request('http://localhost:8911', { method: 'GET', @@ -221,7 +221,7 @@ describe('createSupabaseAuthMiddleware()', () => { getRoles: vi.fn().mockReturnValue(['admin', 'editor']), } - const middleware = createSupabaseAuthMiddleware(optionsWithExtractRole) + const [middleware] = initSupabaseAuthMiddleware(optionsWithExtractRole) const request = new Request('http://localhost:8911/authenticated-request', { method: 'GET', headers: new Headers({ @@ -229,8 +229,8 @@ describe('createSupabaseAuthMiddleware()', () => { }), }) const req = new MiddlewareRequest(request) - - const result = await middleware(req, MiddlewareResponse.next()) + const res = new MiddlewareResponse() + const result = await middleware(req, res) expect(result).toBeDefined() expect(req).toBeDefined() From 783a76e27b9141a0eb3a8b073106a59f4d87650b Mon Sep 17 00:00:00 2001 From: David Thyresson Date: Tue, 28 May 2024 10:45:24 -0400 Subject: [PATCH 18/20] explain getRole usage in readme --- .../supabase/middleware/README.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/auth-providers/supabase/middleware/README.md b/packages/auth-providers/supabase/middleware/README.md index bf2b37c13b02..74a3a8779608 100644 --- a/packages/auth-providers/supabase/middleware/README.md +++ b/packages/auth-providers/supabase/middleware/README.md @@ -1,11 +1,5 @@ # Supabase Middleware ---- - -NOTE: This README needs to be updated when the Supabase Web Auth will create a client and register the middleware - ----- - ```tsx filename='entry.server.tsx' import type { TagDescriptor } from '@redwoodjs/web' @@ -20,12 +14,24 @@ interface Props { meta?: TagDescriptor[] } +type SupabaseAppMetadata = { + provider: string + providers: string[] + roles: string[] +} + export const registerMiddleware = () => { const supabaseAuthMiddleware = initSupabaseMiddleware({ // Optional. If not set, Supabase will use its own `currentUser` function // instead of your app's getCurrentUser, + // Optional. If you wish to enforce RBAC, define a function to return roles. + // Typically, one will define roles in Supabase in the user's app_metadata. + getRoles: ({ app_metadata }: { app_metadata: SupabaseAppMetadata }) => { + return app_metadata.roles + }, }) + return [supabaseAuthMiddleware] } From dc7ae9cd99019eef08d865e3908e3767b7404165 Mon Sep 17 00:00:00 2001 From: David Thyresson Date: Tue, 28 May 2024 11:20:31 -0400 Subject: [PATCH 19/20] add how to set roles in supabase --- .../supabase/middleware/README.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/auth-providers/supabase/middleware/README.md b/packages/auth-providers/supabase/middleware/README.md index 74a3a8779608..63ab40e0f161 100644 --- a/packages/auth-providers/supabase/middleware/README.md +++ b/packages/auth-providers/supabase/middleware/README.md @@ -43,3 +43,43 @@ export const ServerEntry: React.FC = ({ css, meta }) => { ) } ``` + +### How To Set Roles in Supabase + +Typically, one will define roles in Supabase in the user's `app_metadata`. + +Supabase `app_metadata` includes the provider attribute indicates the first provider that the user used to sign up with. The providers attribute indicates the list of providers that the user can use to login with. + +You can add information to `app_metadata` via `SQL`: + +You can set a single role: + +```sql +update AUTH.users + set raw_app_meta_data = raw_app_meta_data || '{"roles": "admin"}' +where + id = '11111111-1111-1111-1111-111111111111'; +``` + +Or multiple roles: + +```sql +update AUTH.users + set raw_app_meta_data = raw_app_meta_data || '{"roles": ["admin", "owner"]}' +where + id = '11111111-1111-1111-1111-111111111111'; +``` + +Alternatively, you can update the user's `app_metadata` via the [Auth Admin `update user` api](https://supabase.com/docs/reference/javascript/auth-admin-updateuserbyid). Only a service role api request can modify the user app_metadata. + +> A custom data object to store the user's application specific metadata. This maps to the `auth.users.app_metadata` column. Only a service role can modify. The `app_metadata` should be a JSON object that includes app-specific info, such as identity providers, roles, and other access control information. + +```ts +const { data: user, error } = await supabase.auth.admin.updateUserById( + '11111111-1111-1111-1111-111111111111', + { app_metadata: { roles: ['admin', 'owner'] } } +) +``` + +Note: You may see a `role` attribute on the Supabase user. This is an internal claim used by Postgres to perform Row Level Security (RLS) checks. + From d2a27721db493b42db6b5b23bf6a79ae347d09ec Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Tue, 28 May 2024 23:04:28 +0700 Subject: [PATCH 20/20] Update readme with more explanation on roles --- .../supabase/middleware/README.md | 62 ++++++++++++++++--- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/packages/auth-providers/supabase/middleware/README.md b/packages/auth-providers/supabase/middleware/README.md index 63ab40e0f161..45f7c47b2c0b 100644 --- a/packages/auth-providers/supabase/middleware/README.md +++ b/packages/auth-providers/supabase/middleware/README.md @@ -14,12 +14,6 @@ interface Props { meta?: TagDescriptor[] } -type SupabaseAppMetadata = { - provider: string - providers: string[] - roles: string[] -} - export const registerMiddleware = () => { const supabaseAuthMiddleware = initSupabaseMiddleware({ // Optional. If not set, Supabase will use its own `currentUser` function @@ -27,9 +21,7 @@ export const registerMiddleware = () => { getCurrentUser, // Optional. If you wish to enforce RBAC, define a function to return roles. // Typically, one will define roles in Supabase in the user's app_metadata. - getRoles: ({ app_metadata }: { app_metadata: SupabaseAppMetadata }) => { - return app_metadata.roles - }, + getRoles }) return [supabaseAuthMiddleware] @@ -44,6 +36,8 @@ export const ServerEntry: React.FC = ({ css, meta }) => { } ``` +## About Roles + ### How To Set Roles in Supabase Typically, one will define roles in Supabase in the user's `app_metadata`. @@ -83,3 +77,53 @@ const { data: user, error } = await supabase.auth.admin.updateUserById( Note: You may see a `role` attribute on the Supabase user. This is an internal claim used by Postgres to perform Row Level Security (RLS) checks. +### What is the default implementation? +If you do not supply a `getRoles` function, we look in the `app_metadata.roles` property. + +If you only had a string, e.g. +``` +{ + app_metadata: { + provider: 'email', + providers: ['email'], + roles: 'admin', // <-- ⭐ notice this is a string + }, + user_metadata: { + ... +} +``` + +it will convert the roles here to `['admin']`. + +If you place your roles somewhere else, you will need to provide an implementation of the `getRoles` function. e.g. + +``` +{ + app_metadata: { + provider: 'email', + providers: ['email'], + organization: { + name: 'acme', + userRoles: ['admin'] + } + }, + user_metadata: { + ... +} +``` + + +```js +// In entry.server.jsx +export const registerMiddleware = () => { + const supabaseAuthMiddleware = initSupabaseMiddleware({ + // Customise where you get your roles from + getRoles: (decoded) => { + return decoded.app_metadata.organization?.userRoles + } + }) + + return [supabaseAuthMiddleware] +} + +```