diff --git a/server/routes/oidc/interaction/verify.get.ts b/server/routes/oidc/interaction/verify.get.ts index 30b447b..8dbeacd 100644 --- a/server/routes/oidc/interaction/verify.get.ts +++ b/server/routes/oidc/interaction/verify.get.ts @@ -5,19 +5,28 @@ * * 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 + * 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"; -import { getOidcProvider } from "../../../utils/oidc-provider.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) { - throw createError({ statusCode: 400, statusMessage: "Token is required" }); + return sendRedirect(event, OIDC_ERROR("Login link is missing its token."), 302); } const config = useRuntimeConfig(event); @@ -26,29 +35,30 @@ export default defineEventHandler(async (event) => { try { decoded = jwt.verify(token as string, config.jwtSecret) as typeof decoded; } catch { - throw createError({ - statusCode: 401, - statusMessage: "Invalid or expired token", - }); + 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) { - throw createError({ statusCode: 404, statusMessage: "Member not found" }); + return sendRedirect(event, OIDC_ERROR("Member not found."), 302); } if (member.status === "suspended" || member.status === "cancelled") { - throw createError({ - statusCode: 403, - statusMessage: `Account is ${member.status}`, - }); + return sendRedirect( + event, + OIDC_ERROR(`Account is ${member.status}.`), + 302 + ); } - // Set Ghost Guild session cookie for future SSO const sessionToken = jwt.sign( - { memberId: member._id, email: member.email }, + { memberId: member._id, email: member.email, tv: member.tokenVersion }, config.jwtSecret, { expiresIn: "7d" } ); @@ -57,19 +67,12 @@ export default defineEventHandler(async (event) => { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", + path: "/", 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 } - ); + // 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); });