// DIDI Analysis Tool — OIDC/OAuth2 PKCE auth module (Keycloak, realm didi-clients) // Loaded in the MV3 service worker via importScripts. Exposes self.DidiAuth. // // Flow: chrome.identity.launchWebAuthFlow → Keycloak authorization endpoint // (PKCE S256 + state) → code exchange on the token endpoint → tokens persisted // in chrome.storage.local. Access token auto-refreshes; logout revokes the // Keycloak session (end_session with refresh_token). (() => { // Adresele mediului vin exclusiv din config.js (self.DidiConfig) — vezi acolo și // explicația schemei HTTPS (pagina de login) / HTTP (token & API) pentru LAN. const { KC_NAV_BASE, KC_API_BASE, REALM, CLIENT_ID } = self.DidiConfig; const REDIRECT_URI = `https://${chrome.runtime.id}.chromiumapp.org/oidc`; const OIDC = { auth: `${KC_NAV_BASE}/realms/${REALM}/protocol/openid-connect/auth`, token: `${KC_API_BASE}/realms/${REALM}/protocol/openid-connect/token`, logout: `${KC_API_BASE}/realms/${REALM}/protocol/openid-connect/logout`, }; const STORAGE_KEY = 'didiTokens'; const REFRESH_SKEW_MS = 30 * 1000; // refresh 30s before expiry // ===== helpers ===== function base64UrlEncode(bytes) { let str = ''; for (const b of bytes) str += String.fromCharCode(b); return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } function randomString(byteLen = 32) { const bytes = new Uint8Array(byteLen); crypto.getRandomValues(bytes); return base64UrlEncode(bytes); } async function sha256Base64Url(input) { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input)); return base64UrlEncode(new Uint8Array(digest)); } function decodeJwtPayload(token) { try { const payload = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); return JSON.parse(atob(payload)); } catch { return {}; } } async function getStoredTokens() { const data = await chrome.storage.local.get([STORAGE_KEY]); return data[STORAGE_KEY] || null; } async function storeTokens(tokenResponse) { const claims = decodeJwtPayload(tokenResponse.access_token); const tokens = { access_token: tokenResponse.access_token, refresh_token: tokenResponse.refresh_token, expires_at: Date.now() + (tokenResponse.expires_in || 60) * 1000, sub: claims.sub || null, email: claims.email || null, name: claims.name || claims.preferred_username || null, }; await chrome.storage.local.set({ [STORAGE_KEY]: tokens }); return tokens; } // ===== login (interactive) ===== async function login() { const codeVerifier = randomString(48); const codeChallenge = await sha256Base64Url(codeVerifier); const state = randomString(16); const nonce = randomString(16); const authUrl = `${OIDC.auth}?` + new URLSearchParams({ client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, response_type: 'code', scope: 'openid profile email', state, nonce, code_challenge: codeChallenge, code_challenge_method: 'S256', }).toString(); const redirectUrl = await chrome.identity.launchWebAuthFlow({ url: authUrl, interactive: true }); if (!redirectUrl) throw new Error('Autentificare anulată'); const params = new URL(redirectUrl).searchParams; if (params.get('error')) { throw new Error(params.get('error_description') || params.get('error')); } if (params.get('state') !== state) { throw new Error('State mismatch (posibil CSRF) — autentificare respinsă'); } const code = params.get('code'); if (!code) throw new Error('Răspuns fără cod de autorizare'); const resp = await fetch(OIDC.token, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', client_id: CLIENT_ID, code, redirect_uri: REDIRECT_URI, code_verifier: codeVerifier, }), }); if (!resp.ok) { throw new Error(`Schimbul de cod a eșuat: ${resp.status} ${await resp.text()}`); } return storeTokens(await resp.json()); } // ===== token refresh (single-flight) ===== let refreshPromise = null; async function refreshTokens(refreshToken) { if (!refreshPromise) { refreshPromise = (async () => { const resp = await fetch(OIDC.token, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', client_id: CLIENT_ID, refresh_token: refreshToken, }), }); if (!resp.ok) { await chrome.storage.local.remove([STORAGE_KEY]); throw new Error('Sesiune expirată — autentifică-te din nou'); } return storeTokens(await resp.json()); })().finally(() => { refreshPromise = null; }); } return refreshPromise; } /** Returns a valid access token, refreshing it if needed. Throws if logged out. */ async function getValidToken() { let tokens = await getStoredTokens(); if (!tokens?.access_token) { throw new Error('Neautentificat. Deschide extensia și apasă Login.'); } if (Date.now() >= tokens.expires_at - REFRESH_SKEW_MS) { tokens = await refreshTokens(tokens.refresh_token); } return tokens.access_token; } // ===== state / logout ===== async function getAuthState() { const tokens = await getStoredTokens(); if (!tokens?.access_token) return { authenticated: false }; return { authenticated: true, email: tokens.email, name: tokens.name, sub: tokens.sub }; } /** User identity claims for API payloads (user_id/user_email). */ async function getUserClaims() { const tokens = await getStoredTokens(); return { user_id: tokens?.sub || '', user_email: tokens?.email || '' }; } async function logout() { const tokens = await getStoredTokens(); if (tokens?.refresh_token) { try { await fetch(OIDC.logout, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: CLIENT_ID, refresh_token: tokens.refresh_token }), }); } catch (e) { console.warn('[AUTH] Keycloak logout failed (clearing local session anyway):', e.message); } } await chrome.storage.local.remove([STORAGE_KEY]); } self.DidiAuth = { login, logout, getValidToken, getAuthState, getUserClaims }; })();