ghostguild-org/server/routes/oidc/interaction/verify.get.ts
Jennie Robinson Faber 8a529a8e7c Add OIDC provider for Outline wiki SSO
Add oidc-provider with MongoDB adapter so ghostguild.org can act as
the identity provider for the self-hosted Outline wiki. Members
authenticate via the existing magic-link flow, with automatic SSO
when an active session exists. Includes interaction routes, well-known
discovery endpoint, and login page.
2026-03-01 15:46:01 +00:00

75 lines
2 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 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 }
);
});