/** * 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 | 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: false }, 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 = ` Signing out — Ghost Guild

Signing you out…

${form} `; }, 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, _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; }