ghostguild-org/server/routes/oidc/interaction/verify.get.ts
Jennie Robinson Faber 3ad22a8b67
Some checks failed
Test / vitest (push) Failing after 6m13s
Test / visual (push) Has been skipped
Test / playwright (push) Has been skipped
Test / Notify on failure (push) Successful in 3s
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.
2026-04-15 18:18:33 +01:00

78 lines
2.6 KiB
TypeScript

/**
* 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);
});