Migrate three render callbacks in oidc-provider (logoutSource,
postLogoutSuccessSource, renderError) from the baked guildPageShell
helper to Nuxt pages under app/pages/auth/, so they go through the
font module and design system instead of a shadow copy.
- Delete guildPageShell (~103 lines of shadow design system).
- Add /auth/logout-success, /auth/oidc-error, /auth/logout-confirm
pages built on dashed-box + btn + main.css tokens.
- renderError now allow-lists error + error_description into query
params and lets Vue default interpolation escape them, closing an
XSS where OIDC error fields were concatenated into raw HTML.
- logoutSource extracts the xsrf from oidc-provider's stable form
output, sets it as an httpOnly 2-minute cookie, and redirects to
/auth/logout-confirm. The confirm page reads the cookie during SSR,
persists the value to useState, and clears the cookie so it's
strictly one-time use. Defensive fallback keeps the raw auto-submit
form if oidc-provider ever changes its form format.
- Fix form actions emitting http:// in production at the root cause:
oidc-provider extends Koa but calls super() with no args, so
app.proxy defaults to false and ctx.protocol ignores
X-Forwarded-Proto. Set _provider.proxy = true after construction;
remove the bogus proxy:true config key (silently ignored) and the
form.replace('http://', 'https://') symptom patch. Make the
x-forwarded-proto override in the catchall conditional on
production + missing header (was unconditional + dead code).
- Add site-wide .btn:focus-visible rule in main.css for WCAG 2.4.7.
Verified in browser: Brygada 1918 loads on all three pages, contrast
ratios pass AA in dark + light, XSS payload escapes to text nodes
only, Set-Cookie: Max-Age=0 enforces one-time xsrf use, no
horizontal overflow at 500px, no console errors.
188 lines
6.5 KiB
TypeScript
188 lines
6.5 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: 600, // 10 minutes
|
|
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) => {
|
|
// oidc-provider's form HTML is a stable format (see node_modules/
|
|
// oidc-provider/lib/actions/end_session.js:90):
|
|
// <form id="op.logoutForm" method="post" action="..."><input
|
|
// type="hidden" name="xsrf" value="HEX"/></form>
|
|
// We extract just the xsrf token and hand off to a Nuxt page at
|
|
// /auth/logout-confirm that renders a styled form posting back to
|
|
// /oidc/session/end/confirm with that xsrf value. The token rides
|
|
// in a short-lived httpOnly cookie so it never hits the URL.
|
|
const match = form.match(/name="xsrf"\s+value="([^"]+)"/);
|
|
if (!match) {
|
|
// Defensive: if oidc-provider ever changes its form format, fall
|
|
// back to the raw form so logout still works.
|
|
ctx.type = "html";
|
|
ctx.status = 200;
|
|
ctx.body = `<!DOCTYPE html><html><body>${form}<script>document.getElementById('op.logoutForm').submit()</script></body></html>`;
|
|
return;
|
|
}
|
|
ctx.cookies.set("oidc_logout_xsrf", match[1], {
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
maxAge: 120_000, // 2 minutes
|
|
path: "/",
|
|
overwrite: true,
|
|
signed: false,
|
|
});
|
|
ctx.redirect("/auth/logout-confirm");
|
|
},
|
|
postLogoutSuccessSource: async (ctx: any) => {
|
|
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;
|
|
}
|