From de3bcc479afcc7747516c05153d3ad0bbd4546ef Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sat, 11 Apr 2026 23:21:46 +0100 Subject: [PATCH] fix(auth): rewire OIDC logout/error flow through Nuxt pages 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. --- app/assets/css/main.css | 6 + app/pages/auth/logout-confirm.vue | 121 ++++++++++++++++++++ app/pages/auth/logout-success.vue | 71 ++++++++++++ app/pages/auth/oidc-error.vue | 115 +++++++++++++++++++ server/routes/oidc/[...].ts | 13 ++- server/utils/oidc-provider.ts | 184 +++++++----------------------- 6 files changed, 367 insertions(+), 143 deletions(-) create mode 100644 app/pages/auth/logout-confirm.vue create mode 100644 app/pages/auth/logout-success.vue create mode 100644 app/pages/auth/oidc-error.vue diff --git a/app/assets/css/main.css b/app/assets/css/main.css index 16dfa71..ff66d93 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -174,6 +174,12 @@ p a, blockquote a { background: var(--surface-hover); border-color: var(--border-d); } +/* WCAG 2.4.7 — keyboard focus must be visibly indicated. Dashed outline + echoes the design system's zine/dashed aesthetic. */ +.btn:focus-visible { + outline: 2px dashed var(--candle); + outline-offset: 3px; +} .btn-primary { background: var(--candle); color: var(--bg); diff --git a/app/pages/auth/logout-confirm.vue b/app/pages/auth/logout-confirm.vue new file mode 100644 index 0000000..3061387 --- /dev/null +++ b/app/pages/auth/logout-confirm.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/app/pages/auth/logout-success.vue b/app/pages/auth/logout-success.vue new file mode 100644 index 0000000..6b6608e --- /dev/null +++ b/app/pages/auth/logout-success.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/app/pages/auth/oidc-error.vue b/app/pages/auth/oidc-error.vue new file mode 100644 index 0000000..9a060c4 --- /dev/null +++ b/app/pages/auth/oidc-error.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/server/routes/oidc/[...].ts b/server/routes/oidc/[...].ts index 39528f0..bd41dd6 100644 --- a/server/routes/oidc/[...].ts +++ b/server/routes/oidc/[...].ts @@ -17,8 +17,17 @@ export default defineEventHandler(async (event) => { // The provider's routes config includes the /oidc prefix, // so pass the full path through without stripping. - // Traefik terminates TLS — tell the provider we're on https - req.headers["x-forwarded-proto"] = "https"; + // In production, Traefik sets X-Forwarded-Proto: https. Keep a defensive + // assignment only if the header isn't already present, and never in dev + // (where forcing https would make oidc-provider emit https://localhost URLs + // that the browser can't reach). The provider has app.proxy = true, so it + // honors whatever value is in this header. + if ( + process.env.NODE_ENV === "production" && + !req.headers["x-forwarded-proto"] + ) { + req.headers["x-forwarded-proto"] = "https"; + } // Hand off to oidc-provider's Connect-style callback const callback = provider.callback() as Function; diff --git a/server/utils/oidc-provider.ts b/server/utils/oidc-provider.ts index 2c9fafe..71af11b 100644 --- a/server/utils/oidc-provider.ts +++ b/server/utils/oidc-provider.ts @@ -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 ` - - - - - ${title} — Ghost Guild - - - -
- ${bodyContent} -
- -`; -} - let _provider: InstanceType | 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", ` -

Sign Out

-

Do you want to sign out of your Ghost Guild session?

-

This will sign you out of the wiki and any other connected services.

- ${secureForm} -
- - Stay signed in -
- `, "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): + //
+ // 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 = `${form}`; + 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", ` -

Signed Out

-

You have been successfully signed out.

- - `); + ctx.redirect("/auth/logout-success"); }, }, }, @@ -252,17 +149,15 @@ export async function getOidcProvider() { }, renderError: async (ctx: any, out: Record, _error: Error) => { - const details = Object.entries(out) - .map(([key, value]) => `${key}: ${value}`) - .join("
"); - ctx.body = guildPageShell("Something Went Wrong", ` -

Something Went Wrong

-

An error occurred during authentication. Please try again.

-
${details}
- - `); + // 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; }