1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 07:25:19 +02:00

add middleware check, refactor axios provider

This commit is contained in:
Tyler Myracle 2024-01-12 18:34:42 -06:00
parent e740fcb497
commit 633765d4b0
23 changed files with 220 additions and 196 deletions

View file

@ -16,7 +16,7 @@ NX_AUTH0_CLIENT_SECRET=
AUTH0_DEPLOY_CLIENT_SECRET= AUTH0_DEPLOY_CLIENT_SECRET=
POSTMARK_SMTP_PASS= POSTMARK_SMTP_PASS=
NX_SESSION_SECRET= NX_SESSION_SECRET=
AUTH_SECRET= NEXTAUTH_SECRET=
# If you want to test any code that utilizes the AWS SDK locally, add temporary STAGING credentials (can be retrieved from SSO dashboard) # If you want to test any code that utilizes the AWS SDK locally, add temporary STAGING credentials (can be retrieved from SSO dashboard)
AWS_ACCESS_KEY_ID= AWS_ACCESS_KEY_ID=

View file

@ -2,11 +2,8 @@ import {
type ModalKey, type ModalKey,
type ModalManagerAction, type ModalManagerAction,
ModalManagerContext, ModalManagerContext,
useUserApi,
useLocalStorage,
} from '@maybe-finance/client/shared' } from '@maybe-finance/client/shared'
import { type PropsWithChildren, useReducer, useEffect } from 'react' import { type PropsWithChildren, useReducer } from 'react'
import { LinkAccountFlow } from '@maybe-finance/client/features'
function reducer( function reducer(
state: Record<ModalKey, { isOpen: boolean; props: any }>, state: Record<ModalKey, { isOpen: boolean; props: any }>,
@ -24,42 +21,11 @@ function reducer(
* Manages auto-prompt modals and regular modals to avoid stacking collisions * Manages auto-prompt modals and regular modals to avoid stacking collisions
*/ */
export default function ModalManager({ children }: PropsWithChildren) { export default function ModalManager({ children }: PropsWithChildren) {
const [accountLinkHidden, setAccountLinkHidden] = useLocalStorage('account-link-hidden', false) const [, dispatch] = useReducer(reducer, {
const [state, dispatch] = useReducer(reducer, {
linkAuth0Accounts: { isOpen: false, props: null }, linkAuth0Accounts: { isOpen: false, props: null },
}) })
const allClosed = Object.values(state).every((v) => v.isOpen === false)
const { useAuth0Profile } = useUserApi()
const auth0Profile = useAuth0Profile()
useEffect(() => {
const autoPrompt = auth0Profile.data?.autoPromptEnabled === true
const provider = auth0Profile.data?.suggestedIdentities?.[0]?.provider
if (autoPrompt && provider && allClosed && !accountLinkHidden) {
dispatch({
type: 'open',
key: 'linkAuth0Accounts',
props: { secondaryProvider: provider },
})
}
}, [auth0Profile.data, dispatch, allClosed, accountLinkHidden])
return ( return (
<ModalManagerContext.Provider value={{ dispatch }}> <ModalManagerContext.Provider value={{ dispatch }}>{children}</ModalManagerContext.Provider>
{children}
<LinkAccountFlow
isOpen={state.linkAuth0Accounts.isOpen}
onClose={() => {
dispatch({ type: 'close', key: 'linkAuth0Accounts' })
setAccountLinkHidden(true)
}}
{...state.linkAuth0Accounts.props}
/>
</ModalManagerContext.Provider>
) )
} }

View file

@ -1,13 +1,35 @@
import NextAuth, { type SessionStrategy } from 'next-auth' import NextAuth from 'next-auth'
import type { SessionStrategy, NextAuthOptions } from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials' import CredentialsProvider from 'next-auth/providers/credentials'
import { z } from 'zod' import { z } from 'zod'
import { PrismaClient } from '@prisma/client' import { PrismaClient, type Prisma } from '@prisma/client'
import { PrismaAdapter } from '@auth/prisma-adapter' import { PrismaAdapter } from '@auth/prisma-adapter'
import axios from 'axios'
import bcrypt from 'bcrypt' import bcrypt from 'bcrypt'
const prisma = new PrismaClient() let prismaInstance: PrismaClient | null = null
axios.defaults.baseURL = `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3333'}/v1`
function getPrismaInstance() {
if (!prismaInstance) {
prismaInstance = new PrismaClient()
}
return prismaInstance
}
const prisma = getPrismaInstance()
async function createAuthUser(
data: Prisma.AuthUserCreateInput & { firstName: string; lastName: string }
) {
const authUser = await prisma.authUser.create({ data: { ...data } })
return authUser
}
async function getAuthUserByEmail(email: string) {
if (!email) throw new Error('No email provided')
return await prisma.authUser.findUnique({
where: { email },
})
}
const authPrisma = { const authPrisma = {
account: prisma.authAccount, account: prisma.authAccount,
@ -18,62 +40,82 @@ const authPrisma = {
export const authOptions = { export const authOptions = {
adapter: PrismaAdapter(authPrisma), adapter: PrismaAdapter(authPrisma),
secret: process.env.AUTH_SECRET || 'CHANGE_ME', secret: process.env.NEXTAUTH_SECRET || 'CHANGE_ME',
pages: { pages: {
signIn: '/login', signIn: '/login',
}, },
session: { session: {
strategy: 'jwt' as SessionStrategy, strategy: 'jwt' as SessionStrategy,
maxAge: 7 * 24 * 60 * 60, // 7 Days maxAge: 1 * 24 * 60 * 60, // 1 Day
}, },
providers: [ providers: [
CredentialsProvider({ CredentialsProvider({
name: 'Credentials', name: 'Credentials',
type: 'credentials', type: 'credentials',
credentials: { credentials: {
firstName: { label: 'First name', type: 'text', placeholder: 'First name' },
lastName: { label: 'Last name', type: 'text', placeholder: 'Last name' },
email: { label: 'Email', type: 'email', placeholder: 'hello@maybe.co' }, email: { label: 'Email', type: 'email', placeholder: 'hello@maybe.co' },
password: { label: 'Password', type: 'password' }, password: { label: 'Password', type: 'password' },
}, },
async authorize(credentials) { async authorize(credentials) {
const parsedCredentials = z const parsedCredentials = z
.object({ .object({
name: z.string().optional(), firstName: z.string().optional(),
lastName: z.string().optional(),
email: z.string().email(), email: z.string().email(),
password: z.string().min(6), password: z.string().min(6),
}) })
.safeParse(credentials) .safeParse(credentials)
if (parsedCredentials.success) { if (parsedCredentials.success) {
const { name, email, password } = parsedCredentials.data const { firstName, lastName, email, password } = parsedCredentials.data
const { data } = await axios.get(`/auth-users`, { const authUser = await getAuthUserByEmail(email)
params: { email: email },
headers: { 'Content-Type': 'application/json' },
})
// TODO: use superjson to parse this more cleanly if (!authUser) {
const user = data.data['json'] if (!firstName || !lastName) throw new Error('First and last name required')
if (!user) {
const hashedPassword = await bcrypt.hash(password, 10) const hashedPassword = await bcrypt.hash(password, 10)
const { data } = await axios.post('/auth-users', { const newAuthUser = await createAuthUser({
name, firstName,
lastName,
name: `${firstName} ${lastName}`,
email, email,
password: hashedPassword, password: hashedPassword,
}) })
const newUser = data.data['json']
if (newUser) return newUser if (newAuthUser) return newAuthUser
throw new Error('Could not create user') throw new Error('Could not create user')
} }
const passwordsMatch = await bcrypt.compare(password, user.password) const passwordsMatch = await bcrypt.compare(password, authUser.password!)
if (passwordsMatch) return user if (passwordsMatch) return authUser
} }
return null return null
}, },
}), }),
], ],
} callbacks: {
async jwt({ token, user: authUser, account }: { token: any; user: any; account: any }) {
if (authUser && account) {
token.sub = authUser.id
token['https://maybe.co/email'] = authUser.email
token.firstName = authUser.firstName
token.lastName = authUser.lastName
}
return token
},
async session({ session, token }: { session: any; token: any }) {
session.user = token.sub
session.sub = token.sub
session['https://maybe.co/email'] = token['https://maybe.co/email']
session.firstName = token.firstName
session.lastName = token.lastName
return session
},
},
} as NextAuthOptions
export default NextAuth(authOptions) export default NextAuth(authOptions)

View file

@ -38,7 +38,7 @@ export default function LoginPage() {
strategy="lazyOnload" strategy="lazyOnload"
/> />
<div className="absolute inset-0 flex flex-col items-center justify-center"> <div className="absolute inset-0 flex flex-col items-center justify-center">
<div className="p-px w-96 bg-white bg-opacity-10 card-light rounded-3xl radial-gradient-background"> <div className="p-px w-80 md:w-96 bg-white bg-opacity-10 card-light rounded-3xl radial-gradient-background">
<div className="bg-black bg-opacity-75 p-8 rounded-3xl w-full h-full items-center flex flex-col radial-gradient-background-dark"> <div className="bg-black bg-opacity-75 p-8 rounded-3xl w-full h-full items-center flex flex-col radial-gradient-background-dark">
<img <img
className="mb-8" className="mb-8"

View file

@ -8,7 +8,8 @@ import Script from 'next/script'
import Link from 'next/link' import Link from 'next/link'
export default function RegisterPage() { export default function RegisterPage() {
const [name, setName] = useState('') const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [isValid, setIsValid] = useState(false) const [isValid, setIsValid] = useState(false)
@ -23,14 +24,16 @@ export default function RegisterPage() {
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault() e.preventDefault()
setName('') setFirstName('')
setLastName('')
setEmail('') setEmail('')
setPassword('') setPassword('')
await signIn('credentials', { await signIn('credentials', {
email, email,
password, password,
name, firstName,
lastName,
redirect: false, redirect: false,
}) })
} }
@ -55,9 +58,15 @@ export default function RegisterPage() {
<form className="space-y-4 w-full px-4" onSubmit={onSubmit}> <form className="space-y-4 w-full px-4" onSubmit={onSubmit}>
<Input <Input
type="text" type="text"
label="Name" label="First name"
value={name} value={firstName}
onChange={(e) => setName(e.currentTarget.value)} onChange={(e) => setFirstName(e.currentTarget.value)}
/>
<Input
type="text"
label="Last name"
value={lastName}
onChange={(e) => setLastName(e.currentTarget.value)}
/> />
<Input <Input
type="text" type="text"

View file

@ -19,7 +19,7 @@ import logger from './lib/logger'
import prisma from './lib/prisma' import prisma from './lib/prisma'
import { import {
defaultErrorHandler, defaultErrorHandler,
validateAuth0Jwt, validateAuthJwt,
superjson, superjson,
authErrorHandler, authErrorHandler,
maintenance, maintenance,
@ -28,7 +28,6 @@ import {
} from './middleware' } from './middleware'
import { import {
usersRouter, usersRouter,
authUserRouter,
accountsRouter, accountsRouter,
connectionsRouter, connectionsRouter,
adminRouter, adminRouter,
@ -89,7 +88,7 @@ app.use(express.static(__dirname + '/assets'))
const origin = [env.NX_CLIENT_URL, ...env.NX_CORS_ORIGINS] const origin = [env.NX_CLIENT_URL, ...env.NX_CORS_ORIGINS]
logger.info(`CORS origins: ${origin}`) logger.info(`CORS origins: ${origin}`)
app.use(cors({ origin })) app.use(cors({ origin, credentials: true }))
app.options('*', cors() as RequestHandler) app.options('*', cors() as RequestHandler)
app.set('view engine', 'ejs').set('views', __dirname + '/app/admin/views') app.set('view engine', 'ejs').set('views', __dirname + '/app/admin/views')
@ -117,7 +116,7 @@ app.use(express.json({ limit: '50mb' })) // Finicity sends large response bodies
app.use( app.use(
'/trpc', '/trpc',
validateAuth0Jwt, validateAuthJwt,
trpcExpress.createExpressMiddleware({ trpcExpress.createExpressMiddleware({
router: appRouter, router: appRouter,
createContext: createTRPCContext, createContext: createTRPCContext,
@ -149,10 +148,9 @@ app.use('/tools', devOnly, toolsRouter)
app.use('/v1', webhooksRouter) app.use('/v1', webhooksRouter)
app.use('/v1', publicRouter) app.use('/v1', publicRouter)
app.use('/v1/auth-users', authUserRouter)
// All routes AFTER this line are protected via OAuth // All routes AFTER this line are protected via OAuth
app.use('/v1', validateAuth0Jwt) app.use('/v1', validateAuthJwt)
// Private routes // Private routes
app.use('/v1/users', usersRouter) app.use('/v1/users', usersRouter)

View file

@ -283,23 +283,23 @@ const stripeWebhooks = new StripeWebhookHandler(
) )
// helper function for parsing JWT and loading User record // helper function for parsing JWT and loading User record
// TODO: update this with roles, identity, and metadata
async function getCurrentUser(jwt: NonNullable<Request['user']>) { async function getCurrentUser(jwt: NonNullable<Request['user']>) {
if (!jwt.sub) throw new Error(`jwt missing sub`) if (!jwt.sub) throw new Error(`jwt missing sub`)
if (!jwt['https://maybe.co/email']) throw new Error(`jwt missing email`) if (!jwt['https://maybe.co/email']) throw new Error(`jwt missing email`)
const user = const user =
(await prisma.user.findUnique({ (await prisma.user.findUnique({
where: { auth0Id: jwt.sub }, where: { authId: jwt.sub },
})) ?? })) ??
(await prisma.user.upsert({ (await prisma.user.upsert({
where: { auth0Id: jwt.sub }, where: { authId: jwt.sub },
create: { create: {
auth0Id: jwt.sub,
authId: jwt.sub, authId: jwt.sub,
email: jwt['https://maybe.co/email'], email: jwt['https://maybe.co/email'],
picture: jwt[SharedType.Auth0CustomNamespace.Picture], picture: jwt['picture'],
firstName: jwt[SharedType.Auth0CustomNamespace.UserMetadata]?.['firstName'], firstName: jwt['firstName'],
lastName: jwt[SharedType.Auth0CustomNamespace.UserMetadata]?.['lastName'], lastName: jwt['lastName'],
}, },
update: {}, update: {},
})) }))

View file

@ -3,6 +3,7 @@ export * from './error-handler'
export * from './auth-error-handler' export * from './auth-error-handler'
export * from './superjson' export * from './superjson'
export * from './validate-auth0-jwt' export * from './validate-auth0-jwt'
export * from './validate-auth-jwt'
export * from './validate-plaid-jwt' export * from './validate-plaid-jwt'
export * from './validate-finicity-signature' export * from './validate-finicity-signature'
export { default as maintenance } from './maintenance' export { default as maintenance } from './maintenance'

View file

@ -0,0 +1,30 @@
import cookieParser from 'cookie-parser'
import { getToken } from 'next-auth/jwt'
const SECRET = process.env.NEXTAUTH_SECRET
export const validateAuthJwt = async (req, res, next) => {
cookieParser(SECRET)(req, res, async (err) => {
if (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
if (req.cookies && 'next-auth.session-token' in req.cookies) {
try {
const token = await getToken({ req, secret: SECRET })
if (token) {
req.user = token
return next()
} else {
return res.status(401).json({ message: 'Unauthorized' })
}
} catch (error) {
console.error('Error in token validation', error)
return res.status(500).json({ message: 'Internal Server Error' })
}
} else {
return res.status(401).json({ message: 'Unauthorized' })
}
})
}

View file

@ -1,37 +1,6 @@
import { Router } from 'express' import { Router } from 'express'
import { z } from 'zod'
import endpoint from '../lib/endpoint'
// Placeholder router if needed
const router = Router() const router = Router()
router.get(
'/',
endpoint.create({
resolve: async ({ ctx, req }) => {
const email = req.query.email
const user = await ctx.authUserService.getByEmail(email as string)
if (user) return user
return null
},
})
)
router.post(
'/',
endpoint.create({
input: z.object({
name: z.string(),
email: z.string().email(),
password: z.string().min(6),
}),
resolve: async ({ input, ctx }) => {
return await ctx.authUserService.create({
name: input.name,
email: input.email,
password: input.password,
})
},
})
)
export default router export default router

View file

@ -47,13 +47,12 @@ router.post(
trialLapsed: z.boolean().default(false), trialLapsed: z.boolean().default(false),
}), }),
resolve: async ({ ctx, input }) => { resolve: async ({ ctx, input }) => {
ctx.logger.debug(`Resetting CI user ${ctx.user!.auth0Id}`) ctx.logger.debug(`Resetting CI user ${ctx.user!.authId}`)
await ctx.prisma.$transaction([ await ctx.prisma.$transaction([
ctx.prisma.$executeRaw`DELETE FROM "user" WHERE auth0_id=${ctx.user!.auth0Id};`, ctx.prisma.$executeRaw`DELETE FROM "user" WHERE auth_id=${ctx.user!.authId};`,
ctx.prisma.user.create({ ctx.prisma.user.create({
data: { data: {
auth0Id: ctx.user!.auth0Id,
authId: ctx.user!.authId, authId: ctx.user!.authId,
email: 'REPLACE_THIS', email: 'REPLACE_THIS',
dob: new Date('1990-01-01'), dob: new Date('1990-01-01'),

View file

@ -105,6 +105,7 @@ router.put(
}) })
) )
// TODO: Remove this endpoint
router.get( router.get(
'/auth0-profile', '/auth0-profile',
endpoint.create({ endpoint.create({
@ -122,7 +123,7 @@ router.put(
}), }),
resolve: ({ input, ctx }) => { resolve: ({ input, ctx }) => {
return ctx.managementClient.updateUser( return ctx.managementClient.updateUser(
{ id: ctx.user!.auth0Id }, { id: ctx.user!.authId }, // TODO: Remove this endpoint
{ user_metadata: { enrolled_mfa: input.enrolled_mfa } } { user_metadata: { enrolled_mfa: input.enrolled_mfa } }
) )
}, },
@ -276,6 +277,7 @@ router.get(
}) })
) )
// TODO: Remove this endpoint or refactor to work with new Auth
router.post( router.post(
'/link-accounts', '/link-accounts',
endpoint.create({ endpoint.create({
@ -284,7 +286,7 @@ router.post(
secondaryProvider: z.string(), secondaryProvider: z.string(),
}), }),
resolve: async ({ input, ctx }) => { resolve: async ({ input, ctx }) => {
return ctx.userService.linkAccounts(ctx.user!.auth0Id, input.secondaryProvider, { return ctx.userService.linkAccounts(ctx.user!.authId, input.secondaryProvider, {
token: input.secondaryJWT, token: input.secondaryJWT,
domain: env.NX_AUTH0_CUSTOM_DOMAIN, domain: env.NX_AUTH0_CUSTOM_DOMAIN,
audience: env.NX_AUTH0_AUDIENCE, audience: env.NX_AUTH0_AUDIENCE,
@ -293,6 +295,7 @@ router.post(
}) })
) )
// TODO: Remove this endpoint or refactor to work with new Auth
router.post( router.post(
'/unlink-account', '/unlink-account',
endpoint.create({ endpoint.create({
@ -302,7 +305,7 @@ router.post(
}), }),
resolve: async ({ input, ctx }) => { resolve: async ({ input, ctx }) => {
return ctx.userService.unlinkAccounts( return ctx.userService.unlinkAccounts(
ctx.user!.auth0Id, ctx.user!.authId,
input.secondaryAuth0Id, input.secondaryAuth0Id,
input.secondaryProvider as UnlinkAccountsParamsProvider input.secondaryProvider as UnlinkAccountsParamsProvider
) )
@ -310,19 +313,20 @@ router.post(
}) })
) )
// TODO: Refactor this to use the Auth Id instead of Auth0
router.post( router.post(
'/resend-verification-email', '/resend-verification-email',
endpoint.create({ endpoint.create({
input: z.object({ input: z.object({
auth0Id: z.string().optional(), authId: z.string().optional(),
}), }),
resolve: async ({ input, ctx }) => { resolve: async ({ input, ctx }) => {
const auth0Id = input.auth0Id ?? ctx.user?.auth0Id const authId = input.authId ?? ctx.user?.authId
if (!auth0Id) throw new Error('User not found') if (!authId) throw new Error('User not found')
await ctx.managementClient.sendEmailVerification({ user_id: auth0Id }) await ctx.managementClient.sendEmailVerification({ user_id: authId })
ctx.logger.info(`Sent verification email to ${auth0Id}`) ctx.logger.info(`Sent verification email to ${authId}`)
return { success: true } return { success: true }
}, },

View file

@ -82,7 +82,7 @@ export class BullQueueEventHandler implements IBullQueueEventHandler {
} }
private async getUserFromJob(job: Job) { private async getUserFromJob(job: Job) {
let user: Pick<User, 'id' | 'auth0Id'> | undefined let user: Pick<User, 'id' | 'authId'> | undefined
try { try {
if (job.queue.name === 'sync-account' && 'accountId' in job.data) { if (job.queue.name === 'sync-account' && 'accountId' in job.data) {

View file

@ -1,11 +1,8 @@
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import type { SharedType } from '@maybe-finance/shared' import type { SharedType } from '@maybe-finance/shared'
import { superjson } from '@maybe-finance/shared' import { superjson } from '@maybe-finance/shared'
import { createContext, type PropsWithChildren, useCallback, useMemo } from 'react' import { createContext, type PropsWithChildren, useMemo } from 'react'
import { useAuth0 } from '@auth0/auth0-react'
import Axios from 'axios' import Axios from 'axios'
import * as Sentry from '@sentry/react'
import { useRouter } from 'next/router'
type CreateInstanceOptions = { type CreateInstanceOptions = {
getToken?: () => Promise<string | null> getToken?: () => Promise<string | null>
@ -15,7 +12,6 @@ type CreateInstanceOptions = {
} }
export type AxiosContextValue = { export type AxiosContextValue = {
getToken: () => Promise<string | null>
defaultBaseUrl: string defaultBaseUrl: string
axios: AxiosInstance axios: AxiosInstance
createInstance: (options?: CreateInstanceOptions) => AxiosInstance createInstance: (options?: CreateInstanceOptions) => AxiosInstance
@ -78,56 +74,28 @@ function createInstance(options?: CreateInstanceOptions) {
*/ */
export function AxiosProvider({ children }: PropsWithChildren) { export function AxiosProvider({ children }: PropsWithChildren) {
// Rather than storing access token in localStorage (insecure), we use this method to retrieve it prior to making API calls // Rather than storing access token in localStorage (insecure), we use this method to retrieve it prior to making API calls
const { getAccessTokenSilently, isAuthenticated, loginWithRedirect } = useAuth0()
const router = useRouter()
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3333' const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3333'
const getToken = useCallback(async () => {
if (!isAuthenticated) return null
try {
const token = await getAccessTokenSilently()
return token
} catch (err) {
const authErr =
err && typeof err === 'object' && 'error' in err && typeof err['error'] === 'string'
? err['error']
: null
const isRecoverable = authErr
? [
'mfa_required',
'consent_required',
'interaction_required',
'login_required',
].includes(authErr)
: false
if (isRecoverable) {
await loginWithRedirect({ appState: { returnTo: router.asPath } })
} else {
Sentry.captureException(err)
}
return null
}
}, [isAuthenticated, getAccessTokenSilently, loginWithRedirect, router])
// Expose a default instance with auth, superjson, headers // Expose a default instance with auth, superjson, headers
const defaultInstance = useMemo(() => { const defaultInstance = useMemo(() => {
const defaultHeaders = { 'Content-Type': 'application/json' } const defaultHeaders = {
'Content-Type': 'application/json',
'Access-Control-Allow-Credentials': true,
}
return createInstance({ return createInstance({
getToken, axiosOptions: {
axiosOptions: { baseURL: `${API_URL}/v1`, headers: defaultHeaders }, baseURL: `${API_URL}/v1`,
headers: defaultHeaders,
withCredentials: true,
},
serialize: true, serialize: true,
deserialize: true, deserialize: true,
}) })
}, [getToken, API_URL]) }, [API_URL])
return ( return (
<AxiosContext.Provider <AxiosContext.Provider
value={{ value={{
getToken,
defaultBaseUrl: `${API_URL}/v1`, defaultBaseUrl: `${API_URL}/v1`,
axios: defaultInstance, axios: defaultInstance,
createInstance, createInstance,

View file

@ -22,9 +22,9 @@ export class AuthUserService implements IAuthUserService {
}) })
} }
async create(data: Prisma.AuthUserCreateInput) { async create(data: Prisma.AuthUserCreateInput & { firstName: string; lastName: string }) {
const user = await this.prisma.authUser.create({ data: { ...data } }) const authUser = await this.prisma.authUser.create({ data: { ...data } })
return user return authUser
} }
async delete(id: AuthUser['id']) { async delete(id: AuthUser['id']) {

View file

@ -27,7 +27,7 @@ export class StripeWebhookHandler implements IStripeWebhookHandler {
await this.prisma.user.updateMany({ await this.prisma.user.updateMany({
where: { where: {
auth0Id: session.client_reference_id, authId: session.client_reference_id,
}, },
data: { data: {
trialEnd: null, trialEnd: null,

View file

@ -65,12 +65,14 @@ export class UserService implements IUserService {
}) })
} }
// TODO: Update this to use new Auth
async getAuth0Profile(user: User): Promise<SharedType.Auth0Profile> { async getAuth0Profile(user: User): Promise<SharedType.Auth0Profile> {
if (!user.email) throw new Error('No email found for user') if (!user.email) throw new Error('No email found for user')
const usersWithMatchingEmail = await this.auth0.getUsersByEmail(user.email) const usersWithMatchingEmail = await this.auth0.getUsersByEmail(user.email)
const autoPromptEnabled = user.linkAccountDismissedAt == null const autoPromptEnabled = user.linkAccountDismissedAt == null
const currentUser = usersWithMatchingEmail.find((u) => u.user_id === user.auth0Id) // TODO: Update this to use new Auth
const currentUser = usersWithMatchingEmail.find((u) => u.user_id === user.authId)
const primaryIdentity = currentUser?.identities?.find( const primaryIdentity = currentUser?.identities?.find(
(identity) => !('profileData' in identity) (identity) => !('profileData' in identity)
) )
@ -85,7 +87,7 @@ export class UserService implements IUserService {
.filter( .filter(
(match) => (match) =>
match.email_verified && match.email_verified &&
match.user_id !== user.auth0Id && match.user_id !== user.authId &&
match.identities?.at(0) != null match.identities?.at(0) != null
) )
.map((user) => user.identities!.at(0)!) .map((user) => user.identities!.at(0)!)
@ -157,9 +159,10 @@ export class UserService implements IUserService {
// Delete Stripe customer, ending any active subscriptions // Delete Stripe customer, ending any active subscriptions
if (user.stripeCustomerId) await this.stripe.customers.del(user.stripeCustomerId) if (user.stripeCustomerId) await this.stripe.customers.del(user.stripeCustomerId)
// Delete user from Auth0 so that it cannot be accessed in a partially-purged state // Delete user from Auth so that it cannot be accessed in a partially-purged state
this.logger.info(`Removing user ${user.id} from Auth0 (${user.auth0Id})`) // TODO: Update this to use new Auth
await this.auth0.deleteUser({ id: user.auth0Id }) this.logger.info(`Removing user ${user.id} from Auth (${user.authId})`)
await this.prisma.authUser.delete({ where: { id: user.authId } })
await this.purgeQueue.add('purge-user', { userId: user.id }) await this.purgeQueue.add('purge-user', { userId: user.id })
@ -312,7 +315,7 @@ export class UserService implements IUserService {
const user = await this.prisma.user.findUniqueOrThrow({ const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId }, where: { id: userId },
select: { select: {
auth0Id: true, authId: true,
onboarding: true, onboarding: true,
dob: true, dob: true,
household: true, household: true,
@ -327,10 +330,12 @@ export class UserService implements IUserService {
}, },
}) })
const auth0User = await this.auth0.getUser({ id: user.auth0Id }) const authUser = await this.prisma.authUser.findUniqueOrThrow({
where: { id: user.authId },
})
// Auth0 has this mis-typed and it comes in as a 'true' string // NextAuth used DateTime for this field
const email_verified = auth0User.email_verified as unknown as string | boolean const email_verified = authUser.emailVerified === null ? false : true
const typedOnboarding = user.onboarding as OnboardingState | null const typedOnboarding = user.onboarding as OnboardingState | null
const onboardingState = typedOnboarding const onboardingState = typedOnboarding
@ -341,8 +346,8 @@ export class UserService implements IUserService {
{ {
...user, ...user,
onboarding: onboardingState, onboarding: onboardingState,
emailVerified: email_verified === true || email_verified === 'true', emailVerified: email_verified,
isAppleIdentity: auth0User.identities?.[0].provider === 'apple', isAppleIdentity: false,
}, },
onboardingState.markedComplete onboardingState.markedComplete
) )
@ -543,8 +548,9 @@ export class UserService implements IUserService {
return onboarding return onboarding
} }
// TODO: Update to work with new Auth
async linkAccounts( async linkAccounts(
primaryAuth0Id: User['auth0Id'], primaryAuth0Id: User['authId'],
provider: string, provider: string,
secondaryJWT: { token: string; domain: string; audience: string } secondaryJWT: { token: string; domain: string; audience: string }
) { ) {
@ -554,7 +560,7 @@ export class UserService implements IUserService {
secondaryJWT.audience secondaryJWT.audience
) )
const user = await this.prisma.user.findFirst({ where: { auth0Id: validatedJWT.auth0Id } }) const user = await this.prisma.user.findFirst({ where: { authId: validatedJWT.authId } })
if (user?.stripePriceId) { if (user?.stripePriceId) {
throw new Error( throw new Error(
@ -563,14 +569,14 @@ export class UserService implements IUserService {
} }
return this.auth0.linkUsers(primaryAuth0Id, { return this.auth0.linkUsers(primaryAuth0Id, {
user_id: validatedJWT.auth0Id, user_id: validatedJWT.authId,
provider, provider,
}) })
} }
async unlinkAccounts( async unlinkAccounts(
primaryAuth0Id: User['auth0Id'], primaryAuth0Id: User['authId'],
secondaryAuth0Id: User['auth0Id'], secondaryAuth0Id: User['authId'],
secondaryProvider: UnlinkAccountsParamsProvider secondaryProvider: UnlinkAccountsParamsProvider
) { ) {
const response = await this.auth0.unlinkUsers({ const response = await this.auth0.unlinkUsers({

View file

@ -13,7 +13,7 @@ export async function validateRS256JWT(
domain: string, domain: string,
audience: string audience: string
): Promise<{ ): Promise<{
auth0Id: string authId: string
userMetadata: SharedType.MaybeUserMetadata userMetadata: SharedType.MaybeUserMetadata
appMetadata: SharedType.MaybeAppMetadata appMetadata: SharedType.MaybeAppMetadata
}> { }> {
@ -50,7 +50,7 @@ export async function validateRS256JWT(
if (typeof payload !== 'object') return reject('payload not an object') if (typeof payload !== 'object') return reject('payload not an object')
resolve({ resolve({
auth0Id: payload.sub!, authId: payload.sub!,
appMetadata: payload[SharedType.Auth0CustomNamespace.AppMetadata] ?? {}, appMetadata: payload[SharedType.Auth0CustomNamespace.AppMetadata] ?? {},
userMetadata: payload[SharedType.Auth0CustomNamespace.UserMetadata] ?? {}, userMetadata: payload[SharedType.Auth0CustomNamespace.UserMetadata] ?? {},
}) })

View file

@ -102,6 +102,7 @@
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bull": "^4.10.2", "bull": "^4.10.2",
"classnames": "^2.3.1", "classnames": "^2.3.1",
"cookie-parser": "^1.4.6",
"core-js": "^3.6.5", "core-js": "^3.6.5",
"cors": "^2.8.5", "cors": "^2.8.5",
"crypto-js": "^4.1.1", "crypto-js": "^4.1.1",
@ -203,6 +204,7 @@
"@testing-library/react": "13.4.0", "@testing-library/react": "13.4.0",
"@testing-library/user-event": "^13.2.1", "@testing-library/user-event": "^13.2.1",
"@types/auth0": "^2.35.7", "@types/auth0": "^2.35.7",
"@types/bcrypt": "^5.0.2",
"@types/cors": "^2.8.12", "@types/cors": "^2.8.12",
"@types/crypto-js": "^4.1.1", "@types/crypto-js": "^4.1.1",
"@types/d3-array": "^3.0.3", "@types/d3-array": "^3.0.3",

View file

@ -0,0 +1,11 @@
/*
Warnings:
- You are about to drop the column `auth0_id` on the `user` table. All the data in the column will be lost.
*/
-- DropIndex
DROP INDEX "user_auth0_id_key";
-- AlterTable
ALTER TABLE "user" DROP COLUMN "auth0_id";

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "auth_user" ADD COLUMN "first_name" TEXT,
ADD COLUMN "last_name" TEXT;

View file

@ -395,7 +395,6 @@ model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
auth0Id String @unique @map("auth0_id")
authId String @unique @map("auth_id") // NextAuth user id authId String @unique @map("auth_id") // NextAuth user id
// profile // profile
@ -585,6 +584,8 @@ model AuthAccount {
model AuthUser { model AuthUser {
id String @id @default(cuid()) id String @id @default(cuid())
name String? name String?
firstName String? @map("first_name")
lastName String? @map("last_name")
email String? @unique email String? @unique
emailVerified DateTime? @map("email_verified") emailVerified DateTime? @map("email_verified")
password String? password String?

View file

@ -5724,6 +5724,13 @@
dependencies: dependencies:
"@babel/types" "^7.3.0" "@babel/types" "^7.3.0"
"@types/bcrypt@^5.0.2":
version "5.0.2"
resolved "https://registry.yarnpkg.com/@types/bcrypt/-/bcrypt-5.0.2.tgz#22fddc11945ea4fbc3655b3e8b8847cc9f811477"
integrity sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==
dependencies:
"@types/node" "*"
"@types/body-parser@*": "@types/body-parser@*":
version "1.19.2" version "1.19.2"
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0"
@ -9412,11 +9419,24 @@ convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0:
dependencies: dependencies:
safe-buffer "~5.1.1" safe-buffer "~5.1.1"
cookie-parser@^1.4.6:
version "1.4.6"
resolved "https://registry.yarnpkg.com/cookie-parser/-/cookie-parser-1.4.6.tgz#3ac3a7d35a7a03bbc7e365073a26074824214594"
integrity sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==
dependencies:
cookie "0.4.1"
cookie-signature "1.0.6"
cookie-signature@1.0.6: cookie-signature@1.0.6:
version "1.0.6" version "1.0.6"
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==
cookie@0.4.1, cookie@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==
cookie@0.4.2: cookie@0.4.2:
version "0.4.2" version "0.4.2"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432"
@ -9432,11 +9452,6 @@ cookie@0.6.0:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"
integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==
cookie@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==
cookiejar@^2.1.2: cookiejar@^2.1.2:
version "2.1.3" version "2.1.3"
resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.3.tgz#fc7a6216e408e74414b90230050842dacda75acc" resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.3.tgz#fc7a6216e408e74414b90230050842dacda75acc"