From 5d544b90ba786c073700c5453f8bdab65837c7ba Mon Sep 17 00:00:00 2001 From: Oscar Sanderson Date: Tue, 20 Oct 2026 10:24:18 +0800 Subject: [PATCH 1/2] feat(fapihttp): return a fetch's response headers, and send Accept-Language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FetchResponse had only the body and status code, so a caller couldn't read a header a server sends with its answer without bypassing fapihttp's hardening: the DPoP-Nonce an OpenID4VCI Nonce Response carries (OpenID4VCI 1.0 §7.2, RFC 9449 §8), say. FetchResponse.Header now holds the response's header fields, the final response's after any redirects, as a copy. FetchRequest and PostRequest gain AcceptLanguage, sent as the Accept-Language header (RFC 9110 §12.5.4): OpenID4VCI 1.0 §12.2.2 recommends a Wallet send it when fetching Credential Issuer Metadata, so display metadata comes in the user's language. It follows Accept's rules (printable ASCII, at most 1024 bytes). It's a named field rather than a header map: the headers fapihttp sends, and the absence of the rest (cookies, credentials), are part of what it hardens. Co-Authored-By: Claude Opus 5.5 --- fapihttp/client.go | 81 ++++++++++++++++++++++++++--------- fapihttp/headers_test.go | 92 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 19 deletions(-) create mode 100644 fapihttp/headers_test.go diff --git a/fapihttp/client.go b/fapihttp/client.go index 7aab9d8c..1544e5a5 100644 --- a/fapihttp/client.go +++ b/fapihttp/client.go @@ -241,31 +241,65 @@ type FetchRequest struct { // content types are accepted: ExpectedContentType and // AlternateContentTypes still decide that. Accept string + + // AcceptLanguage, if set, is sent as the request's Accept-Language + // header (RFC 9110 §12.5.4), for a response in the user's language: + // OpenID4VCI 1.0 §12.2.2 recommends a Wallet send it when fetching + // Credential Issuer Metadata, say. It follows Accept's rules. No + // other request header can be set: the ones this package sends, and + // the absence of the rest (cookies, credentials), are part of what + // it hardens. + AcceptLanguage string } -// maxAcceptLength bounds FetchRequest.Accept and PostRequest.Accept. +// maxAcceptLength bounds FetchRequest.Accept and AcceptLanguage, and +// PostRequest's. const maxAcceptLength = 1024 // checkAccept refuses an Accept header value that isn't printable ASCII, // or is longer than maxAcceptLength, so a caller can't smuggle another // header or a line break into the request. func checkAccept(accept string) error { - if len(accept) > maxAcceptLength { - return fmt.Errorf("fapihttp: accept is %d bytes, more than %d", len(accept), maxAcceptLength) + return checkHeaderValue("accept", accept) +} + +// checkHeaderValue applies checkAccept's rules to the value of the +// header name names. +func checkHeaderValue(name, value string) error { + if len(value) > maxAcceptLength { + return fmt.Errorf("fapihttp: %s is %d bytes, more than %d", name, len(value), maxAcceptLength) } - for i := 0; i < len(accept); i++ { - if c := accept[i]; c < 0x20 || c > 0x7e { - return fmt.Errorf("fapihttp: accept has a byte (0x%02x at %d) that isn't printable ASCII", c, i) + for i := 0; i < len(value); i++ { + if c := value[i]; c < 0x20 || c > 0x7e { + return fmt.Errorf("fapihttp: %s has a byte (0x%02x at %d) that isn't printable ASCII", name, c, i) } } return nil } +// requestHeaders are the headers a caller may set on a request. +type requestHeaders struct { + accept, acceptLanguage string +} + +// check applies checkHeaderValue to each. +func (h requestHeaders) check() error { + if err := checkAccept(h.accept); err != nil { + return err + } + return checkHeaderValue("accept-language", h.acceptLanguage) +} + // FetchResponse is a successfully fetched, size- and content-type- // checked response body. type FetchResponse struct { Body []byte StatusCode int + // Header is the response's header fields, the final response's + // after any redirects: a DPoP-Nonce a server sends with its answer + // (RFC 9449 §8, and OpenID4VCI 1.0 §7.2's Nonce Response), say. A + // copy: changing it changes nothing else. + Header http.Header } // Fetch performs req, following at most c.cfg.MaxRedirects same-origin @@ -278,7 +312,8 @@ func (c *Client) Fetch(ctx context.Context, req FetchRequest) (FetchResponse, er if req.ExpectedContentType == "" { return FetchResponse{}, fmt.Errorf("fapihttp: expected content type is required") } - if err := checkAccept(req.Accept); err != nil { + headers := requestHeaders{accept: req.Accept, acceptLanguage: req.AcceptLanguage} + if err := headers.check(); err != nil { return FetchResponse{}, err } @@ -293,7 +328,7 @@ func (c *Client) Fetch(ctx context.Context, req FetchRequest) (FetchResponse, er target := req.URL for hop := 0; ; hop++ { - res, err := c.roundTrip(ctx, target, req.Accept) + res, err := c.roundTrip(ctx, target, headers) if err != nil { return FetchResponse{}, err } @@ -331,15 +366,15 @@ func (c *Client) readResponse(res *http.Response, maxBytes int64, expectedConten if err := checkContentType(res.Header.Get(contentTypeHeader), expectedContentType, alternateContentTypes); err != nil { return FetchResponse{}, err } - return FetchResponse{Body: body, StatusCode: res.StatusCode}, nil + return FetchResponse{Body: body, StatusCode: res.StatusCode, Header: res.Header.Clone()}, nil } // PostRequest describes one outbound POST — the counterpart to // FetchRequest for a caller that needs to submit a body rather than // merely retrieve a resource (e.g. OpenID Federation 1.0 §8's Trust // Mark Status endpoint, POST-only per spec). Like FetchRequest, it is -// narrow and single-purpose: beyond Accept, there is no way to add -// headers or otherwise build an unconstrained request. +// narrow and single-purpose: beyond Accept and AcceptLanguage, there is +// no way to add headers or otherwise build an unconstrained request. type PostRequest struct { // URL is the endpoint to POST to. Validated exactly like // FetchRequest.URL — see its own doc comment for what that does and @@ -361,6 +396,10 @@ type PostRequest struct { // Accept, if set, is sent as the request's Accept header, exactly // like FetchRequest.Accept, under the same rules. Accept string + + // AcceptLanguage, if set, is sent as the request's Accept-Language + // header, exactly like FetchRequest.AcceptLanguage. + AcceptLanguage string } // Post performs req and returns the response, enforcing every @@ -384,7 +423,8 @@ func (c *Client) Post(ctx context.Context, req PostRequest) (FetchResponse, erro if req.ExpectedContentType == "" { return FetchResponse{}, fmt.Errorf("fapihttp: expected content type is required") } - if err := checkAccept(req.Accept); err != nil { + headers := requestHeaders{accept: req.Accept, acceptLanguage: req.AcceptLanguage} + if err := headers.check(); err != nil { return FetchResponse{}, err } @@ -395,22 +435,22 @@ func (c *Client) Post(ctx context.Context, req PostRequest) (FetchResponse, erro return FetchResponse{}, err } - res, err := c.doRoundTrip(ctx, http.MethodPost, req.URL, req.Body, req.ContentType, req.Accept) + res, err := c.doRoundTrip(ctx, http.MethodPost, req.URL, req.Body, req.ContentType, headers) if err != nil { return FetchResponse{}, err } return c.readResponse(res, c.cfg.MaxResponseBytes, req.ExpectedContentType, req.AlternateContentTypes) } -func (c *Client) roundTrip(ctx context.Context, target *url.URL, accept string) (*http.Response, error) { - return c.doRoundTrip(ctx, http.MethodGet, target, nil, "", accept) +func (c *Client) roundTrip(ctx context.Context, target *url.URL, headers requestHeaders) (*http.Response, error) { + return c.doRoundTrip(ctx, http.MethodGet, target, nil, "", headers) } // doRoundTrip is roundTrip generalized over method/body/contentType — -// accept, when set, becomes the Accept header — +// headers, when set, become the Accept and Accept-Language headers — // Fetch's own behavior (GET, no body) is unchanged; Post is the only // other caller. -func (c *Client) doRoundTrip(ctx context.Context, method string, target *url.URL, body []byte, contentType, accept string) (*http.Response, error) { +func (c *Client) doRoundTrip(ctx context.Context, method string, target *url.URL, body []byte, contentType string, headers requestHeaders) (*http.Response, error) { var bodyReader io.Reader if body != nil { bodyReader = bytes.NewReader(body) @@ -422,8 +462,11 @@ func (c *Client) doRoundTrip(ctx context.Context, method string, target *url.URL if contentType != "" { httpReq.Header.Set(contentTypeHeader, contentType) } - if accept != "" { - httpReq.Header.Set("Accept", accept) + if headers.accept != "" { + httpReq.Header.Set("Accept", headers.accept) + } + if headers.acceptLanguage != "" { + httpReq.Header.Set("Accept-Language", headers.acceptLanguage) } // Post's body is sent once, to the target validated above: never // resent to a 307/308 redirect target by an HTTPClient that would diff --git a/fapihttp/headers_test.go b/fapihttp/headers_test.go new file mode 100644 index 00000000..87b838a2 --- /dev/null +++ b/fapihttp/headers_test.go @@ -0,0 +1,92 @@ +package fapihttp_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/idfoundry/fapigo/fapihttp" +) + +// TestFetchAndPostSendAcceptLanguageAndReturnHeaders: AcceptLanguage is +// sent on every hop and on a POST; the response's headers come back in +// FetchResponse.Header (the final response's, after a redirect), as a +// copy. +func TestFetchAndPostSendAcceptLanguageAndReturnHeaders(t *testing.T) { + var mu sync.Mutex + languages := map[string]string{} + var ts *httptest.Server + ts = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + languages[r.Method+" "+r.URL.Path] = r.Header.Get("Accept-Language") + mu.Unlock() + if r.URL.Path == "/a" { + w.Header().Set("DPoP-Nonce", "from-the-redirect") + http.Redirect(w, r, ts.URL+"/b", http.StatusFound) + return + } + w.Header().Set("DPoP-Nonce", "nonce-1") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer ts.Close() + c, err := fapihttp.New(noRedirectClient(ts), validLoopbackConfig()) + if err != nil { + t.Fatalf("New: %v", err) + } + ctx := context.Background() + + res, err := c.Fetch(ctx, fapihttp.FetchRequest{ + URL: mustParseURL(t, ts.URL+"/a"), AcceptLanguage: "de-CH, en;q=0.5", ExpectedContentType: "application/json", + }) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if got := res.Header.Get("DPoP-Nonce"); got != "nonce-1" { + t.Errorf("Fetch Header DPoP-Nonce = %q, want the final response's nonce-1", got) + } + res.Header.Set("DPoP-Nonce", "changed") + + posted, err := c.Post(ctx, fapihttp.PostRequest{ + URL: mustParseURL(t, ts.URL+"/b"), Body: []byte("x=1"), ContentType: "application/x-www-form-urlencoded", + AcceptLanguage: "fr", ExpectedContentType: "application/json", + }) + if err != nil { + t.Fatalf("Post: %v", err) + } + if got := posted.Header.Get("DPoP-Nonce"); got != "nonce-1" { + t.Errorf("Post Header DPoP-Nonce = %q, want nonce-1", got) + } + + mu.Lock() + defer mu.Unlock() + for hop, want := range map[string]string{"GET /a": "de-CH, en;q=0.5", "GET /b": "de-CH, en;q=0.5", "POST /b": "fr"} { + if languages[hop] != want { + t.Errorf("%s: Accept-Language = %q, want %q", hop, languages[hop], want) + } + } +} + +// TestFetchAndPostRefuseAnInvalidAcceptLanguage: AcceptLanguage follows +// Accept's rules, so a line break can't smuggle another header in. +func TestFetchAndPostRefuseAnInvalidAcceptLanguage(t *testing.T) { + c, err := fapihttp.New(http.DefaultClient, validLoopbackConfig()) + if err != nil { + t.Fatalf("New: %v", err) + } + for _, value := range []string{"en\r\nCookie: x", strings.Repeat("a", 1025)} { + if _, err := c.Fetch(context.Background(), fapihttp.FetchRequest{ + URL: mustParseURL(t, "https://127.0.0.1/x"), AcceptLanguage: value, ExpectedContentType: "application/json", + }); err == nil || !strings.Contains(err.Error(), "accept-language") { + t.Errorf("Fetch(AcceptLanguage %q) = %v, want refused", value, err) + } + if _, err := c.Post(context.Background(), fapihttp.PostRequest{ + URL: mustParseURL(t, "https://127.0.0.1/x"), ContentType: "text/plain", AcceptLanguage: value, ExpectedContentType: "application/json", + }); err == nil || !strings.Contains(err.Error(), "accept-language") { + t.Errorf("Post(AcceptLanguage %q) = %v, want refused", value, err) + } + } +} From c53ed48f473bd309f35fca9cf777338f47723107 Mon Sep 17 00:00:00 2001 From: Oscar Sanderson Date: Tue, 20 Oct 2026 10:24:57 +0800 Subject: [PATCH 2/2] feat(client): add ResourceClient.UseDPoPNonce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenID4VCI 1.0 §7.2 lets a Credential Issuer's Nonce Response carry a DPoP nonce in its DPoP-Nonce header, which "the Wallet uses ... when presenting an access token at the Credential Endpoint". The Nonce Endpoint isn't called through ResourceClient, so its nonce never reached Do, whose first request was challenged with use_dpop_nonce. Seeding the nonce cache by hand meant copying its unexported key format. UseDPoPNonce has the next Do to a resource's origin present a nonce it was given, through the nonce cache every client has had since it became the default. A wrong or stale nonce costs only the challenge and retry Do already handles. It does nothing under SenderConstrainMTLS. Co-Authored-By: Claude Opus 5.5 --- client/resource.go | 17 +++++++++++ client/resource_test.go | 63 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/client/resource.go b/client/resource.go index 212f8cf3..04324fd1 100644 --- a/client/resource.go +++ b/client/resource.go @@ -82,6 +82,23 @@ func (c *Client) ClientCredentialsResource(result ClientCredentialsTokenResult) return &ResourceClient{client: c, token: result.AccessToken.Reveal()} } +// UseDPoPNonce has the next Do to resource's origin present nonce in its +// DPoP proof, for a nonce the resource server handed out some other way +// than on a response to Do: OpenID4VCI 1.0 §7.2's Nonce Response may +// carry one in its DPoP-Nonce header, for the Wallet to use "when +// presenting an access token at the Credential Endpoint" (read it from +// fapihttp.FetchResponse.Header). It saves the first request's +// use_dpop_nonce challenge. A nonce is a hint, not a credential: a wrong +// or stale one costs only the challenge and retry Do already handles. +// It does nothing under SenderConstrainMTLS, for an empty nonce, or for +// a ResourceClient whose every Do fails (see ProtectedResource). +func (rc *ResourceClient) UseDPoPNonce(ctx context.Context, resource *url.URL, nonce string) { + if rc.err != nil || resource == nil || rc.client.cfg.SenderConstrain != storage.SenderConstrainDPoP { + return + } + rc.client.cacheDPoPNonce(ctx, resourceNonceScope(resource), nonce) +} + // Do performs req as a sender-constrained request, bounded by // Config.Limits.HTTPTimeout and Config.Limits.MaxHTTPResponseBytes — // the same protections this client applies to its own PAR and diff --git a/client/resource_test.go b/client/resource_test.go index 40ffeb1c..91ceb94d 100644 --- a/client/resource_test.go +++ b/client/resource_test.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "strings" "sync" "testing" @@ -472,3 +473,65 @@ func TestProtectedResourceDoPropagatesHTTPFailureOnRetry(t *testing.T) { t.Fatalf("Do(transport failure on retry) = nil error, want error") } } + +// TestProtectedResourceUseDPoPNonce: a nonce handed to UseDPoPNonce (as +// an OpenID4VCI Nonce Response's DPoP-Nonce header carries one) is +// presented by the next Do to that origin, which then needs no +// use_dpop_nonce challenge; a stale one costs only the usual retry. +func TestProtectedResourceUseDPoPNonce(t *testing.T) { + const current = "nonce-from-the-nonce-endpoint" + var mu sync.Mutex + var calls int + var firstNonce string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + calls++ + nonce, _ := dpopProofClaims(t, r.Header.Get("DPoP"))["nonce"].(string) + if calls == 1 { + firstNonce = nonce + } + mu.Unlock() + if nonce != current { + w.Header().Set("WWW-Authenticate", `DPoP error="use_dpop_nonce"`) + w.Header().Set("DPoP-Nonce", current) + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + deps := validDependencies(t) + deps.HTTP = ts.Client() + c, err := client.New(validConfig(t), deps) + if err != nil { + t.Fatalf("client.New: %v", err) + } + ctx := context.Background() + do := func(rc *client.ResourceClient) { + t.Helper() + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/credential", nil) + res, err := rc.Do(ctx, req) + if err != nil { + t.Fatalf("Do: %v", err) + } + _ = res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", res.StatusCode) + } + } + rc := c.ProtectedResource(client.TokenSet{AccessToken: fapi.NewSecret("at")}) + nonceEndpoint, _ := url.Parse(ts.URL + "/nonce") + rc.UseDPoPNonce(ctx, nonceEndpoint, current) + do(rc) + if calls != 1 || firstNonce != current { + t.Fatalf("calls = %d, first proof nonce %q; want 1 call presenting %q", calls, firstNonce, current) + } + + stale := c.ProtectedResource(client.TokenSet{AccessToken: fapi.NewSecret("at")}) + stale.UseDPoPNonce(ctx, nonceEndpoint, "stale") + calls = 0 + do(stale) + if calls != 2 { + t.Fatalf("calls with a stale nonce = %d, want 2 (challenge, then retry)", calls) + } +}