Repository: coreos/go-oidc
Pull request #381 (2023-06-09 19:09:17)

oidc: use %w verb for wrapping errors
Errors wrapped using %v cannot be unwrapped. This means if there is an underlying error such as `context.Canceled`, a caller cannot reliably discover that error using any method other than string comparison. By swapping to the %w verb, `errors.Is` and `errors.As` become valuable tools for error discovery and behavior differentiation.

While by convention, some of the errors like `fetching keys %v` should probably be changed to `fetching keys: %w` (with the `:` character), this change opts not to do that to help preserve backward compatibility for external error handling that uses string comparison today.

ericchiang: I don't think we want to do this everywhere. Any wrapped errors should have an associated test, since that'd now be an API we have to ensure works for a while.

Is the request here effectively, "methods should wrap context errors"? Does net/http do the same thing?
rliebz: In particular the reason that I made this change is to capture `context.Canceled`. When handling an HTTP request, if the client disconnects, the context attached to the request gets canceled and subsequent calls that use that context will abort early with a `context.Canceled` error. For the application I'm working on, we don't particularly consider those to be actionable "errors", so we log them at a different level than unexpected errors. Using `%v` to "opaque" the error produces the exact same error message as `%w`, except that the error cannot be unwrapped with `Unwrap()`, and therefore `errors.Is` and `errors.As`. 

`context.Canceled` is a good example of an error that might pop up despite this library not directly interacting with it, but IMO wrapping as a best practice has nothing to do with the context library. You can find a fairly large list of standard errors you might see doing HTTP-related things: https://pkg.go.dev/net/http#pkg-variables, as well as things like `net.ErrClosed`, `io.EOF`, and others. Any of which might be relevant to a caller.

The reason you might want to opaque an error is if the underlying code is signalling something to the caller that you don't want to signal to your caller. Sometimes error messages are fine to bubble up, but there's a specific reason why you wouldn't want the behavior of underlying errors to become the behavior of your errors. For example if there is an `ErrNotFound` for something your code is trying to do, but `ErrNotFound` is not appropriate for the operation that your public interface is exposing.

> Does net/http do the same thing?

Sometimes yes, sometimes no (not an exhaustive list, just grabbed a couple of each):
- https://github.com/golang/go/blob/bae7d772e800207ed764b70a9e5963f0c1e8f0aa/src/net/http/transport.go#L2062
- https://github.com/golang/go/blob/bae7d772e800207ed764b70a9e5963f0c1e8f0aa/src/net/http/transport.go#L2269
- https://github.com/golang/go/blob/bae7d772e800207ed764b70a9e5963f0c1e8f0aa/src/net/mail/message.go#L554
- https://github.com/golang/go/blob/bae7d772e800207ed764b70a9e5963f0c1e8f0aa/src/net/http/server.go#L329

So why would the standard library choose one vs. the other? From that cherry-picked sample, the two examples that use `%w` were written in the past year, and the two that use `%v` were written before the existence of the `%w` verb. This isn't universally true, but it does cleanly explain a lot of the behavior. 
ericchiang: Thanks for the reply! I'm familiar with error wrapping. The reason I was asking about net/http was to see if context.Canceled values get passed back up to go-oidc. For example, net/http docs don't claim to wrap errors:

* https://pkg.go.dev/net/http#Client.Do
* https://pkg.go.dev/net/http#Request.WithContext

Error values we return become part of our API contract, and not wrapping errors by default is intentional. I don't want some underlying change to break clients because they were expecting ErrFoo, and we're suddenly returning ErrBar.

