Livrare Lot 3 (Frontend): aplicație web, aplicație mobilă Android, extensie browser
- 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>
This commit is contained in:
commit
cec967f953
321 changed files with 80506 additions and 0 deletions
189
extension/auth.js
Normal file
189
extension/auth.js
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
// 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 };
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue