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);
|
||||
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);
|
||||
|
|
|
|||
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue