252 lines
8.0 KiB
Go
252 lines
8.0 KiB
Go
package dpop
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-jose/go-jose/v3"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests/util"
|
|
)
|
|
|
|
// claims represents the expected payload claims of a DPoP proof.
|
|
type claims struct {
|
|
JTI string `json:"jti"`
|
|
HTM string `json:"htm"`
|
|
HTU string `json:"htu"`
|
|
IAT int64 `json:"iat"`
|
|
ATH string `json:"ath,omitempty"`
|
|
}
|
|
|
|
// CalcATH calculates the base64url encoded SHA-256 hash of an access token
|
|
// to be used as the `ath` claim in a DPoP proof.
|
|
func CalcATH(accessToken string) string {
|
|
hash := sha256.Sum256([]byte(accessToken))
|
|
return base64.RawURLEncoding.EncodeToString(hash[:])
|
|
}
|
|
|
|
// Validator is an interface for validating DPoP proofs.
|
|
type Validator interface {
|
|
ValidateDPopToken(req *http.Request, accessToken string) (string, error)
|
|
}
|
|
|
|
type dpopValidator struct {
|
|
timeWindow time.Duration
|
|
store DpopStore
|
|
}
|
|
|
|
// NewDpopValidator creates a new DPoP validator with the given configuration.
|
|
func NewDpopValidator(timeWindow time.Duration, store DpopStore) Validator {
|
|
return &dpopValidator{
|
|
timeWindow: timeWindow,
|
|
store: store,
|
|
}
|
|
}
|
|
|
|
// ValidateDPopToken validates the DPoP proof in the HTTP headers based on RFC 9449 (https://datatracker.ietf.org/doc/html/rfc9449#name-checking-dpop-proofs).
|
|
// It implements the DPoP proof validation steps described in Section 4.3.
|
|
// Returns the JWK thumbprint on success, or an error if validation fails.
|
|
//
|
|
// Note: If an opaque access token is used, the caller is responsible for extracting
|
|
// and verifying the JKT (e.g., via introspection) against the returned thumbprint.
|
|
func (v *dpopValidator) ValidateDPopToken(req *http.Request, token string) (string, error) {
|
|
dpopJws, err := parseDpopHeaderJws(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
dpopPayload, dpopJwk, err := checkJwsSignature(dpopJws)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
claims, err := parseAndCheckDpopClaims(dpopPayload, req, v.timeWindow)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
dpopJkt, err := calculateJkt(dpopJwk)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if token != "" {
|
|
if err := checkTokenAgainstJwtAth(token, claims); err != nil {
|
|
return "", err
|
|
}
|
|
if tokenCnfJkt := extractJwtCnfJktClaim(token); tokenCnfJkt != "" {
|
|
if tokenCnfJkt != dpopJkt {
|
|
return "", fmt.Errorf("DPoP thumbprint mismatch: token jkt %q != DPoP proof jkt %q", tokenCnfJkt, dpopJkt)
|
|
}
|
|
}
|
|
}
|
|
|
|
if v.store != nil {
|
|
if err := v.checkJwtReplay(req.Context(), dpopJkt, claims); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
return dpopJkt, nil
|
|
}
|
|
|
|
// parseDpopHeaderJws parses the DPoP header string into a JSON Web Signature.
|
|
// It enforces the requirement of a single DPoP header per RFC 9449 Section 4.1 (https://datatracker.ietf.org/doc/html/rfc9449#name-the-dpop-http-header).
|
|
// Returns an error if multiple DPoP headers are present or the header is empty/invalid.
|
|
func parseDpopHeaderJws(req *http.Request) (*jose.JSONWebSignature, error) {
|
|
dpopHeaders := req.Header.Values("DPoP")
|
|
if len(dpopHeaders) == 0 {
|
|
return nil, errors.New("missing DPoP header")
|
|
}
|
|
if len(dpopHeaders) > 1 {
|
|
return nil, errors.New("multiple DPoP headers present")
|
|
}
|
|
|
|
dpopJws, err := jose.ParseSigned(dpopHeaders[0])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse DPoP JWS: %v", err)
|
|
}
|
|
|
|
return dpopJws, nil
|
|
}
|
|
|
|
// checkJwsSignature checks the JWS signature using the embedded JWK.
|
|
// It validates the `typ` and `alg` headers and verifies the signature per RFC 9449 (https://datatracker.ietf.org/doc/html/rfc9449#name-checking-dpop-proofs).
|
|
func checkJwsSignature(dpopJws *jose.JSONWebSignature) ([]byte, *jose.JSONWebKey, error) {
|
|
if len(dpopJws.Signatures) != 1 {
|
|
return nil, nil, errors.New("expected exactly one signature in DPoP JWS")
|
|
}
|
|
|
|
sig := dpopJws.Signatures[0]
|
|
header := sig.Protected
|
|
|
|
// RFC 9449 Section 4.2: typ must be "dpop+jwt"
|
|
typ, ok := header.ExtraHeaders["typ"].(string)
|
|
if !ok || !strings.EqualFold(typ, "dpop+jwt") {
|
|
return nil, nil, errors.New("invalid or missing typ header claim, expected dpop+jwt")
|
|
}
|
|
|
|
// alg must not be "none" (go-jose rejects empty alg, but explicitly check for none)
|
|
if header.Algorithm == "none" || header.Algorithm == "" {
|
|
return nil, nil, errors.New("invalid alg header claim")
|
|
}
|
|
|
|
// JWK must be present
|
|
jwk := header.JSONWebKey
|
|
if jwk == nil {
|
|
return nil, nil, errors.New("missing jwk header claim")
|
|
}
|
|
if !jwk.Valid() {
|
|
return nil, nil, errors.New("invalid jwk header claim")
|
|
}
|
|
|
|
// Verify the signature
|
|
payload, err := dpopJws.Verify(jwk)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to verify DPoP signature: %v", err)
|
|
}
|
|
|
|
return payload, jwk, nil
|
|
}
|
|
|
|
// parseAndCheckDpopClaims parses the payload and validates the required DPoP claims.
|
|
// It verifies htm, htu, jti, and iat per RFC 9449 Section 4.3.
|
|
func parseAndCheckDpopClaims(payloadBytes []byte, req *http.Request, timeWindow time.Duration) (*claims, error) {
|
|
var claims claims
|
|
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal DPoP payload: %v", err)
|
|
}
|
|
|
|
// jti is required
|
|
if claims.JTI == "" {
|
|
return nil, errors.New("missing jti claim")
|
|
}
|
|
|
|
// htm must match the HTTP method
|
|
if !strings.EqualFold(claims.HTM, req.Method) {
|
|
return nil, fmt.Errorf("htm claim (%q) does not match HTTP method (%q)", claims.HTM, req.Method)
|
|
}
|
|
|
|
// htu must match the HTTP URI (excluding query and fragment)
|
|
// Construct absolute URL from request
|
|
scheme := util.GetRequestProto(req)
|
|
host := util.GetRequestHost(req)
|
|
path := util.GetRequestPath(req)
|
|
expectedHTU := fmt.Sprintf("%s://%s%s", scheme, host, path)
|
|
|
|
if !strings.EqualFold(claims.HTU, expectedHTU) {
|
|
return nil, fmt.Errorf("htu claim (%q) does not match expected URI (%q)", claims.HTU, expectedHTU)
|
|
}
|
|
|
|
if claims.IAT == 0 {
|
|
return nil, errors.New("missing iat claim")
|
|
}
|
|
|
|
// iat must be within an acceptable time window
|
|
iatTime := time.Unix(claims.IAT, 0)
|
|
now := time.Now()
|
|
if iatTime.Before(now.Add(-timeWindow)) || iatTime.After(now.Add(timeWindow)) {
|
|
return nil, fmt.Errorf("invalid iat claim: %v is outside acceptable window", iatTime)
|
|
}
|
|
|
|
return &claims, nil
|
|
}
|
|
|
|
// calculateJkt calculates the JWK Thumbprint (RFC 7638) using SHA-256 (https://datatracker.ietf.org/doc/html/rfc7638).
|
|
func calculateJkt(jwk *jose.JSONWebKey) (string, error) {
|
|
dpopThumbprintBytes, err := jwk.Thumbprint(crypto.SHA256)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to calculate JWK thumbprint: %v", err)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(dpopThumbprintBytes), nil
|
|
}
|
|
|
|
// checkTokenAgainstJwtAth validates the ath claim against the access token per RFC 9449 Section 4.3.
|
|
func checkTokenAgainstJwtAth(accessToken string, claims *claims) error {
|
|
// ath must match the base64url encoding of the SHA-256 hash of the access token
|
|
if claims.ATH == "" {
|
|
return errors.New("missing ath claim, required when access token is used")
|
|
}
|
|
expectedATH := CalcATH(accessToken)
|
|
if claims.ATH != expectedATH {
|
|
return fmt.Errorf("ath claim (%q) does not match access token hash (%q)", claims.ATH, expectedATH)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// dpopTokenClaims defines the expected structure of a DPoP-bound access token.
|
|
type dpopTokenClaims struct {
|
|
jwt.RegisteredClaims
|
|
Cnf struct {
|
|
Jkt string `json:"jkt"`
|
|
} `json:"cnf"`
|
|
}
|
|
|
|
func extractJwtCnfJktClaim(tokenString string) string {
|
|
var claims dpopTokenClaims
|
|
// If it fails to parse as a JWT, we assume it's an opaque token where
|
|
// cnf binding must be validated downstream by an introspection endpoint.
|
|
if _, _, err := jwt.NewParser().ParseUnverified(tokenString, &claims); err != nil {
|
|
return ""
|
|
}
|
|
return claims.Cnf.Jkt
|
|
}
|
|
|
|
func (v *dpopValidator) checkJwtReplay(ctx context.Context, jkt string, claims *claims) error {
|
|
iatTime := time.Unix(claims.IAT, 0)
|
|
if added, err := v.store.MarkJtiSeen(ctx, jkt, claims.JTI, iatTime.Add(v.timeWindow*2)); err != nil {
|
|
return fmt.Errorf("failed to check JTI replay status: %v", err)
|
|
} else if !added {
|
|
return errors.New("invalid DPoP proof: jti has already been used (replay attack)")
|
|
}
|
|
return nil
|
|
}
|