Saltar a contenido

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 Indica flujo Authorization Code
client_id partner-portal-app ID del cliente registrado en Keycloak
redirect_uri https://portal.empresa.com/callback Debe coincidir exactamente con el registrado
scope openid profile email offline_access 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 Tipo de grant
code AUTH_CODE_OPACO Código recibido en el callback; de un solo uso
redirect_uri https://portal.empresa.com/callback Debe ser idéntico al del authorization request
client_id partner-portal-app 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.