ghostguild-org/server/utils/oidc-provider.ts
Jennie Robinson Faber 39eb9e039a
Some checks failed
Test / vitest (push) Failing after 6m9s
Test / playwright (push) Has been skipped
Test / visual (push) Has been skipped
Test / Notify on failure (push) Successful in 2s
fix(auth): auto-submit OIDC logout form to eliminate xsrf desync
Users clicking sign-out in the wiki were getting 'xsrf token invalid'.
The old logoutSource extracted the xsrf from oidc-provider's form into
a separate short-lived cookie and bounced through /auth/logout-confirm,
but that dance kept desyncing — the xsrf on the eventual submit didn't
always match the session state on /oidc/session/end/confirm.

Drop the custom confirmation page and auto-submit oidc-provider's own
form inline from logoutSource. The xsrf stays inside the original form
HTML the provider generated, so the validation is guaranteed to match.
Clicking sign-out in the wiki is already confirmation enough.

Also clear the Ghost Guild auth-token cookie in postLogoutSuccessSource
so signing out of the wiki fully signs the user out rather than leaving
a stale ghostguild.org session behind.
2026-04-15 18:26:51 +01:00

197 lines
6.7 KiB
TypeScript

/**
* OIDC Provider configuration for Ghost Guild.
*
* ghostguild.org acts as the identity provider. Outline wiki is the sole
* relying party (client). Members authenticate via the existing magic-link
* flow, and the provider issues standard OIDC tokens so Outline can identify
* them.
*/
import Provider from "oidc-provider";
import { MongoAdapter } from "./oidc-mongodb-adapter.js";
import Member from "../models/member.js";
import { connectDB } from "./mongoose.js";
if (process.env.NODE_ENV === 'production' && !process.env.OIDC_COOKIE_SECRET) {
throw new Error('OIDC_COOKIE_SECRET must be set in production')
}
let _provider: InstanceType<typeof Provider> | null = null;
export async function getOidcProvider() {
if (_provider) return _provider;
const config = useRuntimeConfig();
const issuer = process.env.OIDC_ISSUER || "https://ghostguild.org";
_provider = new Provider(issuer, {
adapter: MongoAdapter,
clients: [
{
client_id: process.env.OIDC_CLIENT_ID || "outline-wiki",
client_secret: process.env.OIDC_CLIENT_SECRET || "",
redirect_uris: [
"https://wiki.ghostguild.org/auth/oidc.callback",
// Local development callback
"http://localhost:3100/auth/oidc.callback",
],
post_logout_redirect_uris: [
"https://wiki.ghostguild.org",
"http://localhost:3100",
],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "client_secret_post",
},
],
claims: {
openid: ["sub"],
profile: ["name", "preferred_username"],
email: ["email", "email_verified"],
},
scopes: ["openid", "profile", "email", "offline_access"],
findAccount: async (_ctx: unknown, id: string) => {
await connectDB();
const member = await (Member as any).findById(id);
if (!member) return undefined;
return {
accountId: id,
async claims(_use: string, _scope: string) {
return {
sub: id,
name: member.name,
preferred_username: member.name,
email: member.email,
email_verified: true,
};
},
};
},
cookies: {
keys: (process.env.OIDC_COOKIE_SECRET || "dev-cookie-secret").split(","),
},
ttl: {
AccessToken: 3600, // 1 hour
AuthorizationCode: 600, // 10 minutes
RefreshToken: 14 * 24 * 60 * 60, // 14 days
Session: 14 * 24 * 60 * 60, // 14 days
Interaction: 900, // 15 minutes — must match magic-link JWT TTL so the interaction outlives the token
Grant: 14 * 24 * 60 * 60, // 14 days
},
features: {
devInteractions: {
enabled: process.env.NODE_ENV !== "production",
},
revocation: { enabled: true },
rpInitiatedLogout: {
enabled: true,
logoutSource: async (ctx: any, form: string) => {
// Auto-submit oidc-provider's own form so the xsrf value stays
// inside the same request cycle that generated it. The previous
// approach extracted the xsrf into a separate cookie and bounced
// through a Nuxt page for a "are you sure?" confirmation, which
// kept desyncing and producing "xsrf token invalid" errors.
// Clicking sign-out in the wiki is already confirmation enough.
ctx.type = "html";
ctx.status = 200;
ctx.body = `<!DOCTYPE html>
<html>
<head>
<title>Signing out — Ghost Guild</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #1a1814; color: #e8d9b8; font-family: "Commit Mono", ui-monospace, monospace; }
body { display: grid; place-items: center; }
p { font-size: 13px; letter-spacing: 0.05em; text-transform: uppercase; color: #b09c76; }
form { display: none; }
</style>
</head>
<body>
<p>Signing you out…</p>
${form}
<script>document.getElementById('op.logoutForm').submit()</script>
</body>
</html>`;
},
postLogoutSuccessSource: async (ctx: any) => {
// Kill the Ghost Guild session cookie so the user is fully signed
// out, not just logged out of Outline.
ctx.cookies.set("auth-token", null, {
httpOnly: true,
sameSite: "lax",
path: "/",
overwrite: true,
signed: false,
});
ctx.redirect("/auth/logout-success");
},
},
},
// Mount all OIDC endpoints under /oidc prefix
routes: {
authorization: "/oidc/auth",
backchannel_authentication: "/oidc/backchannel",
code_verification: "/oidc/device",
device_authorization: "/oidc/device/auth",
end_session: "/oidc/session/end",
introspection: "/oidc/token/introspection",
jwks: "/oidc/jwks",
pushed_authorization_request: "/oidc/request",
registration: "/oidc/reg",
revocation: "/oidc/token/revocation",
token: "/oidc/token",
userinfo: "/oidc/me",
},
interactions: {
url(_ctx: unknown, interaction: { uid: string }) {
return `/oidc/interaction/${interaction.uid}`;
},
},
renderError: async (ctx: any, out: Record<string, string>, _error: Error) => {
// Allow-list only the standard OIDC error response fields. Prevents
// leaking internal error messages / stack traces, keeps the query
// string short, and the Nuxt page escapes them on render via Vue's
// default interpolation (fixes the prior XSS via unescaped HTML
// interpolation in the old guildPageShell implementation).
const params = new URLSearchParams();
if (out.error) params.set("error", out.error);
if (out.error_description) params.set("error_description", out.error_description);
ctx.redirect(`/auth/oidc-error?${params.toString()}`);
},
// Allow Outline to use PKCE but don't require it
pkce: {
required: () => false,
},
// Skip consent for our first-party Outline client
loadExistingGrant: async (ctx: any) => {
const grant = new (ctx.oidc.provider.Grant as any)({
accountId: ctx.oidc.session!.accountId,
clientId: ctx.oidc.client!.clientId,
});
grant.addOIDCScope("openid profile email");
await grant.save();
return grant;
},
});
// oidc-provider extends Koa but calls super() with no args, so app.proxy
// defaults to false — which makes ctx.protocol ignore X-Forwarded-Proto and
// emit http:// URLs for form actions, discovery metadata, authorization
// redirects, etc. Setting proxy = true here makes Koa trust Traefik's
// X-Forwarded-Proto header and build https:// URLs in production.
(_provider as any).proxy = true;
return _provider;
}