Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(rsc-auth): Implement getRoles function in auth mw & update default ServerAuthState #10656

Merged
merged 29 commits into from
May 31, 2024
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a9a9772
feat(rsc-auth): Implement extractRoles function in auth mw
dac09 May 21, 2024
34cec37
Bit more cleanup
dac09 May 21, 2024
940fa63
Bit more cleanup
dac09 May 21, 2024
b88b169
Update invokeMiddleware tests
dac09 May 21, 2024
910e9ad
Update supabase middleware tests
dac09 May 21, 2024
9dc27b1
Update dbAuth middleware tests
dac09 May 21, 2024
11b3712
Merge branch 'main' of github.com:redwoodjs/redwood into feat/extract…
dac09 May 21, 2024
3e22b82
Cleanup red lines in dbAuthMw tests
dac09 May 21, 2024
12e4eea
Add test for extractRoles for supa mw
dac09 May 21, 2024
71fd715
Cleanup createStreamingHandler
dac09 May 21, 2024
5f47249
Fix dbAuth middleware and update tests
dac09 May 22, 2024
dc7dd9c
Merge branch 'main' into feat/extract-role-authmw
dac09 May 22, 2024
a1a2caa
Merge branch 'main' of github.com:redwoodjs/redwood into feat/extract…
dac09 May 22, 2024
a730f93
Add changeset, update dbAuthReadme
dac09 May 22, 2024
f0390de
Merge branch 'feat/extract-role-authmw' of github.com:dac09/redwood i…
dac09 May 22, 2024
21df537
Rename extractRoles to getRoles
dac09 May 25, 2024
8728346
Implement default getRoles for dbAuth
dac09 May 27, 2024
85700ef
Update readme
dac09 May 27, 2024
854fbe0
Add default get roles for supabase
dac09 May 27, 2024
01544bc
Lint
dac09 May 27, 2024
33c6190
Merge branch 'main' into feat/extract-role-authmw
dac09 May 28, 2024
61f6dc1
Merge branch 'main' into feat/extract-role-authmw
dthyresson May 28, 2024
9618e8e
Fix tests to init supabase middleware
dthyresson May 28, 2024
783a76e
explain getRole usage in readme
dthyresson May 28, 2024
dc7ae9c
add how to set roles in supabase
dthyresson May 28, 2024
d2a2772
Update readme with more explanation on roles
dac09 May 28, 2024
5c06594
Merge branch 'main' into feat/extract-role-authmw
dac09 May 30, 2024
0672064
Merge branch 'main' into feat/extract-role-authmw
Tobbe May 30, 2024
ca79741
Merge branch 'main' into feat/extract-role-authmw
Tobbe May 31, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changesets/10656.md
Original file line number Diff line number Diff line change
@@ -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]
}
```
52 changes: 51 additions & 1 deletion packages/auth-providers/dbAuth/middleware/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# DbAuth Middleware

### Example instantiation

```tsx filename='entry.server.tsx'
import type { TagDescriptor } from '@redwoodjs/web'

Expand All @@ -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
})

Expand All @@ -35,3 +38,50 @@ export const ServerEntry: React.FC<Props> = ({ 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: '[email protected]',
id: 'mocked-current-user-1',
roles: 'admin'
}

// In the ServerAuthState
{
cookieHeader: 'session=session_cookie',
currentUser: {
email: '[email protected]',
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 []
}
}
})

```
Original file line number Diff line number Diff line change
@@ -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: '[email protected]',
},
}
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([])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -50,25 +51,17 @@ describe('dbAuthMiddleware', () => {
it('When no cookie headers, pass through the response', async () => {
const options: DbAuthMiddlewareOptions = {
cookieName: '8911',
getCurrentUser: async () => {
return { id: 1, email: '[email protected]' }
},
dbAuthHandler: async () => {
return {
body: 'body',
headers: {},
statusCode: 200,
}
},
getCurrentUser: vi.fn(),
dbAuthHandler: vi.fn(),
}

const [middleware] = initDbAuthMiddleware(options)
const req = {
method: 'GET',
headers: new Headers(),
url: 'http://localhost:8911',
} as MiddlewareRequest

// Typecase for the test
const res = await middleware(req, { passthrough: true } as any)

expect(res).toEqual({ passthrough: true })
Expand All @@ -82,6 +75,7 @@ describe('dbAuthMiddleware', () => {
return { id: 'mocked-current-user-1', email: '[email protected]' }
}),
dbAuthHandler: vi.fn(),
getRoles: vi.fn(() => ['f1driver']),
}
const [middleware] = initDbAuthMiddleware(options)

Expand Down Expand Up @@ -109,6 +103,15 @@ describe('dbAuthMiddleware', () => {
email: '[email protected]',
id: 'mocked-current-user-1',
},
roles: ['f1driver'], // Because we override the getRoles function
})

expect(options.getRoles).toHaveBeenCalledWith({
currentUser: {
email: '[email protected]',
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
Expand Down Expand Up @@ -152,6 +155,8 @@ describe('dbAuthMiddleware', () => {
email: '[email protected]',
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
Expand Down Expand Up @@ -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(() => {})
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions packages/auth-providers/dbAuth/middleware/src/defaultGetRoles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export const defaultGetRoles = (decoded: Record<string, any>): string[] => {
try {
const roles = decoded?.currentUser?.roles

if (Array.isArray(roles)) {
return roles
} else {
return roles ? [roles] : []
}
} catch (e) {
return []
}
}
9 changes: 7 additions & 2 deletions packages/auth-providers/dbAuth/middleware/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,12 +20,14 @@ export interface DbAuthMiddlewareOptions {
req: Request | APIGatewayProxyEvent,
context?: Context,
) => DbAuthResponse
getRoles?: (decoded: any) => string[]
getCurrentUser: GetCurrentUser
}

export const initDbAuthMiddleware = ({
dthyresson marked this conversation as resolved.
Show resolved Hide resolved
dbAuthHandler,
getCurrentUser,
getRoles = defaultGetRoles,
cookieName,
dbAuthUrl = '/middleware/dbauth',
}: DbAuthMiddlewareOptions): [Middleware, '*'] => {
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
18 changes: 12 additions & 6 deletions packages/auth-providers/supabase/middleware/README.md
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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.
dthyresson marked this conversation as resolved.
Show resolved Hide resolved
getRoles: ({ app_metadata }: { app_metadata: SupabaseAppMetadata }) => {
return app_metadata.roles
},
})

return [supabaseAuthMiddleware]
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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: '[email protected]',
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', () => {
dthyresson marked this conversation as resolved.
Show resolved Hide resolved
const decoded = {
aud: 'authenticated',
exp: 1716806712,
iat: 1716803112,
iss: 'https://bubnfbrfzfdryapcuybr.supabase.co/auth/v1',
sub: '75fd8091-e0a7-4e7d-8a8d-138d0eb3ca5a',
email: '[email protected]',
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([])
})
})
Loading
Loading