Skip to content

RFC: DPoP (Demonstration of Proof-of-Possession) Tokens #520

@lakhansamani

Description

@lakhansamani

RFC: DPoP (Demonstration of Proof-of-Possession) Tokens

Phase: 5 — Advanced Security & Enterprise
Priority: P3 — Medium
Estimated Effort: Medium
Depends on: OIDC Provider (#514)


Problem Statement

Standard Bearer tokens can be stolen and replayed from any device. If an access token is intercepted (via network sniffing, log leaks, or compromised middleware), the attacker can use it freely. DPoP (RFC 9449) binds tokens to a cryptographic key held by the client, making stolen tokens useless without the private key. Keycloak 26.4 has full DPoP support. FAPI 2.0 (Financial-grade API) requires it.


Proposed Solution

1. DPoP Flow

1. Client generates an ephemeral key pair (per session):
   { publicKey, privateKey } = generateKeyPair("ES256")

2. Client creates DPoP proof JWT (signed with private key):
   Header: { "typ": "dpop+jwt", "alg": "ES256", "jwk": publicKey }
   Payload: { "htm": "POST", "htu": "https://auth.example.com/oauth/token", "iat": ..., "jti": unique }

3. Client sends token request with DPoP header:
   POST /oauth/token
   DPoP: eyJ...  (the DPoP proof JWT)
   grant_type=authorization_code&code=...

4. Server validates DPoP proof:
   - Verify signature using embedded JWK
   - Check htm matches request method
   - Check htu matches request URL
   - Check jti is unique (replay prevention)
   - Check iat is recent (clock skew tolerance)

5. Server issues DPoP-bound token:
   Access token includes: "cnf": { "jkt": thumbprint(publicKey) }
   Token type returned: "DPoP" (not "Bearer")

6. Client presents token to resource server:
   Authorization: DPoP {access_token}
   DPoP: {new proof JWT for this specific request}

7. Resource server validates:
   - DPoP proof signature
   - Token's cnf.jkt matches proof's JWK thumbprint
   - htm/htu match current request

2. Implementation

New package: internal/dpop/

type DPoPValidator struct {
    nonceStore  memory_store.Provider  // for replay prevention
    clockSkew   time.Duration
}

func (v *DPoPValidator) ValidateProof(proof string, method string, url string) (*DPoPClaims, error) {
    // 1. Parse as JWT without verification first to get JWK from header
    token, _ := jwt.Parse(proof, func(t *jwt.Token) (interface{}, error) {
        // Extract JWK from header
        jwkMap := t.Header["jwk"].(map[string]interface{})
        return parseJWK(jwkMap)
    })
    
    // 2. Validate claims
    claims := token.Claims.(jwt.MapClaims)
    if claims["htm"] != method { return nil, errors.New("htm mismatch") }
    if claims["htu"] != url { return nil, errors.New("htu mismatch") }
    
    // 3. Check jti uniqueness (replay prevention)
    jti := claims["jti"].(string)
    if v.nonceStore.Exists("dpop_jti:" + jti) {
        return nil, errors.New("jti replay detected")
    }
    v.nonceStore.Set("dpop_jti:" + jti, "1", 5*time.Minute) // TTL for jti tracking
    
    // 4. Compute JWK thumbprint (RFC 7638) for token binding
    thumbprint := computeJWKThumbprint(jwkMap)
    
    return &DPoPClaims{JWKThumbprint: thumbprint}, nil
}

Token endpoint changes (internal/http_handlers/token.go):

// If DPoP header present, bind token to key
dpopHeader := c.GetHeader("DPoP")
if dpopHeader != "" {
    dpopClaims, err := dpopValidator.ValidateProof(dpopHeader, "POST", tokenEndpointURL)
    if err != nil {
        return tokenError(c, "invalid_dpop_proof", err.Error())
    }
    
    // Add confirmation claim to access token
    tokenClaims["cnf"] = map[string]string{"jkt": dpopClaims.JWKThumbprint}
    
    // Return DPoP token type
    c.JSON(200, gin.H{
        "access_token": accessToken,
        "token_type":   "DPoP",  // not "Bearer"
        "expires_in":   3600,
    })
} else if cfg.RequireDPoP {
    return tokenError(c, "invalid_request", "DPoP proof required")
}

Resource server validation (for Authorizer's own endpoints like /userinfo):

func DPoPMiddleware(dpopValidator *dpop.DPoPValidator) gin.HandlerFunc {
    return func(c *gin.Context) {
        authHeader := c.GetHeader("Authorization")
        
        if strings.HasPrefix(authHeader, "DPoP ") {
            token := strings.TrimPrefix(authHeader, "DPoP ")
            dpopProof := c.GetHeader("DPoP")
            
            // Validate DPoP proof
            claims, err := dpopValidator.ValidateProof(dpopProof, c.Request.Method, requestURL(c))
            if err != nil {
                c.AbortWithStatusJSON(401, gin.H{"error": "invalid_dpop_proof"})
                return
            }
            
            // Validate token's cnf.jkt matches proof's JWK thumbprint
            tokenClaims := parseToken(token)
            cnf := tokenClaims["cnf"].(map[string]interface{})
            if cnf["jkt"] != claims.JWKThumbprint {
                c.AbortWithStatusJSON(401, gin.H{"error": "dpop_binding_mismatch"})
                return
            }
            
            c.Set("access_token", token)
            c.Set("dpop_bound", true)
            c.Next()
        } else if strings.HasPrefix(authHeader, "Bearer ") {
            // Standard Bearer token — allowed if DPoP not required
            c.Next()
        }
    }
}

3. Configurable Enforcement

  • Per-client: require_dpop field on OIDCClient/Application schema
  • Global: --require-dpop=false CLI flag
  • Graceful migration: Accept both Bearer and DPoP tokens during transition period

CLI Configuration Flags

--require-dpop=false                       # Require DPoP for all clients
--dpop-clock-skew-tolerance=5s             # Allowed clock skew for DPoP proof iat
--dpop-jti-cache-ttl=5m                    # How long to track DPoP proof JTIs for replay prevention

Testing Plan

  • Test DPoP proof generation and validation
  • Test token binding (cnf.jkt claim matches JWK thumbprint)
  • Test replay prevention (reused jti rejected)
  • Test htm/htu validation
  • Test DPoP-bound token rejected when presented as Bearer
  • Test Bearer token accepted when DPoP not required
  • Test per-client DPoP enforcement

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions