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) + } +} 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) + } + } +}