Când te loghezi cu Google, Microsoft, Keycloak sau Auth0, aplicația ta nu vede niciodată parola. Trimite utilizatorul la furnizorul de identitate, care îl trimite înapoi cu un cod de unică folosință; aplicația schimbă codul pe tokenuri. OAuth 2.1 — versiunea curățată a OAuth 2.0 — păstrează un singur flow pentru aplicațiile din browser: authorization code cu PKCE.
PKCE (Proof Key for Code Exchange, pronunțat „pixy”) rezolvă o problemă precisă. O aplicație single-page e un client public: nu are niciun secret pe care să-l ascundă, pentru că tot ce livrează poate fi citit. Are deci nevoie de altă cale să dovedească faptul că cine răscumpără codul e același cu cine l-a cerut. Demo-urile din pagină folosesc criptografie reală (Web Crypto), nu o imitație.
response_type=token, tokenuri în fragmentul URL-ului) a dispărut din OAuth 2.1: tokenurile din URL ajung în istoricul browserului, în loguri și în headerele Referer. Trecerea la code flow cu PKCE e de obicei o schimbare de configurare în bibliotecă și la furnizor.Flow-ul, în șapte pași
Aplicația pregătește câteva valori aleatorii, trimite browserul la furnizor, primește înapoi un cod și îl schimbă — împreună cu o dovadă — pe tokenuri. Doar ultimul pas implică API-ul tău. Parcurge-l în demo; bifează „atacator” ca să vezi ce oprește verificarea lui state.
app (browser) furnizor de identitate API
│ 1. verifier, challenge = S256(verifier), state, nonce │
│ 2. GET /authorize?…&code_challenge&state&nonce ─▶│ │
│ 3. login, MFA, consimțământ │
│◀─ 4. 302 /callback?code=…&state=… (verifică state!) │
│ 5. POST /token code + code_verifier ────────────▶│ S256(verifier) == challenge?
│◀─ 6. access_token, id_token, refresh_token ───────│ │
│ 7. GET /orders Authorization: Bearer <access_token> ─────────────────────▶│PKCE: dovedești că tu ai cerut
Înainte de redirect, aplicația generează un code_verifier aleator și trimite la /authorize doar hash-ul lui, code_challenge. Furnizorul îl ține minte lângă cod. Când aplicația răscumpără codul la /token, trimite chiar verifier-ul; furnizorul îi face hash și compară. Cine interceptează codul — o aplicație rău-intenționată înregistrată pe același redirect, un log, o extensie de browser — nu are verifier-ul, iar codul nu îi folosește la nimic.
/** base64url fără padding (RFC 4648 §5), cum cere PKCE */
function base64url(bytes: Uint8Array): string {
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/** 32 de octeți aleatori → 43 de caractere (minimul permis e 43, maximul 128) */
export function createVerifier(): string {
return base64url(crypto.getRandomValues(new Uint8Array(32)));
}
/** code_challenge = BASE64URL(SHA-256(code_verifier)), metoda S256 */
export async function challengeFor(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
return base64url(new Uint8Array(digest));
}
// RFC 7636, anexa B:
// challengeFor('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')
// → 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'- 1Aplicația generează un verifier
0 caractere · sunt permise între 43 și 128
code_challenge… - 2Doar challenge-ul pleacă la /authorize
Furnizorul nu a primit încă nimic.
- 3Răscumpără codul la /token
Folosește S256, niciodată plain (cu plain, challenge-ul este verifier-ul, deci cine vede redirect-ul are tot). Verifier-ul stă în sessionStorage — doar tabul ăsta — și e șters imediat ce codul a fost schimbat.
state, nonce și redirect_uri
state— o valoare aleatoare salvată înainte de redirect și verificată în/callback. Fără ea, un atacator poate trimite victima la callback-ul tău cu codul atacatorului, iar victima ajunge logată în contul atacatorului (login CSRF). Și PKCE blochează asta, dar furnizorii tot cerstateși oricum ai nevoie de el ca să-ți porți propriul context.nonce— o valoare aleatoare trimisă la/authorizecare trebuie să se întoarcă în ID token. Dovedește că ID token-ul a fost emis pentru acest login, nu copiat dintr-unul anterior.redirect_uri— înregistrat la furnizor exact, fără wildcard-uri. Iar unde mergi după login (returnTo) salvează aplicația ta înainte de redirect; nu-l lua niciodată dintr-un?returnUrl=din bara de adrese, altfel pagina de login devine un open redirect.
import { challengeFor, createVerifier } from './pkce';
const AUTHORIZE = 'https://id.example.com/authorize';
export async function login(returnTo = '/'): Promise<void> {
const verifier = createVerifier();
const state = createVerifier(); // aleator, de unică folosință
const nonce = createVerifier();
// sessionStorage: doar tabul ăsta, dispare când se închide tabul
sessionStorage.setItem('oauth', JSON.stringify({ verifier, state, nonce, returnTo }));
const url = new URL(AUTHORIZE);
url.search = new URLSearchParams({
response_type: 'code',
client_id: 'atlas-spa',
redirect_uri: `${location.origin}/callback`, // înregistrat EXACT, fără wildcard-uri
scope: 'openid profile email',
code_challenge: await challengeFor(verifier),
code_challenge_method: 'S256',
state,
nonce,
}).toString();
location.assign(url);
}Trei tokenuri, trei roluri
Răspunsul de la /token conține până la trei tokenuri, iar confuzia dintre ele e cea mai frecventă greșeală. ID token-ul îi spune aplicației cine s-a logat. Access token-ul îi spune API-ului ce are voie apelantul să facă. Refresh token-ul obține access tokenuri noi fără un login nou. Să decodezi un JWT în browser e util pentru afișare, dar nu e validare: semnătura se verifică cu cheile publice ale furnizorului (JWKS), iar asta e treaba unei biblioteci.
| ID token | Access token | Refresh token | |
|---|---|---|---|
| Citit de | aplicația ta | API-ul tău | furnizor |
| Trimis la | nicăieri — rămâne în aplicație | doar originea API-ului tău | doar /token-ul furnizorului |
| Trăiește | minute | 5–60 de minute | ore sau zile, rotit la fiecare folosire |
Mai sigur: un BFF păstrează tokenurile
După pasul 6, tokenurile stau în browser — exact unde le poate atinge un XSS. Documentul IETF despre OAuth pentru aplicații din browser descrie o opțiune mai puternică: un Backend for Frontend. Un server mic pe propriul tău site face schimbul codului, ține tokenurile pe server și îi dă browserului doar un cookie de sesiune HttpOnly, pe care JavaScript nu îl poate citi.
browser (SPA) BFF (serverul tău, același site) furnizor de identitate
│ GET /auth/login ──────────▶│ PKCE + state + nonce, păstrate pe server ──▶│ /authorize
│◀─ 302 către furnizor ──────│ │
│ …login la furnizor, redirect înapoi la BFF… │
│ │ code + verifier ──────────────────────────▶│ /token
│ │◀── access, id, refresh (rămân AICI) ────────│
│◀─ Set-Cookie: session=…; HttpOnly; Secure; SameSite=Strict │
│ GET /api/orders (cookie) ─▶│ pune Authorization: Bearer … ──▶ API │SPA-ul apelează /api pe propria origine; BFF-ul adaugă access token-ul și se ocupă de refresh. Un XSS poate încă face cereri cât timp pagina e deschisă, dar nu poate fura tokenuri ca să le folosească în altă parte. Prețul: încă o componentă de rulat și protecție CSRF pentru cookie (SameSite, un header anti-CSRF). Pentru tokenurile ținute în browser, interceptorul cu coadă și refresh e pe pagina despre tokenuri și refresh.
În Angular: o bibliotecă, un guard și un interceptor
Nu implementa protocolul singur. Biblioteci certificate ca angular-auth-oidc-client sau angular-oauth2-oidc se ocupă de descoperirea furnizorului, PKCE, validarea ID token-ului inclusiv semnătura, refresh și logout. Codul din pagina asta arată ce fac ele, ca să le poți configura și depana cu ochii deschiși. Partea ta:
- Configurarea: authority, client id,
redirect_uri, scope-uri și code flow cu PKCE — de obicei implicit. - Un guard pe rutele private, care pornește login-ul și ține minte unde să revină. Vezi guards & resolvers.
- Un interceptor care adaugă access token-ul doar pentru originea API-ului tău. Un token trimis la orice URL ajunge la terți; vezi securitatea pentru restul imaginii.
Când DA, când NU
- Login printr-un furnizor de identitate extern: SSO de companie, Google, Microsoft, Apple.
- Mai multe aplicații care împart același login și aceiași utilizatori.
- MFA, passkeys și recuperarea contului, pe care preferi să le lași specialiștilor.
- O singură aplicație cu propriii utilizatori și propriul backend: un cookie de sesiune clasic poate fi tot ce îți trebuie. Tokenurile și refresh-ul au pagina lor.
- Apeluri server-to-server: acolo e flow-ul client credentials, pe server, niciodată în browser.
- Să scrii singur protocolul în producție: folosește o bibliotecă certificată.
Capcane frecvente
- Implicit flow sau tokenuri în URL. Ajung în istoric, în loguri și în headerul
Referer. - Fără verificarea lui
state. Callback-ul acceptă un cod pe care aplicația ta nu l-a cerut niciodată. - ID token-ul trimis la API. E pentru aplicație; API-ul primește access tokenuri, cu audience-ul corect.
- Decodare în loc de validare. Fără verificarea semnăturii, oricine poate scrie un JWT cu orice claims.
- Verifier-ul în
localStorage, păstrat după folosire. Locul lui e însessionStorageși moare odată cu schimbul. ?returnUrl=luat din bara de adrese. Pagina ta de login devine un open redirect folosit la phishing.
Fișă rapidă
SPA-uri (OAuth 2.1) authorization code + PKCE, nimic altceva (fără implicit flow)
code_verifier 43–128 caractere aleatorii, în sessionStorage, șters după folosire
code_challenge BASE64URL(SHA-256(verifier)), metoda S256 (niciodată "plain")
state aleator, verificat pe /callback: oprește login CSRF
nonce aleator, trebuie să fie în ID token: oprește tokenurile reluate
redirect_uri înregistrat exact, fără wildcard-uri; returnTo salvat ÎNAINTE de redirect
ID token cine s-a logat (pentru aplicație); validezi iss, aud, exp, nonce, semnătura
access token ce ai voie să apelezi (pentru API); doar către originea API-ului tău
refresh token rotit la fiecare folosire sau deloc în browser
cel mai sigur un BFF: tokenurile pe server, un cookie HttpOnly în browser
logout sesiunea locală + end_session_endpoint al furnizorului
nu-l scrie singur biblioteci certificate: angular-auth-oidc-client, angular-oauth2-oidc