Happy to wrap errors where its appropriate. But any wrapped errors need a test to verify that those values are actually surfaced.
rliebz: I don't think I've really ever seen functions advertise their internal errors as wrapped vs. opaque(ed?). But `net/http` certainly does wrap frequently. Following the first link for `Client.Do`, we find [the first error here](https://cs.opensource.google/go/go/+/refs/tags/go1.20.5:src/net/http/client.go;l=593-596). And `url.Error` implements the `Unwrap` method, even though aren't wrapping specifically with `%w`. They also define an [error wrapper shortly after](https://cs.opensource.google/go/go/+/refs/tags/go1.20.5:src/net/http/client.go;l=610-626) which wraps all subsequent errors in the function that end up being returned. [In one specific place we do see opaquing before wrapping](https://cs.opensource.google/go/go/+/refs/tags/go1.20.5:src/net/http/client.go;l=641), but that line hasn't been touched since error wrapping was introduced to the standard library, so I think that's a side-effect of attempting to annotate the error with a message rather than a specific intent to opaque it, although I could definitely be wrong.

One thing to also point out is that not wrapping errors produces the same behavior as wrapping with the `%w` verb. So [here for example](https://github.com/coreos/go-oidc/blob/b203e58c24394ddf5e816706a7645f01280245c7/oidc/oidc.go#L210), there's an error bubbling up that's going to satisfy `errors.Is` on whatever the underlying error returns because it wasn't actively being opaqued with `%v`. 

That said, I don't think an "opaque by default" strategy is unreasonable, so happy to swap most of this back to how it was. I went ahead and pared this change down to the errors that specifically bubble up from HTTP calls, since that should cover my use case, and added a test to demonstrate. A number of error code paths aren't covered yet and would be quite tricky to try to hit, but hopefully what's there now is a good coverage for what's changing.

Patch:
diff --git a/oidc/jwks.go b/oidc/jwks.go
index 7df3e9f..b1e3f7e 100644
--- a/oidc/jwks.go
+++ b/oidc/jwks.go
@@ -159,7 +159,7 @@ func (r *RemoteKeySet) verify(ctx context.Context, jws *jose.JSONWebSignature) (
 	// https://openid.net/specs/openid-connect-core-1_0.html#RotateSigKeys
 	keys, err := r.keysFromRemote(ctx)
 	if err != nil {
-		return nil, fmt.Errorf("fetching keys %v", err)
+		return nil, fmt.Errorf("fetching keys %w", err)
 	}
 
 	for _, key := range keys {
@@ -228,7 +228,7 @@ func (r *RemoteKeySet) updateKeys() ([]jose.JSONWebKey, error) {
 
 	resp, err := doRequest(r.ctx, req)
 	if err != nil {
-		return nil, fmt.Errorf("oidc: get keys failed %v", err)
+		return nil, fmt.Errorf("oidc: get keys failed %w", err)
 	}
 	defer resp.Body.Close()
 


Test patch:
diff --git a/oidc/jwks_test.go b/oidc/jwks_test.go
index 704bdc3..b9545ea 100644
--- a/oidc/jwks_test.go
+++ b/oidc/jwks_test.go
@@ -9,6 +9,7 @@ import (
 	"crypto/rand"
 	"crypto/rsa"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"net/http"
 	"net/http/httptest"
@@ -138,6 +139,41 @@ func TestMismatchedKeyID(t *testing.T) {
 	testKeyVerify(t, key2, bad, key1, key2)
 }
 
+func TestKeyVerifyContextCanceled(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	payload := []byte("a secret")
+
+	good := newECDSAKey(t)
+	jws, err := jose.ParseSigned(good.sign(t, payload))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	ch := make(chan struct{})
+	defer close(ch)
+
+	s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		<-ch
+	}))
+	defer s.Close()
+
+	rks := newRemoteKeySet(ctx, s.URL, nil)
+
+	cancel()
+
+	// Ensure the token verifies.
+	_, err = rks.verify(ctx, jws)
+	if err == nil {
+		t.Fatal("expected context canceled, got nil error")
+	}
+
+	if !errors.Is(err, context.Canceled) {
+		t.Errorf("expected error to be %q got %q", context.Canceled, err)
+	}
+}
+
 func testKeyVerify(t *testing.T, good, bad *signingKey, verification ...*signingKey) {
 	ctx, cancel := context.WithCancel(context.Background())
 	defer cancel()
@@ -259,7 +295,7 @@ func BenchmarkVerify(b *testing.B) {
 
 	key := newRSAKey(b)
 
-	now := time.Date(2022, 01, 29, 0, 0, 0, 0, time.UTC)
+	now := time.Date(2022, 1, 29, 0, 0, 0, 0, time.UTC)
 	exp := now.Add(time.Hour)
 	payload := []byte(fmt.Sprintf(`{
 		"iss": "https://example.com",