fix(auth): rewire OIDC logout/error flow through Nuxt pages
Some checks failed
Test / playwright (push) Blocked by required conditions
Test / Notify on failure (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
Test / vitest (push) Has been cancelled

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.
This commit is contained in:
Jennie Robinson Faber 2026-04-11 23:21:46 +01:00
parent 98d3610a08
commit de3bcc479a
6 changed files with 367 additions and 143 deletions

View file

@ -15,114 +15,6 @@ if (process.env.NODE_ENV === 'production' && !process.env.OIDC_COOKIE_SECRET) {
throw new Error('OIDC_COOKIE_SECRET must be set in production')
}
/**
* Renders a standalone HTML page in the guild dark style.
* Used for OIDC logout/error screens that are served outside Nuxt.
*/
function guildPageShell(title: string, bodyContent: string, extraStyles = "") {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title} Ghost Guild</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: #1a1510;
background-image:
radial-gradient(ellipse at 20% 50%, rgba(154, 111, 44, 0.06) 0%, transparent 60%),
radial-gradient(ellipse at 80% 50%, rgba(154, 111, 44, 0.04) 0%, transparent 60%);
color: #bfb3a2;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.card {
background-color: #2a241c;
border: 1px solid rgba(154, 111, 44, 0.15);
border-radius: 12px;
box-shadow: 0 0 30px rgba(208, 158, 78, 0.06);
padding: 2.5rem;
width: 100%;
max-width: 420px;
text-align: center;
}
h1 {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-size: 1.5rem;
font-weight: 700;
color: #d09e4e;
margin-bottom: 0.75rem;
}
p { line-height: 1.6; margin-bottom: 1rem; }
.subtext { font-size: 0.875rem; color: #6b5f4d; }
.btn-primary {
display: inline-block;
background-color: #d09e4e;
color: #1a1510;
padding: 0.625rem 1.5rem;
border-radius: 6px;
border: none;
font-size: 0.875rem;
font-weight: 700;
cursor: pointer;
text-decoration: none;
transition: background-color 0.15s;
}
.btn-primary:hover { background-color: #e0b86e; }
.btn-secondary {
display: inline-block;
background-color: transparent;
color: #f0ebe4;
padding: 0.625rem 1.5rem;
border-radius: 6px;
border: 1px solid rgba(208, 158, 78, 0.4);
font-size: 0.875rem;
font-weight: 400;
cursor: pointer;
text-decoration: none;
transition: border-color 0.15s, color 0.15s;
}
.btn-secondary:hover {
border-color: rgba(224, 184, 110, 0.6);
color: #f5e6c5;
}
.actions { display: flex; gap: 0.75rem; justify-content: center; margin-top: 1.5rem; }
.brand {
margin-top: 2rem;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-size: 0.75rem;
font-variant: small-caps;
letter-spacing: 0.05em;
color: #6b5f4d;
}
.error-detail {
margin-top: 1rem;
background-color: #1a1510;
border: 1px solid rgba(154, 111, 44, 0.1);
border-radius: 6px;
padding: 1rem;
font-family: 'Ubuntu Mono', 'Courier New', monospace;
font-size: 0.75rem;
color: #6b5f4d;
text-align: left;
word-break: break-word;
}
${extraStyles}
</style>
</head>
<body>
<div class="card">
${bodyContent}
</div>
</body>
</html>`;
}
let _provider: InstanceType<typeof Provider> | null = null;
export async function getOidcProvider() {
@ -134,9 +26,6 @@ export async function getOidcProvider() {
_provider = new Provider(issuer, {
adapter: MongoAdapter,
// Trust X-Forwarded-Proto from Traefik reverse proxy
proxy: true,
clients: [
{
client_id: process.env.OIDC_CLIENT_ID || "outline-wiki",
@ -204,27 +93,35 @@ export async function getOidcProvider() {
rpInitiatedLogout: {
enabled: true,
logoutSource: async (ctx: any, form: string) => {
// oidc-provider generates http:// form actions behind reverse proxy
const secureForm = form.replace('http://ghostguild.org', 'https://ghostguild.org');
ctx.body = guildPageShell("Sign Out", `
<h1>Sign Out</h1>
<p>Do you want to sign out of your Ghost Guild session?</p>
<p class="subtext">This will sign you out of the wiki and any other connected services.</p>
${secureForm}
<div class="actions">
<button class="btn-primary" form="op.logoutForm" type="submit" value="yes" name="logout">Yes, sign me out</button>
<a class="btn-secondary" href="https://wiki.ghostguild.org">Stay signed in</a>
</div>
`, "form#op\\.logoutForm { display: none; }");
// 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.body = guildPageShell("Signed Out", `
<h1>Signed Out</h1>
<p>You have been successfully signed out.</p>
<div class="actions">
<a class="btn-primary" href="https://wiki.ghostguild.org">Return to Wiki</a>
</div>
`);
ctx.redirect("/auth/logout-success");
},
},
},
@ -252,17 +149,15 @@ export async function getOidcProvider() {
},
renderError: async (ctx: any, out: Record<string, string>, _error: Error) => {
const details = Object.entries(out)
.map(([key, value]) => `<strong>${key}:</strong> ${value}`)
.join("<br>");
ctx.body = guildPageShell("Something Went Wrong", `
<h1>Something Went Wrong</h1>
<p>An error occurred during authentication. Please try again.</p>
<div class="error-detail">${details}</div>
<div class="actions">
<a class="btn-primary" href="https://wiki.ghostguild.org">Return to Wiki</a>
</div>
`);
// 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
@ -282,5 +177,12 @@ export async function getOidcProvider() {
},
});
// 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;
}