diff --git a/.changesets/10656.md b/.changesets/10656.md new file mode 100644 index 000000000000..8307e1eda502 --- /dev/null +++ b/.changesets/10656.md @@ -0,0 +1,23 @@ +- feat(rsc-auth): Implement getRoles function in auth mw & update default ServerAuthState (#10656) by @dac09 + +- 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 + +## Example usage +```tsx +// In entry.server.tsx +export const registerMiddleware = () => { + // This actually returns [dbAuthMiddleware, '*'] + const authMw = initDbAuthMiddleware({ + dbAuthHandler, + getCurrentUser, + getRoles: (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..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' @@ -18,9 +20,10 @@ interface Props { export const registerMiddleware = () => { // This actually returns [dbAuthMiddleware, '*'] const authMw = initDbAuthMiddleware({ - cookieName, dbAuthHandler, getCurrentUser, + // cookieName optional + // getRoles optional // dbAuthUrl? optional }) @@ -35,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 [] + } + } + }) + +``` 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 95a518841db1..512b30de7475 100644 --- a/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts +++ b/packages/auth-providers/dbAuth/middleware/src/__tests__/initDbAuthMiddleware.test.ts @@ -8,6 +8,7 @@ import { MiddlewareResponse, } from '@redwoodjs/vite/middleware' +import { middlewareDefaultAuthProviderState } from '../../../../../auth/dist/AuthProvider/AuthProviderState' import type { DbAuthMiddlewareOptions } from '../index' import { initDbAuthMiddleware } from '../index' const FIXTURE_PATH = path.resolve( @@ -50,17 +51,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', @@ -68,7 +62,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 }) @@ -82,6 +75,7 @@ describe('dbAuthMiddleware', () => { return { id: 'mocked-current-user-1', email: 'user-1@example.com' } }), dbAuthHandler: vi.fn(), + getRoles: vi.fn(() => ['f1driver']), } const [middleware] = initDbAuthMiddleware(options) @@ -109,6 +103,15 @@ describe('dbAuthMiddleware', () => { email: 'user-1@example.com', id: 'mocked-current-user-1', }, + roles: ['f1driver'], // Because we override the getRoles function + }) + + expect(options.getRoles).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 @@ -152,6 +155,8 @@ describe('dbAuthMiddleware', () => { email: 'user-1@example.com', id: 'mocked-current-user-1', }, + // No get roles function, so it should be empty + roles: [], }) // Allow react render, because body is not defined, and status code not redirect @@ -500,6 +505,12 @@ describe('dbAuthMiddleware', () => { }) describe('handle exception cases', async () => { + const unauthenticatedServerAuthState = { + ...middlewareDefaultAuthProviderState, + cookieHeader: null, + roles: [], + } + beforeAll(() => { // So that we don't see errors in console when running negative cases vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -578,7 +589,11 @@ describe('dbAuthMiddleware', () => { 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 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 6a6b9283655e..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 @@ -18,12 +20,14 @@ export interface DbAuthMiddlewareOptions { req: Request | APIGatewayProxyEvent, context?: Context, ) => DbAuthResponse + getRoles?: (decoded: any) => string[] getCurrentUser: GetCurrentUser } export const initDbAuthMiddleware = ({ dbAuthHandler, getCurrentUser, + getRoles = defaultGetRoles, cookieName, dbAuthUrl = '/middleware/dbauth', }: DbAuthMiddlewareOptions): [Middleware, '*'] => { @@ -71,7 +75,7 @@ export const initDbAuthMiddleware = ({ try { // Call the dbAuth auth decoder. For dbAuth we have direct access to the `dbAuthSession` function. // Other providers will be slightly different. - const { currentUser } = await validateSession({ + const { currentUser, decryptedSession } = await validateSession({ req, cookieName, getCurrentUser, @@ -84,11 +88,12 @@ export const initDbAuthMiddleware = ({ hasError: false, userMetadata: currentUser, // dbAuth doesn't have userMetadata cookieHeader, + roles: getRoles(decryptedSession), }) } catch (e) { // Clear server auth context console.error('Error decrypting dbAuth cookie \n', e) - 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/README.md b/packages/auth-providers/supabase/middleware/README.md index bf2b37c13b02..45f7c47b2c0b 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' @@ -25,7 +19,11 @@ export const registerMiddleware = () => { // 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 }) + return [supabaseAuthMiddleware] } @@ -37,3 +35,95 @@ 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`. + +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. + +### 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] +} + +``` 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/__tests__/createSupabaseAuthMiddleware.test.ts b/packages/auth-providers/supabase/middleware/src/__tests__/initSupabaseAuthMiddleware.test.ts similarity index 77% 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 1d74c2da47c0..08adf13a5cbe 100644 --- a/packages/auth-providers/supabase/middleware/src/__tests__/createSupabaseAuthMiddleware.test.ts +++ b/packages/auth-providers/supabase/middleware/src/__tests__/initSupabaseAuthMiddleware.test.ts @@ -3,10 +3,7 @@ import path from 'node:path' import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { vi } from 'vitest' -import { - middlewareDefaultAuthProviderState, - // type ServerAuthState, -} from '@redwoodjs/auth' +import { middlewareDefaultAuthProviderState } from '@redwoodjs/auth' import { authDecoder } from '@redwoodjs/auth-supabase-api' import { MiddlewareRequest, @@ -71,8 +68,14 @@ const options: SupabaseAuthMiddlewareOptions = { }, } -describe('createSupabaseAuthMiddleware()', () => { - it('creates middleware for Supabase SSR auth', async () => { +describe('initSupabaseAuthMiddleware()', () => { + const unauthenticatedServerAuthState = { + ...middlewareDefaultAuthProviderState, + roles: [], + cookieHeader: null, + } + + it('initializes middleware for Supabase SSR auth', async () => { const [middleware] = initSupabaseAuthMiddleware(options) const request = new Request('http://localhost:8911', { method: 'GET', @@ -88,7 +91,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 () => { @@ -105,7 +108,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] = initSupabaseAuthMiddleware(options) @@ -125,7 +128,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 () => { @@ -145,7 +151,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 () => { @@ -169,7 +178,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 () => { @@ -197,6 +209,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' }, + } + }, + getRoles: vi.fn().mockReturnValue(['admin', 'editor']), + } + + const [middleware] = initSupabaseAuthMiddleware(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 res = new MiddlewareResponse() + const result = await middleware(req, res) + 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.getRoles).toHaveBeenCalledWith({ + sub: 'abc123', + }) + }) + it('authenticated request sets userMetadata', async () => { const optionsWithUserMetadata: SupabaseAuthMiddlewareOptions = { getCurrentUser: async () => { @@ -267,7 +325,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/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 [] + } +} diff --git a/packages/auth-providers/supabase/middleware/src/index.ts b/packages/auth-providers/supabase/middleware/src/index.ts index e5e477fa7d18..49c5c4360ee2 100644 --- a/packages/auth-providers/supabase/middleware/src/index.ts +++ b/packages/auth-providers/supabase/middleware/src/index.ts @@ -11,6 +11,7 @@ import { clearAuthState } from './util' export interface SupabaseAuthMiddlewareOptions { getCurrentUser: GetCurrentUser + getRoles?: (decoded: any) => string[] } /** @@ -18,6 +19,7 @@ export interface SupabaseAuthMiddlewareOptions { */ const initSupabaseAuthMiddleware = ({ getCurrentUser, + getRoles, }: SupabaseAuthMiddlewareOptions): [Middleware, '*'] => { const middleware = async ( req: MiddlewareRequest, @@ -72,6 +74,8 @@ const initSupabaseAuthMiddleware = ({ isAuthenticated: !!currentUser, hasError: false, userMetadata: userMetadata || currentUser, + cookieHeader, + roles: getRoles ? getRoles(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/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 26a10a995ff8..8affb3529567 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!, } } 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 1b9ebda81e02..ae45626e961c 100644 --- a/packages/vite/src/middleware/MiddlewareRequest.ts +++ b/packages/vite/src/middleware/MiddlewareRequest.ts @@ -7,30 +7,47 @@ import { import { CookieJar } from './CookieJar.js' -class AuthStateJar { - private _data: T +class AuthStateJar { + private _data: ServerAuthState | null + private _initialState: ServerAuthState - constructor(data?: T) { - this._data = data as T + constructor(initialState: ServerAuthState) { + this._data = initialState + 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: any) { + set(value: ServerAuthState | null) { 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.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 87660be974ca..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' @@ -19,19 +16,21 @@ 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, middleware?: Middleware, options?: MiddlewareInvokeOptions, ): Promise<[MiddlewareResponse, ServerAuthState]> => { + const mwReq = new MiddlewareRequest(req) + if (typeof middleware !== 'function') { - setupServerStore(req, middlewareDefaultAuthProviderState) + setServerAuthState(mwReq.serverAuthState.get()) - return [MiddlewareResponse.next(), middlewareDefaultAuthProviderState] + return [MiddlewareResponse.next(), mwReq.serverAuthState.get()] } - const mwReq = new MiddlewareRequest(req) let mwRes: MiddlewareResponse = MiddlewareResponse.next() try { @@ -52,8 +51,8 @@ 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()] } @@ -64,14 +63,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()) + setServerAuthState(mwReq.serverAuthState.get()) } return [mwRes, mwReq.serverAuthState.get()] } - -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 fbb03313a7a2..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 = {} @@ -94,13 +102,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. 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 = {}