Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 72 additions & 2 deletions internal/handler/auth_verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"github.com/highflame-ai/zeroid/internal/middleware"
"github.com/highflame-ai/zeroid/internal/oautherror"
"github.com/highflame-ai/zeroid/pkg/dpop"
)

func (a *API) registerAuthVerifyRoute(router chi.Router) {
Expand All @@ -27,8 +28,12 @@ func (a *API) registerAuthVerifyRoute(router chi.Router) {
//
// 1. Reads the Bearer JWT from the Authorization header.
// 2. Introspects it (signature + revocation check).
// 3. On success: returns 200 with identity claims as response headers.
// 4. On failure: returns 401.
// 3. When the token carries cnf.jkt, requires a matching DPoP proof
// (RFC 9449 §6.1 sender-constraint). Forward-auth is the resource
// server for every proxied upstream; surfacing cnf without enforcing
// it would accept a stolen bound token as a bare Bearer.
// 4. On success: returns 200 with identity claims as response headers.
// 5. On failure: returns 401.
//
// Proxy config snippets:
//
Expand Down Expand Up @@ -78,6 +83,10 @@ func (a *API) authVerifyHandler(w http.ResponseWriter, r *http.Request) {
return
}

if rejectBoundTokenWithoutDPoP(w, r, token, claims, a.dpopVerifier, prm) {
return
}

headerMap := map[string]string{
"sub": "X-Forwarded-User",
"identity_type": "X-Zeroid-Identity-Type",
Expand All @@ -103,3 +112,64 @@ func (a *API) authVerifyHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"active":true}`))
}

// cnfJKTFromClaims pulls cnf.jkt from an introspection result. Introspect
// surfaces cnf as map[string]any (JWT nested object); issuance also uses
// map[string]string. Accept both so a shape quirk cannot skip enforcement.
func cnfJKTFromClaims(claims map[string]any) string {
raw, ok := claims["cnf"]
if !ok || raw == nil {
return ""
}
switch cnf := raw.(type) {
case map[string]any:
if jkt, _ := cnf["jkt"].(string); jkt != "" {
return jkt
}
case map[string]string:
return cnf["jkt"]
}
return ""
}

// rejectBoundTokenWithoutDPoP enforces RFC 9449 §6.1 sender-constraint for
// DPoP-bound access tokens on the forward-auth path. Returns true when the
// response has already been written (caller must return). Unbound tokens and
// a nil verifier leave the request untouched.
func rejectBoundTokenWithoutDPoP(w http.ResponseWriter, r *http.Request, accessToken string, claims map[string]any, verifier *dpop.Verifier, prm string) bool {
jkt := cnfJKTFromClaims(claims)
if jkt == "" || verifier == nil {
return false
}

proofJWT := r.Header.Get("DPoP")
if proofJWT == "" {
w.Header().Set("WWW-Authenticate", middleware.WWWAuthenticate(oautherror.InvalidToken, "token is DPoP-bound; DPoP proof header is required", prm))
http.Error(w, `{"error":"invalid_token","error_description":"token is DPoP-bound; DPoP proof header is required"}`, http.StatusUnauthorized)
return true
}

htu := middleware.EffectiveRequestURL(r.Context())
if htu == "" {
// RequestURLMiddleware always installs on the public router; keep a
// defensive fallback for unit tests that call the helper directly.
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
htu = scheme + "://" + r.Host + r.URL.Path
}

if _, err := verifier.ValidateBoundToToken(r.Context(), dpop.ValidateRequest{
ProofJWT: proofJWT,
Method: r.Method,
URL: htu,
AccessToken: accessToken,
}, jkt); err != nil {
log.Warn().Err(err).Str("path", r.URL.Path).Msg("auth/verify: DPoP proof rejected")
w.Header().Set("WWW-Authenticate", middleware.WWWAuthenticate(oautherror.InvalidToken, "invalid DPoP proof", prm))
http.Error(w, `{"error":"invalid_token","error_description":"invalid DPoP proof"}`, http.StatusUnauthorized)
return true
}
return false
}
139 changes: 139 additions & 0 deletions internal/handler/auth_verify_dpop_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package handler

import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/lestrrat-go/jwx/v4/jwa"
"github.com/lestrrat-go/jwx/v4/jwk"
"github.com/lestrrat-go/jwx/v4/jws"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/highflame-ai/zeroid/pkg/dpop"
)

func TestCnfJKTFromClaims(t *testing.T) {
assert.Equal(t, "", cnfJKTFromClaims(nil))
assert.Equal(t, "", cnfJKTFromClaims(map[string]any{"active": true}))
assert.Equal(t, "abc", cnfJKTFromClaims(map[string]any{
"cnf": map[string]any{"jkt": "abc"},
}))
assert.Equal(t, "def", cnfJKTFromClaims(map[string]any{
"cnf": map[string]string{"jkt": "def"},
}))
}

func TestRejectBoundTokenWithoutDPoP(t *testing.T) {
verifier, err := dpop.NewVerifier(dpop.Config{Store: dpop.NewMemoryStore()})
require.NoError(t, err)

boundClaims := map[string]any{
"active": true,
"cnf": map[string]any{"jkt": "thumbprint-of-agent-key"},
}
unboundClaims := map[string]any{"active": true}

t.Run("unbound token passes without DPoP header", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://rs.test/oauth2/token/verify", nil)
rr := httptest.NewRecorder()
rejected := rejectBoundTokenWithoutDPoP(rr, req, "access-token", unboundClaims, verifier, "https://issuer/.well-known/oauth-protected-resource")
assert.False(t, rejected)
assert.Equal(t, http.StatusOK, rr.Code) // helper did not write
})

t.Run("bound token without DPoP header is rejected", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://rs.test/oauth2/token/verify", nil)
rr := httptest.NewRecorder()
rejected := rejectBoundTokenWithoutDPoP(rr, req, "access-token", boundClaims, verifier, "https://issuer/.well-known/oauth-protected-resource")
assert.True(t, rejected)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
assert.Contains(t, rr.Header().Get("WWW-Authenticate"), "invalid_token")
assert.Contains(t, rr.Body.String(), "DPoP-bound")
})

t.Run("nil verifier leaves bound token alone", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://rs.test/oauth2/token/verify", nil)
rr := httptest.NewRecorder()
rejected := rejectBoundTokenWithoutDPoP(rr, req, "access-token", boundClaims, nil, "")
assert.False(t, rejected)
})

t.Run("bound token with matching DPoP proof is accepted", func(t *testing.T) {
dpopKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
jkt := dpopThumbprint(t, &dpopKey.PublicKey)
accessToken := "bound-access-token-for-ath"
proof := buildBoundDPoPProof(t, dpopKey, http.MethodGet, "http://rs.test/oauth2/token/verify", accessToken)

req := httptest.NewRequest(http.MethodGet, "http://rs.test/oauth2/token/verify", nil)
req.Header.Set("DPoP", proof)
rr := httptest.NewRecorder()
claims := map[string]any{"cnf": map[string]any{"jkt": jkt}}
rejected := rejectBoundTokenWithoutDPoP(rr, req, accessToken, claims, verifier, "")
assert.False(t, rejected, "matching proof must pass; body=%s", rr.Body.String())
})

t.Run("bound token with wrong-key DPoP proof is rejected", func(t *testing.T) {
dpopKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
accessToken := "bound-access-token-wrong-key"
proof := buildBoundDPoPProof(t, dpopKey, http.MethodGet, "http://rs.test/oauth2/token/verify", accessToken)

req := httptest.NewRequest(http.MethodGet, "http://rs.test/oauth2/token/verify", nil)
req.Header.Set("DPoP", proof)
rr := httptest.NewRecorder()
claims := map[string]any{"cnf": map[string]any{"jkt": "not-the-proof-key-thumbprint"}}
rejected := rejectBoundTokenWithoutDPoP(rr, req, accessToken, claims, verifier, "")
assert.True(t, rejected)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
})
}

func dpopThumbprint(t *testing.T, pub *ecdsa.PublicKey) string {
t.Helper()
k, err := jwk.Import[jwk.Key](pub)
require.NoError(t, err)
tb, err := k.Thumbprint(crypto.SHA256)
require.NoError(t, err)
return base64.RawURLEncoding.EncodeToString(tb)
}

func buildBoundDPoPProof(t *testing.T, priv *ecdsa.PrivateKey, method, htu, accessToken string) string {
t.Helper()
privJWK, err := jwk.Import[jwk.Key](priv)
require.NoError(t, err)
pubJWK, err := jwk.Import[jwk.Key](&priv.PublicKey)
require.NoError(t, err)

sum := sha256.Sum256([]byte(accessToken))
ath := base64.RawURLEncoding.EncodeToString(sum[:])

payload, err := json.Marshal(map[string]any{
"htm": method,
"htu": htu,
"iat": time.Now().Unix(),
"jti": base64.RawURLEncoding.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano))),
"ath": ath,
})
require.NoError(t, err)

hdrs := jws.NewHeaders()
require.NoError(t, hdrs.Set("typ", "dpop+jwt"))
require.NoError(t, hdrs.Set("jwk", pubJWK))

signed, err := jws.Sign(payload,
jws.WithKey(jwa.ES256(), privJWK, jws.WithProtectedHeaders(hdrs)),
)
require.NoError(t, err)
return string(signed)
}