diff --git a/internal/handler/auth_verify.go b/internal/handler/auth_verify.go index 045d152d..3486a276 100644 --- a/internal/handler/auth_verify.go +++ b/internal/handler/auth_verify.go @@ -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) { @@ -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: // @@ -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", @@ -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 +} diff --git a/internal/handler/auth_verify_dpop_test.go b/internal/handler/auth_verify_dpop_test.go new file mode 100644 index 00000000..e7c43edb --- /dev/null +++ b/internal/handler/auth_verify_dpop_test.go @@ -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) +}