From 4f6e4d6fbaa2769fccbcabe40f69edb9b79bb09d Mon Sep 17 00:00:00 2001 From: TristonianJones Date: Mon, 24 Aug 2026 20:51:22 -0700 Subject: [PATCH 1/3] DPoP support --- ext/security/dpop/BUILD.bazel | 37 ++ ext/security/dpop/dpop.go | 1018 ++++++++++++++++++++++++++++++ ext/security/dpop/dpop_test.go | 699 ++++++++++++++++++++ ext/security/dpop/export_test.go | 20 + 4 files changed, 1774 insertions(+) create mode 100644 ext/security/dpop/BUILD.bazel create mode 100644 ext/security/dpop/dpop.go create mode 100644 ext/security/dpop/dpop_test.go create mode 100644 ext/security/dpop/export_test.go diff --git a/ext/security/dpop/BUILD.bazel b/ext/security/dpop/BUILD.bazel new file mode 100644 index 000000000..88736492f --- /dev/null +++ b/ext/security/dpop/BUILD.bazel @@ -0,0 +1,37 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +package( + default_visibility = ["//visibility:public"], + licenses = ["notice"], # Apache 2.0 +) + +go_library( + name = "go_default_library", + srcs = [ + "dpop.go", + ], + importpath = "cel.dev/cel-go/ext/security/dpop", + deps = [ + "//cel:go_default_library", + "//common/types:go_default_library", + "//common/types/ref:go_default_library", + "//ext/security/jwt:go_default_library", + ], +) + +go_test( + name = "go_default_test", + size = "small", + srcs = [ + "dpop_test.go", + "export_test.go", + ], + embed = [ + ":go_default_library", + ], + deps = [ + "//cel:go_default_library", + "//common/types:go_default_library", + "//ext/security/jwt:go_default_library", + ], +) diff --git a/ext/security/dpop/dpop.go b/ext/security/dpop/dpop.go new file mode 100644 index 000000000..d5a2bb6b6 --- /dev/null +++ b/ext/security/dpop/dpop.go @@ -0,0 +1,1018 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package dpop implements CEL extension functions for OAuth 2.0 Demonstrating Proof of Possession (DPoP) +// proof parsing, JWK thumbprint confirmation, access token hash validation, and request matching per RFC 9449. +package dpop + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "fmt" + "net/url" + "path" + "reflect" + "slices" + "strings" + "time" + + "cel.dev/cel-go/cel" + "cel.dev/cel-go/common/types" + "cel.dev/cel-go/common/types/ref" + "cel.dev/cel-go/ext/security/jwt" +) + +const ( + // dpopProofType is the CEL type name for dpop.Proof. + dpopProofType = "dpop.Proof" + maxProofSize = 10 * 1024 * 1024 // 10MB maximum allowed proof size + + // Required header type for DPoP proof JWTs per RFC 9449 Section 4.2. + dpopHeaderType = "dpop+jwt" +) + +func defaultNowFunc() time.Time { + return time.Now().UTC() +} + +// Library returns a cel.EnvOption to configure extended functions for DPoP proof parsing, +// request verification, and key confirmation inspection. +func Library(options ...Option) cel.EnvOption { + l := &dpopLib{ + version: ^uint32(0), + now: defaultNowFunc, + } + for _, o := range options { + l = o(l) + } + return cel.Lib(l) +} + +// Option declares a functional operator for configuring DPoP extension library behavior. +type Option func(*dpopLib) *dpopLib + +// Version sets the library version for DPoP extensions. +func Version(version uint32) Option { + return func(l *dpopLib) *dpopLib { + l.version = version + return l + } +} + +// ValidateTimes enables automatic time validation (iat) during proof parsing with an optional maximum age and clock leeway. +func ValidateTimes(maxAge time.Duration, leeway ...time.Duration) Option { + return func(l *dpopLib) *dpopLib { + l.validateTimes = true + l.maxAge = maxAge + if len(leeway) > 0 { + l.clockLeeway = leeway[0] + } + return l + } +} + +// MaxAge sets the maximum acceptable age for DPoP proof iat creation timestamp. +func MaxAge(maxAge time.Duration) Option { + return func(l *dpopLib) *dpopLib { + l.maxAge = maxAge + return l + } +} + +// Clock sets a custom clock function for time validation (defaults to time.Now). +func Clock(nowFunc func() time.Time) Option { + return func(l *dpopLib) *dpopLib { + l.now = nowFunc + return l + } +} + +// ClockLeeway sets the tolerance window when checking proof time claims (iat). +func ClockLeeway(leeway time.Duration) Option { + return func(l *dpopLib) *dpopLib { + l.clockLeeway = leeway + return l + } +} + +// AllowedAlgorithms restricts acceptable JWS alg values for DPoP proof JWTs. +func AllowedAlgorithms(algs ...string) Option { + return func(l *dpopLib) *dpopLib { + l.allowedAlgorithms = append(l.allowedAlgorithms, algs...) + return l + } +} + +type dpopLib struct { + version uint32 + validateTimes bool + maxAge time.Duration + clockLeeway time.Duration + now func() time.Time + allowedAlgorithms []string +} + +// LibraryName returns the CEL library identifier string. +func (*dpopLib) LibraryName() string { + return "cel.lib.ext.security.dpop" +} + +// CompileOptions returns environment options for declaring CEL functions and types. +func (l *dpopLib) CompileOptions() []cel.EnvOption { + celProofType := cel.ObjectType(dpopProofType) + proofType, err := types.NewNativeType(reflect.TypeFor[Proof](), types.ParseStructTag("cel")) + if err != nil { + panic(fmt.Errorf("failed to create dpop proof type: %w", err)) + } + var adapt func() types.Adapter = func() types.Adapter { + return types.DefaultTypeAdapter + } + celJWTTokenType := cel.ObjectType("jwt.Token") + + return []cel.EnvOption{ + cel.OptionalTypes(), + cel.Types(proofType), + func(e *cel.Env) (*cel.Env, error) { + adapt = func() types.Adapter { return e.CELTypeAdapter() } + return e, nil + }, + cel.Function("dpop.parse", + cel.FunctionDocs( + "Parses a DPoP proof JWT string into a structured Proof representation per RFC 9449.", + "Automatically strips leading 'DPoP ' prefixes if present.", + ), + cel.Overload("dpop_parse_string", + []*cel.Type{cel.StringType}, + cel.OptionalType(celProofType), + cel.OverloadExamples( + "dpop.parse(dpopHeaderStr)", + "dpop.parse('DPoP eyJ0eXAiOiJkcG9wK2p3dCIs...')", + ), + cel.UnaryBinding(func(arg ref.Val) ref.Val { + proofStr := arg.(types.String) + p, err := ParseProof(string(proofStr)) + if err != nil { + return types.NewErr("parse dpop proof failed: %w", err) + } + if len(l.allowedAlgorithms) > 0 && !slices.Contains(l.allowedAlgorithms, p.Algorithm) { + return types.OptionalNone + } + if l.validateTimes && !l.isProofTimeValid(p) { + return types.OptionalNone + } + return types.OptionalOf(adapt().NativeToValue(p)) + }), + ), + ), + cel.Function("dpop.ath", + cel.FunctionDocs( + "Computes the RFC 9449 access token hash (ath): base64url(sha256(access_token)).", + "Automatically strips leading 'DPoP ' or 'Bearer ' prefixes if present.", + ), + cel.Overload("dpop_ath_string", + []*cel.Type{cel.StringType}, + cel.StringType, + cel.OverloadExamples( + "dpop.ath(accessTokenStr)", + "dpop.ath('DPoP Kz~8mXK1...')", + ), + cel.UnaryBinding(func(arg ref.Val) ref.Val { + tokenStr := string(arg.(types.String)) + return types.String(ComputeAccessTokenHash(tokenStr)) + }), + ), + ), + cel.Function("dpop.thumbprint", + cel.FunctionDocs( + "Computes the RFC 7638 SHA-256 JWK Thumbprint (base64url encoded) from a JWK map.", + ), + cel.Overload("dpop_thumbprint_map", + []*cel.Type{cel.MapType(cel.StringType, cel.DynType)}, + cel.StringType, + cel.OverloadExamples( + "dpop.thumbprint(proof.jwk)", + ), + cel.UnaryBinding(func(arg ref.Val) ref.Val { + jwkVal := arg.Value() + jwkMap, ok := jwkVal.(map[string]any) + if !ok { + if genericMap, ok := jwkVal.(map[ref.Val]ref.Val); ok { + jwkMap = make(map[string]any, len(genericMap)) + for k, v := range genericMap { + jwkMap[fmt.Sprint(k.Value())] = v.Value() + } + } else { + return types.NewErr("expected map[string]dyn for JWK, got %T", jwkVal) + } + } + tp, err := ComputeJWKThumbprint(jwkMap) + if err != nil { + return types.NewErr("failed to compute JWK thumbprint: %w", err) + } + return types.String(tp) + }), + ), + ), + cel.Function("claim", + cel.FunctionDocs( + "Queries a custom claim value by key name from the DPoP proof payload, returning an optional dynamic value.", + ), + cel.MemberOverload("dpop_proof_claim_string", + []*cel.Type{celProofType, cel.StringType}, + cel.OptionalType(cel.DynType), + cel.OverloadExamples( + "proof.claim('nonce')", + ), + cel.BinaryBinding(func(targetVal, claimNameVal ref.Val) ref.Val { + target := targetVal.Value().(*Proof) + claimName := claimNameVal.(types.String) + return target.Claim(adapt(), string(claimName)) + }), + ), + cel.MemberOverload("dpop_proof_opt_claim_string", + []*cel.Type{cel.OptionalType(celProofType), cel.StringType}, + cel.OptionalType(cel.DynType), + cel.OverloadExamples( + "dpop.parse(proofStr).claim('nonce')", + ), + cel.BinaryBinding(func(targetVal, claimNameVal ref.Val) ref.Val { + optTarget := targetVal.(*types.Optional) + if !optTarget.HasValue() { + return types.OptionalNone + } + target, ok := optTarget.GetValue().Value().(*Proof) + if !ok { + return types.ValOrErr(optTarget.GetValue(), "expected dpop.Proof") + } + claimName := claimNameVal.(types.String) + return target.Claim(adapt(), string(claimName)) + }), + ), + ), + cel.Function("matchesRequest", + cel.FunctionDocs( + "Validates that the DPoP proof's htm and htu claims match the HTTP request method and target URI.", + "Performs RFC 3986 syntax and scheme normalization on the target URI, ignoring query and fragment parts.", + ), + cel.MemberOverload("dpop_proof_matches_request_string_string", + []*cel.Type{celProofType, cel.StringType, cel.StringType}, + cel.BoolType, + cel.OverloadExamples( + "proof.matchesRequest('POST', 'https://server.example.com/token')", + ), + cel.FunctionBinding(func(args ...ref.Val) ref.Val { + target := args[0].Value().(*Proof) + method := string(args[1].(types.String)) + targetURI := string(args[2].(types.String)) + return types.Bool(target.MatchesRequest(method, targetURI)) + }), + ), + cel.MemberOverload("dpop_proof_opt_matches_request_string_string", + []*cel.Type{cel.OptionalType(celProofType), cel.StringType, cel.StringType}, + cel.BoolType, + cel.OverloadExamples( + "dpop.parse(proofStr).matchesRequest('POST', 'https://server.example.com/token')", + ), + cel.FunctionBinding(func(args ...ref.Val) ref.Val { + optTarget := args[0].(*types.Optional) + if !optTarget.HasValue() { + return types.False + } + target, ok := optTarget.GetValue().Value().(*Proof) + if !ok { + return types.ValOrErr(optTarget.GetValue(), "expected dpop.Proof") + } + method := string(args[1].(types.String)) + targetURI := string(args[2].(types.String)) + return types.Bool(target.MatchesRequest(method, targetURI)) + }), + ), + ), + cel.Function("matchesMethod", + cel.FunctionDocs( + "Validates that the DPoP proof's htm claim matches the given HTTP method.", + ), + cel.MemberOverload("dpop_proof_matches_method_string", + []*cel.Type{celProofType, cel.StringType}, + cel.BoolType, + cel.BinaryBinding(func(targetVal, methodVal ref.Val) ref.Val { + target := targetVal.Value().(*Proof) + method := string(methodVal.(types.String)) + return types.Bool(target.MatchesMethod(method)) + }), + ), + cel.MemberOverload("dpop_proof_opt_matches_method_string", + []*cel.Type{cel.OptionalType(celProofType), cel.StringType}, + cel.BoolType, + cel.BinaryBinding(func(targetVal, methodVal ref.Val) ref.Val { + optTarget := targetVal.(*types.Optional) + if !optTarget.HasValue() { + return types.False + } + target, ok := optTarget.GetValue().Value().(*Proof) + if !ok { + return types.ValOrErr(optTarget.GetValue(), "expected dpop.Proof") + } + method := string(methodVal.(types.String)) + return types.Bool(target.MatchesMethod(method)) + }), + ), + ), + cel.Function("matchesURI", + cel.FunctionDocs( + "Validates that the DPoP proof's htu claim matches the given HTTP target URI (ignoring query/fragment, with normalization).", + ), + cel.MemberOverload("dpop_proof_matches_uri_string", + []*cel.Type{celProofType, cel.StringType}, + cel.BoolType, + cel.BinaryBinding(func(targetVal, uriVal ref.Val) ref.Val { + target := targetVal.Value().(*Proof) + targetURI := string(uriVal.(types.String)) + return types.Bool(target.MatchesURI(targetURI)) + }), + ), + cel.MemberOverload("dpop_proof_opt_matches_uri_string", + []*cel.Type{cel.OptionalType(celProofType), cel.StringType}, + cel.BoolType, + cel.BinaryBinding(func(targetVal, uriVal ref.Val) ref.Val { + optTarget := targetVal.(*types.Optional) + if !optTarget.HasValue() { + return types.False + } + target, ok := optTarget.GetValue().Value().(*Proof) + if !ok { + return types.ValOrErr(optTarget.GetValue(), "expected dpop.Proof") + } + targetURI := string(uriVal.(types.String)) + return types.Bool(target.MatchesURI(targetURI)) + }), + ), + ), + cel.Function("matchesHtu", + cel.FunctionDocs( + "Alias for matchesURI: validates that the DPoP proof's htu claim matches the given HTTP target URI.", + ), + cel.MemberOverload("dpop_proof_matches_htu_string", + []*cel.Type{celProofType, cel.StringType}, + cel.BoolType, + cel.BinaryBinding(func(targetVal, uriVal ref.Val) ref.Val { + target := targetVal.Value().(*Proof) + targetURI := string(uriVal.(types.String)) + return types.Bool(target.MatchesURI(targetURI)) + }), + ), + cel.MemberOverload("dpop_proof_opt_matches_htu_string", + []*cel.Type{cel.OptionalType(celProofType), cel.StringType}, + cel.BoolType, + cel.BinaryBinding(func(targetVal, uriVal ref.Val) ref.Val { + optTarget := targetVal.(*types.Optional) + if !optTarget.HasValue() { + return types.False + } + target, ok := optTarget.GetValue().Value().(*Proof) + if !ok { + return types.ValOrErr(optTarget.GetValue(), "expected dpop.Proof") + } + targetURI := string(uriVal.(types.String)) + return types.Bool(target.MatchesURI(targetURI)) + }), + ), + ), + cel.Function("matchesAccessToken", + cel.FunctionDocs( + "Validates that the DPoP proof's ath claim matches the base64url SHA-256 hash of the presented access token.", + ), + cel.MemberOverload("dpop_proof_matches_access_token_string", + []*cel.Type{celProofType, cel.StringType}, + cel.BoolType, + cel.BinaryBinding(func(targetVal, tokenVal ref.Val) ref.Val { + target := targetVal.Value().(*Proof) + tokenStr := string(tokenVal.(types.String)) + return types.Bool(target.MatchesAccessToken(tokenStr)) + }), + ), + cel.MemberOverload("dpop_proof_opt_matches_access_token_string", + []*cel.Type{cel.OptionalType(celProofType), cel.StringType}, + cel.BoolType, + cel.BinaryBinding(func(targetVal, tokenVal ref.Val) ref.Val { + optTarget := targetVal.(*types.Optional) + if !optTarget.HasValue() { + return types.False + } + target, ok := optTarget.GetValue().Value().(*Proof) + if !ok { + return types.ValOrErr(optTarget.GetValue(), "expected dpop.Proof") + } + tokenStr := string(tokenVal.(types.String)) + return types.Bool(target.MatchesAccessToken(tokenStr)) + }), + ), + ), + cel.Function("matchesNonce", + cel.FunctionDocs( + "Validates that the DPoP proof's nonce claim matches the expected server-provided nonce.", + ), + cel.MemberOverload("dpop_proof_matches_nonce_string", + []*cel.Type{celProofType, cel.StringType}, + cel.BoolType, + cel.BinaryBinding(func(targetVal, nonceVal ref.Val) ref.Val { + target := targetVal.Value().(*Proof) + nonceStr := string(nonceVal.(types.String)) + return types.Bool(target.MatchesNonce(nonceStr)) + }), + ), + cel.MemberOverload("dpop_proof_opt_matches_nonce_string", + []*cel.Type{cel.OptionalType(celProofType), cel.StringType}, + cel.BoolType, + cel.BinaryBinding(func(targetVal, nonceVal ref.Val) ref.Val { + optTarget := targetVal.(*types.Optional) + if !optTarget.HasValue() { + return types.False + } + target, ok := optTarget.GetValue().Value().(*Proof) + if !ok { + return types.ValOrErr(optTarget.GetValue(), "expected dpop.Proof") + } + nonceStr := string(nonceVal.(types.String)) + return types.Bool(target.MatchesNonce(nonceStr)) + }), + ), + ), + cel.Function("matchesConfirmation", + cel.FunctionDocs( + "Validates that the DPoP proof's public key thumbprint matches the jkt confirmation method from an access token or introspection map.", + ), + cel.MemberOverload("dpop_proof_matches_confirmation_string", + []*cel.Type{celProofType, cel.StringType}, + cel.BoolType, + cel.BinaryBinding(func(targetVal, jktVal ref.Val) ref.Val { + target := targetVal.Value().(*Proof) + jktStr := string(jktVal.(types.String)) + return types.Bool(target.MatchesConfirmationString(jktStr)) + }), + ), + cel.MemberOverload("dpop_proof_opt_matches_confirmation_string", + []*cel.Type{cel.OptionalType(celProofType), cel.StringType}, + cel.BoolType, + cel.BinaryBinding(func(targetVal, jktVal ref.Val) ref.Val { + optTarget := targetVal.(*types.Optional) + if !optTarget.HasValue() { + return types.False + } + target, ok := optTarget.GetValue().Value().(*Proof) + if !ok { + return types.ValOrErr(optTarget.GetValue(), "expected dpop.Proof") + } + jktStr := string(jktVal.(types.String)) + return types.Bool(target.MatchesConfirmationString(jktStr)) + }), + ), + cel.MemberOverload("dpop_proof_matches_confirmation_map", + []*cel.Type{celProofType, cel.MapType(cel.StringType, cel.DynType)}, + cel.BoolType, + cel.BinaryBinding(func(targetVal, cnfVal ref.Val) ref.Val { + target := targetVal.Value().(*Proof) + cnfMap, ok := cnfVal.Value().(map[string]any) + if !ok { + if genericMap, ok := cnfVal.Value().(map[ref.Val]ref.Val); ok { + cnfMap = make(map[string]any, len(genericMap)) + for k, v := range genericMap { + cnfMap[fmt.Sprint(k.Value())] = v.Value() + } + } else { + return types.False + } + } + return types.Bool(target.MatchesConfirmationMap(cnfMap)) + }), + ), + cel.MemberOverload("dpop_proof_opt_matches_confirmation_map", + []*cel.Type{cel.OptionalType(celProofType), cel.MapType(cel.StringType, cel.DynType)}, + cel.BoolType, + cel.BinaryBinding(func(targetVal, cnfVal ref.Val) ref.Val { + optTarget := targetVal.(*types.Optional) + if !optTarget.HasValue() { + return types.False + } + target, ok := optTarget.GetValue().Value().(*Proof) + if !ok { + return types.ValOrErr(optTarget.GetValue(), "expected dpop.Proof") + } + cnfMap, ok := cnfVal.Value().(map[string]any) + if !ok { + if genericMap, ok := cnfVal.Value().(map[ref.Val]ref.Val); ok { + cnfMap = make(map[string]any, len(genericMap)) + for k, v := range genericMap { + cnfMap[fmt.Sprint(k.Value())] = v.Value() + } + } else { + return types.False + } + } + return types.Bool(target.MatchesConfirmationMap(cnfMap)) + }), + ), + ), + cel.Function("matchesToken", + cel.FunctionDocs( + "Validates that the DPoP proof's public key matches the cnf.jkt confirmation claim inside a parsed jwt.Token.", + ), + cel.MemberOverload("dpop_proof_matches_token_jwt_token", + []*cel.Type{celProofType, celJWTTokenType}, + cel.BoolType, + cel.BinaryBinding(func(targetVal, tokenVal ref.Val) ref.Val { + target := targetVal.Value().(*Proof) + tok, ok := tokenVal.Value().(*jwt.Token) + if !ok { + return types.ValOrErr(tokenVal, "expected jwt.Token") + } + return types.Bool(target.MatchesToken(tok)) + }), + ), + cel.MemberOverload("dpop_proof_opt_matches_token_jwt_token", + []*cel.Type{cel.OptionalType(celProofType), celJWTTokenType}, + cel.BoolType, + cel.BinaryBinding(func(targetVal, tokenVal ref.Val) ref.Val { + optTarget := targetVal.(*types.Optional) + if !optTarget.HasValue() { + return types.False + } + target, ok := optTarget.GetValue().Value().(*Proof) + if !ok { + return types.ValOrErr(optTarget.GetValue(), "expected dpop.Proof") + } + tok, ok := tokenVal.Value().(*jwt.Token) + if !ok { + return types.ValOrErr(tokenVal, "expected jwt.Token") + } + return types.Bool(target.MatchesToken(tok)) + }), + ), + cel.MemberOverload("dpop_proof_opt_matches_token_opt_jwt_token", + []*cel.Type{cel.OptionalType(celProofType), cel.OptionalType(celJWTTokenType)}, + cel.BoolType, + cel.BinaryBinding(func(targetVal, tokenVal ref.Val) ref.Val { + optTarget := targetVal.(*types.Optional) + if !optTarget.HasValue() { + return types.False + } + target, ok := optTarget.GetValue().Value().(*Proof) + if !ok { + return types.ValOrErr(optTarget.GetValue(), "expected dpop.Proof") + } + optTok := tokenVal.(*types.Optional) + if !optTok.HasValue() { + return types.False + } + tok, ok := optTok.GetValue().Value().(*jwt.Token) + if !ok { + return types.ValOrErr(optTok.GetValue(), "expected jwt.Token") + } + return types.Bool(target.MatchesToken(tok)) + }), + ), + cel.MemberOverload("dpop_proof_matches_token_opt_jwt_token", + []*cel.Type{celProofType, cel.OptionalType(celJWTTokenType)}, + cel.BoolType, + cel.BinaryBinding(func(targetVal, tokenVal ref.Val) ref.Val { + target := targetVal.Value().(*Proof) + optTok := tokenVal.(*types.Optional) + if !optTok.HasValue() { + return types.False + } + tok, ok := optTok.GetValue().Value().(*jwt.Token) + if !ok { + return types.ValOrErr(optTok.GetValue(), "expected jwt.Token") + } + return types.Bool(target.MatchesToken(tok)) + }), + ), + ), + } +} + +// ProgramOptions returns program options for DPoP extensions. +func (l *dpopLib) ProgramOptions() []cel.ProgramOption { + return nil +} + +func (l *dpopLib) isProofTimeValid(p *Proof) bool { + return !l.validateTimes || p.IsValidAt(l.now(), l.maxAge, l.clockLeeway) +} + +// Proof represents a parsed OAuth 2.0 DPoP proof JWT (RFC 9449). +// A Proof instance and its associated Header and Payload maps MUST be treated as immutable once parsed or created. +type Proof struct { + // Header fields + Algorithm string `json:"alg" cel:"alg"` + KeyID string `json:"kid,omitempty" cel:"keyId"` + Type string `json:"typ" cel:"type"` + JWK map[string]any `json:"jwk" cel:"jwk"` + + // Derived public key thumbprint (RFC 7638 SHA-256 JWK Thumbprint) + Thumbprint string `json:"jkt" cel:"thumbprint"` + + // Payload claims (RFC 9449) + ID string `json:"jti" cel:"id"` + Method string `json:"htm" cel:"method"` + URI string `json:"htu" cel:"uri"` + IssuedAt time.Time `json:"iat" cel:"iat"` + AccessTokenHash string `json:"ath,omitempty" cel:"ath"` + Nonce string `json:"nonce,omitempty" cel:"nonce"` + + // Raw JSON payload and header associated with the proof including custom claims. + // Must be treated as read-only once initialized. + Payload map[string]any `json:"-" cel:"-"` + Header map[string]any `json:"-" cel:"-"` +} + +// IsValidAt checks whether the creation timestamp (iat) is valid relative to refTime, optional maxAge, and clock leeway tolerance. +func (p *Proof) IsValidAt(refTime time.Time, maxAge time.Duration, leeway time.Duration) bool { + now := refTime.UTC() + lateNow := now.Add(leeway) + + // Issued-at time is present and in the future. + if !p.IssuedAt.IsZero() && p.IssuedAt.Compare(lateNow) > 0 { + return false + } + // Check max age if configured + if maxAge > 0 && !p.IssuedAt.IsZero() { + earlyBound := now.Add(-maxAge - leeway) + if p.IssuedAt.Compare(earlyBound) < 0 { + return false + } + } + + return true +} + +// MatchesMethod checks whether the DPoP proof's htm claim matches the given HTTP request method. +func (p *Proof) MatchesMethod(method string) bool { + return strings.EqualFold(strings.TrimSpace(p.Method), strings.TrimSpace(method)) +} + +// MatchesURI checks whether the DPoP proof's htu claim matches the given target URI per RFC 9449 / RFC 3986. +// Normalizes scheme/host to lowercase, removes default ports (:80, :443), cleans path segments, and ignores query/fragment. +func (p *Proof) MatchesURI(targetURI string) bool { + normProofURI, err := normalizeTargetURI(p.URI) + if err != nil { + return false + } + normTargetURI, err := normalizeTargetURI(targetURI) + if err != nil { + return false + } + return normProofURI == normTargetURI +} + +// MatchesRequest checks whether both the method (htm) and target URI (htu) match the incoming HTTP request. +func (p *Proof) MatchesRequest(method, targetURI string) bool { + return p.MatchesMethod(method) && p.MatchesURI(targetURI) +} + +// MatchesAccessToken validates that the DPoP proof's ath claim equals the SHA-256 base64url hash of the presented access token. +func (p *Proof) MatchesAccessToken(accessToken string) bool { + if p.AccessTokenHash == "" { + return false + } + expectedATH := ComputeAccessTokenHash(accessToken) + return subtle.ConstantTimeCompare([]byte(p.AccessTokenHash), []byte(expectedATH)) == 1 +} + +// MatchesNonce validates that the DPoP proof's nonce claim matches the expected nonce. +func (p *Proof) MatchesNonce(expectedNonce string) bool { + if p.Nonce == "" || expectedNonce == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(p.Nonce), []byte(expectedNonce)) == 1 +} + +// MatchesConfirmationString validates that the DPoP proof's JWK thumbprint matches the given jkt string. +func (p *Proof) MatchesConfirmationString(jkt string) bool { + if p.Thumbprint == "" || jkt == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(p.Thumbprint), []byte(jkt)) == 1 +} + +// MatchesConfirmationMap validates that the DPoP proof's JWK thumbprint matches the jkt member in a cnf confirmation map. +func (p *Proof) MatchesConfirmationMap(cnf map[string]any) bool { + if cnf == nil { + return false + } + jkt, ok := cnf["jkt"].(string) + if !ok || jkt == "" { + return false + } + return p.MatchesConfirmationString(jkt) +} + +// MatchesToken validates that the DPoP proof's JWK thumbprint matches the cnf.jkt confirmation claim inside a jwt.Token. +func (p *Proof) MatchesToken(token *jwt.Token) bool { + if token == nil || token.Payload == nil { + return false + } + cnf, ok := token.Payload["cnf"].(map[string]any) + if !ok || cnf == nil { + return false + } + return p.MatchesConfirmationMap(cnf) +} + +// Claim queries a claim value by key name using the provided types.Adapter, returning an optional dyn value. +func (p *Proof) Claim(adapter types.Adapter, claimName string) ref.Val { + val, ok := p.Payload[claimName] + if !ok || val == nil { + return types.OptionalNone + } + refVal := adapter.NativeToValue(val) + if types.IsError(refVal) { + return refVal + } + return types.OptionalOf(refVal) +} + +// NewProof constructs a validated Proof from decoded JSON header and payload maps. +// Signature verification of the proof must be performed prior to passing the proof to CEL. +func NewProof(header, payload map[string]any) (*Proof, error) { + typ, ok := header["typ"].(string) + if !ok || !strings.EqualFold(strings.TrimSpace(typ), dpopHeaderType) { + return nil, fmt.Errorf("invalid or missing 'typ' header: expected %q, got %q", dpopHeaderType, typ) + } + + alg, ok := header["alg"].(string) + if !ok || alg == "" { + return nil, fmt.Errorf("missing required header: 'alg'") + } + if strings.EqualFold(alg, "none") { + return nil, fmt.Errorf("insecure algorithm 'none' is not allowed for DPoP proof") + } + if strings.HasPrefix(strings.ToUpper(alg), "HS") { + return nil, fmt.Errorf("symmetric MAC algorithm %q is not allowed for DPoP proof", alg) + } + + jwkRaw, ok := header["jwk"] + if !ok || jwkRaw == nil { + return nil, fmt.Errorf("missing required header: 'jwk'") + } + jwkMap, ok := jwkRaw.(map[string]any) + if !ok || len(jwkMap) == 0 { + return nil, fmt.Errorf("invalid header 'jwk': expected non-empty JSON object") + } + + // Validate JWK does not contain private key components (RFC 9449 Section 4.2) + if err := validateJWKPublicKeyOnly(jwkMap); err != nil { + return nil, err + } + + thumbprint, err := ComputeJWKThumbprint(jwkMap) + if err != nil { + return nil, fmt.Errorf("failed to compute JWK thumbprint: %w", err) + } + + jti, ok := payload["jti"].(string) + if !ok || strings.TrimSpace(jti) == "" { + return nil, fmt.Errorf("missing required claim: 'jti'") + } + + htm, ok := payload["htm"].(string) + if !ok || strings.TrimSpace(htm) == "" { + return nil, fmt.Errorf("missing required claim: 'htm'") + } + + htu, ok := payload["htu"].(string) + if !ok || strings.TrimSpace(htu) == "" { + return nil, fmt.Errorf("missing required claim: 'htu'") + } + + iat, err := types.ParseTimestamp(payload["iat"]) + if err != nil || iat.IsZero() { + return nil, fmt.Errorf("missing or invalid required claim: 'iat'") + } + + return &Proof{ + Algorithm: alg, + KeyID: optString(header, "kid"), + Type: typ, + JWK: jwkMap, + Thumbprint: thumbprint, + ID: jti, + Method: htm, + URI: htu, + IssuedAt: iat, + AccessTokenHash: optString(payload, "ath"), + Nonce: optString(payload, "nonce"), + Payload: payload, + Header: header, + }, nil +} + +// ParseProof parses a DPoP proof JWT string into a structured Proof. +// Automatically strips leading 'DPoP ' prefixes if present. +func ParseProof(proofStr string) (*Proof, error) { + proofStr = trimDPoPPrefix(proofStr) + if len(proofStr) > maxProofSize { + return nil, fmt.Errorf("dpop proof size exceeds maximum allowed limit of %d bytes", maxProofSize) + } + + parts := strings.SplitN(proofStr, ".", 4) + if len(parts) < 2 || len(parts) > 3 { + return nil, fmt.Errorf("invalid token format: expected 2 or 3 parts, got %d", len(parts)) + } + + headerBytes, err := decodeBase64Segment(parts[0]) + if err != nil { + return nil, fmt.Errorf("failed to decode header: %w", err) + } + + var header map[string]any + if err := json.Unmarshal(headerBytes, &header); err != nil { + return nil, fmt.Errorf("failed to parse header JSON: %w", err) + } + + payloadBytes, err := decodeBase64Segment(parts[1]) + if err != nil { + return nil, fmt.Errorf("failed to decode payload: %w", err) + } + + var payload map[string]any + if err := json.Unmarshal(payloadBytes, &payload); err != nil { + return nil, fmt.Errorf("failed to parse payload JSON: %w", err) + } + + return NewProof(header, payload) +} + +// ComputeAccessTokenHash computes the RFC 9449 access token hash (ath): base64url(sha256(access_token)). +// Automatically strips leading 'DPoP ' or 'Bearer ' prefixes if present. +func ComputeAccessTokenHash(token string) string { + token = trimTokenPrefix(token) + h := sha256.Sum256([]byte(token)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} + +// ComputeJWKThumbprint computes the RFC 7638 SHA-256 JWK Thumbprint (base64url encoded without padding). +func ComputeJWKThumbprint(jwk map[string]any) (string, error) { + kty, ok := jwk["kty"].(string) + if !ok || kty == "" { + return "", fmt.Errorf("missing or invalid 'kty' in JWK") + } + + var canonicalJSON string + switch kty { + case "RSA": + e, ok := jwk["e"].(string) + if !ok || e == "" { + return "", fmt.Errorf("missing or invalid 'e' in RSA JWK") + } + n, ok := jwk["n"].(string) + if !ok || n == "" { + return "", fmt.Errorf("missing or invalid 'n' in RSA JWK") + } + canonicalJSON = fmt.Sprintf(`{"e":%q,"kty":"RSA","n":%q}`, e, n) + + case "EC": + crv, ok := jwk["crv"].(string) + if !ok || crv == "" { + return "", fmt.Errorf("missing or invalid 'crv' in EC JWK") + } + x, ok := jwk["x"].(string) + if !ok || x == "" { + return "", fmt.Errorf("missing or invalid 'x' in EC JWK") + } + y, ok := jwk["y"].(string) + if !ok || y == "" { + return "", fmt.Errorf("missing or invalid 'y' in EC JWK") + } + canonicalJSON = fmt.Sprintf(`{"crv":%q,"kty":"EC","x":%q,"y":%q}`, crv, x, y) + + case "OKP": + crv, ok := jwk["crv"].(string) + if !ok || crv == "" { + return "", fmt.Errorf("missing or invalid 'crv' in OKP JWK") + } + x, ok := jwk["x"].(string) + if !ok || x == "" { + return "", fmt.Errorf("missing or invalid 'x' in OKP JWK") + } + canonicalJSON = fmt.Sprintf(`{"crv":%q,"kty":"OKP","x":%q}`, crv, x) + + default: + return "", fmt.Errorf("unsupported key type %q for JWK thumbprint calculation", kty) + } + + h := sha256.Sum256([]byte(canonicalJSON)) + return base64.RawURLEncoding.EncodeToString(h[:]), nil +} + +func validateJWKPublicKeyOnly(jwk map[string]any) error { + kty, _ := jwk["kty"].(string) + if strings.EqualFold(kty, "oct") { + return fmt.Errorf("symmetric key type 'oct' is forbidden in DPoP JWK header") + } + + // Forbidden private key members across RSA, EC, OKP, and generic JWKs + forbiddenPrivateFields := []string{"d", "p", "q", "dp", "dq", "qi", "dmp1", "dmq1", "oth", "k"} + for _, field := range forbiddenPrivateFields { + if _, exists := jwk[field]; exists { + return fmt.Errorf("private key parameter %q MUST NOT be present in DPoP proof JWK header", field) + } + } + return nil +} + +func normalizeTargetURI(rawURI string) (string, error) { + rawURI = strings.TrimSpace(rawURI) + if rawURI == "" { + return "", fmt.Errorf("empty URI") + } + + u, err := url.Parse(rawURI) + if err != nil { + return "", fmt.Errorf("invalid URI %q: %w", rawURI, err) + } + + if u.Scheme == "" || u.Host == "" { + return "", fmt.Errorf("URI must include scheme and host: %q", rawURI) + } + + scheme := strings.ToLower(u.Scheme) + host := strings.ToLower(u.Hostname()) + port := u.Port() + + // Strip standard default ports per scheme + if (scheme == "http" && port == "80") || (scheme == "https" && port == "443") { + port = "" + } + + effectiveHost := host + if strings.Contains(host, ":") { // IPv6 literal + effectiveHost = "[" + strings.Trim(host, "[]") + "]" + } + if port != "" { + effectiveHost = effectiveHost + ":" + port + } + + cleanPath := path.Clean(u.Path) + if cleanPath == "." || cleanPath == "" { + cleanPath = "/" + } else if !strings.HasPrefix(cleanPath, "/") { + cleanPath = "/" + cleanPath + } + + return scheme + "://" + effectiveHost + cleanPath, nil +} + +func optString(m map[string]any, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +func trimDPoPPrefix(proofStr string) string { + proofStr = strings.TrimSpace(proofStr) + if strings.HasPrefix(strings.ToLower(proofStr), "dpop ") { + return strings.TrimSpace(proofStr[5:]) + } + return proofStr +} + +func trimTokenPrefix(tokenStr string) string { + tokenStr = strings.TrimSpace(tokenStr) + lower := strings.ToLower(tokenStr) + if strings.HasPrefix(lower, "dpop ") { + return strings.TrimSpace(tokenStr[5:]) + } + if strings.HasPrefix(lower, "bearer ") { + return strings.TrimSpace(tokenStr[7:]) + } + return tokenStr +} + +func decodeBase64Segment(seg string) ([]byte, error) { + seg = strings.TrimSpace(seg) + if b, err := base64.RawURLEncoding.DecodeString(seg); err == nil { + return b, nil + } + if b, err := base64.URLEncoding.DecodeString(seg); err == nil { + return b, nil + } + if b, err := base64.RawStdEncoding.DecodeString(seg); err == nil { + return b, nil + } + return base64.StdEncoding.DecodeString(seg) +} diff --git a/ext/security/dpop/dpop_test.go b/ext/security/dpop/dpop_test.go new file mode 100644 index 000000000..62b4218d8 --- /dev/null +++ b/ext/security/dpop/dpop_test.go @@ -0,0 +1,699 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dpop_test + +import ( + "encoding/base64" + "encoding/json" + "reflect" + "testing" + "time" + + "cel.dev/cel-go/cel" + "cel.dev/cel-go/ext/security/dpop" + "cel.dev/cel-go/ext/security/jwt" +) + +func createTestDPoP(t *testing.T, header, payload map[string]any) string { + t.Helper() + hBytes, err := json.Marshal(header) + if err != nil { + t.Fatalf("json.Marshal header failed: %v", err) + } + pBytes, err := json.Marshal(payload) + if err != nil { + t.Fatalf("json.Marshal payload failed: %v", err) + } + + hB64 := base64.RawURLEncoding.EncodeToString(hBytes) + pB64 := base64.RawURLEncoding.EncodeToString(pBytes) + sigB64 := base64.RawURLEncoding.EncodeToString([]byte("signature-placeholder")) + + return hB64 + "." + pB64 + "." + sigB64 +} + +func createTestJWT(t *testing.T, header, payload map[string]any) string { + t.Helper() + hBytes, err := json.Marshal(header) + if err != nil { + t.Fatalf("json.Marshal header failed: %v", err) + } + pBytes, err := json.Marshal(payload) + if err != nil { + t.Fatalf("json.Marshal payload failed: %v", err) + } + + hB64 := base64.RawURLEncoding.EncodeToString(hBytes) + pB64 := base64.RawURLEncoding.EncodeToString(pBytes) + sigB64 := base64.RawURLEncoding.EncodeToString([]byte("sig")) + + return hB64 + "." + pB64 + "." + sigB64 +} + +func evalExpr(t *testing.T, env *cel.Env, expr string, vars map[string]any) any { + t.Helper() + ast, issues := env.Compile(expr) + if issues != nil && issues.Err() != nil { + t.Fatalf("Compile(%q) failed: %v", expr, issues.Err()) + } + prg, err := env.Program(ast) + if err != nil { + t.Fatalf("Program(%q) failed: %v", expr, err) + } + val, _, err := prg.Eval(vars) + if err != nil { + t.Fatalf("Eval(%q) failed: %v", expr, err) + } + return val.Value() +} + +func TestRFC9449Vectors(t *testing.T) { + // RFC 9449 Section 4.1 Figure 2 / Figure 4 + ecJWK := map[string]any{ + "kty": "EC", + "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs", + "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA", + "crv": "P-256", + } + header := map[string]any{ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": ecJWK, + } + payload := map[string]any{ + "jti": "-BwC3ESc6acc2lTc", + "htm": "POST", + "htu": "https://server.example.com/token", + "iat": 1562262616, + } + + proofStr := createTestDPoP(t, header, payload) + proof, err := dpop.ParseProof(proofStr) + if err != nil { + t.Fatalf("ParseProof failed: %v", err) + } + + // RFC 9449 Section 6.1 Figure 9 expected JWK thumbprint (jkt) + expectedJKT := "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" + if proof.Thumbprint != expectedJKT { + t.Errorf("Thumbprint = %q, want %q", proof.Thumbprint, expectedJKT) + } + + // RFC 9449 Section 7 Figure 13 / 14 expected access token hash (ath) + accessToken := "Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU" + expectedATH := "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo" + computedATH := dpop.ComputeAccessTokenHash(accessToken) + if computedATH != expectedATH { + t.Errorf("ComputeAccessTokenHash = %q, want %q", computedATH, expectedATH) + } + + // Test with "DPoP " prefix + computedATHWithPrefix := dpop.ComputeAccessTokenHash("DPoP " + accessToken) + if computedATHWithPrefix != expectedATH { + t.Errorf("ComputeAccessTokenHash with DPoP prefix = %q, want %q", computedATHWithPrefix, expectedATH) + } +} + +func TestDPoPCELIntegration(t *testing.T) { + ecJWK := map[string]any{ + "kty": "EC", + "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs", + "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA", + "crv": "P-256", + } + header := map[string]any{ + "typ": "dpop+jwt", + "alg": "ES256", + "kid": "key-test-1", + "jwk": ecJWK, + } + payload := map[string]any{ + "jti": "e1j3V_bKic8-LAEB", + "htm": "GET", + "htu": "https://resource.example.org/protectedresource", + "iat": 1562262618, + "ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo", + "nonce": "nonce-xyz-123", + "custom": "custom-value", + } + + proofStr := createTestDPoP(t, header, payload) + accessTokenStr := "Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU" + dpopAuthHeader := "DPoP " + accessTokenStr + + // Create a JWT access token with cnf claim containing jkt + jwtAccessHeader := map[string]any{"alg": "ES256", "typ": "JWT"} + jwtAccessPayload := map[string]any{ + "iss": "https://server.example.com", + "sub": "someone@example.com", + "aud": "https://resource.example.org", + "exp": 1562266216, + "iat": 1562262616, + "cnf": map[string]any{ + "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I", + }, + } + jwtTokenStr := createTestJWT(t, jwtAccessHeader, jwtAccessPayload) + + env, err := cel.NewEnv( + dpop.Library(), + jwt.Library(), + cel.Variable("proofStr", cel.StringType), + cel.Variable("dpopHeader", cel.StringType), + cel.Variable("tokenStr", cel.StringType), + cel.Variable("authHeader", cel.StringType), + cel.Variable("jwtTokenStr", cel.StringType), + ) + if err != nil { + t.Fatalf("cel.NewEnv failed: %v", err) + } + + vars := map[string]any{ + "proofStr": proofStr, + "dpopHeader": "DPoP " + proofStr, + "tokenStr": accessTokenStr, + "authHeader": dpopAuthHeader, + "jwtTokenStr": jwtTokenStr, + } + + tests := []struct { + name string + expr string + want any + }{ + { + name: "parse_has_value", + expr: `dpop.parse(proofStr).hasValue()`, + want: true, + }, + { + name: "parse_dpop_header_prefix", + expr: `dpop.parse(dpopHeader).hasValue()`, + want: true, + }, + { + name: "proof_alg", + expr: `dpop.parse(proofStr).value().alg == 'ES256'`, + want: true, + }, + { + name: "proof_keyId", + expr: `dpop.parse(proofStr).value().keyId == 'key-test-1'`, + want: true, + }, + { + name: "proof_type", + expr: `dpop.parse(proofStr).value().type == 'dpop+jwt'`, + want: true, + }, + { + name: "proof_id", + expr: `dpop.parse(proofStr).value().id == 'e1j3V_bKic8-LAEB'`, + want: true, + }, + { + name: "proof_method", + expr: `dpop.parse(proofStr).value().method == 'GET'`, + want: true, + }, + { + name: "proof_uri", + expr: `dpop.parse(proofStr).value().uri == 'https://resource.example.org/protectedresource'`, + want: true, + }, + { + name: "proof_ath", + expr: `dpop.parse(proofStr).value().ath == 'fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo'`, + want: true, + }, + { + name: "proof_nonce", + expr: `dpop.parse(proofStr).value().nonce == 'nonce-xyz-123'`, + want: true, + }, + { + name: "proof_thumbprint", + expr: `dpop.parse(proofStr).value().thumbprint == '0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I'`, + want: true, + }, + { + name: "dpop_ath_func", + expr: `dpop.ath(tokenStr) == 'fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo'`, + want: true, + }, + { + name: "dpop_ath_from_auth_header", + expr: `dpop.ath(authHeader) == 'fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo'`, + want: true, + }, + { + name: "dpop_thumbprint_func", + expr: `dpop.thumbprint(dpop.parse(proofStr).value().jwk) == '0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I'`, + want: true, + }, + { + name: "claim_custom", + expr: `dpop.parse(proofStr).value().claim('custom').orValue('') == 'custom-value'`, + want: true, + }, + { + name: "claim_on_opt", + expr: `dpop.parse(proofStr).claim('custom').orValue('') == 'custom-value'`, + want: true, + }, + { + name: "claim_missing", + expr: `dpop.parse(proofStr).claim('missing').hasValue()`, + want: false, + }, + { + name: "matchesMethod_exact", + expr: `dpop.parse(proofStr).matchesMethod('GET')`, + want: true, + }, + { + name: "matchesMethod_case_insensitive", + expr: `dpop.parse(proofStr).matchesMethod('get')`, + want: true, + }, + { + name: "matchesMethod_mismatch", + expr: `dpop.parse(proofStr).matchesMethod('POST')`, + want: false, + }, + { + name: "matchesURI_exact", + expr: `dpop.parse(proofStr).matchesURI('https://resource.example.org/protectedresource')`, + want: true, + }, + { + name: "matchesHtu_alias", + expr: `dpop.parse(proofStr).matchesHtu('https://resource.example.org/protectedresource')`, + want: true, + }, + { + name: "matchesURI_with_default_port_and_case", + expr: `dpop.parse(proofStr).matchesURI('HTTPS://RESOURCE.EXAMPLE.ORG:443/protectedresource')`, + want: true, + }, + { + name: "matchesURI_ignores_query_and_fragment", + expr: `dpop.parse(proofStr).matchesURI('https://resource.example.org/protectedresource?query=1#frag')`, + want: true, + }, + { + name: "matchesURI_path_cleaning", + expr: `dpop.parse(proofStr).matchesURI('https://resource.example.org/foo/../protectedresource')`, + want: true, + }, + { + name: "matchesURI_mismatch", + expr: `dpop.parse(proofStr).matchesURI('https://other.example.org/protectedresource')`, + want: false, + }, + { + name: "matchesRequest_success", + expr: `dpop.parse(proofStr).matchesRequest('GET', 'https://resource.example.org/protectedresource')`, + want: true, + }, + { + name: "matchesRequest_on_value", + expr: `dpop.parse(proofStr).value().matchesRequest('GET', 'https://resource.example.org/protectedresource')`, + want: true, + }, + { + name: "matchesRequest_method_mismatch", + expr: `dpop.parse(proofStr).matchesRequest('POST', 'https://resource.example.org/protectedresource')`, + want: false, + }, + { + name: "matchesAccessToken_raw", + expr: `dpop.parse(proofStr).matchesAccessToken(tokenStr)`, + want: true, + }, + { + name: "matchesAccessToken_header", + expr: `dpop.parse(proofStr).matchesAccessToken(authHeader)`, + want: true, + }, + { + name: "matchesAccessToken_mismatch", + expr: `dpop.parse(proofStr).matchesAccessToken('wrong-token')`, + want: false, + }, + { + name: "matchesNonce_success", + expr: `dpop.parse(proofStr).matchesNonce('nonce-xyz-123')`, + want: true, + }, + { + name: "matchesNonce_mismatch", + expr: `dpop.parse(proofStr).matchesNonce('other-nonce')`, + want: false, + }, + { + name: "matchesConfirmation_string", + expr: `dpop.parse(proofStr).matchesConfirmation('0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I')`, + want: true, + }, + { + name: "matchesConfirmation_map", + expr: `dpop.parse(proofStr).matchesConfirmation({'jkt': '0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I'})`, + want: true, + }, + { + name: "matchesToken_with_parsed_jwt", + expr: `dpop.parse(proofStr).matchesToken(jwt.parse(jwtTokenStr))`, + want: true, + }, + { + name: "matchesToken_with_parsed_jwt_value", + expr: `dpop.parse(proofStr).matchesToken(jwt.parse(jwtTokenStr).value())`, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := evalExpr(t, env, tc.expr, vars) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("Eval(%q) = %v (%T), want %v (%T)", tc.expr, got, got, tc.want, tc.want) + } + }) + } +} + +func TestJWKThumbprintAlgorithms(t *testing.T) { + // RSA Key thumbprint test + rsaJWK := map[string]any{ + "kty": "RSA", + "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + "e": "AQAB", + } + rsaThumbprint, err := dpop.ComputeJWKThumbprint(rsaJWK) + if err != nil { + t.Fatalf("ComputeJWKThumbprint(RSA) failed: %v", err) + } + // RFC 7638 Section 3.1 RSA thumbprint test vector: NzbLsHIexยอด... + // NzbLsHIexUVQuOfNReIsTyXOvjX676Yu5_BpYZShqKE + expectedRSA := "NzbLsHIexUVQuOfNReIsTyXOvjX676Yu5_BpYZShqKE" + if rsaThumbprint != expectedRSA { + t.Errorf("RSA Thumbprint = %q, want %q", rsaThumbprint, expectedRSA) + } + + // OKP Key (Ed25519) thumbprint test (RFC 8037) + okpJWK := map[string]any{ + "kty": "OKP", + "crv": "Ed25519", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + } + okpThumbprint, err := dpop.ComputeJWKThumbprint(okpJWK) + if err != nil { + t.Fatalf("ComputeJWKThumbprint(OKP) failed: %v", err) + } + if okpThumbprint == "" { + t.Errorf("expected non-empty OKP thumbprint") + } + + // Unsupported key type + badJWK := map[string]any{ + "kty": "UNKNOWN", + } + if _, err := dpop.ComputeJWKThumbprint(badJWK); err == nil { + t.Errorf("expected error for unknown kty") + } +} + +func TestSecurityValidations(t *testing.T) { + validJWK := map[string]any{ + "kty": "EC", + "crv": "P-256", + "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs", + "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA", + } + + tests := []struct { + name string + header map[string]any + payload map[string]any + expectErr bool + }{ + { + name: "insecure_alg_none", + header: map[string]any{ + "typ": "dpop+jwt", + "alg": "none", + "jwk": validJWK, + }, + payload: map[string]any{ + "jti": "id-1", + "htm": "GET", + "htu": "https://server.example.com", + "iat": 1562262616, + }, + expectErr: true, + }, + { + name: "symmetric_alg_hs256", + header: map[string]any{ + "typ": "dpop+jwt", + "alg": "HS256", + "jwk": validJWK, + }, + payload: map[string]any{ + "jti": "id-1", + "htm": "GET", + "htu": "https://server.example.com", + "iat": 1562262616, + }, + expectErr: true, + }, + { + name: "symmetric_key_type_oct", + header: map[string]any{ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": map[string]any{ + "kty": "oct", + "k": "secret-key", + }, + }, + payload: map[string]any{ + "jti": "id-1", + "htm": "GET", + "htu": "https://server.example.com", + "iat": 1562262616, + }, + expectErr: true, + }, + { + name: "private_key_parameter_in_jwk", + header: map[string]any{ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": map[string]any{ + "kty": "EC", + "crv": "P-256", + "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs", + "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA", + "d": "private-key-d-field", + }, + }, + payload: map[string]any{ + "jti": "id-1", + "htm": "GET", + "htu": "https://server.example.com", + "iat": 1562262616, + }, + expectErr: true, + }, + { + name: "invalid_typ", + header: map[string]any{ + "typ": "JWT", + "alg": "ES256", + "jwk": validJWK, + }, + payload: map[string]any{ + "jti": "id-1", + "htm": "GET", + "htu": "https://server.example.com", + "iat": 1562262616, + }, + expectErr: true, + }, + { + name: "missing_jti", + header: map[string]any{ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": validJWK, + }, + payload: map[string]any{ + "htm": "GET", + "htu": "https://server.example.com", + "iat": 1562262616, + }, + expectErr: true, + }, + { + name: "missing_htm", + header: map[string]any{ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": validJWK, + }, + payload: map[string]any{ + "jti": "id-1", + "htu": "https://server.example.com", + "iat": 1562262616, + }, + expectErr: true, + }, + { + name: "missing_htu", + header: map[string]any{ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": validJWK, + }, + payload: map[string]any{ + "jti": "id-1", + "htm": "GET", + "iat": 1562262616, + }, + expectErr: true, + }, + { + name: "missing_iat", + header: map[string]any{ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": validJWK, + }, + payload: map[string]any{ + "jti": "id-1", + "htm": "GET", + "htu": "https://server.example.com", + }, + expectErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + proofStr := createTestDPoP(t, tc.header, tc.payload) + _, err := dpop.ParseProof(proofStr) + if (err != nil) != tc.expectErr { + t.Errorf("ParseProof() err = %v, expectErr = %v", err, tc.expectErr) + } + }) + } +} + +func TestTimeValidationOptions(t *testing.T) { + fixedNow := time.Unix(1700000000, 0).UTC() + header := map[string]any{ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": map[string]any{ + "kty": "EC", + "crv": "P-256", + "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs", + "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA", + }, + } + + tokValid := createTestDPoP(t, header, map[string]any{ + "jti": "id-valid", + "htm": "GET", + "htu": "https://server.example.com", + "iat": fixedNow.Add(-30 * time.Second).Unix(), + }) + tokFuture := createTestDPoP(t, header, map[string]any{ + "jti": "id-future", + "htm": "GET", + "htu": "https://server.example.com", + "iat": fixedNow.Add(2 * time.Minute).Unix(), + }) + tokExpired := createTestDPoP(t, header, map[string]any{ + "jti": "id-expired", + "htm": "GET", + "htu": "https://server.example.com", + "iat": fixedNow.Add(-10 * time.Minute).Unix(), + }) + + env, err := cel.NewEnv( + dpop.Library( + dpop.Clock(func() time.Time { return fixedNow }), + dpop.ValidateTimes(5*time.Minute, 10*time.Second), + dpop.AllowedAlgorithms("ES256", "PS256"), + ), + cel.Variable("tokValid", cel.StringType), + cel.Variable("tokFuture", cel.StringType), + cel.Variable("tokExpired", cel.StringType), + ) + if err != nil { + t.Fatalf("cel.NewEnv failed: %v", err) + } + + vars := map[string]any{ + "tokValid": tokValid, + "tokFuture": tokFuture, + "tokExpired": tokExpired, + } + + if got := evalExpr(t, env, `dpop.parse(tokValid).hasValue()`, vars); got != true { + t.Errorf("expected tokValid to have value, got %v", got) + } + if got := evalExpr(t, env, `dpop.parse(tokFuture).hasValue()`, vars); got != false { + t.Errorf("expected tokFuture to be None, got %v", got) + } + if got := evalExpr(t, env, `dpop.parse(tokExpired).hasValue()`, vars); got != false { + t.Errorf("expected tokExpired to be None, got %v", got) + } +} + +func TestAllowedAlgorithmsOption(t *testing.T) { + headerRS := map[string]any{ + "typ": "dpop+jwt", + "alg": "RS256", + "jwk": map[string]any{ + "kty": "RSA", + "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + "e": "AQAB", + }, + } + tokRS := createTestDPoP(t, headerRS, map[string]any{ + "jti": "id-rs", + "htm": "GET", + "htu": "https://server.example.com", + "iat": time.Now().Unix(), + }) + + // Allow only ES256 + env, err := cel.NewEnv( + dpop.Library(dpop.AllowedAlgorithms("ES256")), + cel.Variable("tokRS", cel.StringType), + ) + if err != nil { + t.Fatalf("cel.NewEnv failed: %v", err) + } + + vars := map[string]any{"tokRS": tokRS} + if got := evalExpr(t, env, `dpop.parse(tokRS).hasValue()`, vars); got != false { + t.Errorf("expected tokRS to be rejected by allowed algorithms, got %v", got) + } +} diff --git a/ext/security/dpop/export_test.go b/ext/security/dpop/export_test.go new file mode 100644 index 000000000..9e3f9262f --- /dev/null +++ b/ext/security/dpop/export_test.go @@ -0,0 +1,20 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dpop + +// NewDPoPLib constructs an internal dpopLib instance for testing. +func NewDPoPLib() *dpopLib { + return &dpopLib{} +} From 5beb5bfa887a2b3f94bd91e9d4a906c505502716 Mon Sep 17 00:00:00 2001 From: TristonianJones Date: Tue, 25 Aug 2026 13:26:03 -0700 Subject: [PATCH 2/3] DPoP library for working with MCP payloads --- ext/security/dpop/README.md | 238 +++++++++++ ext/security/dpop/dpop.go | 752 ++++++++++++--------------------- ext/security/dpop/dpop_test.go | 443 +++++++++++++++---- ext/security/jwt/jwt.go | 16 +- 4 files changed, 883 insertions(+), 566 deletions(-) create mode 100644 ext/security/dpop/README.md diff --git a/ext/security/dpop/README.md b/ext/security/dpop/README.md new file mode 100644 index 000000000..f205217df --- /dev/null +++ b/ext/security/dpop/README.md @@ -0,0 +1,238 @@ +# DPoP Extension + +The DPoP extension library provides CEL functions and types for validating OAuth 2.0 +Demonstrating Proof of Possession (DPoP) proof tokens per [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449). + +## Configuration + +Import `cel.dev/cel-go/ext/security/dpop` and add `dpop.Library(options...)` to the CEL environment: + +```go +import "cel.dev/cel-go/ext/security/dpop" + +env, err := cel.NewEnv( + dpop.Library( + dpop.ValidateTimes(5 * time.Minute), // Optional proof age / iat validation + dpop.AllowedAlgorithms("ES256", "RS256"), // Optional JWS algorithm allowlist + ), +) +``` + +### Options + +* `dpop.ValidateTimes`: Enables automatic validation of the `iat` creation timestamp during parsing. Proofs with timestamps in the future or older than `maxAge` (plus leeway) evaluate to `optional.none()`. +* `dpop.AllowedAlgorithms`: Restricts acceptable JWS signing algorithms (e.g. `"ES256"`, `"RS256"`, `"EdDSA"`). +* `dpop.Clock`: Overrides the clock source for time validations (defaults to `time.Now().UTC`). +* `dpop.ClockLeeway`: Configures clock skew tolerance for time claims. + +--- + +## CEL Functions + +### dpop.parse + +Parses a DPoP proof JWT string into an `optional`. Automatically strips any leading `DPoP ` prefix if present. + +Validates RFC 9449 structure and security requirements during parse: +- Header `typ` MUST be `dpop+jwt`. +- Header `alg` MUST be an asymmetric algorithm (symmetric `HS*` and `none` are rejected). +- Header `jwk` MUST be a valid public key and MUST NOT contain private key parameters (`d`, `p`, `q`, etc.) or symmetric keys (`oct`). +- Payload MUST contain `jti`, `htm`, `htu`, and `iat`. + +```cel +dpop.parse() -> > +``` + +Examples: + +```cel +dpop.parse(request.headers['dpop']).hasValue() +dpop.parse('DPoP eyJ0eXAiOiJkcG9wK2p3dCIs...').value().method == 'POST' +``` + +--- + +### matchesRequest + +Validates that the DPoP proof's HTTP method (`htm`) and target URI (`htu`) claims match the expected request parameters. + +Performs RFC 3986 and RFC 9449 URI syntax normalization: +- Lowercases scheme and hostname. +- Removes standard default ports (`:80` for HTTP, `:443` for HTTPS). +- Resolves relative path segments (`.` and `..`). +- Ignores query (`?query=...`) and fragment (`#fragment`) components. + +Can be called directly on `dpop.Proof` or on `optional` (evaluates to `false` if `optional.none()`). + +```cel +.matchesRequest(, ) -> +>.matchesRequest(, ) -> +``` + +Examples: + +```cel +// Authorization server / Token endpoint validation: +dpop.parse(request.headers['dpop']).matchesRequest(request.method, request.url) + +// Protected resource endpoint validation: +proof.matchesRequest('GET', 'https://resource.example.org/api/orders') +``` + +--- + +### matchesToken + +Validates that the DPoP proof matches the presented access token. Simultaneously performs dual checks: + +1. **Access Token Hash (`ath`)**: Verifies that the proof's `ath` claim matches the `base64url(sha256(token))` hash of the presented access token string. +2. **Public Key Confirmation (`cnf.jkt`)**: If the token is a JWT with a `cnf` confirmation claim, validates that `cnf.jkt` matches the DPoP proof's RFC 7638 JWK thumbprint. + +Accepts either a token string (with optional `DPoP ` / `Bearer ` prefix) or a parsed `jwt.Token` object. + +```cel +.matchesToken() -> +.matchesToken() -> +>.matchesToken() -> +>.matchesToken() -> +>.matchesToken(>) -> +``` + +Examples: + +```cel +// Pass authorization header directly: +dpop.parse(request.headers['dpop']).matchesToken(request.headers['authorization']) + +// Pass parsed JWT token from the jwt extension: +dpop.parse(request.headers['dpop']).matchesToken(jwt.parse(request.headers['authorization'])) +``` + +--- + +### matchesNonce + +Validates that the DPoP proof's `nonce` claim matches the expected server-provided challenge nonce. Performs constant-time comparison to prevent timing attacks. + +```cel +.matchesNonce() -> +>.matchesNonce() -> +``` + +Examples: + +```cel +dpop.parse(request.headers['dpop']).matchesNonce(serverNonce) +``` + +--- + +### claim + +Queries a custom or standard claim from the DPoP proof payload by key name, returning an `optional`. + +```cel +.claim() -> > +>.claim() -> > +``` + +Examples: + +```cel +proof.claim('nonce').orValue('') +dpop.parse(request.headers['dpop']).claim('custom_claim').hasValue() +``` + +--- + +## Direct Field Access + +A `dpop.Proof` instance exposes the following strongly typed fields: + +| Field | CEL Type | Description | +| :--- | :--- | :--- | +| `id` | `string` | Unique proof token identifier (`jti`) | +| `method` | `string` | HTTP request method (`htm`) | +| `uri` | `string` | HTTP target URI (`htu`) | +| `iat` | `timestamp` | Proof creation timestamp (`iat`) | +| `accessTokenHash` | `string` | Access token hash (`ath`), empty if omitted | +| `nonce` | `string` | Server nonce challenge (`nonce`), empty if omitted | +| `thumbprint` | `string` | RFC 7638 SHA-256 JWK thumbprint computed from header `jwk` | +| `alg` | `string` | JWS signing algorithm from header (e.g. `"ES256"`) | +| `keyId` | `string` | Key identifier (`kid`) from header, empty if omitted | + +--- + +## Security Model: CEL vs. Host Application + +DPoP verification per RFC 9449 requires coordination between CEL policy evaluation and host application middleware: + +```mermaid +flowchart TD + Req["Incoming HTTP Request
Headers: Authorization: DPoP <token>, DPoP: <proof_jwt>"] + --> Host["Host Application Middleware
1. Cryptographic Signature Verification (verify JWS against header JWK)
2. Replay Protection (check/record unique 'jti' in Redis/Cache)
3. Nonce Generation/Validation (issue/check DPoP-Nonce header)"] + --> CEL["CEL Policy Evaluation
1. Header & claim structure validation (dpop.parse)
2. HTTP method & target URI matching (matchesRequest)
3. Access Token Hash & key confirmation verification (matchesToken)
4. Nonce claim matching (matchesNonce)"] +``` + +### 1. Responsibilities of CEL +- Validates RFC 9449 proof structure, header parameters, and required claims. +- Performs URI syntax and scheme normalization per RFC 3986. +- Verifies Access Token Hash (`ath`) and key confirmation (`cnf.jkt`) matching. +- Compares server nonces using constant-time algorithms. +- Evaluates custom authorization rules against proof and token claims. + +### 2. Responsibilities of the Host Application (Caller) +- **Signature Verification**: Because CEL is a policy evaluation language, the host application MUST verify the cryptographic signature of the DPoP proof against the public key extracted from the header `jwk` before trusting the proof. +- **Replay Protection**: CEL is stateless and cannot maintain cross-request state. The host application MUST store and enforce the uniqueness of the `jti` claim within the acceptable time window (e.g. in Redis, Memcached, or an in-memory cache). +- **Nonce Lifecycle**: If the server requires DPoP nonces (RFC 9449 Section 8), the host application must generate and return nonces via the `DPoP-Nonce` HTTP header. + +--- + +## Implementer Tools (Go API) + +The `dpop` package provides exported Go helper utilities to assist host implementers with cryptographic tasks: + +### Nonce Generation & Validation +```go +// Stateful / Random Nonce (for storage in cache): +nonce, err := dpop.GenerateNonce() + +// Stateless / Distributed Nonce (HMAC-signed with timestamp and optional context): +secretKey := []byte("server-secret-key") +statelessNonce, err := dpop.GenerateStatelessNonce(secretKey, clientIP, clientID) + +// Validate stateless nonce signature, age, and context in constant time: +isValid := dpop.ValidateStatelessNonce(statelessNonce, secretKey, 2*time.Minute, clientIP, clientID) +``` + +### Access Token Hashing & JWK Thumbprints +```go +// Compute RFC 9449 ath hash: base64url(sha256(accessToken)) +ath := dpop.ComputeAccessTokenHash(rawAccessToken) + +// Compute RFC 7638 SHA-256 JWK thumbprint (jkt) for RSA, EC, and OKP keys: +thumbprint, err := dpop.ComputeJWKThumbprint(jwkMap) +``` + +--- + +## Example CEL Policies + +### Token Endpoint / Authorization Server + +```cel +// Validate that the request was accompanied by a valid DPoP proof bound to the token endpoint +cel.bind(proof, dpop.parse(request.headers['dpop']), + proof.matchesRequest(request.method, request.url) && + proof.matchesToken(request.headers['authorization']) +) +``` + +### Protected Resource Endpoint + +```cel +// Validate method, URI, access token binding (ath + cnf.jkt), and server nonce challenge +proof.matchesRequest(request.method, request.url) && +proof.matchesToken(request.headers['authorization']) && +proof.matchesNonce(serverNonce) +``` diff --git a/ext/security/dpop/dpop.go b/ext/security/dpop/dpop.go index d5a2bb6b6..d4914df90 100644 --- a/ext/security/dpop/dpop.go +++ b/ext/security/dpop/dpop.go @@ -12,11 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package dpop implements CEL extension functions for OAuth 2.0 Demonstrating Proof of Possession (DPoP) -// proof parsing, JWK thumbprint confirmation, access token hash validation, and request matching per RFC 9449. +// Package dpop implements CEL extension functions and Go helper utilities for OAuth 2.0 +// Demonstrating Proof of Possession (DPoP) per RFC 9449. package dpop import ( + "crypto/hmac" + "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" @@ -26,6 +28,7 @@ import ( "path" "reflect" "slices" + "strconv" "strings" "time" @@ -142,6 +145,10 @@ func (l *dpopLib) CompileOptions() []cel.EnvOption { } celJWTTokenType := cel.ObjectType("jwt.Token") + proofOverloads := func(baseID string, argTypes []*cel.Type, resType *cel.Type, fallback ref.Val, fn func(*Proof, []ref.Val) ref.Val) []cel.FunctionOpt { + return proofMemberOverloadPair(celProofType, baseID, argTypes, resType, fallback, fn) + } + return []cel.EnvOption{ cel.OptionalTypes(), cel.Types(proofType), @@ -177,433 +184,124 @@ func (l *dpopLib) CompileOptions() []cel.EnvOption { }), ), ), - cel.Function("dpop.ath", - cel.FunctionDocs( - "Computes the RFC 9449 access token hash (ath): base64url(sha256(access_token)).", - "Automatically strips leading 'DPoP ' or 'Bearer ' prefixes if present.", - ), - cel.Overload("dpop_ath_string", - []*cel.Type{cel.StringType}, - cel.StringType, - cel.OverloadExamples( - "dpop.ath(accessTokenStr)", - "dpop.ath('DPoP Kz~8mXK1...')", - ), - cel.UnaryBinding(func(arg ref.Val) ref.Val { - tokenStr := string(arg.(types.String)) - return types.String(ComputeAccessTokenHash(tokenStr)) - }), - ), - ), - cel.Function("dpop.thumbprint", - cel.FunctionDocs( - "Computes the RFC 7638 SHA-256 JWK Thumbprint (base64url encoded) from a JWK map.", - ), - cel.Overload("dpop_thumbprint_map", - []*cel.Type{cel.MapType(cel.StringType, cel.DynType)}, - cel.StringType, - cel.OverloadExamples( - "dpop.thumbprint(proof.jwk)", - ), - cel.UnaryBinding(func(arg ref.Val) ref.Val { - jwkVal := arg.Value() - jwkMap, ok := jwkVal.(map[string]any) - if !ok { - if genericMap, ok := jwkVal.(map[ref.Val]ref.Val); ok { - jwkMap = make(map[string]any, len(genericMap)) - for k, v := range genericMap { - jwkMap[fmt.Sprint(k.Value())] = v.Value() - } - } else { - return types.NewErr("expected map[string]dyn for JWK, got %T", jwkVal) - } - } - tp, err := ComputeJWKThumbprint(jwkMap) - if err != nil { - return types.NewErr("failed to compute JWK thumbprint: %w", err) - } - return types.String(tp) - }), - ), - ), - cel.Function("claim", - cel.FunctionDocs( - "Queries a custom claim value by key name from the DPoP proof payload, returning an optional dynamic value.", - ), - cel.MemberOverload("dpop_proof_claim_string", - []*cel.Type{celProofType, cel.StringType}, - cel.OptionalType(cel.DynType), - cel.OverloadExamples( - "proof.claim('nonce')", - ), - cel.BinaryBinding(func(targetVal, claimNameVal ref.Val) ref.Val { - target := targetVal.Value().(*Proof) - claimName := claimNameVal.(types.String) - return target.Claim(adapt(), string(claimName)) - }), - ), - cel.MemberOverload("dpop_proof_opt_claim_string", - []*cel.Type{cel.OptionalType(celProofType), cel.StringType}, - cel.OptionalType(cel.DynType), - cel.OverloadExamples( - "dpop.parse(proofStr).claim('nonce')", - ), - cel.BinaryBinding(func(targetVal, claimNameVal ref.Val) ref.Val { - optTarget := targetVal.(*types.Optional) - if !optTarget.HasValue() { - return types.OptionalNone - } - target, ok := optTarget.GetValue().Value().(*Proof) - if !ok { - return types.ValOrErr(optTarget.GetValue(), "expected dpop.Proof") - } - claimName := claimNameVal.(types.String) - return target.Claim(adapt(), string(claimName)) - }), - ), - ), - cel.Function("matchesRequest", - cel.FunctionDocs( - "Validates that the DPoP proof's htm and htu claims match the HTTP request method and target URI.", - "Performs RFC 3986 syntax and scheme normalization on the target URI, ignoring query and fragment parts.", - ), - cel.MemberOverload("dpop_proof_matches_request_string_string", - []*cel.Type{celProofType, cel.StringType, cel.StringType}, - cel.BoolType, - cel.OverloadExamples( - "proof.matchesRequest('POST', 'https://server.example.com/token')", - ), - cel.FunctionBinding(func(args ...ref.Val) ref.Val { - target := args[0].Value().(*Proof) - method := string(args[1].(types.String)) - targetURI := string(args[2].(types.String)) - return types.Bool(target.MatchesRequest(method, targetURI)) - }), - ), - cel.MemberOverload("dpop_proof_opt_matches_request_string_string", - []*cel.Type{cel.OptionalType(celProofType), cel.StringType, cel.StringType}, - cel.BoolType, - cel.OverloadExamples( - "dpop.parse(proofStr).matchesRequest('POST', 'https://server.example.com/token')", - ), - cel.FunctionBinding(func(args ...ref.Val) ref.Val { - optTarget := args[0].(*types.Optional) - if !optTarget.HasValue() { - return types.False - } - target, ok := optTarget.GetValue().Value().(*Proof) - if !ok { - return types.ValOrErr(optTarget.GetValue(), "expected dpop.Proof") - } - method := string(args[1].(types.String)) - targetURI := string(args[2].(types.String)) - return types.Bool(target.MatchesRequest(method, targetURI)) - }), - ), - ), - cel.Function("matchesMethod", - cel.FunctionDocs( - "Validates that the DPoP proof's htm claim matches the given HTTP method.", - ), - cel.MemberOverload("dpop_proof_matches_method_string", - []*cel.Type{celProofType, cel.StringType}, - cel.BoolType, - cel.BinaryBinding(func(targetVal, methodVal ref.Val) ref.Val { - target := targetVal.Value().(*Proof) - method := string(methodVal.(types.String)) - return types.Bool(target.MatchesMethod(method)) - }), - ), - cel.MemberOverload("dpop_proof_opt_matches_method_string", - []*cel.Type{cel.OptionalType(celProofType), cel.StringType}, - cel.BoolType, - cel.BinaryBinding(func(targetVal, methodVal ref.Val) ref.Val { - optTarget := targetVal.(*types.Optional) - if !optTarget.HasValue() { - return types.False - } - target, ok := optTarget.GetValue().Value().(*Proof) - if !ok { - return types.ValOrErr(optTarget.GetValue(), "expected dpop.Proof") - } - method := string(methodVal.(types.String)) - return types.Bool(target.MatchesMethod(method)) - }), - ), + makeFunction("claim", + "Queries a custom claim value by key name from the DPoP proof payload, returning an optional dynamic value.", + proofOverloads("dpop_proof_claim_string", []*cel.Type{cel.StringType}, cel.OptionalType(cel.DynType), types.OptionalNone, func(p *Proof, args []ref.Val) ref.Val { + return p.Claim(adapt(), string(args[0].(types.String))) + })..., ), - cel.Function("matchesURI", - cel.FunctionDocs( - "Validates that the DPoP proof's htu claim matches the given HTTP target URI (ignoring query/fragment, with normalization).", - ), - cel.MemberOverload("dpop_proof_matches_uri_string", - []*cel.Type{celProofType, cel.StringType}, - cel.BoolType, - cel.BinaryBinding(func(targetVal, uriVal ref.Val) ref.Val { - target := targetVal.Value().(*Proof) - targetURI := string(uriVal.(types.String)) - return types.Bool(target.MatchesURI(targetURI)) - }), - ), - cel.MemberOverload("dpop_proof_opt_matches_uri_string", - []*cel.Type{cel.OptionalType(celProofType), cel.StringType}, - cel.BoolType, - cel.BinaryBinding(func(targetVal, uriVal ref.Val) ref.Val { - optTarget := targetVal.(*types.Optional) - if !optTarget.HasValue() { - return types.False - } - target, ok := optTarget.GetValue().Value().(*Proof) - if !ok { - return types.ValOrErr(optTarget.GetValue(), "expected dpop.Proof") - } - targetURI := string(uriVal.(types.String)) - return types.Bool(target.MatchesURI(targetURI)) - }), - ), + makeFunction("matchesRequest", + "Validates that the DPoP proof's htm and htu claims match the HTTP request method and target URI.\nPerforms RFC 3986 syntax and scheme normalization on the target URI, ignoring query and fragment parts.", + proofOverloads("dpop_proof_matches_request_string_string", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, types.False, func(p *Proof, args []ref.Val) ref.Val { + return types.Bool(p.MatchesRequest(string(args[0].(types.String)), string(args[1].(types.String)))) + })..., ), - cel.Function("matchesHtu", - cel.FunctionDocs( - "Alias for matchesURI: validates that the DPoP proof's htu claim matches the given HTTP target URI.", - ), - cel.MemberOverload("dpop_proof_matches_htu_string", - []*cel.Type{celProofType, cel.StringType}, - cel.BoolType, - cel.BinaryBinding(func(targetVal, uriVal ref.Val) ref.Val { - target := targetVal.Value().(*Proof) - targetURI := string(uriVal.(types.String)) - return types.Bool(target.MatchesURI(targetURI)) - }), - ), - cel.MemberOverload("dpop_proof_opt_matches_htu_string", - []*cel.Type{cel.OptionalType(celProofType), cel.StringType}, - cel.BoolType, - cel.BinaryBinding(func(targetVal, uriVal ref.Val) ref.Val { - optTarget := targetVal.(*types.Optional) - if !optTarget.HasValue() { - return types.False - } - target, ok := optTarget.GetValue().Value().(*Proof) - if !ok { - return types.ValOrErr(optTarget.GetValue(), "expected dpop.Proof") - } - targetURI := string(uriVal.(types.String)) - return types.Bool(target.MatchesURI(targetURI)) - }), - ), + makeFunction("matchesNonce", + "Validates that the DPoP proof's nonce claim matches the expected server-provided nonce.", + proofOverloads("dpop_proof_matches_nonce_string", []*cel.Type{cel.StringType}, cel.BoolType, types.False, func(p *Proof, args []ref.Val) ref.Val { + return types.Bool(p.MatchesNonce(string(args[0].(types.String)))) + })..., ), - cel.Function("matchesAccessToken", - cel.FunctionDocs( - "Validates that the DPoP proof's ath claim matches the base64url SHA-256 hash of the presented access token.", - ), - cel.MemberOverload("dpop_proof_matches_access_token_string", - []*cel.Type{celProofType, cel.StringType}, - cel.BoolType, - cel.BinaryBinding(func(targetVal, tokenVal ref.Val) ref.Val { - target := targetVal.Value().(*Proof) - tokenStr := string(tokenVal.(types.String)) - return types.Bool(target.MatchesAccessToken(tokenStr)) - }), - ), - cel.MemberOverload("dpop_proof_opt_matches_access_token_string", - []*cel.Type{cel.OptionalType(celProofType), cel.StringType}, - cel.BoolType, - cel.BinaryBinding(func(targetVal, tokenVal ref.Val) ref.Val { - optTarget := targetVal.(*types.Optional) - if !optTarget.HasValue() { - return types.False - } - target, ok := optTarget.GetValue().Value().(*Proof) - if !ok { - return types.ValOrErr(optTarget.GetValue(), "expected dpop.Proof") - } - tokenStr := string(tokenVal.(types.String)) - return types.Bool(target.MatchesAccessToken(tokenStr)) + makeFunction("matchesToken", + "Validates that the DPoP proof matches the access token, simultaneously checking both the accessTokenHash (ath) and cnf.jkt key confirmation.\nAccepts either a token string (e.g. from the Authorization header) or a parsed jwt.Token.", + concatFunctionOpts( + proofOverloads("dpop_proof_matches_token_string", []*cel.Type{cel.StringType}, cel.BoolType, types.False, func(p *Proof, args []ref.Val) ref.Val { + return evalMatchesToken(p, args[0]) }), - ), - ), - cel.Function("matchesNonce", - cel.FunctionDocs( - "Validates that the DPoP proof's nonce claim matches the expected server-provided nonce.", - ), - cel.MemberOverload("dpop_proof_matches_nonce_string", - []*cel.Type{celProofType, cel.StringType}, - cel.BoolType, - cel.BinaryBinding(func(targetVal, nonceVal ref.Val) ref.Val { - target := targetVal.Value().(*Proof) - nonceStr := string(nonceVal.(types.String)) - return types.Bool(target.MatchesNonce(nonceStr)) + proofOverloads("dpop_proof_matches_token_jwt_token", []*cel.Type{celJWTTokenType}, cel.BoolType, types.False, func(p *Proof, args []ref.Val) ref.Val { + return evalMatchesToken(p, args[0]) }), - ), - cel.MemberOverload("dpop_proof_opt_matches_nonce_string", - []*cel.Type{cel.OptionalType(celProofType), cel.StringType}, - cel.BoolType, - cel.BinaryBinding(func(targetVal, nonceVal ref.Val) ref.Val { - optTarget := targetVal.(*types.Optional) - if !optTarget.HasValue() { - return types.False - } - target, ok := optTarget.GetValue().Value().(*Proof) - if !ok { - return types.ValOrErr(optTarget.GetValue(), "expected dpop.Proof") - } - nonceStr := string(nonceVal.(types.String)) - return types.Bool(target.MatchesNonce(nonceStr)) + proofOverloads("dpop_proof_matches_token_opt_jwt_token", []*cel.Type{cel.OptionalType(celJWTTokenType)}, cel.BoolType, types.False, func(p *Proof, args []ref.Val) ref.Val { + return evalMatchesToken(p, args[0]) }), - ), + )..., ), - cel.Function("matchesConfirmation", - cel.FunctionDocs( - "Validates that the DPoP proof's public key thumbprint matches the jkt confirmation method from an access token or introspection map.", - ), - cel.MemberOverload("dpop_proof_matches_confirmation_string", - []*cel.Type{celProofType, cel.StringType}, - cel.BoolType, - cel.BinaryBinding(func(targetVal, jktVal ref.Val) ref.Val { - target := targetVal.Value().(*Proof) - jktStr := string(jktVal.(types.String)) - return types.Bool(target.MatchesConfirmationString(jktStr)) - }), - ), - cel.MemberOverload("dpop_proof_opt_matches_confirmation_string", - []*cel.Type{cel.OptionalType(celProofType), cel.StringType}, - cel.BoolType, - cel.BinaryBinding(func(targetVal, jktVal ref.Val) ref.Val { - optTarget := targetVal.(*types.Optional) - if !optTarget.HasValue() { - return types.False - } - target, ok := optTarget.GetValue().Value().(*Proof) - if !ok { - return types.ValOrErr(optTarget.GetValue(), "expected dpop.Proof") - } - jktStr := string(jktVal.(types.String)) - return types.Bool(target.MatchesConfirmationString(jktStr)) - }), - ), - cel.MemberOverload("dpop_proof_matches_confirmation_map", - []*cel.Type{celProofType, cel.MapType(cel.StringType, cel.DynType)}, - cel.BoolType, - cel.BinaryBinding(func(targetVal, cnfVal ref.Val) ref.Val { - target := targetVal.Value().(*Proof) - cnfMap, ok := cnfVal.Value().(map[string]any) - if !ok { - if genericMap, ok := cnfVal.Value().(map[ref.Val]ref.Val); ok { - cnfMap = make(map[string]any, len(genericMap)) - for k, v := range genericMap { - cnfMap[fmt.Sprint(k.Value())] = v.Value() - } - } else { - return types.False - } - } - return types.Bool(target.MatchesConfirmationMap(cnfMap)) - }), - ), - cel.MemberOverload("dpop_proof_opt_matches_confirmation_map", - []*cel.Type{cel.OptionalType(celProofType), cel.MapType(cel.StringType, cel.DynType)}, - cel.BoolType, - cel.BinaryBinding(func(targetVal, cnfVal ref.Val) ref.Val { - optTarget := targetVal.(*types.Optional) - if !optTarget.HasValue() { - return types.False - } - target, ok := optTarget.GetValue().Value().(*Proof) - if !ok { - return types.ValOrErr(optTarget.GetValue(), "expected dpop.Proof") - } - cnfMap, ok := cnfVal.Value().(map[string]any) - if !ok { - if genericMap, ok := cnfVal.Value().(map[ref.Val]ref.Val); ok { - cnfMap = make(map[string]any, len(genericMap)) - for k, v := range genericMap { - cnfMap[fmt.Sprint(k.Value())] = v.Value() - } - } else { - return types.False - } - } - return types.Bool(target.MatchesConfirmationMap(cnfMap)) - }), - ), + } +} + +func makeFunction(name string, doc string, opts ...cel.FunctionOpt) cel.EnvOption { + allOpts := make([]cel.FunctionOpt, 0, len(opts)+1) + allOpts = append(allOpts, cel.FunctionDocs(doc)) + allOpts = append(allOpts, opts...) + return cel.Function(name, allOpts...) +} + +func withProofReceiver(fallback ref.Val, fn func(*Proof, []ref.Val) ref.Val) func(...ref.Val) ref.Val { + return func(args ...ref.Val) ref.Val { + switch target := args[0].(type) { + case *types.Optional: + if !target.HasValue() { + return fallback + } + p, ok := target.GetValue().Value().(*Proof) + if !ok { + return types.ValOrErr(target.GetValue(), "expected dpop.Proof") + } + return fn(p, args[1:]) + default: + p, ok := target.Value().(*Proof) + if !ok { + return types.ValOrErr(target, "expected dpop.Proof") + } + return fn(p, args[1:]) + } + } +} + +func proofMemberOverloadPair( + celProofType *cel.Type, + baseID string, + argTypes []*cel.Type, + resultType *cel.Type, + fallback ref.Val, + fn func(*Proof, []ref.Val) ref.Val, +) []cel.FunctionOpt { + binding := withProofReceiver(fallback, fn) + return []cel.FunctionOpt{ + cel.MemberOverload( + baseID, + append([]*cel.Type{celProofType}, argTypes...), + resultType, + cel.FunctionBinding(binding), ), - cel.Function("matchesToken", - cel.FunctionDocs( - "Validates that the DPoP proof's public key matches the cnf.jkt confirmation claim inside a parsed jwt.Token.", - ), - cel.MemberOverload("dpop_proof_matches_token_jwt_token", - []*cel.Type{celProofType, celJWTTokenType}, - cel.BoolType, - cel.BinaryBinding(func(targetVal, tokenVal ref.Val) ref.Val { - target := targetVal.Value().(*Proof) - tok, ok := tokenVal.Value().(*jwt.Token) - if !ok { - return types.ValOrErr(tokenVal, "expected jwt.Token") - } - return types.Bool(target.MatchesToken(tok)) - }), - ), - cel.MemberOverload("dpop_proof_opt_matches_token_jwt_token", - []*cel.Type{cel.OptionalType(celProofType), celJWTTokenType}, - cel.BoolType, - cel.BinaryBinding(func(targetVal, tokenVal ref.Val) ref.Val { - optTarget := targetVal.(*types.Optional) - if !optTarget.HasValue() { - return types.False - } - target, ok := optTarget.GetValue().Value().(*Proof) - if !ok { - return types.ValOrErr(optTarget.GetValue(), "expected dpop.Proof") - } - tok, ok := tokenVal.Value().(*jwt.Token) - if !ok { - return types.ValOrErr(tokenVal, "expected jwt.Token") - } - return types.Bool(target.MatchesToken(tok)) - }), - ), - cel.MemberOverload("dpop_proof_opt_matches_token_opt_jwt_token", - []*cel.Type{cel.OptionalType(celProofType), cel.OptionalType(celJWTTokenType)}, - cel.BoolType, - cel.BinaryBinding(func(targetVal, tokenVal ref.Val) ref.Val { - optTarget := targetVal.(*types.Optional) - if !optTarget.HasValue() { - return types.False - } - target, ok := optTarget.GetValue().Value().(*Proof) - if !ok { - return types.ValOrErr(optTarget.GetValue(), "expected dpop.Proof") - } - optTok := tokenVal.(*types.Optional) - if !optTok.HasValue() { - return types.False - } - tok, ok := optTok.GetValue().Value().(*jwt.Token) - if !ok { - return types.ValOrErr(optTok.GetValue(), "expected jwt.Token") - } - return types.Bool(target.MatchesToken(tok)) - }), - ), - cel.MemberOverload("dpop_proof_matches_token_opt_jwt_token", - []*cel.Type{celProofType, cel.OptionalType(celJWTTokenType)}, - cel.BoolType, - cel.BinaryBinding(func(targetVal, tokenVal ref.Val) ref.Val { - target := targetVal.Value().(*Proof) - optTok := tokenVal.(*types.Optional) - if !optTok.HasValue() { - return types.False - } - tok, ok := optTok.GetValue().Value().(*jwt.Token) - if !ok { - return types.ValOrErr(optTok.GetValue(), "expected jwt.Token") - } - return types.Bool(target.MatchesToken(tok)) - }), - ), + cel.MemberOverload( + baseID+"_opt", + append([]*cel.Type{cel.OptionalType(celProofType)}, argTypes...), + resultType, + cel.FunctionBinding(binding), ), } } +func evalMatchesToken(p *Proof, tokenVal ref.Val) ref.Val { + if opt, ok := tokenVal.(*types.Optional); ok { + if !opt.HasValue() { + return types.False + } + tokenVal = opt.GetValue() + } + switch tok := tokenVal.Value().(type) { + case string: + return types.Bool(p.MatchesTokenString(tok)) + case *jwt.Token: + return types.Bool(p.MatchesToken(tok)) + default: + return types.ValOrErr(tokenVal, "expected string or jwt.Token") + } +} + +func concatFunctionOpts(lists ...[]cel.FunctionOpt) []cel.FunctionOpt { + var total int + for _, l := range lists { + total += len(l) + } + res := make([]cel.FunctionOpt, 0, total) + for _, l := range lists { + res = append(res, l...) + } + return res +} + // ProgramOptions returns program options for DPoP extensions. func (l *dpopLib) ProgramOptions() []cel.ProgramOption { return nil @@ -620,7 +318,7 @@ type Proof struct { Algorithm string `json:"alg" cel:"alg"` KeyID string `json:"kid,omitempty" cel:"keyId"` Type string `json:"typ" cel:"type"` - JWK map[string]any `json:"jwk" cel:"jwk"` + JWK map[string]any `json:"jwk" cel:"-"` // Derived public key thumbprint (RFC 7638 SHA-256 JWK Thumbprint) Thumbprint string `json:"jkt" cel:"thumbprint"` @@ -630,7 +328,7 @@ type Proof struct { Method string `json:"htm" cel:"method"` URI string `json:"htu" cel:"uri"` IssuedAt time.Time `json:"iat" cel:"iat"` - AccessTokenHash string `json:"ath,omitempty" cel:"ath"` + AccessTokenHash string `json:"ath,omitempty" cel:"accessTokenHash"` Nonce string `json:"nonce,omitempty" cel:"nonce"` // Raw JSON payload and header associated with the proof including custom claims. @@ -664,9 +362,9 @@ func (p *Proof) MatchesMethod(method string) bool { return strings.EqualFold(strings.TrimSpace(p.Method), strings.TrimSpace(method)) } -// MatchesURI checks whether the DPoP proof's htu claim matches the given target URI per RFC 9449 / RFC 3986. +// MatchesTargetURI checks whether the DPoP proof's htu claim matches the given target URI per RFC 9449 / RFC 3986. // Normalizes scheme/host to lowercase, removes default ports (:80, :443), cleans path segments, and ignores query/fragment. -func (p *Proof) MatchesURI(targetURI string) bool { +func (p *Proof) MatchesTargetURI(targetURI string) bool { normProofURI, err := normalizeTargetURI(p.URI) if err != nil { return false @@ -680,7 +378,7 @@ func (p *Proof) MatchesURI(targetURI string) bool { // MatchesRequest checks whether both the method (htm) and target URI (htu) match the incoming HTTP request. func (p *Proof) MatchesRequest(method, targetURI string) bool { - return p.MatchesMethod(method) && p.MatchesURI(targetURI) + return p.MatchesMethod(method) && p.MatchesTargetURI(targetURI) } // MatchesAccessToken validates that the DPoP proof's ath claim equals the SHA-256 base64url hash of the presented access token. @@ -720,11 +418,32 @@ func (p *Proof) MatchesConfirmationMap(cnf map[string]any) bool { return p.MatchesConfirmationString(jkt) } -// MatchesToken validates that the DPoP proof's JWK thumbprint matches the cnf.jkt confirmation claim inside a jwt.Token. +// MatchesTokenString validates that the DPoP proof matches the given access token string: +// 1. Validates that the DPoP proof's accessTokenHash matches the base64url SHA-256 hash of the presented token string. +// 2. Parses the token string as a JWT and validates that the cnf.jkt confirmation claim matches the DPoP proof's thumbprint. +func (p *Proof) MatchesTokenString(tokenStr string) bool { + if !p.MatchesAccessToken(tokenStr) { + return false + } + tok, err := jwt.ParseToken(tokenStr) + if err != nil { + return false + } + return p.MatchesToken(tok) +} + +// MatchesToken validates that the DPoP proof matches the given jwt.Token: +// 1. Validates that the token's cnf.jkt confirmation claim matches the DPoP proof's thumbprint. +// 2. If token.Raw is populated, also validates that the DPoP proof's accessTokenHash matches the hash of token.Raw. func (p *Proof) MatchesToken(token *jwt.Token) bool { if token == nil || token.Payload == nil { return false } + if token.Raw != "" && p.AccessTokenHash != "" { + if !p.MatchesAccessToken(token.Raw) { + return false + } + } cnf, ok := token.Payload["cnf"].(map[string]any) if !ok || cnf == nil { return false @@ -748,14 +467,14 @@ func (p *Proof) Claim(adapter types.Adapter, claimName string) ref.Val { // NewProof constructs a validated Proof from decoded JSON header and payload maps. // Signature verification of the proof must be performed prior to passing the proof to CEL. func NewProof(header, payload map[string]any) (*Proof, error) { - typ, ok := header["typ"].(string) - if !ok || !strings.EqualFold(strings.TrimSpace(typ), dpopHeaderType) { + typ, err := requireString(header, "typ", "header") + if err != nil || !strings.EqualFold(typ, dpopHeaderType) { return nil, fmt.Errorf("invalid or missing 'typ' header: expected %q, got %q", dpopHeaderType, typ) } - alg, ok := header["alg"].(string) - if !ok || alg == "" { - return nil, fmt.Errorf("missing required header: 'alg'") + alg, err := requireString(header, "alg", "header") + if err != nil { + return nil, err } if strings.EqualFold(alg, "none") { return nil, fmt.Errorf("insecure algorithm 'none' is not allowed for DPoP proof") @@ -764,16 +483,10 @@ func NewProof(header, payload map[string]any) (*Proof, error) { return nil, fmt.Errorf("symmetric MAC algorithm %q is not allowed for DPoP proof", alg) } - jwkRaw, ok := header["jwk"] - if !ok || jwkRaw == nil { + jwkMap, err := requireMap(header, "jwk", "header") + if err != nil { return nil, fmt.Errorf("missing required header: 'jwk'") } - jwkMap, ok := jwkRaw.(map[string]any) - if !ok || len(jwkMap) == 0 { - return nil, fmt.Errorf("invalid header 'jwk': expected non-empty JSON object") - } - - // Validate JWK does not contain private key components (RFC 9449 Section 4.2) if err := validateJWKPublicKeyOnly(jwkMap); err != nil { return nil, err } @@ -783,19 +496,19 @@ func NewProof(header, payload map[string]any) (*Proof, error) { return nil, fmt.Errorf("failed to compute JWK thumbprint: %w", err) } - jti, ok := payload["jti"].(string) - if !ok || strings.TrimSpace(jti) == "" { - return nil, fmt.Errorf("missing required claim: 'jti'") + jti, err := requireString(payload, "jti", "claim") + if err != nil { + return nil, err } - htm, ok := payload["htm"].(string) - if !ok || strings.TrimSpace(htm) == "" { - return nil, fmt.Errorf("missing required claim: 'htm'") + htm, err := requireString(payload, "htm", "claim") + if err != nil { + return nil, err } - htu, ok := payload["htu"].(string) - if !ok || strings.TrimSpace(htu) == "" { - return nil, fmt.Errorf("missing required claim: 'htu'") + htu, err := requireString(payload, "htu", "claim") + if err != nil { + return nil, err } iat, err := types.ParseTimestamp(payload["iat"]) @@ -823,7 +536,7 @@ func NewProof(header, payload map[string]any) (*Proof, error) { // ParseProof parses a DPoP proof JWT string into a structured Proof. // Automatically strips leading 'DPoP ' prefixes if present. func ParseProof(proofStr string) (*Proof, error) { - proofStr = trimDPoPPrefix(proofStr) + proofStr = trimPrefixFold(proofStr, "dpop ") if len(proofStr) > maxProofSize { return nil, fmt.Errorf("dpop proof size exceeds maximum allowed limit of %d bytes", maxProofSize) } @@ -859,53 +572,53 @@ func ParseProof(proofStr string) (*Proof, error) { // ComputeAccessTokenHash computes the RFC 9449 access token hash (ath): base64url(sha256(access_token)). // Automatically strips leading 'DPoP ' or 'Bearer ' prefixes if present. func ComputeAccessTokenHash(token string) string { - token = trimTokenPrefix(token) + token = trimPrefixFold(token, "dpop ", "bearer ") h := sha256.Sum256([]byte(token)) return base64.RawURLEncoding.EncodeToString(h[:]) } // ComputeJWKThumbprint computes the RFC 7638 SHA-256 JWK Thumbprint (base64url encoded without padding). func ComputeJWKThumbprint(jwk map[string]any) (string, error) { - kty, ok := jwk["kty"].(string) - if !ok || kty == "" { + kty, err := requireString(jwk, "kty", "JWK parameter") + if err != nil { return "", fmt.Errorf("missing or invalid 'kty' in JWK") } var canonicalJSON string switch kty { case "RSA": - e, ok := jwk["e"].(string) - if !ok || e == "" { + e, err := requireString(jwk, "e", "RSA JWK parameter") + if err != nil { return "", fmt.Errorf("missing or invalid 'e' in RSA JWK") } - n, ok := jwk["n"].(string) - if !ok || n == "" { + n, err := requireString(jwk, "n", "RSA JWK parameter") + if err != nil { return "", fmt.Errorf("missing or invalid 'n' in RSA JWK") } canonicalJSON = fmt.Sprintf(`{"e":%q,"kty":"RSA","n":%q}`, e, n) case "EC": - crv, ok := jwk["crv"].(string) - if !ok || crv == "" { + crv, err := requireString(jwk, "crv", "EC JWK parameter") + if err != nil { return "", fmt.Errorf("missing or invalid 'crv' in EC JWK") } - x, ok := jwk["x"].(string) - if !ok || x == "" { + x, err := requireString(jwk, "x", "EC JWK parameter") + if err != nil { return "", fmt.Errorf("missing or invalid 'x' in EC JWK") } - y, ok := jwk["y"].(string) - if !ok || y == "" { + y, err := requireString(jwk, "y", "EC JWK parameter") + if err != nil { return "", fmt.Errorf("missing or invalid 'y' in EC JWK") } canonicalJSON = fmt.Sprintf(`{"crv":%q,"kty":"EC","x":%q,"y":%q}`, crv, x, y) case "OKP": - crv, ok := jwk["crv"].(string) - if !ok || crv == "" { + crv, err := requireString(jwk, "crv", "OKP JWK parameter") + if err != nil { return "", fmt.Errorf("missing or invalid 'crv' in OKP JWK") } - x, ok := jwk["x"].(string) - if !ok || x == "" { + x, err := requireString(jwk, "x", "OKP JWK parameter") + if err != nil { return "", fmt.Errorf("missing or invalid 'x' in OKP JWK") } canonicalJSON = fmt.Sprintf(`{"crv":%q,"kty":"OKP","x":%q}`, crv, x) @@ -976,31 +689,37 @@ func normalizeTargetURI(rawURI string) (string, error) { return scheme + "://" + effectiveHost + cleanPath, nil } -func optString(m map[string]any, key string) string { - if v, ok := m[key].(string); ok { - return v +func requireString(m map[string]any, key, context string) (string, error) { + v, ok := m[key].(string) + if !ok || strings.TrimSpace(v) == "" { + return "", fmt.Errorf("missing or invalid required %s: %q", context, key) } - return "" + return strings.TrimSpace(v), nil } -func trimDPoPPrefix(proofStr string) string { - proofStr = strings.TrimSpace(proofStr) - if strings.HasPrefix(strings.ToLower(proofStr), "dpop ") { - return strings.TrimSpace(proofStr[5:]) +func requireMap(m map[string]any, key, context string) (map[string]any, error) { + v, ok := m[key].(map[string]any) + if !ok || len(v) == 0 { + return nil, fmt.Errorf("missing or empty required %s object: %q", context, key) } - return proofStr + return v, nil } -func trimTokenPrefix(tokenStr string) string { - tokenStr = strings.TrimSpace(tokenStr) - lower := strings.ToLower(tokenStr) - if strings.HasPrefix(lower, "dpop ") { - return strings.TrimSpace(tokenStr[5:]) +func optString(m map[string]any, key string) string { + if v, ok := m[key].(string); ok { + return v } - if strings.HasPrefix(lower, "bearer ") { - return strings.TrimSpace(tokenStr[7:]) + return "" +} + +func trimPrefixFold(s string, prefixes ...string) string { + s = strings.TrimSpace(s) + for _, prefix := range prefixes { + if len(s) >= len(prefix) && strings.EqualFold(s[:len(prefix)], prefix) { + return strings.TrimSpace(s[len(prefix):]) + } } - return tokenStr + return s } func decodeBase64Segment(seg string) ([]byte, error) { @@ -1016,3 +735,76 @@ func decodeBase64Segment(seg string) ([]byte, error) { } return base64.StdEncoding.DecodeString(seg) } + +// GenerateNonce creates a cryptographically secure 256-bit base64url-encoded random nonce. +func GenerateNonce() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to generate random nonce: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// GenerateStatelessNonce creates an HMAC-signed timestamped nonce valid across distributed servers. +// The nonce is formatted as: base64url(payload).base64url(hmac_signature) +// where payload contains the unix timestamp, random entropy, and optional context strings. +func GenerateStatelessNonce(secretKey []byte, context ...string) (string, error) { + now := time.Now().UTC().Unix() + entropy := make([]byte, 16) + if _, err := rand.Read(entropy); err != nil { + return "", fmt.Errorf("failed to generate nonce entropy: %w", err) + } + payload := fmt.Sprintf("%d:%s:%s", now, base64.RawURLEncoding.EncodeToString(entropy), strings.Join(context, ":")) + + mac := hmac.New(sha256.New, secretKey) + mac.Write([]byte(payload)) + sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + + return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + sig, nil +} + +// ValidateStatelessNonce verifies that the stateless nonce has a valid HMAC signature, has not expired, +// and matches any optional context strings. +func ValidateStatelessNonce(nonce string, secretKey []byte, maxAge time.Duration, context ...string) bool { + parts := strings.Split(nonce, ".") + if len(parts) != 2 { + return false + } + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return false + } + expectedSigBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return false + } + + mac := hmac.New(sha256.New, secretKey) + mac.Write(payloadBytes) + if subtle.ConstantTimeCompare(mac.Sum(nil), expectedSigBytes) != 1 { + return false + } + + payloadParts := strings.Split(string(payloadBytes), ":") + if len(payloadParts) < 2 { + return false + } + ts, err := strconv.ParseInt(payloadParts[0], 10, 64) + if err != nil { + return false + } + nonceTime := time.Unix(ts, 0).UTC() + now := time.Now().UTC() + if now.Sub(nonceTime) > maxAge || nonceTime.Sub(now) > 10*time.Second { + return false + } + + if len(context) > 0 { + expectedContext := strings.Join(context, ":") + actualContext := strings.Join(payloadParts[2:], ":") + if subtle.ConstantTimeCompare([]byte(actualContext), []byte(expectedContext)) != 1 { + return false + } + } + return true +} diff --git a/ext/security/dpop/dpop_test.go b/ext/security/dpop/dpop_test.go index 62b4218d8..aebdc386c 100644 --- a/ext/security/dpop/dpop_test.go +++ b/ext/security/dpop/dpop_test.go @@ -166,11 +166,23 @@ func TestDPoPCELIntegration(t *testing.T) { }, } jwtTokenStr := createTestJWT(t, jwtAccessHeader, jwtAccessPayload) + jwtATH := dpop.ComputeAccessTokenHash(jwtTokenStr) + jwtProofPayload := map[string]any{ + "jti": "e1j3V_bKic8-LAEB", + "htm": "GET", + "htu": "https://resource.example.org/protectedresource", + "iat": 1562262618, + "ath": jwtATH, + "nonce": "nonce-xyz-123", + "custom": "custom-value", + } + jwtProofStr := createTestDPoP(t, header, jwtProofPayload) env, err := cel.NewEnv( dpop.Library(), jwt.Library(), cel.Variable("proofStr", cel.StringType), + cel.Variable("jwtProofStr", cel.StringType), cel.Variable("dpopHeader", cel.StringType), cel.Variable("tokenStr", cel.StringType), cel.Variable("authHeader", cel.StringType), @@ -182,6 +194,7 @@ func TestDPoPCELIntegration(t *testing.T) { vars := map[string]any{ "proofStr": proofStr, + "jwtProofStr": jwtProofStr, "dpopHeader": "DPoP " + proofStr, "tokenStr": accessTokenStr, "authHeader": dpopAuthHeader, @@ -234,8 +247,8 @@ func TestDPoPCELIntegration(t *testing.T) { want: true, }, { - name: "proof_ath", - expr: `dpop.parse(proofStr).value().ath == 'fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo'`, + name: "proof_accessTokenHash", + expr: `dpop.parse(proofStr).value().accessTokenHash == 'fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo'`, want: true, }, { @@ -248,21 +261,6 @@ func TestDPoPCELIntegration(t *testing.T) { expr: `dpop.parse(proofStr).value().thumbprint == '0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I'`, want: true, }, - { - name: "dpop_ath_func", - expr: `dpop.ath(tokenStr) == 'fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo'`, - want: true, - }, - { - name: "dpop_ath_from_auth_header", - expr: `dpop.ath(authHeader) == 'fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo'`, - want: true, - }, - { - name: "dpop_thumbprint_func", - expr: `dpop.thumbprint(dpop.parse(proofStr).value().jwk) == '0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I'`, - want: true, - }, { name: "claim_custom", expr: `dpop.parse(proofStr).value().claim('custom').orValue('') == 'custom-value'`, @@ -278,51 +276,6 @@ func TestDPoPCELIntegration(t *testing.T) { expr: `dpop.parse(proofStr).claim('missing').hasValue()`, want: false, }, - { - name: "matchesMethod_exact", - expr: `dpop.parse(proofStr).matchesMethod('GET')`, - want: true, - }, - { - name: "matchesMethod_case_insensitive", - expr: `dpop.parse(proofStr).matchesMethod('get')`, - want: true, - }, - { - name: "matchesMethod_mismatch", - expr: `dpop.parse(proofStr).matchesMethod('POST')`, - want: false, - }, - { - name: "matchesURI_exact", - expr: `dpop.parse(proofStr).matchesURI('https://resource.example.org/protectedresource')`, - want: true, - }, - { - name: "matchesHtu_alias", - expr: `dpop.parse(proofStr).matchesHtu('https://resource.example.org/protectedresource')`, - want: true, - }, - { - name: "matchesURI_with_default_port_and_case", - expr: `dpop.parse(proofStr).matchesURI('HTTPS://RESOURCE.EXAMPLE.ORG:443/protectedresource')`, - want: true, - }, - { - name: "matchesURI_ignores_query_and_fragment", - expr: `dpop.parse(proofStr).matchesURI('https://resource.example.org/protectedresource?query=1#frag')`, - want: true, - }, - { - name: "matchesURI_path_cleaning", - expr: `dpop.parse(proofStr).matchesURI('https://resource.example.org/foo/../protectedresource')`, - want: true, - }, - { - name: "matchesURI_mismatch", - expr: `dpop.parse(proofStr).matchesURI('https://other.example.org/protectedresource')`, - want: false, - }, { name: "matchesRequest_success", expr: `dpop.parse(proofStr).matchesRequest('GET', 'https://resource.example.org/protectedresource')`, @@ -334,23 +287,23 @@ func TestDPoPCELIntegration(t *testing.T) { want: true, }, { - name: "matchesRequest_method_mismatch", - expr: `dpop.parse(proofStr).matchesRequest('POST', 'https://resource.example.org/protectedresource')`, - want: false, + name: "matchesRequest_with_default_port_and_case", + expr: `dpop.parse(proofStr).matchesRequest('get', 'HTTPS://RESOURCE.EXAMPLE.ORG:443/protectedresource?query=1#frag')`, + want: true, }, { - name: "matchesAccessToken_raw", - expr: `dpop.parse(proofStr).matchesAccessToken(tokenStr)`, + name: "matchesRequest_path_cleaning", + expr: `dpop.parse(proofStr).matchesRequest('GET', 'https://resource.example.org/foo/../protectedresource')`, want: true, }, { - name: "matchesAccessToken_header", - expr: `dpop.parse(proofStr).matchesAccessToken(authHeader)`, - want: true, + name: "matchesRequest_method_mismatch", + expr: `dpop.parse(proofStr).matchesRequest('POST', 'https://resource.example.org/protectedresource')`, + want: false, }, { - name: "matchesAccessToken_mismatch", - expr: `dpop.parse(proofStr).matchesAccessToken('wrong-token')`, + name: "matchesRequest_uri_mismatch", + expr: `dpop.parse(proofStr).matchesRequest('GET', 'https://other.example.org/protectedresource')`, want: false, }, { @@ -364,23 +317,18 @@ func TestDPoPCELIntegration(t *testing.T) { want: false, }, { - name: "matchesConfirmation_string", - expr: `dpop.parse(proofStr).matchesConfirmation('0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I')`, - want: true, - }, - { - name: "matchesConfirmation_map", - expr: `dpop.parse(proofStr).matchesConfirmation({'jkt': '0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I'})`, + name: "matchesToken_with_jwt_string", + expr: `dpop.parse(jwtProofStr).matchesToken(jwtTokenStr)`, want: true, }, { name: "matchesToken_with_parsed_jwt", - expr: `dpop.parse(proofStr).matchesToken(jwt.parse(jwtTokenStr))`, + expr: `dpop.parse(jwtProofStr).matchesToken(jwt.parse(jwtTokenStr))`, want: true, }, { name: "matchesToken_with_parsed_jwt_value", - expr: `dpop.parse(proofStr).matchesToken(jwt.parse(jwtTokenStr).value())`, + expr: `dpop.parse(jwtProofStr).matchesToken(jwt.parse(jwtTokenStr).value())`, want: true, }, } @@ -406,9 +354,9 @@ func TestJWKThumbprintAlgorithms(t *testing.T) { if err != nil { t.Fatalf("ComputeJWKThumbprint(RSA) failed: %v", err) } - // RFC 7638 Section 3.1 RSA thumbprint test vector: NzbLsHIexยอด... - // NzbLsHIexUVQuOfNReIsTyXOvjX676Yu5_BpYZShqKE - expectedRSA := "NzbLsHIexUVQuOfNReIsTyXOvjX676Yu5_BpYZShqKE" + // RFC 7638 Section 3.1 RSA thumbprint test vector: + // NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs + expectedRSA := "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs" if rsaThumbprint != expectedRSA { t.Errorf("RSA Thumbprint = %q, want %q", rsaThumbprint, expectedRSA) } @@ -697,3 +645,330 @@ func TestAllowedAlgorithmsOption(t *testing.T) { t.Errorf("expected tokRS to be rejected by allowed algorithms, got %v", got) } } + +func TestMatchesTokenBothChecks(t *testing.T) { + // 1. Create a DPoP-bound JWT access token + jwtHeader := map[string]any{"alg": "ES256", "typ": "JWT"} + jwtPayload := map[string]any{ + "iss": "https://auth.example.com", + "sub": "user-42", + "aud": "https://api.example.com", + "exp": time.Now().Add(1 * time.Hour).Unix(), + "iat": time.Now().Unix(), + "cnf": map[string]any{ + "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I", + }, + } + boundJWT := createTestJWT(t, jwtHeader, jwtPayload) + + // Calculate ATH for this exact JWT + jwtATH := dpop.ComputeAccessTokenHash(boundJWT) + + // 2. Create DPoP Proof matching this JWT's thumbprint and ATH + ecJWK := map[string]any{ + "kty": "EC", + "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs", + "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA", + "crv": "P-256", + } + proofHeader := map[string]any{ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": ecJWK, + } + proofPayload := map[string]any{ + "jti": "jti-unique-999", + "htm": "GET", + "htu": "https://api.example.com/data", + "iat": time.Now().Unix(), + "ath": jwtATH, + } + proofStr := createTestDPoP(t, proofHeader, proofPayload) + + // Another JWT with SAME cnf.jkt but different payload (different ATH) + otherJWTPayload := map[string]any{ + "iss": "https://auth.example.com", + "sub": "user-different-id", + "aud": "https://api.example.com", + "exp": time.Now().Add(1 * time.Hour).Unix(), + "iat": time.Now().Unix(), + "cnf": map[string]any{ + "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I", + }, + } + otherJWT := createTestJWT(t, jwtHeader, otherJWTPayload) + + // Another JWT with WRONG cnf.jkt + wrongCnfJWTPayload := map[string]any{ + "iss": "https://auth.example.com", + "sub": "user-42", + "aud": "https://api.example.com", + "exp": time.Now().Add(1 * time.Hour).Unix(), + "iat": time.Now().Unix(), + "cnf": map[string]any{ + "jkt": "WRONG_JKT_THUMBPRINT_VALUE", + }, + } + wrongCnfJWT := createTestJWT(t, jwtHeader, wrongCnfJWTPayload) + + env, err := cel.NewEnv( + dpop.Library(), + jwt.Library(), + cel.Variable("proofStr", cel.StringType), + cel.Variable("boundJWT", cel.StringType), + cel.Variable("dpopBoundJWT", cel.StringType), + cel.Variable("otherJWT", cel.StringType), + cel.Variable("wrongCnfJWT", cel.StringType), + cel.Variable("opaqueToken", cel.StringType), + ) + if err != nil { + t.Fatalf("cel.NewEnv failed: %v", err) + } + + vars := map[string]any{ + "proofStr": proofStr, + "boundJWT": boundJWT, + "dpopBoundJWT": "DPoP " + boundJWT, + "otherJWT": otherJWT, + "wrongCnfJWT": wrongCnfJWT, + "opaqueToken": "opaque-non-jwt-token-string", + } + + tests := []struct { + name string + expr string + want bool + }{ + { + name: "matchesToken_with_matching_jwt_string", + expr: `dpop.parse(proofStr).matchesToken(boundJWT)`, + want: true, + }, + { + name: "matchesToken_with_matching_dpop_header_string", + expr: `dpop.parse(proofStr).matchesToken(dpopBoundJWT)`, + want: true, + }, + { + name: "matchesToken_with_parsed_jwt_token", + expr: `dpop.parse(proofStr).matchesToken(jwt.parse(boundJWT))`, + want: true, + }, + { + name: "matchesToken_with_parsed_jwt_token_value", + expr: `dpop.parse(proofStr).matchesToken(jwt.parse(boundJWT).value())`, + want: true, + }, + { + name: "matchesToken_rejects_ath_mismatch_even_if_jkt_matches", + expr: `dpop.parse(proofStr).matchesToken(otherJWT)`, + want: false, + }, + { + name: "matchesToken_rejects_parsed_jwt_ath_mismatch", + expr: `dpop.parse(proofStr).matchesToken(jwt.parse(otherJWT))`, + want: false, + }, + { + name: "matchesToken_rejects_wrong_jkt_confirmation", + expr: `dpop.parse(proofStr).matchesToken(wrongCnfJWT)`, + want: false, + }, + { + name: "matchesToken_rejects_opaque_non_jwt_string", + expr: `dpop.parse(proofStr).matchesToken(opaqueToken)`, + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := evalExpr(t, env, tc.expr, vars) + if got != tc.want { + t.Errorf("Eval(%q) = %v, want %v", tc.expr, got, tc.want) + } + }) + } +} + +func TestMatchesChained(t *testing.T) { + jwtHeader := map[string]any{"alg": "ES256", "typ": "JWT"} + jwtPayload := map[string]any{ + "iss": "https://auth.example.com", + "sub": "user-42", + "aud": "https://api.example.com", + "exp": time.Now().Add(1 * time.Hour).Unix(), + "iat": time.Now().Unix(), + "cnf": map[string]any{ + "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I", + }, + } + boundJWT := createTestJWT(t, jwtHeader, jwtPayload) + jwtATH := dpop.ComputeAccessTokenHash(boundJWT) + + ecJWK := map[string]any{ + "kty": "EC", + "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs", + "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA", + "crv": "P-256", + } + proofHeader := map[string]any{ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": ecJWK, + } + proofPayload := map[string]any{ + "jti": "jti-unique-matches-123", + "htm": "POST", + "htu": "https://api.example.com/orders", + "iat": time.Now().Unix(), + "ath": jwtATH, + "nonce": "server-nonce-valid-789", + } + proofStr := createTestDPoP(t, proofHeader, proofPayload) + + env, err := cel.NewEnv( + dpop.Library(), + jwt.Library(), + cel.Variable("proofStr", cel.StringType), + cel.Variable("boundJWT", cel.StringType), + cel.Variable("method", cel.StringType), + cel.Variable("url", cel.StringType), + cel.Variable("nonce", cel.StringType), + ) + if err != nil { + t.Fatalf("cel.NewEnv failed: %v", err) + } + + vars := map[string]any{ + "proofStr": proofStr, + "boundJWT": boundJWT, + "method": "POST", + "url": "https://api.example.com/orders", + "nonce": "server-nonce-valid-789", + } + + tests := []struct { + name string + expr string + want bool + }{ + { + name: "matchesRequest_method_and_uri", + expr: `dpop.parse(proofStr).matchesRequest(method, url)`, + want: true, + }, + { + name: "matchesRequest_method_mismatch", + expr: `dpop.parse(proofStr).matchesRequest('GET', url)`, + want: false, + }, + { + name: "matchesRequest_uri_mismatch", + expr: `dpop.parse(proofStr).matchesRequest(method, 'https://other.example.com/orders')`, + want: false, + }, + { + name: "matchesToken_string", + expr: `dpop.parse(proofStr).matchesToken(boundJWT)`, + want: true, + }, + { + name: "matchesToken_header_prefix", + expr: `dpop.parse(proofStr).matchesToken('DPoP ' + boundJWT)`, + want: true, + }, + { + name: "matchesToken_jwt_token_object", + expr: `dpop.parse(proofStr).matchesToken(jwt.parse(boundJWT))`, + want: true, + }, + { + name: "matchesNonce_valid", + expr: `dpop.parse(proofStr).matchesNonce(nonce)`, + want: true, + }, + { + name: "matchesNonce_mismatch", + expr: `dpop.parse(proofStr).matchesNonce('wrong-nonce')`, + want: false, + }, + { + name: "chained_matchesRequest_matchesToken_matchesNonce", + expr: `dpop.parse(proofStr).matchesRequest(method, url) && dpop.parse(proofStr).matchesToken(boundJWT) && dpop.parse(proofStr).matchesNonce(nonce)`, + want: true, + }, + { + name: "chained_with_parsed_jwt_token", + expr: `dpop.parse(proofStr).matchesRequest(method, url) && dpop.parse(proofStr).matchesToken(jwt.parse(boundJWT)) && dpop.parse(proofStr).matchesNonce(nonce)`, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := evalExpr(t, env, tc.expr, vars) + if got != tc.want { + t.Errorf("Eval(%q) = %v, want %v", tc.expr, got, tc.want) + } + }) + } +} + +func TestNonceUtilities(t *testing.T) { + t.Run("GenerateNonce_random", func(t *testing.T) { + n1, err := dpop.GenerateNonce() + if err != nil { + t.Fatalf("GenerateNonce failed: %v", err) + } + n2, err := dpop.GenerateNonce() + if err != nil { + t.Fatalf("GenerateNonce failed: %v", err) + } + if n1 == "" || n2 == "" { + t.Fatalf("expected non-empty nonces") + } + if n1 == n2 { + t.Fatalf("expected distinct random nonces, got identical: %q", n1) + } + }) + + t.Run("StatelessNonce_valid", func(t *testing.T) { + key := []byte("super-secret-hmac-key-for-nonce") + nonce, err := dpop.GenerateStatelessNonce(key, "client-ip-127.0.0.1", "client-id-abc") + if err != nil { + t.Fatalf("GenerateStatelessNonce failed: %v", err) + } + + // Validate with correct key, maxAge, and context + if !dpop.ValidateStatelessNonce(nonce, key, 1*time.Minute, "client-ip-127.0.0.1", "client-id-abc") { + t.Fatalf("ValidateStatelessNonce failed on valid nonce %q", nonce) + } + + // Validate with wrong key + wrongKey := []byte("wrong-key-value-123456789012345") + if dpop.ValidateStatelessNonce(nonce, wrongKey, 1*time.Minute, "client-ip-127.0.0.1", "client-id-abc") { + t.Fatalf("ValidateStatelessNonce should reject wrong key") + } + + // Validate with wrong context + if dpop.ValidateStatelessNonce(nonce, key, 1*time.Minute, "client-ip-10.0.0.1", "client-id-abc") { + t.Fatalf("ValidateStatelessNonce should reject mismatched context") + } + + // Validate with 0 maxAge (expired) + if dpop.ValidateStatelessNonce(nonce, key, -1*time.Second, "client-ip-127.0.0.1", "client-id-abc") { + t.Fatalf("ValidateStatelessNonce should reject expired nonce") + } + + // Validate malformed nonces + if dpop.ValidateStatelessNonce("invalid.malformed.parts", key, 1*time.Minute) { + t.Fatalf("ValidateStatelessNonce should reject invalid parts") + } + if dpop.ValidateStatelessNonce("invalid", key, 1*time.Minute) { + t.Fatalf("ValidateStatelessNonce should reject single segment") + } + }) +} + + diff --git a/ext/security/jwt/jwt.go b/ext/security/jwt/jwt.go index 553a2cdfe..a6abfd3a5 100644 --- a/ext/security/jwt/jwt.go +++ b/ext/security/jwt/jwt.go @@ -252,6 +252,9 @@ type Token struct { // Raw JSON payload associated with the token including custom claims. // Must be treated as read-only once initialized. Payload map[string]any `json:"-" cel:"-"` + + // Raw holds the original token string (without scheme prefix) if parsed via ParseToken. + Raw string `json:"-" cel:"-"` } // IsValidAt checks whether the token time claims (iat, nbf, exp) are valid at the given reference time with clock leeway tolerance. @@ -415,7 +418,12 @@ func ParseToken(tokenStr string) (*Token, error) { if err := json.Unmarshal(payloadBytes, &payload); err != nil { return nil, fmt.Errorf("failed to parse payload JSON: %w", err) } - return NewToken(header, payload) + tok, err := NewToken(header, payload) + if err != nil { + return nil, err + } + tok.Raw = tokenStr + return tok, nil } func optString(m map[string]any, key string) string { @@ -427,9 +435,13 @@ func optString(m map[string]any, key string) string { func trimBearerPrefix(tokenStr string) string { tokenStr = strings.TrimSpace(tokenStr) - if strings.HasPrefix(strings.ToLower(tokenStr), "bearer ") { + lower := strings.ToLower(tokenStr) + if strings.HasPrefix(lower, "bearer ") { return strings.TrimSpace(tokenStr[7:]) } + if strings.HasPrefix(lower, "dpop ") { + return strings.TrimSpace(tokenStr[5:]) + } return tokenStr } From ce72180ae71d2649e60a9eddbb9d476879b21f70 Mon Sep 17 00:00:00 2001 From: TristonianJones Date: Tue, 25 Aug 2026 15:40:34 -0700 Subject: [PATCH 3/3] Additional test coverage --- ext/security/dpop/BUILD.bazel | 1 - ext/security/dpop/dpop.go | 15 +- ext/security/dpop/dpop_test.go | 814 +++++++++++++++++++++++++++++++ ext/security/dpop/export_test.go | 20 - 4 files changed, 824 insertions(+), 26 deletions(-) delete mode 100644 ext/security/dpop/export_test.go diff --git a/ext/security/dpop/BUILD.bazel b/ext/security/dpop/BUILD.bazel index 88736492f..6bbdb4036 100644 --- a/ext/security/dpop/BUILD.bazel +++ b/ext/security/dpop/BUILD.bazel @@ -24,7 +24,6 @@ go_test( size = "small", srcs = [ "dpop_test.go", - "export_test.go", ], embed = [ ":go_default_library", diff --git a/ext/security/dpop/dpop.go b/ext/security/dpop/dpop.go index d4914df90..d5833b0d2 100644 --- a/ext/security/dpop/dpop.go +++ b/ext/security/dpop/dpop.go @@ -51,17 +51,22 @@ func defaultNowFunc() time.Time { return time.Now().UTC() } -// Library returns a cel.EnvOption to configure extended functions for DPoP proof parsing, -// request verification, and key confirmation inspection. -func Library(options ...Option) cel.EnvOption { +// NewDPoPLib creates a new DPoP library with the given options. +func NewDPoPLib(opts ...Option) *dpopLib { l := &dpopLib{ version: ^uint32(0), now: defaultNowFunc, } - for _, o := range options { + for _, o := range opts { l = o(l) } - return cel.Lib(l) + return l +} + +// Library returns a cel.EnvOption to configure extended functions for DPoP proof parsing, +// request verification, and key confirmation inspection. +func Library(options ...Option) cel.EnvOption { + return cel.Lib(NewDPoPLib(options...)) } // Option declares a functional operator for configuring DPoP extension library behavior. diff --git a/ext/security/dpop/dpop_test.go b/ext/security/dpop/dpop_test.go index aebdc386c..3aa98b648 100644 --- a/ext/security/dpop/dpop_test.go +++ b/ext/security/dpop/dpop_test.go @@ -15,13 +15,18 @@ package dpop_test import ( + "crypto/hmac" + "crypto/sha256" "encoding/base64" "encoding/json" + "fmt" "reflect" + "strings" "testing" "time" "cel.dev/cel-go/cel" + "cel.dev/cel-go/common/types" "cel.dev/cel-go/ext/security/dpop" "cel.dev/cel-go/ext/security/jwt" ) @@ -968,7 +973,816 @@ func TestNonceUtilities(t *testing.T) { if dpop.ValidateStatelessNonce("invalid", key, 1*time.Minute) { t.Fatalf("ValidateStatelessNonce should reject single segment") } + if dpop.ValidateStatelessNonce("bad_b64.sig", key, 1*time.Minute) { + t.Fatalf("ValidateStatelessNonce should reject invalid b64 payload") + } + if dpop.ValidateStatelessNonce("cGF5bG9hZA.bad_sig", key, 1*time.Minute) { + t.Fatalf("ValidateStatelessNonce should reject invalid b64 sig") + } + if dpop.ValidateStatelessNonce("bm9fc2VwYXJhdG9ycw.c2lnbmF0dXJl", key, 1*time.Minute) { + t.Fatalf("ValidateStatelessNonce should reject payload without timestamp separator") + } + // Malformed timestamp in payload + badTsPayload := base64.RawURLEncoding.EncodeToString([]byte("not_a_ts:entropy:ctx")) + if dpop.ValidateStatelessNonce(badTsPayload+".c2ln", key, 1*time.Minute) { + t.Fatalf("ValidateStatelessNonce should reject invalid timestamp") + } + // Future timestamp outside 10s window + futureTime := time.Now().UTC().Add(1 * time.Hour).Unix() + futurePayload := []byte(fmt.Sprintf("%d:entropy:ctx", futureTime)) + mac := hmac.New(sha256.New, key) + mac.Write(futurePayload) + futureSig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + futureNonce := base64.RawURLEncoding.EncodeToString(futurePayload) + "." + futureSig + if dpop.ValidateStatelessNonce(futureNonce, key, 1*time.Minute, "ctx") { + t.Fatalf("ValidateStatelessNonce should reject far-future timestamp") + } + + // Tampered valid-base64 signature + nonceParts := strings.Split(nonce, ".") + fakeSig := base64.RawURLEncoding.EncodeToString([]byte("wrong-signature-32-bytes-long!")) + if dpop.ValidateStatelessNonce(nonceParts[0]+"."+fakeSig, key, 1*time.Minute, "client-ip-127.0.0.1", "client-id-abc") { + t.Fatalf("ValidateStatelessNonce should reject tampered valid-base64 signature") + } + + // Single-part signed payload (len(payloadParts) < 2) + singlePart := []byte("singlepartnoparts") + mac1 := hmac.New(sha256.New, key) + mac1.Write(singlePart) + sig1 := base64.RawURLEncoding.EncodeToString(mac1.Sum(nil)) + nonce1 := base64.RawURLEncoding.EncodeToString(singlePart) + "." + sig1 + if dpop.ValidateStatelessNonce(nonce1, key, 1*time.Minute) { + t.Fatalf("ValidateStatelessNonce should reject single part payload") + } + + // Non-integer timestamp signed payload + badTsPart := []byte("notanumber:entropy:ctx") + mac2 := hmac.New(sha256.New, key) + mac2.Write(badTsPart) + sig2 := base64.RawURLEncoding.EncodeToString(mac2.Sum(nil)) + nonce2 := base64.RawURLEncoding.EncodeToString(badTsPart) + "." + sig2 + if dpop.ValidateStatelessNonce(nonce2, key, 1*time.Minute, "ctx") { + t.Fatalf("ValidateStatelessNonce should reject invalid integer timestamp") + } + + // Nonce generated without context validated with context required + noCtxNonce, err := dpop.GenerateStatelessNonce(key) + if err != nil { + t.Fatalf("GenerateStatelessNonce without context failed: %v", err) + } + if dpop.ValidateStatelessNonce(noCtxNonce, key, 1*time.Minute, "required-ctx") { + t.Fatalf("ValidateStatelessNonce should reject nonce lacking expected context") + } + if !dpop.ValidateStatelessNonce(noCtxNonce, key, 1*time.Minute) { + t.Fatalf("ValidateStatelessNonce should accept nonce without context") + } + }) +} + +func TestDPoPOptions(t *testing.T) { + now := time.Unix(1700000000, 0).UTC() + + t.Run("Version_Option", func(t *testing.T) { + lib := dpop.NewDPoPLib(dpop.Version(1)) + if lib == nil { + t.Fatal("expected non-nil lib") + } + }) + + t.Run("MaxAge_Option", func(t *testing.T) { + lib := dpop.NewDPoPLib(dpop.MaxAge(15 * time.Minute)) + if lib == nil { + t.Fatal("expected non-nil lib") + } + }) + + t.Run("ClockLeeway_Option", func(t *testing.T) { + lib := dpop.NewDPoPLib(dpop.ClockLeeway(10 * time.Second)) + if lib == nil { + t.Fatal("expected non-nil lib") + } + }) + + t.Run("ValidateTimes_DefaultClock", func(t *testing.T) { + env, err := cel.NewEnv( + cel.Variable("proof", cel.StringType), + dpop.Library( + dpop.ValidateTimes(5 * time.Minute), + ), + ) + if err != nil { + t.Fatalf("NewEnv failed: %v", err) + } + ecJWK := map[string]any{ + "kty": "EC", + "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs", + "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA", + "crv": "P-256", + } + proofStr := createTestDPoP(t, map[string]any{ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": ecJWK, + }, map[string]any{ + "jti": "jti-123", + "htm": "GET", + "htu": "https://example.com/resource", + "iat": time.Now().UTC().Unix(), + }) + + got := evalExpr(t, env, `dpop.parse(proof).hasValue()`, map[string]any{"proof": proofStr}) + if got != true { + t.Errorf("dpop.parse with default clock failed: got %v, want true", got) + } + }) + + t.Run("ValidateTimes_CustomClock", func(t *testing.T) { + env, err := cel.NewEnv( + cel.Variable("proof", cel.StringType), + dpop.Library( + dpop.ValidateTimes(5*time.Minute, 10*time.Second), + dpop.Clock(func() time.Time { return now }), + ), + ) + if err != nil { + t.Fatalf("NewEnv failed: %v", err) + } + ecJWK := map[string]any{ + "kty": "EC", + "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs", + "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA", + "crv": "P-256", + } + // Expired proof (> 5 min before now) + expiredProof := createTestDPoP(t, map[string]any{ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": ecJWK, + }, map[string]any{ + "jti": "jti-123", + "htm": "GET", + "htu": "https://example.com/resource", + "iat": now.Add(-10 * time.Minute).Unix(), + }) + got := evalExpr(t, env, `dpop.parse(proof).hasValue()`, map[string]any{"proof": expiredProof}) + if got != false { + t.Errorf("expected expired proof to evaluate to optional.none(), got %v", got) + } + }) + + t.Run("AllowedAlgorithms_CEL_Rejection", func(t *testing.T) { + env, err := cel.NewEnv( + cel.Variable("proof", cel.StringType), + dpop.Library( + dpop.AllowedAlgorithms("RS256"), + ), + ) + if err != nil { + t.Fatalf("NewEnv failed: %v", err) + } + ecJWK := map[string]any{ + "kty": "EC", + "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs", + "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA", + "crv": "P-256", + } + es256Proof := createTestDPoP(t, map[string]any{ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": ecJWK, + }, map[string]any{ + "jti": "jti-123", + "htm": "GET", + "htu": "https://example.com/resource", + "iat": time.Now().UTC().Unix(), + }) + + got := evalExpr(t, env, `dpop.parse(proof).hasValue()`, map[string]any{"proof": es256Proof}) + if got != false { + t.Errorf("expected disallowed algorithm ES256 to evaluate to optional.none(), got %v", got) + } + }) +} + +func TestParseProofErrorsAndLimits(t *testing.T) { + ecJWK := map[string]any{ + "kty": "EC", + "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs", + "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA", + "crv": "P-256", + } + + t.Run("exceeds_max_size", func(t *testing.T) { + hugeProof := strings.Repeat("a", 65537) + _, err := dpop.ParseProof(hugeProof) + if err == nil { + t.Fatal("expected error for oversized proof") + } + }) + + t.Run("invalid_segment_counts", func(t *testing.T) { + for _, s := range []string{"single_part", "one.two.three.four.five"} { + if _, err := dpop.ParseProof(s); err == nil { + t.Fatalf("expected error for %q", s) + } + } + }) + + t.Run("invalid_base64_header_and_payload", func(t *testing.T) { + if _, err := dpop.ParseProof("???bad_b64???.eyJqdGkiOiIxIn0.sig"); err == nil { + t.Fatal("expected error for bad header b64") + } + if _, err := dpop.ParseProof("eyJhbGciOiJFUzI1NiJ9.???bad_payload???.sig"); err == nil { + t.Fatal("expected error for bad payload b64") + } + }) + + t.Run("invalid_json_header_and_payload", func(t *testing.T) { + notJSON := base64.RawURLEncoding.EncodeToString([]byte("not json")) + validB64 := base64.RawURLEncoding.EncodeToString([]byte(`{"typ":"dpop+jwt"}`)) + if _, err := dpop.ParseProof(notJSON + "." + validB64 + ".sig"); err == nil { + t.Fatal("expected error for non-json header") + } + if _, err := dpop.ParseProof(validB64 + "." + notJSON + ".sig"); err == nil { + t.Fatal("expected error for non-json payload") + } + }) + + t.Run("invalid_typ_header", func(t *testing.T) { + for _, typVal := range []any{"jwt", "application/json", 123, nil, ""} { + header := map[string]any{"typ": typVal, "alg": "ES256", "jwk": ecJWK} + payload := map[string]any{"jti": "1", "htm": "GET", "htu": "https://example.com", "iat": 1000} + if _, err := dpop.NewProof(header, payload); err == nil { + t.Fatalf("expected error for typ=%v", typVal) + } + } + }) + + t.Run("invalid_alg_header", func(t *testing.T) { + for _, algVal := range []any{"none", "HS256", "HS384", "HS512", "", 123, nil} { + header := map[string]any{"typ": "dpop+jwt", "alg": algVal, "jwk": ecJWK} + payload := map[string]any{"jti": "1", "htm": "GET", "htu": "https://example.com", "iat": 1000} + if _, err := dpop.NewProof(header, payload); err == nil { + t.Fatalf("expected error for alg=%v", algVal) + } + } + }) + + t.Run("invalid_jwk_header", func(t *testing.T) { + for _, jwkVal := range []any{nil, "not-a-map", map[string]any{}, 123} { + header := map[string]any{"typ": "dpop+jwt", "alg": "ES256", "jwk": jwkVal} + payload := map[string]any{"jti": "1", "htm": "GET", "htu": "https://example.com", "iat": 1000} + if _, err := dpop.NewProof(header, payload); err == nil { + t.Fatalf("expected error for jwk=%v", jwkVal) + } + } + }) + + t.Run("forbidden_jwk_private_keys", func(t *testing.T) { + for _, key := range []string{"d", "p", "q", "dp", "dq", "qi", "dmp1", "dmq1", "oth", "k"} { + jwkWithPriv := map[string]any{ + "kty": "EC", + "crv": "P-256", + "x": "x", + "y": "y", + key: "secret-value", + } + header := map[string]any{"typ": "dpop+jwt", "alg": "ES256", "jwk": jwkWithPriv} + payload := map[string]any{"jti": "1", "htm": "GET", "htu": "https://example.com", "iat": 1000} + if _, err := dpop.NewProof(header, payload); err == nil { + t.Fatalf("expected error for private JWK member %q", key) + } + } + // oct symmetric key + octJWK := map[string]any{"kty": "oct", "k": "c2VjcmV0"} + header := map[string]any{"typ": "dpop+jwt", "alg": "ES256", "jwk": octJWK} + payload := map[string]any{"jti": "1", "htm": "GET", "htu": "https://example.com", "iat": 1000} + if _, err := dpop.NewProof(header, payload); err == nil { + t.Fatal("expected error for oct JWK") + } + }) + + t.Run("missing_required_payload_claims", func(t *testing.T) { + validClaims := map[string]any{ + "jti": "jti-val", + "htm": "GET", + "htu": "https://example.com", + "iat": 1700000000, + } + header := map[string]any{"typ": "dpop+jwt", "alg": "ES256", "jwk": ecJWK} + + for _, missingKey := range []string{"jti", "htm", "htu", "iat"} { + payload := make(map[string]any) + for k, v := range validClaims { + if k != missingKey { + payload[k] = v + } + } + if _, err := dpop.NewProof(header, payload); err == nil { + t.Fatalf("expected error when %q claim is missing", missingKey) + } + } + + // Invalid iat timestamp + badIatPayload := map[string]any{ + "jti": "jti-val", + "htm": "GET", + "htu": "https://example.com", + "iat": "not-a-timestamp", + } + if _, err := dpop.NewProof(header, badIatPayload); err == nil { + t.Fatal("expected error for non-timestamp iat") + } + }) +} + +func TestJWKThumbprintAlgorithmsAndErrors(t *testing.T) { + t.Run("RSA_JWK", func(t *testing.T) { + rsaJWK := map[string]any{ + "kty": "RSA", + "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + "e": "AQAB", + } + thumb, err := dpop.ComputeJWKThumbprint(rsaJWK) + if err != nil { + t.Fatalf("ComputeJWKThumbprint RSA failed: %v", err) + } + if thumb != "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs" { + t.Errorf("RSA thumbprint = %q, want RFC 7638 vector", thumb) + } + + // Missing e or n + if _, err := dpop.ComputeJWKThumbprint(map[string]any{"kty": "RSA", "n": "n"}); err == nil { + t.Fatal("expected error for missing e") + } + if _, err := dpop.ComputeJWKThumbprint(map[string]any{"kty": "RSA", "e": "AQAB"}); err == nil { + t.Fatal("expected error for missing n") + } + }) + + t.Run("OKP_JWK", func(t *testing.T) { + okpJWK := map[string]any{ + "kty": "OKP", + "crv": "Ed25519", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + } + thumb, err := dpop.ComputeJWKThumbprint(okpJWK) + if err != nil { + t.Fatalf("ComputeJWKThumbprint OKP failed: %v", err) + } + if thumb == "" { + t.Fatal("expected non-empty OKP thumbprint") + } + + // Missing crv or x + if _, err := dpop.ComputeJWKThumbprint(map[string]any{"kty": "OKP", "x": "x"}); err == nil { + t.Fatal("expected error for missing crv") + } + if _, err := dpop.ComputeJWKThumbprint(map[string]any{"kty": "OKP", "crv": "Ed25519"}); err == nil { + t.Fatal("expected error for missing x") + } + }) + + t.Run("EC_JWK_Errors", func(t *testing.T) { + if _, err := dpop.ComputeJWKThumbprint(map[string]any{"kty": "EC", "x": "x", "y": "y"}); err == nil { + t.Fatal("expected error for missing crv") + } + if _, err := dpop.ComputeJWKThumbprint(map[string]any{"kty": "EC", "crv": "P-256", "y": "y"}); err == nil { + t.Fatal("expected error for missing x") + } + if _, err := dpop.ComputeJWKThumbprint(map[string]any{"kty": "EC", "crv": "P-256", "x": "x"}); err == nil { + t.Fatal("expected error for missing y") + } + }) + + t.Run("Unsupported_and_empty_kty", func(t *testing.T) { + if _, err := dpop.ComputeJWKThumbprint(map[string]any{}); err == nil { + t.Fatal("expected error for empty JWK") + } + if _, err := dpop.ComputeJWKThumbprint(map[string]any{"kty": "UNSUPPORTED"}); err == nil { + t.Fatal("expected error for unsupported kty") + } + }) +} + +func TestMatchesTargetURIExtensive(t *testing.T) { + ecJWK := map[string]any{ + "kty": "EC", + "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs", + "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA", + "crv": "P-256", + } + proof, err := dpop.NewProof(map[string]any{ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": ecJWK, + }, map[string]any{ + "jti": "jti-1", + "htm": "GET", + "htu": "https://example.com/path", + "iat": 1700000000, + }) + if err != nil { + t.Fatalf("NewProof failed: %v", err) + } + + tests := []struct { + name string + uri string + want bool + }{ + { + name: "exact_match", + uri: "https://example.com/path", + want: true, + }, + { + name: "default_port_stripped_matches", + uri: "https://example.com:443/path", + want: true, + }, + { + name: "http_default_port_stripped", + uri: "http://example.com:80/path", + want: false, // scheme is http vs https + }, + { + name: "dot_segments_cleaned_matches", + uri: "https://example.com/a/b/../../path", + want: true, + }, + { + name: "custom_port_mismatch", + uri: "https://example.com:8443/path", + want: false, + }, + { + name: "scheme_mismatch", + uri: "http://example.com/path", + want: false, + }, + { + name: "empty_uri", + uri: "", + want: false, + }, + { + name: "invalid_scheme", + uri: "//example.com/path", + want: false, + }, + { + name: "missing_host", + uri: "http:", + want: false, + }, + { + name: "ipv6_literal_host_with_default_port", + uri: "https://[2001:db8::1]:443/path", + want: false, // host mismatch + }, + { + name: "ipv6_literal_host_with_custom_port", + uri: "https://[2001:db8::1]:8443/path", + want: false, // host mismatch + }, + { + name: "root_path", + uri: "https://example.com", + want: false, // / vs /path + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := proof.MatchesTargetURI(tc.uri) + if got != tc.want { + t.Errorf("MatchesTargetURI(%q) = %v, want %v", tc.uri, got, tc.want) + } + }) + } + + // Test proof with root path + rootProof, err := dpop.NewProof(map[string]any{ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": ecJWK, + }, map[string]any{ + "jti": "jti-1", + "htm": "GET", + "htu": "https://example.com", + "iat": 1700000000, + }) + if err != nil { + t.Fatalf("NewProof failed: %v", err) + } + if !rootProof.MatchesTargetURI("https://example.com/") { + t.Errorf("expected root proof to match https://example.com/") + } + + // Test proof with HTTP scheme and default port 80 + httpProof, err := dpop.NewProof(map[string]any{ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": ecJWK, + }, map[string]any{ + "jti": "jti-1", + "htm": "GET", + "htu": "http://example.com:80/path", + "iat": 1700000000, + }) + if err != nil { + t.Fatalf("NewProof failed: %v", err) + } + if !httpProof.MatchesTargetURI("http://example.com/path") { + t.Errorf("expected http proof to match http://example.com/path") + } +} + +func TestDirectProofMethodsEdgeCases(t *testing.T) { + ecJWK := map[string]any{ + "kty": "EC", + "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs", + "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA", + "crv": "P-256", + } + proof, err := dpop.NewProof(map[string]any{ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": ecJWK, + }, map[string]any{ + "jti": "jti-1", + "htm": "GET", + "htu": "https://example.com/api", + "iat": 1700000000, + "nil_val": nil, + }) + if err != nil { + t.Fatalf("NewProof failed: %v", err) + } + + t.Run("MatchesTargetURI_failures", func(t *testing.T) { + if proof.MatchesTargetURI("invalid uri :::") { + t.Error("expected false for invalid URI") + } + emptyProof := &dpop.Proof{} + if emptyProof.MatchesTargetURI("https://example.com") { + t.Error("expected false for proof with empty URI") + } + }) + + t.Run("MatchesAccessToken_empty", func(t *testing.T) { + if proof.MatchesAccessToken("token") { + t.Error("expected false when proof has no ath claim") + } + }) + + t.Run("MatchesNonce_empty", func(t *testing.T) { + if proof.MatchesNonce("nonce") { + t.Error("expected false when proof has no nonce claim") + } + }) + + t.Run("MatchesConfirmationString_empty_and_mismatch", func(t *testing.T) { + emptyProof := &dpop.Proof{} + if emptyProof.MatchesConfirmationString("thumb") { + t.Error("expected false when proof has empty thumbprint") + } + if proof.MatchesConfirmationString("different-length-thumb") { + t.Error("expected false for mismatched thumbprint") + } + }) + + t.Run("MatchesConfirmationMap_failures", func(t *testing.T) { + if proof.MatchesConfirmationMap(nil) { + t.Error("expected false for nil cnf") + } + if proof.MatchesConfirmationMap(map[string]any{"jkt": 123}) { + t.Error("expected false for non-string jkt") + } + if proof.MatchesConfirmationMap(map[string]any{"jkt": ""}) { + t.Error("expected false for empty jkt") + } + }) + + t.Run("MatchesToken_failures", func(t *testing.T) { + if proof.MatchesToken(nil) { + t.Error("expected false for nil token") + } + if proof.MatchesToken(&jwt.Token{}) { + t.Error("expected false for token with nil payload") + } + if proof.MatchesToken(&jwt.Token{Payload: map[string]any{}}) { + t.Error("expected false for token without cnf") + } + if proof.MatchesToken(&jwt.Token{Payload: map[string]any{"cnf": "not-a-map"}}) { + t.Error("expected false for token with invalid cnf type") + } + // Raw ath mismatch + proofWithAth := &dpop.Proof{ + AccessTokenHash: "different-ath", + Thumbprint: proof.Thumbprint, + } + tokWithCnf := &jwt.Token{ + Raw: "some-raw-token", + Payload: map[string]any{ + "cnf": map[string]any{ + "jkt": proof.Thumbprint, + }, + }, + } + if proofWithAth.MatchesToken(tokWithCnf) { + t.Error("expected false for mismatched raw token ath") + } + }) + + t.Run("MatchesTokenString_non_jwt", func(t *testing.T) { + proofWithAth := &dpop.Proof{ + AccessTokenHash: dpop.ComputeAccessTokenHash("opaque-secret-token"), + Thumbprint: proof.Thumbprint, + } + // opaque token matches ath, but is not a valid JWT -> returns false + if proofWithAth.MatchesTokenString("opaque-secret-token") { + t.Error("expected false for non-JWT token string") + } + }) + + t.Run("Claim_nil_missing_and_unadaptable", func(t *testing.T) { + env, err := cel.NewEnv() + if err != nil { + t.Fatalf("NewEnv failed: %v", err) + } + adapter := env.CELTypeAdapter() + + if proof.Claim(adapter, "missing") != types.OptionalNone { + t.Error("expected OptionalNone for missing claim") + } + if proof.Claim(adapter, "nil_val") != types.OptionalNone { + t.Error("expected OptionalNone for nil claim value") + } + + // Unadaptable value returning error + badProof := &dpop.Proof{ + Payload: map[string]any{ + "unadaptable": make(chan int), + }, + } + res := badProof.Claim(adapter, "unadaptable") + if !types.IsError(res) { + t.Errorf("expected error for unadaptable claim, got %v", res) + } }) } +func TestParseProofBase64Variants(t *testing.T) { + ecJWK := map[string]any{ + "kty": "EC", + "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs", + "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA", + "crv": "P-256", + } + header := map[string]any{ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": ecJWK, + } + payload := map[string]any{ + "jti": "jti-123", + "htm": "GET", + "htu": "https://example.com/api", + "iat": 1700000000, + "custom": "?>>??<