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 @@
+
+
+
+
+
+
+
+
+
+
+ Do you want to sign out of your Ghost Guild session?
+
+
+ This will sign you out of the wiki and any other connected services.
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+ You have been successfully signed out of your session.
+
+
+
+ Return to Wiki
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+ An error occurred during authentication. Please try again.
+
+
+
+
{{ errorCode }}
+
+ {{ errorDescription }}
+
+
+
+
+ Return to Wiki
+
+
+
+
+
+
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}
-
- `, "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;
}