const MischLabsAuth = (() => { const TOKEN_KEY = 'mischlabs.dashboard.tokens'; const PKCE_KEY = 'mischlabs.dashboard.pkce'; const CONFIG_KEY = 'mischlabs.dashboard.authConfig'; const SCOPES = 'openid profile email'; let configPromise = null; let cachedConfig = null; function base64UrlEncode(buffer) { return btoa(String.fromCharCode(...new Uint8Array(buffer))) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/g, ''); } function randomString(length = 64) { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; const bytes = new Uint8Array(length); crypto.getRandomValues(bytes); return [...bytes].map((byte) => chars[byte % chars.length]).join(''); } async function createChallenge(verifier) { const data = new TextEncoder().encode(verifier); const digest = await crypto.subtle.digest('SHA-256', data); return base64UrlEncode(digest); } async function getConfig() { if (cachedConfig) return cachedConfig; if (!configPromise) { configPromise = fetch('/api/auth/config', { cache: 'no-store' }) .then((response) => { if (!response.ok) throw new Error(`Auth config returned ${response.status}`); return response.json(); }) .then((config) => { cachedConfig = config; sessionStorage.setItem(CONFIG_KEY, JSON.stringify(config)); return config; }) .catch((error) => { const fallback = JSON.parse(sessionStorage.getItem(CONFIG_KEY) || 'null'); if (fallback) return fallback; throw error; }); } return configPromise; } function redirectUri() { const path = window.location.pathname === '/index.html' ? '/' : window.location.pathname; return `${window.location.origin}${path}`; } function decodeJwt(token) { const payload = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); const json = decodeURIComponent(atob(payload).split('').map((char) => ( `%${(`00${char.charCodeAt(0).toString(16)}`).slice(-2)}` )).join('')); return JSON.parse(json); } function tokens() { try { const raw = sessionStorage.getItem(TOKEN_KEY); return raw ? JSON.parse(raw) : null; } catch { return null; } } function storeTokens(nextTokens) { sessionStorage.setItem(TOKEN_KEY, JSON.stringify(nextTokens)); } function clearTokens() { sessionStorage.removeItem(TOKEN_KEY); sessionStorage.removeItem(PKCE_KEY); } function isAuthenticated() { const current = tokens(); if (!current?.id_token) return false; try { return Date.now() < decodeJwt(current.id_token).exp * 1000; } catch { return false; } } function claims() { const current = tokens(); if (!current?.id_token) return null; try { return decodeJwt(current.id_token); } catch { return null; } } function authHeader() { const current = tokens(); return current?.id_token ? { Authorization: `Bearer ${current.id_token}` } : {}; } async function login() { const config = await getConfig(); const verifier = randomString(); const challenge = await createChallenge(verifier); const state = randomString(32); const redirect = redirectUri(); sessionStorage.setItem(PKCE_KEY, JSON.stringify({ verifier, state, redirect })); const params = new URLSearchParams({ client_id: config.clientId, redirect_uri: redirect, response_type: 'code', scope: config.scopes || SCOPES, state, code_challenge: challenge, code_challenge_method: 'S256' }); window.location.href = `${config.authority}/protocol/openid-connect/auth?${params}`; } async function handleCallback() { const url = new URL(window.location.href); const code = url.searchParams.get('code'); const state = url.searchParams.get('state'); const error = url.searchParams.get('error'); if (error) { window.history.replaceState({}, document.title, redirectUri()); throw new Error(`SSO Fehler: ${error}`); } if (!code) return false; const config = await getConfig(); const stored = JSON.parse(sessionStorage.getItem(PKCE_KEY) || '{}'); if (!stored.verifier || stored.state !== state) { throw new Error('SSO Antwort konnte nicht verifiziert werden.'); } const body = new URLSearchParams({ grant_type: 'authorization_code', client_id: config.clientId, redirect_uri: stored.redirect || redirectUri(), code, code_verifier: stored.verifier }); const response = await fetch(`${config.authority}/protocol/openid-connect/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body }); if (!response.ok) { throw new Error(`Token endpoint returned ${response.status}`); } storeTokens(await response.json()); sessionStorage.removeItem(PKCE_KEY); window.history.replaceState({}, document.title, redirectUri()); return true; } async function logout() { const config = await getConfig(); const idToken = tokens()?.id_token; clearTokens(); const params = new URLSearchParams({ client_id: config.clientId, post_logout_redirect_uri: redirectUri() }); if (idToken) params.set('id_token_hint', idToken); window.location.href = `${config.authority}/protocol/openid-connect/logout?${params}`; } function displayName() { const currentClaims = claims(); return currentClaims?.name || currentClaims?.preferred_username || currentClaims?.email || 'Angemeldet'; } return { authHeader, claims, clearTokens, displayName, getConfig, handleCallback, isAuthenticated, login, logout }; })();