ghostguild-org/server/utils/auth.js
Jennie Robinson Faber 9b79ae6bf4 refactor(auth): rename paymentBridge → signupBridge
After commit 90acc35 issued the cookie for $0 signups too, the "payment"
framing was wrong — there's no payment in a $0 signup. The cookie is
about bridging the gap between signup-form submit and email verify, not
about payment specifically.

Changes:
- setPaymentBridgeCookie  → setSignupBridgeCookie
- getPaymentBridgeMember  → getSignupBridgeMember
- Cookie wire name        payment-bridge → signup-bridge
- JWT scope               payment_bridge → signup_bridge

Touches both /api/helcim/subscription (signup activation) and
/api/helcim/initialize-payment (paid Helcim checkout) which both consume
the cookie. In-flight signup sessions started before this lands will
need to re-submit the form (cookie name mismatch); cutover hasn't
happened yet, so the only impact is local dev sessions.
2026-04-30 15:31:54 +01:00

173 lines
4.4 KiB
JavaScript

import jwt from 'jsonwebtoken'
import Member from '../models/member.js'
import { connectDB } from './mongoose.js'
/**
* Issue a session JWT and set the auth-token cookie for the given member.
* Mirrors the cookie options used by verify.post.js.
*/
export function setAuthCookie(event, member) {
const token = jwt.sign(
{ memberId: member._id.toString(), email: member.email, tv: member.tokenVersion || 0 },
useRuntimeConfig(event).jwtSecret,
{ expiresIn: '7d' }
)
setCookie(event, 'auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7
})
}
/**
* Issue a 30-minute signup-bridge cookie scoped to membership-signup flow.
*
* The signup flow (POST /api/helcim/customer) defers the full session cookie
* to email-verify (magic link). The bridge cookie lets the in-progress signup
* complete its activation step (free or paid) before that magic link is
* clicked: /api/helcim/subscription accepts it for $0 activation, and
* /api/helcim/initialize-payment accepts it for paid Helcim checkout.
* The cookie is NOT honored by requireAuth and grants nothing else.
*/
export function setSignupBridgeCookie(event, member) {
const token = jwt.sign(
{
memberId: member._id.toString(),
email: member.email,
scope: 'signup_bridge'
},
useRuntimeConfig(event).jwtSecret,
{ expiresIn: '30m' }
)
setCookie(event, 'signup-bridge', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 30
})
}
/**
* Verify a signup-bridge cookie and return the associated Member, or null.
* Used by /api/helcim/subscription and /api/helcim/initialize-payment to
* let the in-progress signup complete activation before email verification.
*/
export async function getSignupBridgeMember(event) {
const token = getCookie(event, 'signup-bridge')
if (!token) return null
let decoded
try {
decoded = jwt.verify(token, useRuntimeConfig(event).jwtSecret)
} catch {
return null
}
if (decoded.scope !== 'signup_bridge') return null
await connectDB()
const member = await Member.findById(decoded.memberId)
if (!member) return null
return member
}
/**
* Verify JWT from cookie and return the decoded member.
* Throws 401 if token is missing or invalid.
*/
export async function requireAuth(event) {
await connectDB()
const token = getCookie(event, 'auth-token')
if (!token) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
})
}
let decoded
try {
decoded = jwt.verify(token, useRuntimeConfig().jwtSecret)
} catch (err) {
throw createError({
statusCode: 401,
statusMessage: 'Invalid or expired token'
})
}
const member = await Member.findById(decoded.memberId)
if (!member) {
throw createError({
statusCode: 401,
statusMessage: 'Member not found'
})
}
if (member.status === 'suspended' || member.status === 'cancelled') {
throw createError({
statusCode: 403,
statusMessage: 'Account is ' + member.status
})
}
// Verify session has not been revoked (tokenVersion incremented on logout)
if (decoded.tv !== member.tokenVersion) {
throw createError({
statusCode: 401,
statusMessage: 'Session has been revoked'
})
}
return member
}
/**
* Return the signed-in Member without throwing.
* Returns null when no cookie, invalid token, or member not found/inactive.
* Mirrors the non-throwing JWT check in api/auth/status.get.js.
*/
export async function getOptionalMember(event) {
await connectDB()
const token = getCookie(event, 'auth-token')
if (!token) return null
let decoded
try {
decoded = jwt.verify(token, useRuntimeConfig(event).jwtSecret)
} catch {
return null
}
const member = await Member.findById(decoded.memberId)
if (!member) return null
if (member.status === 'suspended' || member.status === 'cancelled') return null
if (decoded.tv !== member.tokenVersion) return null
return member
}
/**
* Verify JWT and require admin role.
* Throws 401 if not authenticated, 403 if not admin.
*/
export async function requireAdmin(event) {
const member = await requireAuth(event)
if (member.role !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Admin access required'
})
}
return member
}