Files
OpenSquawk/app/pages/auth/callback.vue
itsrubberduck 56d3c991d7 fix(sso): hand the issuer /auth/callback so the code gets redeemed
The auth guard asked the issuer to come back to the page the visitor wanted.
The issuer appends ?code= to whatever URL it is given, but only /auth/callback
redeems a code — so it arrived on the target page, sat there unread, the guard
found no session and bounced back for a fresh code. The browser ping-ponged
between the two hosts until the user gave up.

The guard now hands over /auth/callback and carries the wanted page in its
redirect parameter, which is exactly what that page already expected. A spent
code in the URL is dropped rather than carried along, so a stale link cannot
turn into a redemption error one hop later.

URL building moved into shared/utils/ssoHandoff.ts because the same mistake
existed twice — the retry button on the callback page had it too — and because
a redirect loop deserves a regression test that does not need a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 19:14:20 +02:00

135 lines
3.3 KiB
Vue

<template>
<div class="callback-shell">
<div class="callback-card">
<template v-if="error">
<h1 class="callback-title">Anmeldung fehlgeschlagen</h1>
<p class="callback-text">{{ error }}</p>
<button class="callback-action" type="button" @click="retry">Erneut anmelden</button>
</template>
<template v-else>
<div class="callback-spinner" aria-hidden="true" />
<h1 class="callback-title">Anmeldung wird abgeschlossen </h1>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter, useRuntimeConfig, navigateTo } from '#app'
import { useAuthStore } from '~/stores/auth'
import { buildIssuerLoginUrl } from '~~/shared/utils/ssoHandoff'
// Consumer end of the SSO handoff: the issuer sent the browser here with a
// one-time code. Redeeming it happens server-side (the code alone is useless
// without SERVICE_SECRET); all this page does is trigger it and get out of the
// way.
definePageMeta({ layout: false })
const route = useRoute()
const router = useRouter()
const config = useRuntimeConfig()
const auth = useAuthStore()
const error = ref('')
function safeRedirectTarget(): string {
const raw = String(route.query.redirect || '/')
// Only same-origin paths — never bounce onward to an absolute URL supplied
// in the query string.
return raw.startsWith('/') && !raw.startsWith('//') ? raw : '/'
}
function retry() {
const issuer = String(config.public.authIssuer || '').replace(/\/+$/, '')
if (!issuer) {
return router.replace(`/login?redirect=${encodeURIComponent(safeRedirectTarget())}`)
}
return navigateTo(buildIssuerLoginUrl(issuer, window.location.origin, safeRedirectTarget()), {
external: true,
})
}
onMounted(async () => {
const code = String(route.query.code || '').trim()
if (!code) {
error.value = 'Es wurde kein Anmelde-Code übergeben.'
return
}
try {
await auth.ssoCallback(code)
} catch (err: any) {
error.value = err?.data?.statusMessage
|| err?.statusMessage
|| 'Der Anmelde-Code ist abgelaufen oder wurde bereits verwendet.'
return
}
await router.replace(safeRedirectTarget())
})
</script>
<style scoped>
.callback-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background: #0b1020;
color: #e2e8f0;
}
.callback-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
text-align: center;
max-width: 380px;
}
.callback-title {
font-size: 1.05rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.callback-text {
font-size: 0.9rem;
color: #94a3b8;
line-height: 1.5;
}
.callback-spinner {
width: 34px;
height: 34px;
border-radius: 50%;
border: 2px solid rgba(34, 211, 238, 0.25);
border-top-color: #22d3ee;
animation: callback-spin 0.8s linear infinite;
}
.callback-action {
padding: 9px 18px;
border-radius: 999px;
border: 1px solid rgba(34, 211, 238, 0.45);
background: rgba(34, 211, 238, 0.1);
color: #22d3ee;
font-size: 0.85rem;
cursor: pointer;
}
.callback-action:hover {
background: rgba(34, 211, 238, 0.18);
}
@keyframes callback-spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.callback-spinner { animation-duration: 2.4s; }
}
</style>