feat: add SSO user favorites dashboard
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s
This commit is contained in:
200
auth.js
Normal file
200
auth.js
Normal file
@@ -0,0 +1,200 @@
|
||||
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
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user