/** * Verify magic link token and complete the OIDC login interaction. * * GET /oidc/interaction/verify?token=... * * This is the endpoint the magic link email points to. It: * 1. Verifies the JWT token * 2. Sets the Ghost Guild auth-token cookie * 3. Redirects the user back to the wiki — [uid].get.ts will then SSO them * into a fresh OIDC interaction using that cookie * * We do NOT call provider.interactionFinished here because it requires the * short-lived _interaction cookie on the request, which is missing when the * user clicks the email link on a different device, in a different browser, * in a private window, or after the interaction cookie expired. That path * throws SessionNotFound, which was leaving users stranded on /coming-soon. */ import jwt from "jsonwebtoken"; import Member from "../../../models/member.js"; import { connectDB } from "../../../utils/mongoose.js"; const OIDC_ERROR = (description: string) => `/auth/oidc-error?error=invalid_request&error_description=${encodeURIComponent(description)}`; export default defineEventHandler(async (event) => { const { token } = getQuery(event); if (!token) { return sendRedirect(event, OIDC_ERROR("Login link is missing its token."), 302); } const config = useRuntimeConfig(event); let decoded: { memberId: string; oidcUid: string }; try { decoded = jwt.verify(token as string, config.jwtSecret) as typeof decoded; } catch { return sendRedirect( event, OIDC_ERROR("This login link is invalid or has expired. Please request a new one."), 302 ); } await connectDB(); const member = await (Member as any).findById(decoded.memberId); if (!member) { return sendRedirect(event, OIDC_ERROR("Member not found."), 302); } if (member.status === "suspended" || member.status === "cancelled") { return sendRedirect( event, OIDC_ERROR(`Account is ${member.status}.`), 302 ); } const sessionToken = jwt.sign( { memberId: member._id, email: member.email, tv: member.tokenVersion }, config.jwtSecret, { expiresIn: "7d" } ); setCookie(event, "auth-token", sessionToken, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: 60 * 60 * 24 * 7, }); // Send the user back to the wiki. Outline will bounce them to /oidc/auth, // which creates a fresh interaction whose cookie WILL be present, and then // [uid].get.ts sees the auth-token we just set and SSOs them through. return sendRedirect(event, "https://wiki.ghostguild.org", 302); });