OAuth 2.0 Security Best Practices 2024 - Lo que debes saber
Las recomendaciones de seguridad OAuth evolucionan. ¿Estás al día?
El estado actual
OAuth 2.0 tiene 12 años. Las mejores prácticas han cambiado significativamente.
RFC 9700 “OAuth 2.0 Security Best Current Practice” (2024) consolida todo lo aprendido.
Resumen de cambios importantes
Deprecated (no usar)
- ❌ Implicit flow
- ❌ Resource Owner Password Credentials (ROPC)
- ❌ Tokens en URL fragments
- ❌ Bearer tokens sin binding
Obligatorio
- ✅ PKCE para todos los clientes
- ✅ Exact redirect URI matching
- ✅ Sender-constrained tokens (cuando posible)
- ✅ Refresh token rotation
1. PKCE siempre (no solo para públicos)
Antes
PKCE solo para clientes públicos (móviles, SPAs)
Clientes confidenciales: solo client_secret
Ahora
PKCE para TODOS los clientes, incluso con client_secret
Razón: Previene code injection attacks incluso en clientes confidenciales.
// Servidor: Forzar PKCE
new Client
{
ClientId = "my-confidential-client",
ClientSecrets = { new Secret("secret".Sha256()) },
RequirePkce = true, // Siempre true
AllowedGrantTypes = GrantTypes.Code
}
// Cliente: Siempre enviar PKCE
const codeVerifier = generateRandomString(64);
const codeChallenge = base64UrlEncode(sha256(codeVerifier));
// Authorization request
const authUrl = `${authServer}/authorize?
client_id=${clientId}&
response_type=code&
code_challenge=${codeChallenge}&
code_challenge_method=S256&
redirect_uri=${redirectUri}`;
2. Exact redirect URI matching
Antes (peligroso)
Registrado: https://app.ejemplo.com/callback
Aceptado: https://app.ejemplo.com/callback?evil=param ❌
Aceptado: https://app.ejemplo.com/callback/../other ❌
Ahora (correcto)
Registrado: https://app.ejemplo.com/callback
Solo acepta: https://app.ejemplo.com/callback exactamente
// Configuración correcta
new Client
{
RedirectUris = {
"https://app.ejemplo.com/callback" // Exacto, sin wildcards
},
// NO usar: "https://app.ejemplo.com/*" ❌
}
3. No más Implicit flow
Antes
SPAs usaban: response_type=token
Token en URL fragment: #access_token=xxx
Ahora
SPAs deben usar: response_type=code + PKCE
Token via POST a token endpoint
Razones:
- Tokens en URL son visibles en logs, history, referrer
- No hay refresh tokens en implicit
- No hay forma de verificar audience
// SPA correcto (Authorization Code + PKCE)
async function login() {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// Guardar verifier para después
sessionStorage.setItem('code_verifier', codeVerifier);
// Redirect a authorize
window.location.href = `${authServer}/authorize?
client_id=${clientId}&
response_type=code&
code_challenge=${codeChallenge}&
code_challenge_method=S256&
redirect_uri=${redirectUri}&
scope=openid profile`;
}
// Callback
async function handleCallback() {
const code = new URLSearchParams(window.location.search).get('code');
const codeVerifier = sessionStorage.getItem('code_verifier');
// Intercambiar code por token via POST
const response = await fetch(`${authServer}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
code_verifier: codeVerifier,
client_id: clientId,
redirect_uri: redirectUri
})
});
const tokens = await response.json();
// Token nunca estuvo en URL
}
4. Refresh token rotation
Cada vez que usas un refresh token, recibes uno nuevo y el anterior se invalida.
// Servidor
new Client
{
RefreshTokenUsage = TokenUsage.OneTimeOnly,
RefreshTokenExpiration = TokenExpiration.Sliding,
SlidingRefreshTokenLifetime = 1296000, // 15 días
AbsoluteRefreshTokenLifetime = 2592000 // 30 días máximo
}
Detección de robo
Si un refresh token robado se usa después del legítimo:
1. Usuario legítimo usa refresh_token_1 → recibe refresh_token_2
2. Atacante intenta usar refresh_token_1 (ya invalidado)
3. Servidor detecta reuso de token inválido
4. Servidor revoca TODOS los tokens de esa sesión
5. Usuario debe re-autenticarse
// Implementación de detección
public async Task<TokenResponse> RefreshTokenAsync(string refreshToken)
{
var storedToken = await _tokenStore.GetAsync(refreshToken);
if (storedToken == null)
{
// Token no existe - posible replay attack
// Revocar toda la familia de tokens
await _tokenStore.RevokeTokenFamilyAsync(storedToken.FamilyId);
throw new SecurityException("Refresh token reuse detected");
}
// Emitir nuevo token
var newToken = GenerateRefreshToken();
await _tokenStore.InvalidateAsync(refreshToken);
await _tokenStore.SaveAsync(newToken, storedToken.FamilyId);
return new TokenResponse { RefreshToken = newToken };
}
5. Sender-constrained tokens (DPoP)
Access tokens tradicionales: quien tenga el token puede usarlo.
DPoP (Demonstrating Proof of Possession): token vinculado a clave del cliente.
// Cliente genera par de claves
const keyPair = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'verify']
);
// Crear DPoP proof
function createDPoPProof(httpMethod: string, httpUri: string): string {
const header = { alg: 'ES256', typ: 'dpop+jwt', jwk: publicKeyJwk };
const payload = {
jti: generateUUID(),
htm: httpMethod,
htu: httpUri,
iat: Math.floor(Date.now() / 1000)
};
return sign(header, payload, privateKey);
}
// Token request con DPoP
const response = await fetch(`${authServer}/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'DPoP': createDPoPProof('POST', `${authServer}/token`)
},
body: tokenRequestBody
});
// API request con DPoP
const apiResponse = await fetch('https://api.ejemplo.com/data', {
headers: {
'Authorization': `DPoP ${accessToken}`,
'DPoP': createDPoPProof('GET', 'https://api.ejemplo.com/data')
}
});
6. Pushed Authorization Requests (PAR)
Authorization parameters no van en URL, van en POST previo.
// Paso 1: Push authorization request
const parResponse = await fetch(`${authServer}/par`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'openid profile',
code_challenge: codeChallenge,
code_challenge_method: 'S256'
})
});
const { request_uri } = await parResponse.json();
// request_uri: "urn:ietf:params:oauth:request_uri:abc123"
// Paso 2: Authorize con solo request_uri
window.location.href = `${authServer}/authorize?
client_id=${clientId}&
request_uri=${request_uri}`;
Beneficios:
- Parámetros no expuestos en URL
- Previene manipulación de parámetros
- Mejor auditoría
7. Token storage en SPAs
Evitar
- ❌ localStorage (XSS vulnerable)
- ❌ URL parameters
- ❌ Cookies accesibles por JS
Opciones seguras
Opción 1: Backend for Frontend (BFF)
SPA → BFF (cookie httpOnly) → API
BFF maneja tokens, SPA solo tiene cookie de sesión.
Opción 2: Service Worker
// Tokens almacenados en Service Worker
// No accesibles desde JavaScript principal
self.addEventListener('fetch', (event) => {
if (needsAuth(event.request.url)) {
const token = getStoredToken(); // En SW memory
event.respondWith(
fetch(event.request, {
headers: {
...event.request.headers,
'Authorization': `Bearer ${token}`
}
})
);
}
});
Opción 3: Memory only (refresh frecuente)
// Token solo en memoria, refresh silencioso frecuente
let accessToken: string | null = null;
async function getAccessToken(): Promise<string> {
if (!accessToken || isExpired(accessToken)) {
accessToken = await silentRefresh();
}
return accessToken;
}
8. Validation checklist
Access token validation (Resource Server)
public async Task<ClaimsPrincipal> ValidateTokenAsync(string token)
{
var validationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "https://auth.ejemplo.com",
ValidateAudience = true,
ValidAudience = "my-api",
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1),
ValidateIssuerSigningKey = true,
IssuerSigningKeys = await GetSigningKeysAsync(),
// Para DPoP
ValidateTokenReplay = true
};
var handler = new JwtSecurityTokenHandler();
return handler.ValidateToken(token, validationParameters, out _);
}
ID token validation (Client)
Verificar:
- Signature
- iss (issuer)
- aud (audience = client_id)
- exp (expiration)
- iat (issued at, no muy viejo)
- nonce (si se envió)
- at_hash (si access token presente)
Resumen de recomendaciones 2024
| Práctica | Estado |
|---|---|
| PKCE para todos | ✅ Obligatorio |
| Implicit flow | ❌ Deprecated |
| ROPC | ❌ Deprecated |
| Exact redirect URI | ✅ Obligatorio |
| Refresh token rotation | ✅ Recomendado |
| DPoP | ✅ Recomendado (cuando posible) |
| PAR | ✅ Recomendado (alto riesgo) |
| BFF para SPAs | ✅ Recomendado |
Recursos
- RFC 9700: OAuth 2.0 Security BCP
- RFC 9449: DPoP
- RFC 9126: PAR
- OAuth 2.1 (draft)
¿Tu implementación OAuth sigue estas prácticas? ¿Qué cambios necesitas hacer?