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

chore: add passage and redwood custom auth #52

Merged
merged 2 commits into from
Dec 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 3 additions & 3 deletions api/src/directives/requireAuth/requireAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ describe('requireAuth directive', () => {
})

it('requireAuth has stub implementation. Should not throw when current user', () => {
// If you want to set values in context, pass it through e.g.
// mockRedwoodDirective(requireAuth, { context: { currentUser: { id: 1, name: 'Lebron McGretzky' } }})
const mockExecution = mockRedwoodDirective(requireAuth, { context: {} })
const mockExecution = mockRedwoodDirective(requireAuth, {
context: { currentUser: { id: 1, name: 'Some User' } },
})

expect(mockExecution).not.toThrowError()
})
Expand Down
2 changes: 2 additions & 0 deletions api/src/functions/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import directives from 'src/directives/**/*.{js,ts}'
import sdls from 'src/graphql/**/*.sdl.{js,ts}'
import services from 'src/services/**/*.{js,ts}'

import { getCurrentUser } from 'src/lib/auth'
import { db } from 'src/lib/db'
import { logger } from 'src/lib/logger'

export const handler = createGraphQLHandler({
getCurrentUser,
loggerConfig: { logger, options: { requestId: true, operationName: true } },
directives,
sdls,
Expand Down
133 changes: 117 additions & 16 deletions api/src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,126 @@
import { parseJWT, Decoded } from '@redwoodjs/api'
import { AuthenticationError, ForbiddenError } from '@redwoodjs/graphql-server'

/**
* Represents the user attributes returned by the decoding the
* Authentication provider's JWT together with an optional list of roles.
*/
type RedwoodUser = Record<string, unknown> & { roles?: string[] }

/**
* Once you are ready to add authentication to your application
* you'll build out requireAuth() with real functionality. For
* now we just return `true` so that the calls in services
* have something to check against, simulating a logged
* in user that is allowed to access that service.
* getCurrentUser returns the user information together with
* an optional collection of roles used by requireAuth() to check
* if the user is authenticated or has role-based access
*
* See https://redwoodjs.com/docs/authentication for more info.
* !! BEWARE !! Anything returned from this function will be available to the
* client--it becomes the content of `currentUser` on the web side (as well as
* `context.currentUser` on the api side). You should carefully add additional
* fields to the return object only once you've decided they are safe to be seen
* if someone were to open the Web Inspector in their browser.
*
* @see https://github.com/redwoodjs/redwood/tree/main/packages/auth for examples
*
* @param decoded - The decoded access token containing user info and JWT
* claims like `sub`. Note, this could be null.
* @param { token, SupportedAuthTypes type } - The access token itself as well
* as the auth provider type
* @param { APIGatewayEvent event, Context context } - An optional object which
* contains information from the invoker such as headers and cookies, and the
* context information about the invocation such as IP Address
* @returns RedwoodUser
*/
export const isAuthenticated = () => {
return true
export const getCurrentUser = async (
decoded: Decoded
): Promise<RedwoodUser | null> => {
if (!decoded) {
return null
}

const { roles } = parseJWT({ decoded })

if (roles) {
return { ...decoded, roles }
}

return { ...decoded }
}

export const hasRole = ({ roles }) => {
return roles !== undefined
/**
* The user is authenticated if there is a currentUser in the context
*
* @returns {boolean} - If the currentUser is authenticated
*/
export const isAuthenticated = (): boolean => {
return !!context.currentUser
}

// This is used by the redwood directive
// in ./api/src/directives/requireAuth
/**
* When checking role membership, roles can be a single value, a list, or none.
* You can use Prisma enums too (if you're using them for roles), just import your enum type from `@prisma/client`
*/
type AllowedRoles = string | string[] | undefined

/**
* Checks if the currentUser is authenticated (and assigned one of the given roles)
*
* @param roles: {@link AllowedRoles} - Checks if the currentUser is assigned one of these roles
*
* @returns {boolean} - Returns true if the currentUser is logged in and assigned one of the given roles,
* or when no roles are provided to check against. Otherwise returns false.
*/
export const hasRole = (roles: AllowedRoles): boolean => {
if (!isAuthenticated()) {
return false
}

const currentUserRoles = context.currentUser?.roles

if (typeof roles === 'string') {
if (typeof currentUserRoles === 'string') {
// roles to check is a string, currentUser.roles is a string
return currentUserRoles === roles
} else if (Array.isArray(currentUserRoles)) {
// roles to check is a string, currentUser.roles is an array
return currentUserRoles?.some((allowedRole) => roles === allowedRole)
}
}

if (Array.isArray(roles)) {
if (Array.isArray(currentUserRoles)) {
// roles to check is an array, currentUser.roles is an array
return currentUserRoles?.some((allowedRole) =>
roles.includes(allowedRole)
)
} else if (typeof currentUserRoles === 'string') {
// roles to check is an array, currentUser.roles is a string
return roles.some((allowedRole) => currentUserRoles === allowedRole)
}
}

// roles not found
return false
}

/**
* Use requireAuth in your services to check that a user is logged in,
* whether or not they are assigned a role, and optionally raise an
* error if they're not.
*
* @param roles?: {@link AllowedRoles} - When checking role membership, these roles grant access.
*
* @returns - If the currentUser is authenticated (and assigned one of the given roles)
*
* @throws {@link AuthenticationError} - If the currentUser is not authenticated
* @throws {@link ForbiddenError} - If the currentUser is not allowed due to role permissions
*
* @see https://github.com/redwoodjs/redwood/tree/main/packages/auth for examples
*/
export const requireAuth = ({ roles }: { roles?: AllowedRoles } = {}) => {
if (!isAuthenticated()) {
throw new AuthenticationError("You don't have permission to do that.")
}

// Roles are passed in by the requireAuth directive if you have auth setup
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
export const requireAuth = ({ roles }) => {
return isAuthenticated()
if (roles && !hasRole(roles)) {
throw new ForbiddenError("You don't have access to do that.")
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
},
"devDependencies": {
"@netlify/zip-it-and-ship-it": "^9.27.0",
"@redwoodjs/auth-custom-setup": "6.4.2",
"@redwoodjs/core": "6.4.2"
},
"eslintConfig": {
Expand Down
2 changes: 2 additions & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
},
"dependencies": {
"@datadog/browser-rum": "^5.4.0",
"@passageidentity/passage-elements": "^1.17.0",
"@redwoodjs/auth": "6.4.2",
"@redwoodjs/forms": "6.4.2",
"@redwoodjs/router": "6.4.2",
"@redwoodjs/web": "6.4.2",
Expand Down
10 changes: 7 additions & 3 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,18 @@ import { RedwoodApolloProvider } from '@redwoodjs/web/apollo'
import FatalErrorPage from 'src/pages/FatalErrorPage'
import Routes from 'src/Routes'

import { AuthProvider, useAuth } from './auth'

import './scss/custom.scss'

const App = () => (
<FatalErrorBoundary page={FatalErrorPage}>
<RedwoodProvider titleTemplate="%PageTitle | %AppTitle">
<RedwoodApolloProvider>
<Routes />
</RedwoodApolloProvider>
<AuthProvider>
<RedwoodApolloProvider useAuth={useAuth}>
<Routes />
</RedwoodApolloProvider>
</AuthProvider>
</RedwoodProvider>
</FatalErrorBoundary>
)
Expand Down
4 changes: 3 additions & 1 deletion web/src/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import { Set, Router, Route } from '@redwoodjs/router'

import ScaffoldLayout from 'src/layouts/ScaffoldLayout'

import { useAuth } from './auth'

const Routes = () => {
return (
<Router>
<Router useAuth={useAuth}>
<Set wrap={ScaffoldLayout} title="Uploads" titleTo="uploads" buttonLabel="New Upload" buttonTo="newUpload">
<Route path="/uploads/new" page={UploadNewUploadPage} name="newUpload" />
<Route path="/uploads/{id:Int}/edit" page={UploadEditUploadPage} name="editUpload" />
Expand Down
92 changes: 92 additions & 0 deletions web/src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { createAuthentication } from '@redwoodjs/auth'

// If you're integrating with an auth service provider you should delete this interface.
// Instead you should import the type from their auth client sdk.
export interface AuthClient {
login: () => User
logout: () => void
signup: () => User
getToken: () => string
getUserMetadata: () => User | null
}

// If you're integrating with an auth service provider you should delete this interface.
// This type should be inferred from the general interface above.
interface User {
// The name of the id variable will vary depending on what auth service
// provider you're integrating with. Another common name is `sub`
id: string
email?: string
username?: string
roles: string[]
}

// If you're integrating with an auth service provider you should delete this interface
// This type should be inferred from the general interface above
export interface ValidateResetTokenResponse {
error?: string
[key: string]: string | undefined
}

// Replace this with the auth service provider client sdk
const client = {
login: () => ({
id: 'unique-user-id',
email: '[email protected]',
roles: [],
}),
signup: () => ({
id: 'unique-user-id',
email: '[email protected]',
roles: [],
}),
logout: () => {},
getToken: () => 'super-secret-short-lived-token',
getUserMetadata: () => ({
id: 'unique-user-id',
email: '[email protected]',
roles: [],
}),
}

function createAuth() {
const authImplementation = createAuthImplementation(client)

// You can pass custom provider hooks here if you need to as a second
// argument. See the Redwood framework source code for how that's used
return createAuthentication(authImplementation)
}

// This is where most of the integration work will take place. You should keep
// the shape of this object (i.e. keep all the key names) but change all the
// values/functions to use methods from the auth service provider client sdk
// you're integrating with
function createAuthImplementation(client: AuthClient) {
return {
type: 'custom-auth',
client,
login: async () => client.login(),
logout: async () => client.logout(),
signup: async () => client.signup(),
getToken: async () => client.getToken(),
/**
* Actual user metadata might look something like this
* {
* "id": "11111111-2222-3333-4444-5555555555555",
* "aud": "authenticated",
* "role": "authenticated",
* "roles": ["admin"],
* "email": "[email protected]",
* "app_metadata": {
* "provider": "email"
* },
* "user_metadata": null,
* "created_at": "2016-05-15T19:53:12.368652374-07:00",
* "updated_at": "2016-05-15T19:53:12.368652374-07:00"
* }
*/
getUserMetadata: async () => client.getUserMetadata(),
}
}

export const { AuthProvider, useAuth } = createAuth()
Loading
Loading