/** * 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 session cookie (so future logins are SSO) * 3. Completes the OIDC interaction so the user is redirected back to Outline */ import jwt from "jsonwebtoken"; import Member from "../../../models/member.js"; import { connectDB } from "../../../utils/mongoose.js"; import { getOidcProvider } from "../../../utils/oidc-provider.js"; export default defineEventHandler(async (event) => { const { token } = getQuery(event); if (!token) { throw createError({ statusCode: 400, statusMessage: "Token is required" }); } const config = useRuntimeConfig(event); let decoded: { memberId: string; oidcUid: string }; try { decoded = jwt.verify(token as string, config.jwtSecret) as typeof decoded; } catch { throw createError({ statusCode: 401, statusMessage: "Invalid or expired token", }); } await connectDB(); const member = await (Member as any).findById(decoded.memberId); if (!member) { throw createError({ statusCode: 404, statusMessage: "Member not found" }); } if (member.status === "suspended" || member.status === "cancelled") { throw createError({ statusCode: 403, statusMessage: `Account is ${member.status}`, }); } // Set Ghost Guild session cookie for future SSO const sessionToken = jwt.sign( { memberId: member._id, email: member.email }, config.jwtSecret, { expiresIn: "7d" } ); setCookie(event, "auth-token", sessionToken, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: 60 * 60 * 24 * 7, }); // Complete the OIDC interaction const provider = await getOidcProvider(); const result = { login: { accountId: member._id.toString() }, }; await provider.interactionFinished( event.node.req, event.node.res, result, { mergeWithLastSubmission: false } ); });