Bisher war eine SSO-Anmeldung ohne passendes Konto eine Sackgasse: rote Fehlermeldung, kein Weg weiter. Die Verknuepfung ging nur ueber LINK_BY_EMAIL, das der E-Mail-Verwaltung des Providers vertrauen muss. Jetzt endet der Callback bei einer besitzerlosen Identitaet nicht im Fehler, sondern parkt sie in einem kurzlebigen signierten HttpOnly-Cookie und leitet auf /?sso_link=1. Der Login-Screen bittet daraufhin einmalig um das Calendarr-Passwort; nach erfolgreichem Login loest der Client POST /api/auth/oidc/link-pending ein und die Identitaet haengt an genau dem Konto, das sich gerade ausgewiesen hat. Beide Haelften sind damit bewiesen — das ID-Token haben wir selbst geprueft, das Konto durch das Passwort — und die Vertrauensannahme ueber die E-Mail entfaellt. Das greift fuer alle drei Faelle, in denen eine gueltige Identitaet keinen Besitzer hat: kein Konto vorhanden, Selbstregistrierung aus, oder E-Mail bereits vergeben. Ausserdem: unbekannte Fehler-Slugs werden jetzt mit angezeigt statt hinter "Anmeldung fehlgeschlagen" zu verschwinden — genau die Information, die zur Diagnose fehlte. Und oidc_token_exchange_failed hat eine eigene Meldung, die auf Client-Typ und Redirect-URI hinweist, weil das der haeufigste Konfigurationsfehler ist. Sechs Tests dazu; Ablauf zusaetzlich im Browser gegen einen echten OIDC-Stub-Provider durchgespielt: Hinweis statt Fehler, Verknuepfung nach Passwort-Login, danach Anmeldung allein per SSO. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
125 lines
4.3 KiB
JavaScript
125 lines
4.3 KiB
JavaScript
import { t } from './i18n.js';
|
|
const BASE = '/api';
|
|
|
|
// Paths where a 401 is a normal answer rather than an expired session, so the
|
|
// auto-logout reload must not fire (it would loop on a failed SSO handoff).
|
|
const NO_RELOAD_ON_401 = ['/auth/login', '/auth/oidc/complete', '/auth/oidc/exchange',
|
|
'/auth/oidc/link-pending'];
|
|
|
|
async function request(method, path, body = null, formEncoded = false) {
|
|
const token = localStorage.getItem('token');
|
|
const headers = {};
|
|
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
|
|
let bodyStr = null;
|
|
if (body !== null) {
|
|
if (formEncoded) {
|
|
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
bodyStr = new URLSearchParams(body).toString();
|
|
} else {
|
|
headers['Content-Type'] = 'application/json';
|
|
bodyStr = JSON.stringify(body);
|
|
}
|
|
}
|
|
|
|
const res = await fetch(`${BASE}${path}`, { method, headers, body: bodyStr });
|
|
|
|
if (res.status === 401 && !NO_RELOAD_ON_401.some(p => path.startsWith(p))) {
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('user');
|
|
window.location.reload();
|
|
return null;
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ detail: t('unknown_error') }));
|
|
throw new Error(err.detail || `HTTP ${res.status}`);
|
|
}
|
|
|
|
if (res.status === 204) return null;
|
|
return res.json();
|
|
}
|
|
|
|
async function uploadRequest(path, formData) {
|
|
const token = localStorage.getItem('token');
|
|
const headers = {};
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
|
|
const res = await fetch(`${BASE}${path}`, { method: 'POST', headers, body: formData });
|
|
|
|
if (res.status === 401) {
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('user');
|
|
window.location.reload();
|
|
return null;
|
|
}
|
|
if (!res.ok) {
|
|
// Upload errors may be non-JSON (e.g. an nginx 413/502 HTML page); fall back
|
|
// to the HTTP status so the message is diagnostic, not "unknown error".
|
|
const err = await res.json().catch(() => null);
|
|
const detail = (err && err.detail)
|
|
? err.detail
|
|
: (res.status === 413 ? t('upload_too_large') : `HTTP ${res.status} ${res.statusText || ''}`.trim());
|
|
throw new Error(detail);
|
|
}
|
|
if (res.status === 204) return null;
|
|
return res.json();
|
|
}
|
|
|
|
async function downloadRequest(path, fallbackName) {
|
|
const token = localStorage.getItem('token');
|
|
const headers = {};
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
|
|
const res = await fetch(`${BASE}${path}`, { method: 'GET', headers });
|
|
if (res.status === 401) {
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('user');
|
|
window.location.reload();
|
|
return null;
|
|
}
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ detail: t('unknown_error') }));
|
|
throw new Error(err.detail || `HTTP ${res.status}`);
|
|
}
|
|
// Derive filename from Content-Disposition if present.
|
|
let filename = fallbackName || 'calendar.ics';
|
|
const cd = res.headers.get('Content-Disposition') || '';
|
|
const m = cd.match(/filename="?([^"]+)"?/);
|
|
if (m) filename = m[1];
|
|
|
|
const blob = await res.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
export const api = {
|
|
get: (path) => request('GET', path),
|
|
post: (path, body) => request('POST', path, body),
|
|
put: (path, body) => request('PUT', path, body),
|
|
delete: (path) => request('DELETE', path),
|
|
upload: (path, form) => uploadRequest(path, form),
|
|
download: (path, name) => downloadRequest(path, name),
|
|
|
|
login: (username, password, totp_code = null, remember_me = false) =>
|
|
request('POST', '/auth/login', { username, password, totp_code, remember_me }),
|
|
|
|
setupRequired: () => request('GET', '/auth/setup-required'),
|
|
setup: (data) => request('POST', '/auth/setup', data),
|
|
|
|
// Single sign-on. oidcComplete() trades the one-time HttpOnly handoff cookie
|
|
// set by the OIDC callback for the access token.
|
|
oidcProviders: () => request('GET', '/auth/oidc/providers'),
|
|
oidcComplete: () => request('POST', '/auth/oidc/complete', {}),
|
|
oidcIdentities: () => request('GET', '/auth/oidc/identities'),
|
|
oidcLinkPending: () => request('POST', '/auth/oidc/link-pending', {}),
|
|
oidcUnlink: (id) => request('DELETE', `/auth/oidc/identities/${id}`),
|
|
};
|