Files
mischlabs/auth.js
Kroonk d40329ae7c
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s
feat: add SSO user favorites dashboard
2026-05-21 23:18:23 +02:00

201 lines
5.7 KiB
JavaScript

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
};
})();