Secuencia — Authorization Code + PKCE¶
El flujo Authorization Code con PKCE (Proof Key for Code Exchange) es el estándar obligatorio para clientes públicos que no pueden guardar secretos, como SPAs y aplicaciones móviles. PKCE añade una capa criptográfica que protege contra ataques de interceptación del código de autorización. En la plataforma de identidad, el cliente partner-portal-app utiliza exclusivamente este flujo.
Diagrama de secuencia completo¶
sequenceDiagram
autonumber
actor Usuario as Usuario / Navegador
participant App as partner-portal-app<br/>(SPA)
participant Traefik as Traefik v3<br/>:443
participant KC as Keycloak 26<br/>realm-partners
participant RS as Resource Server<br/>(Spring WebFlux API)
Note over App: 1. Generación PKCE local
App->>App: code_verifier = crypto.randomBytes(32)<br/>code_challenge = BASE64URL(SHA256(code_verifier))
Note over App,KC: 2. Redirección al Authorization Endpoint
App->>Traefik: GET /realms/realm-partners/protocol/openid-connect/auth<br/>?response_type=code<br/>&client_id=partner-portal-app<br/>&redirect_uri=https://portal.empresa.com/callback<br/>&scope=openid profile email offline_access<br/>&state=random_csrf_token<br/>&code_challenge=sJ42...Xq8<br/>&code_challenge_method=S256
Traefik->>KC: Proxy a Keycloak (TLS terminado)
Note over KC: 3. Keycloak presenta pantalla de login
KC-->>Traefik: 200 OK — Formulario de autenticación
Traefik-->>Usuario: Pantalla de login (HTML)
Note over Usuario,KC: 4. Autenticación del usuario
Usuario->>Traefik: POST credenciales (username + password)
Traefik->>KC: Proxy POST
KC->>KC: Valida credenciales contra LDAP/BD<br/>Evalúa políticas MFA si están configuradas
KC-->>Traefik: 302 Redirect a redirect_uri<br/>?code=AUTH_CODE_OPACO<br/>&state=random_csrf_token
Note over App: 5. Recepción del código
Traefik-->>App: 302 Redirect
App->>App: Verifica state == estado_guardado<br/>Extrae authorization code
Note over App,KC: 6. Intercambio de código por tokens (back-channel)
App->>Traefik: POST /realms/realm-partners/protocol/openid-connect/token<br/>Content-Type: application/x-www-form-urlencoded<br/>grant_type=authorization_code<br/>&code=AUTH_CODE_OPACO<br/>&redirect_uri=https://portal.empresa.com/callback<br/>&client_id=partner-portal-app<br/>&code_verifier=VALOR_ORIGINAL_32_BYTES
Traefik->>KC: Proxy POST token endpoint
Note over KC: 7. Validación PKCE en Keycloak
KC->>KC: SHA256(code_verifier) == code_challenge guardado<br/>Código no usado previamente, no expirado<br/>redirect_uri coincide exactamente
KC-->>Traefik: 200 OK — JSON con tokens
Traefik-->>App: access_token (JWT, 5 min)<br/>refresh_token (opaco, 30 días)<br/>id_token (JWT OIDC)<br/>expires_in, token_type
Note over App,RS: 8. Uso del access token
App->>Traefik: GET /api/partners/data<br/>Authorization: Bearer ACCESS_TOKEN_JWT
Traefik->>RS: Proxy con header Bearer
RS->>RS: Valida JWT: firma, exp, iss, aud<br/>Extrae roles/claims
RS-->>App: 200 OK — Datos del recurso
Parámetros del Authorization Request¶
| Parámetro | Valor / Ejemplo | Obligatorio | Descripción |
|---|---|---|---|
response_type |
code |
Sí | Indica flujo Authorization Code |
client_id |
partner-portal-app |
Sí | ID del cliente registrado en Keycloak |
redirect_uri |
https://portal.empresa.com/callback |
Sí | Debe coincidir exactamente con el registrado |
scope |
openid profile email offline_access |
Sí | openid es obligatorio para OIDC; offline_access habilita refresh token |
state |
k9aJ2...mP (aleatorio) |
Recomendado | Protección CSRF; la app verifica que coincida en el callback |
nonce |
v7rQ...xZ (aleatorio) |
Recomendado para OIDC | Incluido en el id_token; protege contra replay attacks |
code_challenge |
sJ42mX...Xq8 |
Sí (PKCE) | BASE64URL(SHA256(code_verifier)), sin padding |
code_challenge_method |
S256 |
Sí (PKCE) | Siempre S256; nunca usar plain en producción |
Parámetros del Token Request¶
| Parámetro | Valor / Ejemplo | Obligatorio | Descripción |
|---|---|---|---|
grant_type |
authorization_code |
Sí | Tipo de grant |
code |
AUTH_CODE_OPACO |
Sí | Código recibido en el callback; de un solo uso |
redirect_uri |
https://portal.empresa.com/callback |
Sí | Debe ser idéntico al del authorization request |
client_id |
partner-portal-app |
Sí | El cliente público no envía client_secret |
code_verifier |
32 bytes aleatorios en Base64URL |
Sí (PKCE) | Keycloak recalcula SHA256 y compara con code_challenge |
Generación de code_verifier y code_challenge¶
// TypeScript / Browser (Web Crypto API)
async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
// code_verifier: 32 bytes aleatorios codificados en Base64URL (sin padding)
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const verifier = base64urlEncode(array);
// code_challenge: SHA-256 del verifier, codificado en Base64URL
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
const challenge = base64urlEncode(new Uint8Array(digest));
return { verifier, challenge };
}
function base64urlEncode(buffer: Uint8Array): string {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
// Java 21 — para aplicaciones nativas/móviles
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
public class PKCEGenerator {
public static String generateCodeVerifier() {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
public static String generateCodeChallenge(String codeVerifier) throws Exception {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(codeVerifier.getBytes("UTF-8"));
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
}
}
Respuesta del Token Endpoint¶
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0yMDI0In0...",
"expires_in": 300,
"refresh_expires_in": 2592000,
"refresh_token": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"not-before-policy": 0,
"session_state": "3a7b8c2d-1234-5678-abcd-ef0123456789",
"scope": "openid profile email offline_access"
}
Configuración del cliente en Keycloak¶
# Configuración del cliente partner-portal-app en realm-partners
client_id: partner-portal-app
client_type: public # Sin client_secret
standard_flow_enabled: true # Authorization Code habilitado
implicit_flow_enabled: false # Nunca en producción
direct_access_grants: false # No Resource Owner Password
pkce_code_challenge_method: S256 # Forzado por Keycloak
valid_redirect_uris:
- "https://portal.empresa.com/callback"
- "https://portal.empresa.com/silent-renew"
web_origins:
- "https://portal.empresa.com"
# Scopes asignados al cliente
default_client_scopes:
- openid
- profile
- email
optional_client_scopes:
- offline_access
- address
- phone
Seguridad del state y nonce
Siempre genera state y nonce con valores criptográficamente aleatorios por cada solicitud de autorización. Guárdalos en sessionStorage (nunca en localStorage o cookies sin flags HttpOnly/Secure). Si el state recibido en el callback no coincide con el guardado, aborta el flujo inmediatamente y muestra error al usuario.
Tiempo de vida del authorization code
El authorization code emitido por Keycloak expira en 60 segundos por defecto. La SPA debe intercambiarlo por tokens inmediatamente al recibir el callback. Si el usuario tarda (p.ej. pestañas múltiples), el código expira y el flujo debe reiniciarse.
Refresh token con offline_access
Al solicitar el scope offline_access, Keycloak emite un refresh token de larga duración (configurable, por defecto 30 días) que persiste incluso si el usuario cierra sesión en la sesión activa. Usar offline_access solo cuando la aplicación genuinamente necesite acceso prolongado sin interacción del usuario.