- Surse complete web (React/Vite) + mobil (React Native/Expo) + extensie (MV3) - Documentație de livrare: ghid utilizare, matrice trasabilitate cerințe, raport testare furnizor - Artefacte binare: imagine Docker didi-frontend:lot3-1.0, APK, extensie v3.2.6 + SHA256SUMS - Configurare adresă platformă externalizată (build args / .env / config.js) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
189 lines
6.5 KiB
JavaScript
189 lines
6.5 KiB
JavaScript
// 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 };
|
|
})();
|