ghostguild-org/server/routes/oidc/interaction/[uid].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

94 lines
2.8 KiB
TypeScript

/**
* OIDC interaction handler — checks for an existing Ghost Guild session.
*
* Flow:
* 1. Outline redirects user to /oidc/auth
* 2. oidc-provider creates an interaction and redirects here
* 3. If the user has a valid auth-token cookie → complete the interaction (SSO)
* 4. Otherwise → redirect to the OIDC login page
*/
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 provider = await getOidcProvider();
const uid = getRouterParam(event, "uid")!;
// Load the interaction details from oidc-provider
const interactionDetails = await provider.interactionDetails(
event.node.req,
event.node.res
);
const { prompt } = interactionDetails;
// ----- Login prompt -----
if (prompt.name === "login") {
// Check for existing Ghost Guild session
const token = getCookie(event, "auth-token");
if (token) {
try {
const config = useRuntimeConfig();
const decoded = jwt.verify(token, config.jwtSecret) as {
memberId: string;
};
await connectDB();
const member = await (Member as any).findById(decoded.memberId);
if (
member &&
member.status !== "suspended" &&
member.status !== "cancelled"
) {
// Auto-complete the login interaction (SSO)
const result = {
login: { accountId: member._id.toString() },
};
await provider.interactionFinished(
event.node.req,
event.node.res,
result,
{ mergeWithLastSubmission: false }
);
return;
}
} catch {
// Token invalid — fall through to login page
}
}
// No valid session — redirect to login page
return sendRedirect(event, `/oidc/login?uid=${uid}`, 302);
}
// ----- Consent prompt -----
if (prompt.name === "consent") {
// Auto-approve consent for our first-party client
const grant = interactionDetails.grantId
? await provider.Grant.find(interactionDetails.grantId)
: new provider.Grant({
accountId: interactionDetails.session!.accountId,
clientId: interactionDetails.params.client_id as string,
});
if (grant) {
grant.addOIDCScope("openid profile email");
await grant.save();
const result = { consent: { grantId: grant.jti } };
await provider.interactionFinished(
event.node.req,
event.node.res,
result,
{ mergeWithLastSubmission: true }
);
return;
}
}
// Fallback — shouldn't reach here normally
throw createError({ statusCode: 400, statusMessage: "Unknown interaction" });
});