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(core): allow user updates #3648

Closed
wants to merge 4 commits into from
Closed
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
13 changes: 13 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,19 @@ export async function NextAuthHandler<
}
}
return {}
case "session":
// Verified CSRF Token required for session updates
if (options.csrfTokenVerified) {
const session = await routes.session({
method: "POST",
user: req.body?.user,
options,
sessionStore,
})
if (session.cookies) cookies.push(...session.cookies)
return { ...session, cookies } as any
}
return { cookies, status: 401 }
default:
}
}
Expand Down
33 changes: 25 additions & 8 deletions src/core/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { fromDate } from "../lib/utils"
import type { Adapter } from "../../adapters"
import type { InternalOptions } from "../../lib/types"
import type { OutgoingResponse } from ".."
import type { Session } from "../.."
import type { Session, User } from "../.."
import type { SessionStore } from "../lib/cookie"

interface SessionParams {
interface SessionParams<M extends "POST" | undefined> {
method?: M
user?: M extends "POST" ? Partial<User> : never
options: InternalOptions
sessionStore: SessionStore
}
Expand All @@ -16,8 +18,8 @@ interface SessionParams {
* for Single Page App clients
*/

export default async function session(
params: SessionParams
export default async function session<M extends "POST" | undefined = undefined>(
params: SessionParams<M>
): Promise<OutgoingResponse<Session | {}>> {
const { options, sessionStore } = params
const {
Expand Down Expand Up @@ -83,14 +85,15 @@ export default async function session(

await events.session?.({ session: newSession, token })
} catch (error) {
// If JWT not verifiable, make sure the cookie for it is removed and return empty object
// If JWT not verifiable, make sure the cookie for it is removed and return
// empty object
logger.error("JWT_SESSION_ERROR", error as Error)

response.cookies?.push(...sessionStore.clean())
}
} else {
try {
const { getSessionAndUser, deleteSession, updateSession } =
const { getSessionAndUser, deleteSession, updateSession, updateUser } =
adapter as Adapter
let userAndSession = await getSessionAndUser(sessionToken)

Expand All @@ -104,7 +107,7 @@ export default async function session(
}

if (userAndSession) {
const { user, session } = userAndSession
let { user, session } = userAndSession

const sessionUpdateAge = options.session.updateAge
// Calculate last updated date to throttle write updates to database
Expand All @@ -115,6 +118,19 @@ export default async function session(
sessionMaxAge * 1000 +
sessionUpdateAge * 1000

if (params.method === "POST" && params.user) {
// TODO: support e-mail change.
// This needs work, as all accounts need to be updated,
// user has to receive a confirmation e-mail
if (params.user.email) {
delete params.user.email
logger.warn("EMAIL_UPDATE_UNSUPPORTED")
}

const newUser = { ...params.user, id: user.id }
user = await updateUser(newUser)
}

const newExpires = fromDate(sessionMaxAge)
// Trigger update of session expiry date and write to database, only
// if the session was last updated more than {sessionUpdateAge} ago
Expand All @@ -125,7 +141,8 @@ export default async function session(
// Pass Session through to the session callback
// @ts-expect-error
const sessionPayload = await callbacks.session({
// By default, only exposes a limited subset of information to the client
// By default, only exposes a limited subset of information to the
// client
// as needed for presentation purposes (e.g. "you are logged in as...").
session: {
user: {
Expand Down
5 changes: 4 additions & 1 deletion src/lib/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ function hasErrorProperty(
return !!(x as any)?.error
}

export type WarningCode = "NEXTAUTH_URL" | "NO_SECRET"
export type WarningCode =
| "NEXTAUTH_URL"
| "NO_SECRET"
| "EMAIL_UPDATE_UNSUPPORTED"

/**
* Override any of the methods, and the rest will use the default logger.
Expand Down
85 changes: 66 additions & 19 deletions src/react/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
// We use HTTP POST requests with CSRF Tokens to protect against CSRF attacks.

import * as React from "react"
import { flushSync } from "react-dom"

import _logger, { proxyLogger } from "../lib/logger"
import parseUrl from "../lib/parse-url"
import { Session } from ".."
import type { Session, User } from ".."
import {
BroadcastChannel,
CtxOrReq,
Expand Down Expand Up @@ -66,13 +68,19 @@ const broadcast = BroadcastChannel()

const logger = proxyLogger(_logger, __NEXTAUTH.basePath)

type UpdateUser = (user: Partial<User>) => Session["user"]

export type SessionContextValue<R extends boolean = false> = R extends true
?
| { data: Session; status: "authenticated" }
| { data: null; status: "loading" }
| { updateUser?: UpdateUser; data: Session; status: "authenticated" }
| { updateUser?: UpdateUser; data: null; status: "loading" }
:
| { data: Session; status: "authenticated" }
| { data: null; status: "unauthenticated" | "loading" }
| { updateUser?: UpdateUser; data: Session; status: "authenticated" }
| {
updateUser?: UpdateUser
data: null
status: "unauthenticated" | "loading"
}

const SessionContext = React.createContext<SessionContextValue | undefined>(
undefined
Expand Down Expand Up @@ -308,7 +316,7 @@ export function SessionProvider(props: SessionProviderProps) {
/** If session was passed, initialize as already synced */
__NEXTAUTH._lastSync = hasInitialSession ? now() : 0

const [session, setSession] = React.useState(() => {
const [data, setData] = React.useState(() => {
if (hasInitialSession) __NEXTAUTH._session = props.session
return props.session
})
Expand All @@ -327,7 +335,7 @@ export function SessionProvider(props: SessionProviderProps) {
__NEXTAUTH._session = await getSession({
broadcast: !storageEvent,
})
setSession(__NEXTAUTH._session)
setData(__NEXTAUTH._session)
return
}

Expand All @@ -350,7 +358,7 @@ export function SessionProvider(props: SessionProviderProps) {
// An event or session staleness occurred, update the client session.
__NEXTAUTH._lastSync = now()
__NEXTAUTH._session = await getSession()
setSession(__NEXTAUTH._session)
setData(__NEXTAUTH._session)
} catch (error) {
logger.error("CLIENT_SESSION_ERROR", error as Error)
} finally {
Expand Down Expand Up @@ -403,17 +411,56 @@ export function SessionProvider(props: SessionProviderProps) {
}
}, [props.refetchInterval])

const value: any = React.useMemo(
() => ({
data: session,
status: loading
? "loading"
: session
? "authenticated"
: "unauthenticated",
}),
[session, loading]
)
const value: any = React.useMemo(() => {
let status

if (loading) {
if (data) status = "updating"
else status = "loading"
} else {
if (data) status = "authenticated"
else status = "unauthenticated"
}

return {
status,
data,
async updateUser(user: Partial<User>) {
if (!data) return data

// REVIEW:
if (!data.user) throw new TypeError("Session must have a `user` object")

try {
setLoading(true)
const csrfToken = await getCsrfToken()
const res = await fetch("/api/auth/session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ user, csrfToken }),
})
if (res.ok) {
const newSession = await res.json()

const update = () => {
setData(newSession)
setLoading(false)
}

const [reactMajorVersion] = React.version.split(".")
// https://github.com/reactwg/react-18/discussions/21
if (reactMajorVersion >= "18") update()
else flushSync(update)
}
} catch (error) {
logger.error("CLIENT_UPDATE_USER_ERROR", error as Error)
setLoading(false)
}
},
}
}, [data, loading])

return (
<SessionContext.Provider value={value}>{children}</SessionContext.Provider>
Expand Down