Gate signup behind an invitation and revoke sessions on suspend

Registration was open to anyone who could reach the app. Signup now
requires an invite: /invite/<token> validates the link and parks the
token in an HttpOnly cookie, /join re-checks it and renders the form,
and the register route consumes a use inside the same transaction that
creates the user - so a duplicate email rolls the use back instead of
burning it. The token never reaches the client, so the form cannot forge
or replay one.

The jwt callback now revalidates the user on every call and returns null
when the account is missing or suspended, which clears the session
cookie. Every API route and server component already branches on
session?.user?.id, so this revokes access everywhere without editing any
of them. The try/catch around that lookup is load-bearing: Auth.js
treats a throw in this callback the same as a null return, so an
unguarded transient database error would sign out every user at once.

authorize() rejects non-ACTIVE users too, so a suspended account cannot
sign in again to mint a fresh token.

/register redirects to /join; it is linked from elsewhere and may be
bookmarked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JP
2026-08-08 20:59:52 +00:00
parent f0c745c50c
commit a6cabc5178
8 changed files with 381 additions and 180 deletions

View File

@@ -9,7 +9,7 @@ import { rateLimit } from "@/lib/rate-limit"
* prefix, so `/share` also covers `/share/<slug>`. Everything else is private -
* see the `authorized` callback and the denylist matcher in src/middleware.ts.
*/
const PUBLIC_ROUTES = ["/login", "/register", "/share"]
const PUBLIC_ROUTES = ["/login", "/register", "/join", "/invite", "/share"]
// Email and password only. Google and GitHub were configured but never had
// credentials set and no account ever linked to them, and dropping them keeps
@@ -38,6 +38,10 @@ const providers = [
const valid = await bcrypt.compare(password, user.password)
if (!valid) return null
// Checked here as well as in the jwt callback, so a suspended user cannot
// simply sign in again to mint a fresh token.
if (user.status !== "ACTIVE") return null
return { id: user.id, email: user.email, name: user.name, image: user.image }
},
}),
@@ -53,15 +57,45 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
signIn: "/login",
},
callbacks: {
jwt({ token, user }) {
/**
* Revalidates the user on every call, so suspending or deleting an account
* ends its session instead of waiting for the JWT to expire.
*
* Returning null clears the session cookie. Because every API route and server
* component already branches on `session?.user?.id`, that revokes access
* everywhere without touching any of them.
*
* The try/catch is load-bearing: Auth.js treats a throw here the same as a null
* return, so an unguarded transient database error would sign out every user at
* once. On failure we keep the existing token and let the next call retry.
*/
async jwt({ token, user }) {
if (user) {
token.id = user.id
}
const userId = token.id as string | undefined
if (!userId) return token
try {
const current = await prisma.user.findUnique({
where: { id: userId },
select: { role: true, status: true },
})
if (!current) return null
if (current.status !== "ACTIVE") return null
token.role = current.role
token.status = current.status
} catch (error) {
console.error("[auth] jwt revalidation failed, keeping session:", error)
}
return token
},
session({ session, token }) {
if (session.user && token.id) {
session.user.id = token.id as string
session.user.role = token.role as "OWNER" | "MEMBER" | undefined
}
return session
},