From 3ad22a8b67b6ebf21a768da697b4c11dc1411da1 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Wed, 15 Apr 2026 18:18:33 +0100 Subject: [PATCH] fix(auth): survive missing OIDC interaction cookie on magic-link click Clicking the wiki magic-link email was producing SessionNotFound: 'interaction session id cookie not found' from provider.interactionFinished, because that call requires the short-lived _interaction cookie to be present on the request. It isn't, when: - the user clicks the email on a different device or browser - the interaction cookie already expired - the user is in private/incognito browsing Those unhandled errors previously bounced to /coming-soon via the coming-soon middleware, stranding users on the pre-register page. Instead of relying on the interaction cookie at the magic-link step: 1. Verify the JWT, look up the member, set the auth-token cookie. 2. Redirect the user back to https://wiki.ghostguild.org. 3. Outline re-initiates OIDC, which creates a fresh interaction whose cookie IS present on the same request, and [uid].get.ts SSOs the user in via the auth-token cookie we just set. Also swap the createError throws for sendRedirect to /auth/oidc-error so token/member/status failures land on the styled error page rather than Nitro's default unhandled-error response. --- server/routes/oidc/interaction/verify.get.ts | 57 ++++++++++---------- 1 file changed, 30 insertions(+), 27 deletions(-) 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); });