From 10ebbdc0774c0d03b650a5b2a4b834e6becbb641 Mon Sep 17 00:00:00 2001 From: Oscar Sanderson Date: Tue, 20 Oct 2026 11:59:24 +0800 Subject: [PATCH] feat(resource): add Verifier.IssueDPoPNonce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A resource server can hand out a DPoP nonce ahead of a request, not only in answer to one: OpenID4VCI 1.0 §7.2 lets the Credential Issuer's Nonce Response carry one in its DPoP-Nonce header, for the Wallet to use at the Credential Endpoint. The nonce must come from the store the Credential Endpoint's Verifier checks, and only Verify could issue one. IssueDPoPNonce issues a fresh nonce the Verifier will accept. It returns "" and no error when Dependencies.Nonces is nil. Its doc notes that called from an endpoint anyone may call, it lets anyone add records, as that endpoint's own c_nonce does. Co-Authored-By: Claude Opus 5.5 --- resource/nonce.go | 18 ++++++++++++++++++ resource/nonce_test.go | 22 ++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/resource/nonce.go b/resource/nonce.go index 347f3c94..3fad9d78 100644 --- a/resource/nonce.go +++ b/resource/nonce.go @@ -27,6 +27,24 @@ func generateDPoPNonce(random io.Reader) (string, error) { return base64.RawURLEncoding.EncodeToString(buf), nil } +// IssueDPoPNonce issues a fresh DPoP nonce this Verifier will accept, for +// a resource server to hand out ahead of a request rather than in answer +// to one: an OpenID4VCI 1.0 §7.2 Nonce Response's DPoP-Nonce header, +// say, which the Wallet then uses at the Credential Endpoint this +// Verifier guards. It returns "" and no error when Dependencies.Nonces is +// nil: the Verifier doesn't use DPoP nonces, so there is none to give. +// +// Each call stores a nonce until Config.Limits.DPoPNonceLifetime has +// passed. Called from an endpoint anyone may call (§7.1's Nonce Endpoint +// is), it lets anyone add records, as that endpoint's own c_nonce does: +// rate-limit it the same way. +func (v *Verifier) IssueDPoPNonce(ctx context.Context) (string, error) { + if v.deps.Nonces == nil { + return "", nil + } + return v.issueDPoPNonce(ctx, v.deps.Clock.Now()) +} + // issueDPoPNonce generates and persists a fresh nonce, valid from now // for v.cfg.Limits.DPoPNonceLifetime. Only ever called once v.deps.Nonces // is known to be non-nil. diff --git a/resource/nonce_test.go b/resource/nonce_test.go index 5df9e334..266f8cab 100644 --- a/resource/nonce_test.go +++ b/resource/nonce_test.go @@ -270,3 +270,25 @@ func TestInsufficientScopeCarriesNextNonce(t *testing.T) { t.Fatalf("status = %d, want 403", rec.Code) } } + +// TestIssueDPoPNonce: a nonce IssueDPoPNonce hands out ahead of a +// request (as an OpenID4VCI Nonce Response does) is accepted by Verify, +// once; a Verifier without Dependencies.Nonces has none to give. +func TestIssueDPoPNonce(t *testing.T) { + ctx := context.Background() + first := newNonceFixture(t, "", nil) + issued, err := first.verifier.IssueDPoPNonce(ctx) + if err != nil || issued == "" { + t.Fatalf("IssueDPoPNonce = %q, %v; want a nonce", issued, err) + } + if _, err := newNonceFixture(t, issued, first.nonces).verify(t); err != nil { + t.Fatalf("Verify(issued nonce) = %v, want accepted", err) + } + if _, err := newNonceFixture(t, issued, first.nonces).verify(t); err == nil { + t.Fatal("Verify(issued nonce again) = nil error, want it refused as spent") + } + + if nonce, err := newFixture(t).verifier.IssueDPoPNonce(ctx); nonce != "" || err != nil { + t.Errorf("IssueDPoPNonce without Nonces = %q, %v; want none and no error", nonce, err) + } +}