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:
@@ -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
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user