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.
This commit is contained in:
parent
98d3610a08
commit
de3bcc479a
6 changed files with 367 additions and 143 deletions
|
|
@ -174,6 +174,12 @@ p a, blockquote a {
|
||||||
background: var(--surface-hover);
|
background: var(--surface-hover);
|
||||||
border-color: var(--border-d);
|
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 {
|
.btn-primary {
|
||||||
background: var(--candle);
|
background: var(--candle);
|
||||||
color: var(--bg);
|
color: var(--bg);
|
||||||
|
|
|
||||||
121
app/pages/auth/logout-confirm.vue
Normal file
121
app/pages/auth/logout-confirm.vue
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ layout: false });
|
||||||
|
useHead({ title: "Sign Out — Ghost Guild" });
|
||||||
|
|
||||||
|
// The xsrf token comes from a short-lived httpOnly cookie set by
|
||||||
|
// oidc-provider's logoutSource callback (see server/utils/oidc-provider.ts).
|
||||||
|
// We consume it during SSR, persist it into useState so the form input
|
||||||
|
// hydrates correctly on the client, and clear the cookie immediately so the
|
||||||
|
// token is strictly one-time use.
|
||||||
|
const xsrf = useState<string>("oidc-logout-xsrf", () => "");
|
||||||
|
|
||||||
|
if (import.meta.server && !xsrf.value) {
|
||||||
|
const cookie = useCookie("oidc_logout_xsrf");
|
||||||
|
if (cookie.value) {
|
||||||
|
xsrf.value = cookie.value;
|
||||||
|
cookie.value = null;
|
||||||
|
} else {
|
||||||
|
// No active logout flow — somebody hit this page directly. Send them
|
||||||
|
// back to the wiki rather than render a dead form.
|
||||||
|
await navigateTo("https://wiki.ghostguild.org", {
|
||||||
|
external: true,
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="auth-shell">
|
||||||
|
<div class="dashed-box auth-box">
|
||||||
|
<header class="auth-header">
|
||||||
|
<p class="section-label">Ghost Guild</p>
|
||||||
|
<h1 class="auth-title">Sign Out</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<hr class="section-divider" />
|
||||||
|
|
||||||
|
<p class="auth-body">
|
||||||
|
Do you want to sign out of your Ghost Guild session?
|
||||||
|
</p>
|
||||||
|
<p class="auth-sub">
|
||||||
|
This will sign you out of the wiki and any other connected services.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/oidc/session/end/confirm"
|
||||||
|
class="auth-form"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="xsrf" :value="xsrf" />
|
||||||
|
<input type="hidden" name="logout" value="yes" />
|
||||||
|
<button type="submit" class="btn btn-primary auth-btn">
|
||||||
|
Yes, sign me out
|
||||||
|
</button>
|
||||||
|
<a href="https://wiki.ghostguild.org" class="btn auth-btn auth-btn-secondary">
|
||||||
|
Stay signed in
|
||||||
|
</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-shell {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
padding: var(--page-pad-y) var(--page-pad-x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-box {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 24px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--candle);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-body {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.55;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
71
app/pages/auth/logout-success.vue
Normal file
71
app/pages/auth/logout-success.vue
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ layout: false });
|
||||||
|
useHead({ title: "Signed Out — Ghost Guild" });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="auth-shell">
|
||||||
|
<div class="dashed-box auth-box">
|
||||||
|
<header class="auth-header">
|
||||||
|
<p class="section-label">Ghost Guild</p>
|
||||||
|
<h1 class="auth-title">Signed Out</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<hr class="section-divider" />
|
||||||
|
|
||||||
|
<p class="auth-body" role="status">
|
||||||
|
You have been successfully signed out of your session.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a href="https://wiki.ghostguild.org" class="btn btn-primary auth-btn">
|
||||||
|
Return to Wiki
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-shell {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
padding: var(--page-pad-y) var(--page-pad-x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-box {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
padding: 24px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--candle);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-body {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.55;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
115
app/pages/auth/oidc-error.vue
Normal file
115
app/pages/auth/oidc-error.vue
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ layout: false });
|
||||||
|
useHead({ title: "Sign-In Error — Ghost Guild" });
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
// Vue's default {{ }} interpolation escapes HTML on render, so these
|
||||||
|
// values from the query string can never execute as markup — fixing the
|
||||||
|
// XSS that existed in the old guildPageShell renderError implementation.
|
||||||
|
const errorCode = computed(() =>
|
||||||
|
typeof route.query.error === "string" ? route.query.error : "",
|
||||||
|
);
|
||||||
|
const errorDescription = computed(() =>
|
||||||
|
typeof route.query.error_description === "string"
|
||||||
|
? route.query.error_description
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
const hasDetail = computed(
|
||||||
|
() => Boolean(errorCode.value) || Boolean(errorDescription.value),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="auth-shell">
|
||||||
|
<div class="dashed-box auth-box">
|
||||||
|
<header class="auth-header">
|
||||||
|
<p class="section-label">Ghost Guild</p>
|
||||||
|
<h1 class="auth-title">Something went wrong</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<hr class="section-divider" />
|
||||||
|
|
||||||
|
<p class="auth-body">
|
||||||
|
An error occurred during authentication. Please try again.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="hasDetail" class="auth-detail" role="status">
|
||||||
|
<p v-if="errorCode" class="auth-detail-code">{{ errorCode }}</p>
|
||||||
|
<p v-if="errorDescription" class="auth-detail-desc">
|
||||||
|
{{ errorDescription }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="https://wiki.ghostguild.org" class="btn btn-primary auth-btn">
|
||||||
|
Return to Wiki
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-shell {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
padding: var(--page-pad-y) var(--page-pad-x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-box {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 24px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--candle);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-body {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.55;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-detail {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: left;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-detail-code {
|
||||||
|
color: var(--ember);
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-detail-desc {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -17,8 +17,17 @@ export default defineEventHandler(async (event) => {
|
||||||
// The provider's routes config includes the /oidc prefix,
|
// The provider's routes config includes the /oidc prefix,
|
||||||
// so pass the full path through without stripping.
|
// so pass the full path through without stripping.
|
||||||
|
|
||||||
// Traefik terminates TLS — tell the provider we're on https
|
// In production, Traefik sets X-Forwarded-Proto: https. Keep a defensive
|
||||||
req.headers["x-forwarded-proto"] = "https";
|
// 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
|
// Hand off to oidc-provider's Connect-style callback
|
||||||
const callback = provider.callback() as Function;
|
const callback = provider.callback() as Function;
|
||||||
|
|
|
||||||
|
|
@ -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')
|
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;
|
let _provider: InstanceType<typeof Provider> | null = null;
|
||||||
|
|
||||||
export async function getOidcProvider() {
|
export async function getOidcProvider() {
|
||||||
|
|
@ -134,9 +26,6 @@ export async function getOidcProvider() {
|
||||||
_provider = new Provider(issuer, {
|
_provider = new Provider(issuer, {
|
||||||
adapter: MongoAdapter,
|
adapter: MongoAdapter,
|
||||||
|
|
||||||
// Trust X-Forwarded-Proto from Traefik reverse proxy
|
|
||||||
proxy: true,
|
|
||||||
|
|
||||||
clients: [
|
clients: [
|
||||||
{
|
{
|
||||||
client_id: process.env.OIDC_CLIENT_ID || "outline-wiki",
|
client_id: process.env.OIDC_CLIENT_ID || "outline-wiki",
|
||||||
|
|
@ -204,27 +93,35 @@ export async function getOidcProvider() {
|
||||||
rpInitiatedLogout: {
|
rpInitiatedLogout: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
logoutSource: async (ctx: any, form: string) => {
|
logoutSource: async (ctx: any, form: string) => {
|
||||||
// oidc-provider generates http:// form actions behind reverse proxy
|
// oidc-provider's form HTML is a stable format (see node_modules/
|
||||||
const secureForm = form.replace('http://ghostguild.org', 'https://ghostguild.org');
|
// oidc-provider/lib/actions/end_session.js:90):
|
||||||
ctx.body = guildPageShell("Sign Out", `
|
// <form id="op.logoutForm" method="post" action="..."><input
|
||||||
<h1>Sign Out</h1>
|
// type="hidden" name="xsrf" value="HEX"/></form>
|
||||||
<p>Do you want to sign out of your Ghost Guild session?</p>
|
// We extract just the xsrf token and hand off to a Nuxt page at
|
||||||
<p class="subtext">This will sign you out of the wiki and any other connected services.</p>
|
// /auth/logout-confirm that renders a styled form posting back to
|
||||||
${secureForm}
|
// /oidc/session/end/confirm with that xsrf value. The token rides
|
||||||
<div class="actions">
|
// in a short-lived httpOnly cookie so it never hits the URL.
|
||||||
<button class="btn-primary" form="op.logoutForm" type="submit" value="yes" name="logout">Yes, sign me out</button>
|
const match = form.match(/name="xsrf"\s+value="([^"]+)"/);
|
||||||
<a class="btn-secondary" href="https://wiki.ghostguild.org">Stay signed in</a>
|
if (!match) {
|
||||||
</div>
|
// Defensive: if oidc-provider ever changes its form format, fall
|
||||||
`, "form#op\\.logoutForm { display: none; }");
|
// 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) => {
|
postLogoutSuccessSource: async (ctx: any) => {
|
||||||
ctx.body = guildPageShell("Signed Out", `
|
ctx.redirect("/auth/logout-success");
|
||||||
<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>
|
|
||||||
`);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -252,17 +149,15 @@ export async function getOidcProvider() {
|
||||||
},
|
},
|
||||||
|
|
||||||
renderError: async (ctx: any, out: Record<string, string>, _error: Error) => {
|
renderError: async (ctx: any, out: Record<string, string>, _error: Error) => {
|
||||||
const details = Object.entries(out)
|
// Allow-list only the standard OIDC error response fields. Prevents
|
||||||
.map(([key, value]) => `<strong>${key}:</strong> ${value}`)
|
// leaking internal error messages / stack traces, keeps the query
|
||||||
.join("<br>");
|
// string short, and the Nuxt page escapes them on render via Vue's
|
||||||
ctx.body = guildPageShell("Something Went Wrong", `
|
// default interpolation (fixes the prior XSS via unescaped HTML
|
||||||
<h1>Something Went Wrong</h1>
|
// interpolation in the old guildPageShell implementation).
|
||||||
<p>An error occurred during authentication. Please try again.</p>
|
const params = new URLSearchParams();
|
||||||
<div class="error-detail">${details}</div>
|
if (out.error) params.set("error", out.error);
|
||||||
<div class="actions">
|
if (out.error_description) params.set("error_description", out.error_description);
|
||||||
<a class="btn-primary" href="https://wiki.ghostguild.org">Return to Wiki</a>
|
ctx.redirect(`/auth/oidc-error?${params.toString()}`);
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Allow Outline to use PKCE but don't require it
|
// 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;
|
return _provider;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue