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.
This commit is contained in:
parent
1e9e9c4d97
commit
3ad22a8b67
1 changed files with 30 additions and 27 deletions
|
|
@ -5,19 +5,28 @@
|
||||||
*
|
*
|
||||||
* This is the endpoint the magic link email points to. It:
|
* This is the endpoint the magic link email points to. It:
|
||||||
* 1. Verifies the JWT token
|
* 1. Verifies the JWT token
|
||||||
* 2. Sets the Ghost Guild session cookie (so future logins are SSO)
|
* 2. Sets the Ghost Guild auth-token cookie
|
||||||
* 3. Completes the OIDC interaction so the user is redirected back to Outline
|
* 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 jwt from "jsonwebtoken";
|
||||||
import Member from "../../../models/member.js";
|
import Member from "../../../models/member.js";
|
||||||
import { connectDB } from "../../../utils/mongoose.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) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const { token } = getQuery(event);
|
const { token } = getQuery(event);
|
||||||
|
|
||||||
if (!token) {
|
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);
|
const config = useRuntimeConfig(event);
|
||||||
|
|
@ -26,29 +35,30 @@ export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
decoded = jwt.verify(token as string, config.jwtSecret) as typeof decoded;
|
decoded = jwt.verify(token as string, config.jwtSecret) as typeof decoded;
|
||||||
} catch {
|
} catch {
|
||||||
throw createError({
|
return sendRedirect(
|
||||||
statusCode: 401,
|
event,
|
||||||
statusMessage: "Invalid or expired token",
|
OIDC_ERROR("This login link is invalid or has expired. Please request a new one."),
|
||||||
});
|
302
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await connectDB();
|
await connectDB();
|
||||||
const member = await (Member as any).findById(decoded.memberId);
|
const member = await (Member as any).findById(decoded.memberId);
|
||||||
|
|
||||||
if (!member) {
|
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") {
|
if (member.status === "suspended" || member.status === "cancelled") {
|
||||||
throw createError({
|
return sendRedirect(
|
||||||
statusCode: 403,
|
event,
|
||||||
statusMessage: `Account is ${member.status}`,
|
OIDC_ERROR(`Account is ${member.status}.`),
|
||||||
});
|
302
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set Ghost Guild session cookie for future SSO
|
|
||||||
const sessionToken = jwt.sign(
|
const sessionToken = jwt.sign(
|
||||||
{ memberId: member._id, email: member.email },
|
{ memberId: member._id, email: member.email, tv: member.tokenVersion },
|
||||||
config.jwtSecret,
|
config.jwtSecret,
|
||||||
{ expiresIn: "7d" }
|
{ expiresIn: "7d" }
|
||||||
);
|
);
|
||||||
|
|
@ -57,19 +67,12 @@ export default defineEventHandler(async (event) => {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
maxAge: 60 * 60 * 24 * 7,
|
maxAge: 60 * 60 * 24 * 7,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Complete the OIDC interaction
|
// Send the user back to the wiki. Outline will bounce them to /oidc/auth,
|
||||||
const provider = await getOidcProvider();
|
// which creates a fresh interaction whose cookie WILL be present, and then
|
||||||
const result = {
|
// [uid].get.ts sees the auth-token we just set and SSOs them through.
|
||||||
login: { accountId: member._id.toString() },
|
return sendRedirect(event, "https://wiki.ghostguild.org", 302);
|
||||||
};
|
|
||||||
|
|
||||||
await provider.interactionFinished(
|
|
||||||
event.node.req,
|
|
||||||
event.node.res,
|
|
||||||
result,
|
|
||||||
{ mergeWithLastSubmission: false }
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue