Skip to content
Merged
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
17 changes: 17 additions & 0 deletions client/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions client/resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
Expand Down Expand Up @@ -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)
}
}
81 changes: 62 additions & 19 deletions fapihttp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
}

Expand All @@ -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)
Expand All @@ -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
Expand Down
92 changes: 92 additions & 0 deletions fapihttp/headers_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading