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.
173 lines
4.4 KiB
JavaScript
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
|
|
}
|