-
-
Notifications
You must be signed in to change notification settings - Fork 204
Open
Labels
enhancementNew feature or requestNew feature or request
Description
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_dpopfield on OIDCClient/Application schema - Global:
--require-dpop=falseCLI 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
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
enhancementNew feature or requestNew feature or request