diff --git a/currency/fixed_format.go b/currency/fixed_format.go index 386405c..e89eeb5 100644 --- a/currency/fixed_format.go +++ b/currency/fixed_format.go @@ -78,7 +78,7 @@ func (cf FixedFormatter) formatFractionalPart(fractionalPart int64) string { offset := Power10(cf.precision) / cf.factor for offset > 1 { - fractionalStr += "0" //nolint:perfsprint + fractionalStr += "0" //nolint:perfsprint,modernize offset /= base } diff --git a/httputil/auth.go b/httputil/auth.go index fef4aa5..170eaf3 100644 --- a/httputil/auth.go +++ b/httputil/auth.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/base64" + "errors" "fmt" "net/http" "strings" @@ -29,6 +30,10 @@ const ( // ErrMissingAuthorizer is caused by internal configuration errors when evaluating authorization. ErrMissingAuthorizer = AuthError("missing authenticator") + // ErrCannotAuthenticate indicates an authenticator could not provide credentials for the user. + // ClientSecurityGroups treats it as "try the next group" rather than a hard failure. + ErrCannotAuthenticate = AuthError("could not authenticate request") + // BasicAuthPrefix as defined by https://datatracker.ietf.org/doc/html/rfc7617 BasicAuthPrefix = "Basic " @@ -52,6 +57,32 @@ type TokenAuthenticatorFunc[T any] func(ctx context.Context, token string) (T, e // BasicAuthenticatorFunc is the signature of a function used to authenticate a request given use user/pass. type BasicAuthenticatorFunc[T any] func(ctx context.Context, user, pass string) (T, error) +// ClientAuthenticateFunc is the signature of a function used to add authentication to an outbound HTTP request. +// It also has an opportunity to wrap the httpClient to be used for the request. Should return [ErrCannotAuthenticate] +// if the method was not able to provide authorization for this user. +type ClientAuthenticateFunc[T any] func(r *http.Request, innerClient *http.Client, user T) (*http.Client, error) + +// ClientTokenAuthenticatorFunc is the signature of a function used to determine the token that should be added +// to an outbound HTTP request as authentication. Should return [ErrCannotAuthenticate] if the method was not able to +// provide authorization for this user. +type ClientTokenAuthenticatorFunc[T any] func(ctx context.Context, user T) (string, error) + +// ClientBasicAuthenticatorFunc is the signature of a function used to determine the username and password +// that should be used to add Basic authentication to an outbound HTTP request. Should return [ErrCannotAuthenticate] +// if the method was not able to provide authorization for this user. +type ClientBasicAuthenticatorFunc[T any] func(ctx context.Context, user T) (string, string, error) + +// ClientCookieAuthenticatorFunc is the signature of a function used to determine the cookie that should be added +// to an outbound HTTP request. Should return [ErrCannotAuthenticate] if the method was not able to provide +// authorization for this user. +type ClientCookieAuthenticatorFunc[T any] func(ctx context.Context, user T) (*http.Cookie, error) + +// ClientWrappingAuthenticatorFunc is the signature of a function used to wrap an http client so that it will +// add authentication. This is intended for integration with x/oauth2. Should return [ErrCannotAuthenticate] if the +// method was not able to provide authorization for this user. +type ClientWrappingAuthenticatorFunc[T any] func( + ctx context.Context, innerClient *http.Client, user T) (*http.Client, error) + // AuthorizeFunc is the signature of a function used to authorize a request. If unable // to authorize the user it returns an error. type AuthorizeFunc[T any] func(ctx context.Context, user T, scopes []string) error @@ -77,6 +108,19 @@ func HeaderAuth[T any](key string, fn TokenAuthenticatorFunc[T]) AuthenticateFun } } +func HeaderClientAuth[T any](key string, fn ClientTokenAuthenticatorFunc[T]) ClientAuthenticateFunc[T] { + return func(r *http.Request, inner *http.Client, user T) (*http.Client, error) { + token, err := fn(r.Context(), user) + if err != nil { + return nil, err + } + + r.Header.Set(key, token) + + return inner, nil + } +} + const bearerAuthPrefix = "Bearer " func BearerAuth[T any](key string, tokenAuth TokenAuthenticatorFunc[T]) AuthenticateFunc[T] { @@ -92,6 +136,19 @@ func BearerAuth[T any](key string, tokenAuth TokenAuthenticatorFunc[T]) Authenti } } +func BearerClientAuth[T any](key string, fn ClientTokenAuthenticatorFunc[T]) ClientAuthenticateFunc[T] { + return func(r *http.Request, inner *http.Client, user T) (*http.Client, error) { + token, err := fn(r.Context(), user) + if err != nil { + return nil, err + } + + r.Header.Set(key, bearerAuthPrefix+token) + + return inner, nil + } +} + func QueryAuth[T any](key string, fn TokenAuthenticatorFunc[T]) AuthenticateFunc[T] { return func(r *http.Request) (T, error) { var empty T @@ -105,6 +162,22 @@ func QueryAuth[T any](key string, fn TokenAuthenticatorFunc[T]) AuthenticateFunc } } +func QueryClientAuth[T any](key string, fn ClientTokenAuthenticatorFunc[T]) ClientAuthenticateFunc[T] { + return func(r *http.Request, inner *http.Client, user T) (*http.Client, error) { + token, err := fn(r.Context(), user) + if err != nil { + return nil, err + } + + q := r.URL.Query() + q.Add(key, token) + + r.URL.RawQuery = q.Encode() + + return inner, nil + } +} + func BasicAuth[T any](authFn BasicAuthenticatorFunc[T]) AuthenticateFunc[T] { return func(r *http.Request) (T, error) { var empty T @@ -133,6 +206,19 @@ func BasicAuth[T any](authFn BasicAuthenticatorFunc[T]) AuthenticateFunc[T] { } } +func BasicClientAuth[T any](fn ClientBasicAuthenticatorFunc[T]) ClientAuthenticateFunc[T] { + return func(r *http.Request, inner *http.Client, user T) (*http.Client, error) { + username, password, err := fn(r.Context(), user) + if err != nil { + return nil, err + } + + r.SetBasicAuth(username, password) + + return inner, nil + } +} + func CookieAuth[T any](key string, fn TokenAuthenticatorFunc[T]) AuthenticateFunc[T] { return func(r *http.Request) (T, error) { var empty T @@ -146,6 +232,32 @@ func CookieAuth[T any](key string, fn TokenAuthenticatorFunc[T]) AuthenticateFun } } +func CookieClientAuth[T any](fn ClientCookieAuthenticatorFunc[T]) ClientAuthenticateFunc[T] { + return func(r *http.Request, inner *http.Client, user T) (*http.Client, error) { + cookie, err := fn(r.Context(), user) + if err != nil { + return nil, err + } + + if cookie != nil { + r.AddCookie(cookie) + } + + return inner, nil + } +} + +func WrapClientAuth[T any](fn ClientWrappingAuthenticatorFunc[T]) ClientAuthenticateFunc[T] { + return func(r *http.Request, inner *http.Client, user T) (*http.Client, error) { + client, err := fn(r.Context(), inner, user) + if err != nil { + return nil, err + } + + return client, nil + } +} + func NewAuthCheck[T any](authenticate AuthenticateFunc[T], authorize AuthorizeFunc[T], scopes ...string) AuthCheck[T] { return AuthCheck[T]{ authenticate: authenticate, @@ -217,3 +329,46 @@ func (s SecurityGroups[T]) Auth(r *http.Request) (T, error) { return user, err } + +// ClientSecurityGroup are valid if all the authenticate functions succeed. +type ClientSecurityGroup[T any] []ClientAuthenticateFunc[T] + +// Auth authenticates a client request with all the authhenticate functions or returns the first failure. +func (s ClientSecurityGroup[T]) Auth(r *http.Request, innerClient *http.Client, u T) (*http.Client, error) { + var err error + outerClient := innerClient + modifiedReq := r.Clone(r.Context()) + + for _, a := range s { + outerClient, err = a(modifiedReq, outerClient, u) + if err != nil { + return nil, err + } + } + + *r = *modifiedReq + + return outerClient, nil +} + +// ClientSecurityGroups are valid if ANY group is valid. +type ClientSecurityGroups[T any] []ClientSecurityGroup[T] + +// Auth authenticates a client request with the first group that successfully authenticates, or returns +// [ErrCannotAuthenticate]. +func (s ClientSecurityGroups[T]) Auth(r *http.Request, innerClient *http.Client, u T) (*http.Client, error) { + for _, a := range s { + outerClient, err := a.Auth(r, innerClient, u) + if errors.Is(err, ErrCannotAuthenticate) { + continue + } + + if err != nil { + return nil, err + } + + return outerClient, nil + } + + return nil, ErrCannotAuthenticate +} diff --git a/httputil/auth_test.go b/httputil/auth_test.go index a10be1b..9c48c63 100644 --- a/httputil/auth_test.go +++ b/httputil/auth_test.go @@ -331,3 +331,352 @@ func TestBasicAuth(t *testing.T) { }) } } + +func strClientAuth(_ context.Context, user string) (string, error) { + switch user { + case "good": + return "token", nil + case "bad": + return "", ErrBad + } + + return "", httputil.ErrCannotAuthenticate +} + +func failClientAuth(_ context.Context, _ string) (string, error) { + return "", ErrBad +} + +func onlyUser(name string) httputil.ClientTokenAuthenticatorFunc[string] { + return func(_ context.Context, user string) (string, error) { + if user == name { + return "token", nil + } + + return "", httputil.ErrCannotAuthenticate + } +} + +func basicClientAuth(_ context.Context, user string) (string, string, error) { + switch user { + case "good": + return "u", "p", nil + case "bad": + return "", "", ErrBad + } + + return "", "", httputil.ErrCannotAuthenticate +} + +func cookieClientAuth(_ context.Context, user string) (*http.Cookie, error) { + switch user { + case "good": + return &http.Cookie{Name: "session", Value: "token"}, nil + case "nil": + return nil, nil + case "bad": + return nil, ErrBad + } + + return nil, httputil.ErrCannotAuthenticate +} + +func TestHeaderClientAuth(t *testing.T) { + type testCase struct { + name string + key string + user string + want string + err error + } + tests := []testCase{ + {"Good", "X-Auth", "good", "token", nil}, + {"Bad", "X-Auth", "bad", "", ErrBad}, + {"Couldnt", "X-Auth", "other", "", httputil.ErrCannotAuthenticate}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest("FOO", "/asdf", nil) + inner := &http.Client{} + + got, err := httputil.HeaderClientAuth(tt.key, strClientAuth)(r, inner, tt.user) + if tt.err != nil { + assert.ErrorIs(t, err, tt.err) + } else { + require.NoError(t, err) + assert.Same(t, inner, got) + } + + assert.Equal(t, tt.want, r.Header.Get(tt.key)) + }) + } +} + +func TestBearerClientAuth(t *testing.T) { + type testCase struct { + name string + key string + user string + want string + err error + } + tests := []testCase{ + {"Good", "Authorization", "good", "Bearer token", nil}, + {"Bad", "Authorization", "bad", "", ErrBad}, + {"Couldnt", "Authorization", "other", "", httputil.ErrCannotAuthenticate}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest("FOO", "/asdf", nil) + inner := &http.Client{} + + got, err := httputil.BearerClientAuth(tt.key, strClientAuth)(r, inner, tt.user) + if tt.err != nil { + assert.ErrorIs(t, err, tt.err) + } else { + require.NoError(t, err) + assert.Same(t, inner, got) + } + + assert.Equal(t, tt.want, r.Header.Get(tt.key)) + }) + } +} + +func TestQueryClientAuth(t *testing.T) { + type testCase struct { + name string + key string + user string + want string + err error + } + tests := []testCase{ + {"Good", "auth", "good", "token", nil}, + {"Bad", "auth", "bad", "", ErrBad}, + {"Couldnt", "auth", "other", "", httputil.ErrCannotAuthenticate}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest("FOO", "/asdf", nil) + inner := &http.Client{} + + got, err := httputil.QueryClientAuth(tt.key, strClientAuth)(r, inner, tt.user) + if tt.err != nil { + assert.ErrorIs(t, err, tt.err) + } else { + require.NoError(t, err) + assert.Same(t, inner, got) + } + + assert.Equal(t, tt.want, r.URL.Query().Get(tt.key)) + }) + } +} + +func TestBasicClientAuth(t *testing.T) { + type testCase struct { + name string + user string + wantUser string + wantPass string + err error + } + tests := []testCase{ + {"Good", "good", "u", "p", nil}, + {"Bad", "bad", "", "", ErrBad}, + {"Couldnt", "other", "", "", httputil.ErrCannotAuthenticate}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest("FOO", "/asdf", nil) + inner := &http.Client{} + + got, err := httputil.BasicClientAuth(basicClientAuth)(r, inner, tt.user) + if tt.err != nil { + assert.ErrorIs(t, err, tt.err) + } else { + require.NoError(t, err) + assert.Same(t, inner, got) + } + + u, p, ok := r.BasicAuth() + if tt.err == nil { + assert.True(t, ok) + assert.Equal(t, tt.wantUser, u) + assert.Equal(t, tt.wantPass, p) + } else { + assert.False(t, ok) + } + }) + } +} + +func TestCookieClientAuth(t *testing.T) { + type testCase struct { + name string + user string + want string + wantErr error + } + tests := []testCase{ + {"Good", "good", "token", nil}, + {"Nil", "nil", "", nil}, + {"Bad", "bad", "", ErrBad}, + {"Couldnt", "other", "", httputil.ErrCannotAuthenticate}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest("FOO", "/asdf", nil) + inner := &http.Client{} + + got, err := httputil.CookieClientAuth(cookieClientAuth)(r, inner, tt.user) + if tt.wantErr != nil { + assert.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + assert.Same(t, inner, got) + } + + cookie, cookieErr := r.Cookie("session") + if tt.want != "" { + require.NoError(t, cookieErr) + assert.Equal(t, tt.want, cookie.Value) + } else { + assert.ErrorIs(t, cookieErr, http.ErrNoCookie) + } + }) + } +} + +func TestWrapClientAuth(t *testing.T) { + inner := &http.Client{} + wrapped := &http.Client{} + + fn := func(_ context.Context, in *http.Client, user string) (*http.Client, error) { + switch user { + case "good": + return wrapped, nil + case "bad": + return nil, ErrBad + } + + return in, httputil.ErrCannotAuthenticate + } + + type testCase struct { + name string + user string + want *http.Client + err error + } + tests := []testCase{ + {"good", "good", wrapped, nil}, + {"bad", "bad", inner, ErrBad}, + {"couldnt", "other", inner, httputil.ErrCannotAuthenticate}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest("FOO", "/asdf", nil) + + got, err := httputil.WrapClientAuth(fn)(r, inner, tt.user) + if tt.err != nil { + assert.ErrorIs(t, err, tt.err) + } else { + require.NoError(t, err) + assert.Same(t, tt.want, got) + } + }) + } +} + +func TestClientSecurityGroup_Auth(t *testing.T) { + inner := &http.Client{} + + type testCase struct { + name string + g httputil.ClientSecurityGroup[string] + user string + wantA string + wantB string + err error + } + tests := []testCase{ + { + "all succeed", + httputil.ClientSecurityGroup[string]{ + httputil.HeaderClientAuth("X-A", strClientAuth), + httputil.HeaderClientAuth("X-B", strClientAuth), + }, + "good", "token", "token", nil, + }, + { + "failure returns inner and leaves request unmodified", + httputil.ClientSecurityGroup[string]{ + httputil.HeaderClientAuth("X-A", strClientAuth), + httputil.HeaderClientAuth("X-B", failClientAuth), + }, + "good", "", "", ErrBad, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest("FOO", "/asdf", nil) + + got, err := tt.g.Auth(r, inner, tt.user) + if tt.err != nil { + assert.ErrorIs(t, err, tt.err) + } else { + require.NoError(t, err) + assert.Same(t, inner, got) + } + + assert.Equal(t, tt.wantA, r.Header.Get("X-A")) + assert.Equal(t, tt.wantB, r.Header.Get("X-B")) + }) + } +} + +func TestClientSecurityGroups_Auth(t *testing.T) { + inner := &http.Client{} + + groups := httputil.ClientSecurityGroups[string]{ + {httputil.HeaderClientAuth("X-A", onlyUser("a"))}, + {httputil.HeaderClientAuth("X-B", onlyUser("b"))}, + } + failing := httputil.ClientSecurityGroups[string]{ + {httputil.HeaderClientAuth("X-A", failClientAuth)}, + {httputil.HeaderClientAuth("X-B", onlyUser("b"))}, + } + + type testCase struct { + name string + s httputil.ClientSecurityGroups[string] + user string + wantA string + wantB string + err error + } + tests := []testCase{ + {"first group succeeds", groups, "a", "token", "", nil}, + {"falls through to a later group", groups, "b", "", "token", nil}, + {"none can authenticate", groups, "c", "", "", httputil.ErrCannotAuthenticate}, + {"true failure stops iteration", failing, "b", "", "", ErrBad}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest("FOO", "/asdf", nil) + + got, err := tt.s.Auth(r, inner, tt.user) + if tt.err != nil { + assert.ErrorIs(t, err, tt.err) + } else { + require.NoError(t, err) + assert.Same(t, inner, got) + } + + assert.Equal(t, tt.wantA, r.Header.Get("X-A")) + assert.Equal(t, tt.wantB, r.Header.Get("X-B")) + }) + } +} diff --git a/httputil/client.go b/httputil/client.go new file mode 100644 index 0000000..235366a --- /dev/null +++ b/httputil/client.go @@ -0,0 +1,22 @@ +package httputil + +import ( + "fmt" + "net/http" + "strconv" +) + +type UnexpectedResponseError struct { + Resp *http.Response + URL string + Body []byte +} + +func (e UnexpectedResponseError) Error() string { + status := "unknown" + if e.Resp != nil { + status = strconv.Itoa(e.Resp.StatusCode) + } + + return fmt.Sprintf("url: %q, status: %s, body: %q: unexpected response status code", e.URL, status, e.Body) +} diff --git a/httputil/client_test.go b/httputil/client_test.go new file mode 100644 index 0000000..37dbca5 --- /dev/null +++ b/httputil/client_test.go @@ -0,0 +1,33 @@ +package httputil_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/bir/iken/httputil" +) + +func TestUnexpectedResponseError_Error(t *testing.T) { + err := httputil.UnexpectedResponseError{ + Resp: &http.Response{StatusCode: http.StatusNotFound}, + URL: "http://example.com/foo", + Body: []byte("not found"), + } + + assert.Equal(t, + `url: "http://example.com/foo", status: 404, body: "not found": unexpected response status code`, + err.Error()) +} + +func TestUnexpectedResponseError_Error_NilResp(t *testing.T) { + err := httputil.UnexpectedResponseError{ + URL: "http://example.com/foo", + Body: []byte("not found"), + } + + assert.Equal(t, + `url: "http://example.com/foo", status: unknown, body: "not found": unexpected response status code`, + err.Error()) +}