From 86a79df9ecfcf09fda0b8e07afbc41154fbb7d9d Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Wed, 8 May 2024 12:06:13 +0800 Subject: [PATCH 001/118] feat: update openapi spec with identity and is_anonymous fields (#1573) ## What kind of change does this PR introduce? Adds the `Identity` and `is_anonymous` fields to OpenAPI spec. This is so we can use the `openapi.yml` as a sgeneral reference from which to generate Hook Payloads, which contain `User` objects. Identity Fields taken from the [identity model](https://github.com/supabase/auth/blob/master/internal/api/identity.go) ## More Context User objects are generated by: 1. Converting the `openapi.yml` into JSONSchema. Currently this is done via OpenAI though a modified version of [a yml to jsonschema converter should work with modifications as well](https://www.npmjs.com/package/yaml-to-json-schema). We don't use the latter as there's an additional step of converting the output jsonschema into a format that JSON Faker can accept (adding the JSONSchema version etc) 2. Using [JSONSchema to generate a fake payload](https://json-schema-faker.js.org/) ## Use The plan is to embed the JSONSchema into each Hook example so developers can copy paste into JSONSchema Faker or similar tool to generate a fake payload. --- openapi.yaml | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/openapi.yaml b/openapi.yaml index 3c9faf500..86c0e8568 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1840,7 +1840,7 @@ components: identities: type: array items: - type: object + $ref: "#/components/schemas/IdentitySchema" banned_until: type: string format: date-time @@ -1853,6 +1853,8 @@ components: deleted_at: type: string format: date-time + is_anonymous: + type: boolean SAMLAttributeMappingSchema: type: object @@ -1958,6 +1960,35 @@ components: Usually one of: - totp + IdentitySchema: + type: object + properties: + identity_id: + type: string + format: uuid + id: + type: string + format: uuid + user_id: + type: string + format: uuid + identity_data: + type: object + provider: + type: string + last_sign_in_at: + type: string + format: date-time + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + email: + type: string + format: email + responses: OAuthCallbackRedirectResponse: description: > From ed2b4907244281e4c54aaef74b1f4c8a8e3d97c9 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Wed, 8 May 2024 13:58:04 +0800 Subject: [PATCH 002/118] fix: use api_external_url domain as localname (#1575) ## What kind of change does this PR introduce? * Fixes an issue where some SMTP providers reject requests when the SMTP client uses a Local Name that is identical to the SMTP Host name. --- go.mod | 2 +- go.sum | 2 ++ internal/mailer/mailer.go | 16 +++++++++------- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index fa8c9c0a8..57ddf929d 100644 --- a/go.mod +++ b/go.mod @@ -72,7 +72,7 @@ require ( github.com/jackc/pgx/v4 v4.18.2 github.com/standard-webhooks/standard-webhooks/libraries v0.0.0-20240303152453-e0e82adf1721 github.com/supabase/hibp v0.0.0-20231124125943-d225752ae869 - github.com/supabase/mailme v0.1.0 + github.com/supabase/mailme v0.2.0 github.com/xeipuuv/gojsonschema v1.2.0 ) diff --git a/go.sum b/go.sum index de4f257b7..ce6cb9af3 100644 --- a/go.sum +++ b/go.sum @@ -492,6 +492,8 @@ github.com/supabase/hibp v0.0.0-20231124125943-d225752ae869 h1:VDuRtwen5Z7QQ5ctu github.com/supabase/hibp v0.0.0-20231124125943-d225752ae869/go.mod h1:eHX5nlSMSnyPjUrbYzeqrA8snCe2SKyfizKjU3dkfOw= github.com/supabase/mailme v0.1.0 h1:1InJqMMPTFqZfRZ/gnsCwOc+oUWCFuxSXhlCCG6zYUo= github.com/supabase/mailme v0.1.0/go.mod h1:kWsnmPfUBZTavlXYkfJrE9unzmmRAIi/kqsxXfEWEY8= +github.com/supabase/mailme v0.2.0 h1:39LHZ4+YOeqoN4MiuncPBC3JarExAa0flmokM24qHNU= +github.com/supabase/mailme v0.2.0/go.mod h1:kWsnmPfUBZTavlXYkfJrE9unzmmRAIi/kqsxXfEWEY8= github.com/twmb/murmur3 v1.1.6 h1:mqrRot1BRxm+Yct+vavLMou2/iJt0tNVTTC0QoIjaZg= github.com/twmb/murmur3 v1.1.6/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go index 8768dbe20..02dc9898b 100644 --- a/internal/mailer/mailer.go +++ b/internal/mailer/mailer.go @@ -50,6 +50,7 @@ func NewMailer(globalConfig *conf.GlobalConfiguration) Mailer { mail.SetHeader("Message-ID", fmt.Sprintf("<%s@gotrue-mailer>", uuid.Must(uuid.NewV4()).String())) from := mail.FormatAddress(globalConfig.SMTP.AdminEmail, globalConfig.SMTP.SenderName) + u, _ := url.ParseRequestURI(globalConfig.API.ExternalURL) var mailClient MailClient if globalConfig.SMTP.Host == "" { @@ -57,13 +58,14 @@ func NewMailer(globalConfig *conf.GlobalConfiguration) Mailer { mailClient = &noopMailClient{} } else { mailClient = &mailme.Mailer{ - Host: globalConfig.SMTP.Host, - Port: globalConfig.SMTP.Port, - User: globalConfig.SMTP.User, - Pass: globalConfig.SMTP.Pass, - From: from, - BaseURL: globalConfig.SiteURL, - Logger: logrus.StandardLogger(), + Host: globalConfig.SMTP.Host, + Port: globalConfig.SMTP.Port, + User: globalConfig.SMTP.User, + Pass: globalConfig.SMTP.Pass, + LocalName: u.Hostname(), + From: from, + BaseURL: globalConfig.SiteURL, + Logger: logrus.StandardLogger(), } } From e5f98cb9e24ecebb0b7dc88c495fd456cc73fcba Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Mon, 13 May 2024 23:32:53 +0800 Subject: [PATCH 003/118] fix: sms verify should update is_anonymous field (#1580) ## What kind of change does this PR introduce? * verifying the phone number of a user should update the `is_anonymous` field to false * add test to prevent any future regression --------- Co-authored-by: Joel Lee --- internal/api/anonymous_test.go | 165 +++++++++++++++++++++------------ internal/api/verify.go | 7 ++ 2 files changed, 113 insertions(+), 59 deletions(-) diff --git a/internal/api/anonymous_test.go b/internal/api/anonymous_test.go index 1260774e3..fdee4cc07 100644 --- a/internal/api/anonymous_test.go +++ b/internal/api/anonymous_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "github.com/supabase/auth/internal/conf" + mail "github.com/supabase/auth/internal/mailer" "github.com/supabase/auth/internal/models" ) @@ -77,66 +78,112 @@ func (ts *AnonymousTestSuite) TestAnonymousLogins() { func (ts *AnonymousTestSuite) TestConvertAnonymousUserToPermanent() { ts.Config.External.AnonymousUsers.Enabled = true - // Request body - var buffer bytes.Buffer - require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{})) - - req := httptest.NewRequest(http.MethodPost, "/signup", &buffer) - req.Header.Set("Content-Type", "application/json") - - w := httptest.NewRecorder() - - ts.API.handler.ServeHTTP(w, req) - require.Equal(ts.T(), http.StatusOK, w.Code) - - signupResponse := &AccessTokenResponse{} - require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&signupResponse)) - - // Add email to anonymous user - require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ - "email": "test@example.com", - })) - - req = httptest.NewRequest(http.MethodPut, "/user", &buffer) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", signupResponse.Token)) - - w = httptest.NewRecorder() - ts.API.handler.ServeHTTP(w, req) - require.Equal(ts.T(), http.StatusOK, w.Code) - - // Check if anonymous user is still anonymous - user, err := models.FindUserByID(ts.API.db, signupResponse.User.ID) - require.NoError(ts.T(), err) - require.NotEmpty(ts.T(), user) - require.True(ts.T(), user.IsAnonymous) - - // Verify email change - require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ - "token_hash": user.EmailChangeTokenNew, - "type": "email_change", - })) - - req = httptest.NewRequest(http.MethodPost, "/verify", &buffer) - req.Header.Set("Content-Type", "application/json") - - w = httptest.NewRecorder() - ts.API.handler.ServeHTTP(w, req) - require.Equal(ts.T(), http.StatusOK, w.Code) - - data := &AccessTokenResponse{} - require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data)) - - // User is a permanent user and not anonymous anymore - assert.Equal(ts.T(), signupResponse.User.ID, data.User.ID) - assert.Equal(ts.T(), ts.Config.JWT.Aud, data.User.Aud) - assert.Equal(ts.T(), "test@example.com", data.User.GetEmail()) - assert.Equal(ts.T(), models.JSONMap(models.JSONMap{"provider": "email", "providers": []interface{}{"email"}}), data.User.AppMetaData) - assert.False(ts.T(), data.User.IsAnonymous) - assert.NotEmpty(ts.T(), data.User.EmailConfirmedAt) + ts.Config.Sms.TestOTP = map[string]string{"1234567890": "000000"} + // test OTPs still require setting up an sms provider + ts.Config.Sms.Provider = "twilio" + ts.Config.Sms.Twilio.AccountSid = "fake-sid" + ts.Config.Sms.Twilio.AuthToken = "fake-token" + ts.Config.Sms.Twilio.MessageServiceSid = "fake-message-service-sid" + + cases := []struct { + desc string + body map[string]interface{} + verificationType string + }{ + { + desc: "convert anonymous user to permanent user with email", + body: map[string]interface{}{ + "email": "test@example.com", + }, + verificationType: "email_change", + }, + { + desc: "convert anonymous user to permanent user with phone", + body: map[string]interface{}{ + "phone": "1234567890", + }, + verificationType: "phone_change", + }, + } - // User should have an email identity - assert.Len(ts.T(), data.User.Identities, 1) + for _, c := range cases { + ts.Run(c.desc, func() { + // Request body + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{})) + + req := httptest.NewRequest(http.MethodPost, "/signup", &buffer) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + signupResponse := &AccessTokenResponse{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&signupResponse)) + + // Add email to anonymous user + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body)) + + req = httptest.NewRequest(http.MethodPut, "/user", &buffer) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", signupResponse.Token)) + + w = httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + // Check if anonymous user is still anonymous + user, err := models.FindUserByID(ts.API.db, signupResponse.User.ID) + require.NoError(ts.T(), err) + require.NotEmpty(ts.T(), user) + require.True(ts.T(), user.IsAnonymous) + + switch c.verificationType { + case mail.EmailChangeVerification: + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "token_hash": user.EmailChangeTokenNew, + "type": c.verificationType, + })) + case phoneChangeVerification: + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "phone": "1234567890", + "token": "000000", + "type": c.verificationType, + })) + } + + req = httptest.NewRequest(http.MethodPost, "/verify", &buffer) + req.Header.Set("Content-Type", "application/json") + + w = httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + data := &AccessTokenResponse{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data)) + + // User is a permanent user and not anonymous anymore + assert.Equal(ts.T(), signupResponse.User.ID, data.User.ID) + assert.Equal(ts.T(), ts.Config.JWT.Aud, data.User.Aud) + assert.False(ts.T(), data.User.IsAnonymous) + + // User should have an identity + assert.Len(ts.T(), data.User.Identities, 1) + + switch c.verificationType { + case mail.EmailChangeVerification: + assert.Equal(ts.T(), "test@example.com", data.User.GetEmail()) + assert.Equal(ts.T(), models.JSONMap(models.JSONMap{"provider": "email", "providers": []interface{}{"email"}}), data.User.AppMetaData) + assert.NotEmpty(ts.T(), data.User.EmailConfirmedAt) + case phoneChangeVerification: + assert.Equal(ts.T(), "1234567890", data.User.GetPhone()) + assert.Equal(ts.T(), models.JSONMap(models.JSONMap{"provider": "phone", "providers": []interface{}{"phone"}}), data.User.AppMetaData) + assert.NotEmpty(ts.T(), data.User.PhoneConfirmedAt) + } + }) + } } func (ts *AnonymousTestSuite) TestRateLimitAnonymousSignups() { diff --git a/internal/api/verify.go b/internal/api/verify.go index f48494b0d..121cb4e2c 100644 --- a/internal/api/verify.go +++ b/internal/api/verify.go @@ -406,6 +406,13 @@ func (a *API) smsVerify(r *http.Request, conn *storage.Connection, user *models. } } + if user.IsAnonymous { + user.IsAnonymous = false + if terr := tx.UpdateOnly(user, "is_anonymous"); terr != nil { + return terr + } + } + if terr := tx.Load(user, "Identities"); terr != nil { return internalServerError("Error refetching identities").WithInternalError(terr) } From c22fc15d2a8383e95a2364f383dfa7dce5f5df88 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Thu, 16 May 2024 21:08:04 +0800 Subject: [PATCH 004/118] fix: improve logging structure (#1583) ## What kind of change does this PR introduce? * Remove unformatted logs which do not confirm to JSON * Previously, we were logging both `time` (not UTC) and `timestamp` (in UTC) which is redundant. I've opted to remove `timestamp` and just log the UTC time as the `time` field, which is supported by logrus * Previously, the `request_id` was not being logged because it was unable to retrieve the context properly. Now, the `request_id` field is added to every log entry, which allows us to filter by `request_id` to see the entire lifecycle of the request * Previously, panics weren't being handled properly and they were just logged as text instead of JSON. The server would return an empty reply, which leads to ugly responses like "Unexpected token < in JSON..." if using fetch in JS. Now, the server returns a proper 500 error response: `{"code":500,"error_code":"unexpected_failure","msg":"Internal Server Error"}` * Added tests for `recoverer` and `NewStructuredLogger` to prevent regression * Remove "request started" log since the `request_id` can be used to keep track of the entire request lifecycle. This cuts down on the noise to signal ratio as well. ## Log format * Panics are now logged like this (note the additional fields like `panic` and `stack` - which is a dump of the stack trace): ```json { "component":"api", "duration":6065700500, "level":"info", "method":"GET", "msg":"request completed", "panic":"test panic", "path":"/panic", "referer":"http://localhost:3001", "remote_addr":"127.0.0.1", "request_id":"4cde5f20-2c3c-4645-bc75-52d6231e22e2", "stack":"goroutine 82 [running]:...rest of stack trace omitted for brevity", "status":500, "time":"2024-05-15T09:37:42Z" } ``` * Requests that call `NewAuditLogEntry` will be logged with the `auth_event` payload in this format (note that the timestamp field no longer exists) ```json { "auth_event": { "action": "token_refreshed", "actor_id": "733fb34d-a6f2-43e1-976a-8e6a456b6889", "actor_name": "Kang Ming Tay", "actor_username": "kang.ming1996@gmail.com", "actor_via_sso": false, "log_type": "token" }, "component": "api", "duration": 75945042, "level": "info", "method": "POST", "msg": "request completed", "path": "/token", "referer": "http://localhost:3001", "remote_addr": "127.0.0.1", "request_id": "08c7e47b-42f4-44dc-a39b-7275ef5bbb45", "status": 200, "time": "2024-05-15T09:40:09Z" } ``` --- internal/api/api.go | 11 +-- internal/api/auth.go | 8 +- internal/api/context.go | 16 ---- internal/api/errors.go | 43 +++++----- internal/api/errors_test.go | 41 +++++++++ internal/api/external.go | 6 +- internal/api/external_oauth.go | 2 +- internal/api/helpers.go | 18 ---- internal/api/hooks.go | 2 +- internal/api/middleware.go | 8 +- internal/api/samlacs.go | 2 +- internal/api/token.go | 2 +- internal/api/token_oidc.go | 4 +- internal/api/verify.go | 4 +- internal/observability/logging.go | 23 ++++- internal/observability/request-logger.go | 85 ++++++++++++------- internal/observability/request-logger_test.go | 72 ++++++++++++++++ internal/utilities/context.go | 28 ++++++ 18 files changed, 260 insertions(+), 115 deletions(-) create mode 100644 internal/observability/request-logger_test.go create mode 100644 internal/utilities/context.go diff --git a/internal/api/api.go b/internal/api/api.go index 9c60dbe7e..26207861f 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -98,21 +98,20 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati logger := observability.NewStructuredLogger(logrus.StandardLogger(), globalConfig) r := newRouter() + r.UseBypass(observability.AddRequestID(globalConfig)) + r.UseBypass(logger) + r.UseBypass(xffmw.Handler) + r.UseBypass(recoverer) if globalConfig.API.MaxRequestDuration > 0 { r.UseBypass(api.timeoutMiddleware(globalConfig.API.MaxRequestDuration)) } - r.Use(addRequestID(globalConfig)) - // request tracing should be added only when tracing or metrics is enabled if globalConfig.Tracing.Enabled || globalConfig.Metrics.Enabled { r.UseBypass(observability.RequestTracing()) } - r.UseBypass(xffmw.Handler) - r.Use(recoverer) - if globalConfig.DB.CleanupEnabled { cleanup := models.NewCleanup(globalConfig) r.UseBypass(api.databaseCleanup(cleanup)) @@ -121,7 +120,6 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati r.Get("/health", api.HealthCheck) r.Route("/callback", func(r *router) { - r.UseBypass(logger) r.Use(api.isValidExternalHost) r.Use(api.loadFlowState) @@ -130,7 +128,6 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati }) r.Route("/", func(r *router) { - r.UseBypass(logger) r.Use(api.isValidExternalHost) r.Get("/settings", api.Settings) diff --git a/internal/api/auth.go b/internal/api/auth.go index 4acbe4f95..c167d8212 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -4,7 +4,7 @@ import ( "context" "fmt" "net/http" - "time" + "strings" "github.com/gofrs/uuid" jwt "github.com/golang-jwt/jwt" @@ -44,11 +44,10 @@ func (a *API) requireNotAnonymous(w http.ResponseWriter, r *http.Request) (conte return ctx, nil } -func (a *API) requireAdmin(ctx context.Context, r *http.Request) (context.Context, error) { +func (a *API) requireAdmin(ctx context.Context) (context.Context, error) { // Find the administrative user claims := getClaims(ctx) if claims == nil { - fmt.Printf("[%s] %s %s %d %s\n", time.Now().Format("2006-01-02 15:04:05"), r.Method, r.RequestURI, http.StatusForbidden, "Invalid token") return nil, forbiddenError(ErrorCodeBadJWT, "Invalid token") } @@ -59,8 +58,7 @@ func (a *API) requireAdmin(ctx context.Context, r *http.Request) (context.Contex return withAdminUser(ctx, &models.User{Role: claims.Role, Email: storage.NullString(claims.Role)}), nil } - fmt.Printf("[%s] %s %s %d %s\n", time.Now().Format("2006-01-02 15:04:05"), r.Method, r.RequestURI, http.StatusForbidden, "this token needs role 'supabase_admin' or 'service_role'") - return nil, forbiddenError(ErrorCodeNotAdmin, "User not allowed") + return nil, forbiddenError(ErrorCodeNotAdmin, "User not allowed").WithInternalMessage(fmt.Sprintf("this token needs to have one of the following roles: %v", strings.Join(adminRoles, ", "))) } func (a *API) extractBearerToken(r *http.Request) (string, error) { diff --git a/internal/api/context.go b/internal/api/context.go index 501ab49f2..b357299a6 100644 --- a/internal/api/context.go +++ b/internal/api/context.go @@ -16,7 +16,6 @@ func (c contextKey) String() string { const ( tokenKey = contextKey("jwt") - requestIDKey = contextKey("request_id") inviteTokenKey = contextKey("invite_token") signatureKey = contextKey("signature") externalProviderTypeKey = contextKey("external_provider_type") @@ -57,21 +56,6 @@ func getClaims(ctx context.Context) *AccessTokenClaims { return token.Claims.(*AccessTokenClaims) } -// withRequestID adds the provided request ID to the context. -func withRequestID(ctx context.Context, id string) context.Context { - return context.WithValue(ctx, requestIDKey, id) -} - -// getRequestID reads the request ID from the context. -func getRequestID(ctx context.Context) string { - obj := ctx.Value(requestIDKey) - if obj == nil { - return "" - } - - return obj.(string) -} - // withUser adds the user to the context. func withUser(ctx context.Context, u *models.User) context.Context { return context.WithValue(ctx, userKey, u) diff --git a/internal/api/errors.go b/internal/api/errors.go index 7fc5472e0..2d40a53f4 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -148,27 +148,28 @@ func httpError(httpStatus int, errorCode ErrorCode, fmtString string, args ...in // Recoverer is a middleware that recovers from panics, logs the panic (and a // backtrace), and returns a HTTP 500 (Internal Server Error) status if // possible. Recoverer prints a request ID if one is provided. -func recoverer(w http.ResponseWriter, r *http.Request) (context.Context, error) { - defer func() { - if rvr := recover(); rvr != nil { - - logEntry := observability.GetLogEntry(r) - if logEntry != nil { - logEntry.Panic(rvr, debug.Stack()) - } else { - fmt.Fprintf(os.Stderr, "Panic: %+v\n", rvr) - debug.PrintStack() - } +func recoverer(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rvr := recover(); rvr != nil { + logEntry := observability.GetLogEntry(r) + if logEntry != nil { + logEntry.Panic(rvr, debug.Stack()) + } else { + fmt.Fprintf(os.Stderr, "Panic: %+v\n", rvr) + debug.PrintStack() + } - se := &HTTPError{ - HTTPStatus: http.StatusInternalServerError, - Message: http.StatusText(http.StatusInternalServerError), + se := &HTTPError{ + HTTPStatus: http.StatusInternalServerError, + Message: http.StatusText(http.StatusInternalServerError), + } + HandleResponseError(se, w, r) } - HandleResponseError(se, w, r) - } - }() - - return nil, nil + }() + next.ServeHTTP(w, r) + } + return http.HandlerFunc(fn) } // ErrorCause is an error interface that contains the method Cause() for returning root cause errors @@ -182,8 +183,8 @@ type HTTPErrorResponse20240101 struct { } func HandleResponseError(err error, w http.ResponseWriter, r *http.Request) { - log := observability.GetLogEntry(r) - errorID := getRequestID(r.Context()) + log := observability.GetLogEntry(r).Entry + errorID := utilities.GetRequestID(r.Context()) apiVersion, averr := DetermineClosestAPIVersion(r.Header.Get(APIVersionHeaderName)) if averr != nil { diff --git a/internal/api/errors_test.go b/internal/api/errors_test.go index fc6135205..5524672e7 100644 --- a/internal/api/errors_test.go +++ b/internal/api/errors_test.go @@ -1,11 +1,16 @@ package api import ( + "bytes" + "encoding/json" "net/http" "net/http/httptest" "testing" + "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/conf" + "github.com/supabase/auth/internal/observability" ) func TestHandleResponseErrorWithHTTPError(t *testing.T) { @@ -62,3 +67,39 @@ func TestHandleResponseErrorWithHTTPError(t *testing.T) { require.Equal(t, example.ExpectedBody, rec.Body.String()) } } + +func TestRecoverer(t *testing.T) { + var logBuffer bytes.Buffer + config, err := conf.LoadGlobal(apiTestConfig) + require.NoError(t, err) + require.NoError(t, observability.ConfigureLogging(&config.Logging)) + + // logrus should write to the buffer so we can check if the logs are output correctly + logrus.SetOutput(&logBuffer) + panicHandler := recoverer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + panic("test panic") + })) + + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodPost, "http://example.com", nil) + require.NoError(t, err) + + panicHandler.ServeHTTP(w, req) + + require.Equal(t, http.StatusInternalServerError, w.Code) + + var data HTTPError + + // panic should return an internal server error + require.NoError(t, json.NewDecoder(w.Body).Decode(&data)) + require.Equal(t, ErrorCodeUnexpectedFailure, data.ErrorCode) + require.Equal(t, http.StatusInternalServerError, data.HTTPStatus) + require.Equal(t, "Internal Server Error", data.Message) + + // panic should log the error message internally + var logs map[string]interface{} + require.NoError(t, json.NewDecoder(&logBuffer).Decode(&logs)) + require.Equal(t, "request panicked", logs["msg"]) + require.Equal(t, "test panic", logs["panic"]) + require.NotEmpty(t, logs["stack"]) +} diff --git a/internal/api/external.go b/internal/api/external.go index ab67d0d93..cf1736f03 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -71,7 +71,7 @@ func (a *API) GetExternalProviderRedirectURL(w http.ResponseWriter, r *http.Requ } redirectURL := utilities.GetReferrer(r, config) - log := observability.GetLogEntry(r) + log := observability.GetLogEntry(r).Entry log.WithField("provider", providerType).Info("Redirecting to external provider") if err := validatePKCEParams(codeChallengeMethod, codeChallenge); err != nil { return "", err @@ -573,8 +573,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide func (a *API) redirectErrors(handler apiHandler, w http.ResponseWriter, r *http.Request, u *url.URL) { ctx := r.Context() - log := observability.GetLogEntry(r) - errorID := getRequestID(ctx) + log := observability.GetLogEntry(r).Entry + errorID := utilities.GetRequestID(ctx) err := handler(w, r) if err != nil { q := getErrorQueryString(err, errorID, log, u.Query()) diff --git a/internal/api/external_oauth.go b/internal/api/external_oauth.go index 6c0972ea8..af3dd51f4 100644 --- a/internal/api/external_oauth.go +++ b/internal/api/external_oauth.go @@ -69,7 +69,7 @@ func (a *API) oAuthCallback(ctx context.Context, r *http.Request, providerType s return nil, badRequestError(ErrorCodeOAuthProviderNotSupported, "Unsupported provider: %+v", err).WithInternalError(err) } - log := observability.GetLogEntry(r) + log := observability.GetLogEntry(r).Entry log.WithFields(logrus.Fields{ "provider": providerType, "code": oauthCode, diff --git a/internal/api/helpers.go b/internal/api/helpers.go index d771dca40..bcdce1416 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -6,30 +6,12 @@ import ( "fmt" "net/http" - "github.com/gofrs/uuid" "github.com/pkg/errors" "github.com/supabase/auth/internal/conf" "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/utilities" ) -func addRequestID(globalConfig *conf.GlobalConfiguration) middlewareHandler { - return func(w http.ResponseWriter, r *http.Request) (context.Context, error) { - id := "" - if globalConfig.API.RequestIDHeader != "" { - id = r.Header.Get(globalConfig.API.RequestIDHeader) - } - if id == "" { - uid := uuid.Must(uuid.NewV4()) - id = uid.String() - } - - ctx := r.Context() - ctx = withRequestID(ctx, id) - return ctx, nil - } -} - func sendJSON(w http.ResponseWriter, status int, obj interface{}) error { w.Header().Set("Content-Type", "application/json") b, err := json.Marshal(obj) diff --git a/internal/api/hooks.go b/internal/api/hooks.go index 4efc33512..a2f693200 100644 --- a/internal/api/hooks.go +++ b/internal/api/hooks.go @@ -85,7 +85,7 @@ func (a *API) runHTTPHook(r *http.Request, hookConfig conf.ExtensibilityPointCon ctx, cancel := context.WithTimeout(ctx, DefaultHTTPHookTimeout) defer cancel() - log := observability.GetLogEntry(r) + log := observability.GetLogEntry(r).Entry requestURL := hookConfig.URI hookLog := log.WithFields(logrus.Fields{ "component": "auth_hook", diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 94efd156b..8360af0a0 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -62,7 +62,7 @@ func (a *API) limitHandler(lmt *limiter.Limiter) middlewareHandler { key := req.Header.Get(limitHeader) if key == "" { - log := observability.GetLogEntry(req) + log := observability.GetLogEntry(req).Entry log.WithField("header", limitHeader).Warn("request does not have a value for the rate limiting header, rate limiting is not applied") return c, nil } else { @@ -145,7 +145,7 @@ func (a *API) requireAdminCredentials(w http.ResponseWriter, req *http.Request) return nil, err } - return a.requireAdmin(ctx, req) + return a.requireAdmin(ctx) } func (a *API) requireEmailProvider(w http.ResponseWriter, req *http.Request) (context.Context, error) { @@ -212,7 +212,7 @@ func (a *API) isValidExternalHost(w http.ResponseWriter, req *http.Request) (con } if u, err = url.ParseRequestURI(baseUrl); err != nil { // fallback to the default hostname - log := observability.GetLogEntry(req) + log := observability.GetLogEntry(req).Entry log.WithField("request_url", baseUrl).Warn(err) if u, err = url.ParseRequestURI(config.API.ExternalURL); err != nil { return ctx, err @@ -251,7 +251,7 @@ func (a *API) databaseCleanup(cleanup *models.Cleanup) func(http.Handler) http.H } db := a.db.WithContext(r.Context()) - log := observability.GetLogEntry(r) + log := observability.GetLogEntry(r).Entry affectedRows, err := cleanup.Clean(db) if err != nil { diff --git a/internal/api/samlacs.go b/internal/api/samlacs.go index d50e16a29..0916a7235 100644 --- a/internal/api/samlacs.go +++ b/internal/api/samlacs.go @@ -49,7 +49,7 @@ func (a *API) SAMLACS(w http.ResponseWriter, r *http.Request) error { db := a.db.WithContext(ctx) config := a.config - log := observability.GetLogEntry(r) + log := observability.GetLogEntry(r).Entry relayStateValue := r.FormValue("RelayState") relayStateUUID := uuid.FromStringOrNil(relayStateValue) diff --git a/internal/api/token.go b/internal/api/token.go index 7d94af344..542c68edf 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -153,7 +153,7 @@ func (a *API) ResourceOwnerPasswordGrant(ctx context.Context, w http.ResponseWri if wpe, ok := err.(*WeakPasswordError); ok { weakPasswordError = wpe } else { - observability.GetLogEntry(r).WithError(err).Warn("Password strength check on sign-in failed") + observability.GetLogEntry(r).Entry.WithError(err).Warn("Password strength check on sign-in failed") } } } diff --git a/internal/api/token_oidc.go b/internal/api/token_oidc.go index bb4370402..1c728bf86 100644 --- a/internal/api/token_oidc.go +++ b/internal/api/token_oidc.go @@ -25,7 +25,7 @@ type IdTokenGrantParams struct { } func (p *IdTokenGrantParams) getProvider(ctx context.Context, config *conf.GlobalConfiguration, r *http.Request) (*oidc.Provider, *conf.OAuthProviderConfiguration, string, []string, error) { - log := observability.GetLogEntry(r) + log := observability.GetLogEntry(r).Entry var cfg *conf.OAuthProviderConfiguration var issuer string @@ -113,7 +113,7 @@ func (p *IdTokenGrantParams) getProvider(ctx context.Context, config *conf.Globa // IdTokenGrant implements the id_token grant type flow func (a *API) IdTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.Request) error { - log := observability.GetLogEntry(r) + log := observability.GetLogEntry(r).Entry db := a.db.WithContext(ctx) config := a.config diff --git a/internal/api/verify.go b/internal/api/verify.go index 121cb4e2c..8a857f685 100644 --- a/internal/api/verify.go +++ b/internal/api/verify.go @@ -433,8 +433,8 @@ func (a *API) prepErrorRedirectURL(err *HTTPError, r *http.Request, rurl string, // Maintain separate query params for hash and query hq := url.Values{} - log := observability.GetLogEntry(r) - errorID := getRequestID(r.Context()) + log := observability.GetLogEntry(r).Entry + errorID := utilities.GetRequestID(r.Context()) err.ErrorID = errorID log.WithError(err.Cause()).Info(err.Error()) if str, ok := oauthErrorMap[err.HTTPStatus]; ok { diff --git a/internal/observability/logging.go b/internal/observability/logging.go index 2e2595679..ff8ac96ea 100644 --- a/internal/observability/logging.go +++ b/internal/observability/logging.go @@ -3,6 +3,7 @@ package observability import ( "os" "sync" + "time" "github.com/bombsimon/logrusr/v3" "github.com/gobuffalo/pop/v6" @@ -22,11 +23,31 @@ var ( loggingOnce sync.Once ) +type CustomFormatter struct { + logrus.JSONFormatter +} + +func NewCustomFormatter() *CustomFormatter { + return &CustomFormatter{ + JSONFormatter: logrus.JSONFormatter{ + DisableTimestamp: false, + TimestampFormat: time.RFC3339, + }, + } +} + +func (f *CustomFormatter) Format(entry *logrus.Entry) ([]byte, error) { + // logrus doesn't support formatting the time in UTC so we need to use a custom formatter + entry.Time = entry.Time.UTC() + return f.JSONFormatter.Format(entry) +} + func ConfigureLogging(config *conf.LoggingConfig) error { var err error loggingOnce.Do(func() { - logrus.SetFormatter(&logrus.JSONFormatter{}) + formatter := NewCustomFormatter() + logrus.SetFormatter(formatter) // use a file if you want if config.File != "" { diff --git a/internal/observability/request-logger.go b/internal/observability/request-logger.go index b928d85bc..3e7a7e356 100644 --- a/internal/observability/request-logger.go +++ b/internal/observability/request-logger.go @@ -6,13 +6,37 @@ import ( "time" chimiddleware "github.com/go-chi/chi/middleware" + "github.com/gofrs/uuid" "github.com/sirupsen/logrus" "github.com/supabase/auth/internal/conf" "github.com/supabase/auth/internal/utilities" ) +func AddRequestID(globalConfig *conf.GlobalConfiguration) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + id := uuid.Must(uuid.NewV4()).String() + if globalConfig.API.RequestIDHeader != "" { + id = r.Header.Get(globalConfig.API.RequestIDHeader) + } + ctx := r.Context() + ctx = utilities.WithRequestID(ctx, id) + next.ServeHTTP(w, r.WithContext(ctx)) + } + return http.HandlerFunc(fn) + } +} + func NewStructuredLogger(logger *logrus.Logger, config *conf.GlobalConfiguration) func(next http.Handler) http.Handler { - return chimiddleware.RequestLogger(&structuredLogger{logger, config}) + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/health" { + next.ServeHTTP(w, r) + } else { + chimiddleware.RequestLogger(&structuredLogger{logger, config})(next).ServeHTTP(w, r) + } + }) + } } type structuredLogger struct { @@ -22,65 +46,62 @@ type structuredLogger struct { func (l *structuredLogger) NewLogEntry(r *http.Request) chimiddleware.LogEntry { referrer := utilities.GetReferrer(r, l.Config) - entry := &structuredLoggerEntry{Logger: logrus.NewEntry(l.Logger)} + e := &logEntry{Entry: logrus.NewEntry(l.Logger)} logFields := logrus.Fields{ "component": "api", "method": r.Method, "path": r.URL.Path, "remote_addr": utilities.GetIPAddress(r), "referer": referrer, - "timestamp": time.Now().UTC().Format(time.RFC3339), } - if reqID := r.Context().Value("request_id"); reqID != nil { - logFields["request_id"] = reqID.(string) + if reqID := utilities.GetRequestID(r.Context()); reqID != "" { + logFields["request_id"] = reqID } - entry.Logger = entry.Logger.WithFields(logFields) - entry.Logger.Infoln("request started") - return entry + e.Entry = e.Entry.WithFields(logFields) + return e } -type structuredLoggerEntry struct { - Logger logrus.FieldLogger +// logEntry implements the chiMiddleware.LogEntry interface +type logEntry struct { + Entry *logrus.Entry } -func (l *structuredLoggerEntry) Write(status, bytes int, elapsed time.Duration) { - l.Logger = l.Logger.WithFields(logrus.Fields{ +func (e *logEntry) Write(status, bytes int, elapsed time.Duration) { + entry := e.Entry.WithFields(logrus.Fields{ "status": status, "duration": elapsed.Nanoseconds(), }) - - l.Logger.Info("request completed") + entry.Info("request completed") + e.Entry = entry } -func (l *structuredLoggerEntry) Panic(v interface{}, stack []byte) { - l.Logger.WithFields(logrus.Fields{ +func (e *logEntry) Panic(v interface{}, stack []byte) { + entry := e.Entry.WithFields(logrus.Fields{ "stack": string(stack), "panic": fmt.Sprintf("%+v", v), - }).Panic("unhandled request panic") + }) + entry.Error("request panicked") + e.Entry = entry } -func GetLogEntry(r *http.Request) logrus.FieldLogger { - entry, _ := chimiddleware.GetLogEntry(r).(*structuredLoggerEntry) - if entry == nil { - return logrus.NewEntry(logrus.StandardLogger()) +func GetLogEntry(r *http.Request) *logEntry { + l, _ := chimiddleware.GetLogEntry(r).(*logEntry) + if l == nil { + return &logEntry{Entry: logrus.NewEntry(logrus.StandardLogger())} } - return entry.Logger + return l } -func LogEntrySetField(r *http.Request, key string, value interface{}) logrus.FieldLogger { - if entry, ok := r.Context().Value(chimiddleware.LogEntryCtxKey).(*structuredLoggerEntry); ok { - entry.Logger = entry.Logger.WithField(key, value) - return entry.Logger +func LogEntrySetField(r *http.Request, key string, value interface{}) { + if l, ok := r.Context().Value(chimiddleware.LogEntryCtxKey).(*logEntry); ok { + l.Entry = l.Entry.WithField(key, value) } - return nil } -func LogEntrySetFields(r *http.Request, fields logrus.Fields) logrus.FieldLogger { - if entry, ok := r.Context().Value(chimiddleware.LogEntryCtxKey).(*structuredLoggerEntry); ok { - entry.Logger = entry.Logger.WithFields(fields) - return entry.Logger +func LogEntrySetFields(r *http.Request, fields logrus.Fields) { + if l, ok := r.Context().Value(chimiddleware.LogEntryCtxKey).(*logEntry); ok { + l.Entry = l.Entry.WithFields(fields) } - return nil } diff --git a/internal/observability/request-logger_test.go b/internal/observability/request-logger_test.go new file mode 100644 index 000000000..7ab244c3f --- /dev/null +++ b/internal/observability/request-logger_test.go @@ -0,0 +1,72 @@ +package observability + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/conf" +) + +const apiTestConfig = "../../hack/test.env" + +func TestLogger(t *testing.T) { + var logBuffer bytes.Buffer + config, err := conf.LoadGlobal(apiTestConfig) + require.NoError(t, err) + + config.Logging.Level = "info" + require.NoError(t, ConfigureLogging(&config.Logging)) + + // logrus should write to the buffer so we can check if the logs are output correctly + logrus.SetOutput(&logBuffer) + + // add request id header + config.API.RequestIDHeader = "X-Request-ID" + addRequestIdHandler := AddRequestID(config) + + logHandler := NewStructuredLogger(logrus.StandardLogger(), config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodPost, "http://example.com/path", nil) + req.Header.Add("X-Request-ID", "test-request-id") + require.NoError(t, err) + addRequestIdHandler(logHandler).ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var logs map[string]interface{} + require.NoError(t, json.NewDecoder(&logBuffer).Decode(&logs)) + require.Equal(t, "api", logs["component"]) + require.Equal(t, http.MethodPost, logs["method"]) + require.Equal(t, "/path", logs["path"]) + require.Equal(t, "test-request-id", logs["request_id"]) + require.NotNil(t, logs["time"]) +} + +func TestExcludeHealthFromLogs(t *testing.T) { + var logBuffer bytes.Buffer + config, err := conf.LoadGlobal(apiTestConfig) + require.NoError(t, err) + + config.Logging.Level = "info" + require.NoError(t, ConfigureLogging(&config.Logging)) + + // logrus should write to the buffer so we can check if the logs are output correctly + logrus.SetOutput(&logBuffer) + + logHandler := NewStructuredLogger(logrus.StandardLogger(), config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "http://example.com/health", nil) + require.NoError(t, err) + logHandler.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + require.Empty(t, logBuffer) +} diff --git a/internal/utilities/context.go b/internal/utilities/context.go new file mode 100644 index 000000000..2818fdd6c --- /dev/null +++ b/internal/utilities/context.go @@ -0,0 +1,28 @@ +package utilities + +import "context" + +type contextKey string + +func (c contextKey) String() string { + return "gotrue api context key " + string(c) +} + +const ( + requestIDKey = contextKey("request_id") +) + +// WithRequestID adds the provided request ID to the context. +func WithRequestID(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, requestIDKey, id) +} + +// GetRequestID reads the request ID from the context. +func GetRequestID(ctx context.Context) string { + obj := ctx.Value(requestIDKey) + if obj == nil { + return "" + } + + return obj.(string) +} From c64ae3dd775e8fb3022239252c31b4ee73893237 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Fri, 17 May 2024 15:23:48 +0800 Subject: [PATCH 005/118] feat: update chi version (#1581) ## What kind of change does this PR introduce? * Upgrades [chi](https://github.com/go-chi/chi) from v4 to v5 --- cmd/serve_cmd.go | 2 +- go.mod | 2 +- go.sum | 6 ++---- internal/api/admin.go | 2 +- internal/api/api.go | 8 +++----- internal/api/api_test.go | 3 +-- internal/api/identity.go | 2 +- internal/api/router.go | 2 +- internal/api/ssoadmin.go | 2 +- internal/observability/request-logger.go | 4 ++-- internal/observability/request-tracing.go | 2 +- 11 files changed, 15 insertions(+), 20 deletions(-) diff --git a/cmd/serve_cmd.go b/cmd/serve_cmd.go index 52b4ad375..8423509ae 100644 --- a/cmd/serve_cmd.go +++ b/cmd/serve_cmd.go @@ -32,7 +32,7 @@ func serve(ctx context.Context) { } defer db.Close() - api := api.NewAPIWithVersion(ctx, config, db, utilities.Version) + api := api.NewAPIWithVersion(config, db, utilities.Version) addr := net.JoinHostPort(config.API.Host, config.API.Port) logrus.Infof("GoTrue API started on: %s", addr) diff --git a/go.mod b/go.mod index 57ddf929d..2feeaebfb 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc github.com/coreos/go-oidc/v3 v3.6.0 github.com/didip/tollbooth/v5 v5.1.1 - github.com/go-chi/chi v4.0.2+incompatible github.com/gobuffalo/validate/v3 v3.3.3 // indirect github.com/gobwas/glob v0.2.3 github.com/gofrs/uuid v4.3.1+incompatible @@ -68,6 +67,7 @@ require ( github.com/crewjam/saml v0.4.14 github.com/deepmap/oapi-codegen v1.12.4 github.com/fatih/structs v1.1.0 + github.com/go-chi/chi/v5 v5.0.12 github.com/gobuffalo/pop/v6 v6.1.1 github.com/jackc/pgx/v4 v4.18.2 github.com/standard-webhooks/standard-webhooks/libraries v0.0.0-20240303152453-e0e82adf1721 diff --git a/go.sum b/go.sum index ce6cb9af3..2e5cb69e0 100644 --- a/go.sum +++ b/go.sum @@ -127,8 +127,8 @@ github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-chi/chi v4.0.2+incompatible h1:maB6vn6FqCxrpz4FqWdh4+lwpyZIQS7YEAUcHlgXVRs= -github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= +github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -490,8 +490,6 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/supabase/hibp v0.0.0-20231124125943-d225752ae869 h1:VDuRtwen5Z7QQ5ctuHUse4wAv/JozkKZkdic5vUV4Lg= github.com/supabase/hibp v0.0.0-20231124125943-d225752ae869/go.mod h1:eHX5nlSMSnyPjUrbYzeqrA8snCe2SKyfizKjU3dkfOw= -github.com/supabase/mailme v0.1.0 h1:1InJqMMPTFqZfRZ/gnsCwOc+oUWCFuxSXhlCCG6zYUo= -github.com/supabase/mailme v0.1.0/go.mod h1:kWsnmPfUBZTavlXYkfJrE9unzmmRAIi/kqsxXfEWEY8= github.com/supabase/mailme v0.2.0 h1:39LHZ4+YOeqoN4MiuncPBC3JarExAa0flmokM24qHNU= github.com/supabase/mailme v0.2.0/go.mod h1:kWsnmPfUBZTavlXYkfJrE9unzmmRAIi/kqsxXfEWEY8= github.com/twmb/murmur3 v1.1.6 h1:mqrRot1BRxm+Yct+vavLMou2/iJt0tNVTTC0QoIjaZg= diff --git a/internal/api/admin.go b/internal/api/admin.go index df837c305..dfb5bf8fa 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -8,7 +8,7 @@ import ( "time" "github.com/fatih/structs" - "github.com/go-chi/chi" + "github.com/go-chi/chi/v5" "github.com/gofrs/uuid" "github.com/sethvargo/go-password/password" "github.com/supabase/auth/internal/api/provider" diff --git a/internal/api/api.go b/internal/api/api.go index 26207861f..51d443331 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -1,14 +1,12 @@ package api import ( - "context" "net/http" "regexp" "time" "github.com/didip/tollbooth/v5" "github.com/didip/tollbooth/v5/limiter" - "github.com/go-chi/chi" "github.com/rs/cors" "github.com/sebest/xff" "github.com/sirupsen/logrus" @@ -51,7 +49,7 @@ func (a *API) Now() time.Time { // NewAPI instantiates a new REST API func NewAPI(globalConfig *conf.GlobalConfiguration, db *storage.Connection) *API { - return NewAPIWithVersion(context.Background(), globalConfig, db, defaultVersion) + return NewAPIWithVersion(globalConfig, db, defaultVersion) } func (a *API) deprecationNotices() { @@ -69,7 +67,7 @@ func (a *API) deprecationNotices() { } // NewAPIWithVersion creates a new REST API using the specified version -func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfiguration, db *storage.Connection, version string) *API { +func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Connection, version string) *API { api := &API{config: globalConfig, db: db, version: version} if api.config.Password.HIBP.Enabled { @@ -293,7 +291,7 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati AllowCredentials: true, }) - api.handler = corsHandler.Handler(chi.ServerBaseContext(ctx, r)) + api.handler = corsHandler.Handler(r) return api } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 032e98a43..87639a09c 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -1,7 +1,6 @@ package api import ( - "context" "testing" "github.com/stretchr/testify/require" @@ -46,7 +45,7 @@ func setupAPIForTestWithCallback(cb func(*conf.GlobalConfiguration, *storage.Con cb(nil, conn) } - return NewAPIWithVersion(context.Background(), config, conn, apiTestVersion), config, nil + return NewAPIWithVersion(config, conn, apiTestVersion), config, nil } func TestEmailEnabledByDefault(t *testing.T) { diff --git a/internal/api/identity.go b/internal/api/identity.go index e3bcf3cad..7acf7c25c 100644 --- a/internal/api/identity.go +++ b/internal/api/identity.go @@ -5,7 +5,7 @@ import ( "net/http" "github.com/fatih/structs" - "github.com/go-chi/chi" + "github.com/go-chi/chi/v5" "github.com/gofrs/uuid" "github.com/pkg/errors" "github.com/supabase/auth/internal/api/provider" diff --git a/internal/api/router.go b/internal/api/router.go index 70b41f22d..1feb66d3f 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -4,7 +4,7 @@ import ( "context" "net/http" - "github.com/go-chi/chi" + "github.com/go-chi/chi/v5" ) func newRouter() *router { diff --git a/internal/api/ssoadmin.go b/internal/api/ssoadmin.go index fae328c4f..72d88dbac 100644 --- a/internal/api/ssoadmin.go +++ b/internal/api/ssoadmin.go @@ -10,7 +10,7 @@ import ( "github.com/crewjam/saml" "github.com/crewjam/saml/samlsp" - "github.com/go-chi/chi" + "github.com/go-chi/chi/v5" "github.com/gofrs/uuid" "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/observability" diff --git a/internal/observability/request-logger.go b/internal/observability/request-logger.go index 3e7a7e356..804feca3b 100644 --- a/internal/observability/request-logger.go +++ b/internal/observability/request-logger.go @@ -5,7 +5,7 @@ import ( "net/http" "time" - chimiddleware "github.com/go-chi/chi/middleware" + chimiddleware "github.com/go-chi/chi/v5/middleware" "github.com/gofrs/uuid" "github.com/sirupsen/logrus" "github.com/supabase/auth/internal/conf" @@ -68,7 +68,7 @@ type logEntry struct { Entry *logrus.Entry } -func (e *logEntry) Write(status, bytes int, elapsed time.Duration) { +func (e *logEntry) Write(status, bytes int, header http.Header, elapsed time.Duration, extra interface{}) { entry := e.Entry.WithFields(logrus.Fields{ "status": status, "duration": elapsed.Nanoseconds(), diff --git a/internal/observability/request-tracing.go b/internal/observability/request-tracing.go index c35925547..aefa994db 100644 --- a/internal/observability/request-tracing.go +++ b/internal/observability/request-tracing.go @@ -4,7 +4,7 @@ import ( "context" "net/http" - "github.com/go-chi/chi" + "github.com/go-chi/chi/v5" "github.com/sirupsen/logrus" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel/attribute" From 39ca026035f6c61d206d31772c661b326c2a424c Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Fri, 17 May 2024 09:24:20 +0200 Subject: [PATCH 006/118] feat: remove legacy lookup in users for one_time_tokens (phase II) (#1569) Removes legacy lookups in `auth.users` for when a corresponding entry in `one_time_tokens` is not found. Phase II of the refactor, based on #1558, to be released after it's deployed for a few days. --------- Co-authored-by: Kang Ming --- internal/api/admin_test.go | 5 + internal/api/external_test.go | 4 + internal/api/invite_test.go | 1 + internal/api/resend_test.go | 4 + internal/api/signup_test.go | 4 +- internal/api/verify_test.go | 507 ++++++++++++++---------------- internal/models/connection.go | 1 + internal/models/one_time_token.go | 62 +--- internal/models/user_test.go | 13 +- 9 files changed, 261 insertions(+), 340 deletions(-) diff --git a/internal/api/admin_test.go b/internal/api/admin_test.go index 615bba6c7..c57659414 100644 --- a/internal/api/admin_test.go +++ b/internal/api/admin_test.go @@ -594,6 +594,11 @@ func (ts *AdminTestSuite) TestAdminUserSoftDeletion() { "provider": "email", } require.NoError(ts.T(), ts.API.db.Create(u)) + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.ConfirmationToken, models.ConfirmationToken)) + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.RecoveryToken, models.RecoveryToken)) + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.EmailChangeTokenCurrent, models.EmailChangeTokenCurrent)) + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.EmailChangeTokenNew, models.EmailChangeTokenNew)) + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetPhone(), u.PhoneChangeToken, models.PhoneChangeToken)) // create user identities _, err = ts.API.createNewIdentity(ts.API.db, u, "email", map[string]interface{}{ diff --git a/internal/api/external_test.go b/internal/api/external_test.go index ca82aede3..09fdcc433 100644 --- a/internal/api/external_test.go +++ b/internal/api/external_test.go @@ -56,6 +56,10 @@ func (ts *ExternalTestSuite) createUser(providerId string, email string, name st ts.Require().NoError(err, "Error making new user") ts.Require().NoError(ts.API.db.Create(u), "Error creating user") + if confirmationToken != "" { + ts.Require().NoError(models.CreateOneTimeToken(ts.API.db, u.ID, email, u.ConfirmationToken, models.ConfirmationToken), "Error creating one-time confirmation/invite token") + } + i, err := models.NewIdentity(u, "email", map[string]interface{}{ "sub": u.ID.String(), "email": email, diff --git a/internal/api/invite_test.go b/internal/api/invite_test.go index 1ced4caeb..1d502adc8 100644 --- a/internal/api/invite_test.go +++ b/internal/api/invite_test.go @@ -211,6 +211,7 @@ func (ts *InviteTestSuite) TestVerifyInvite() { user.ConfirmationToken = crypto.GenerateTokenHash(c.email, c.requestBody["token"].(string)) require.NoError(ts.T(), err) require.NoError(ts.T(), ts.API.db.Create(user)) + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, user.ID, user.GetEmail(), user.ConfirmationToken, models.ConfirmationToken)) // Find test user _, err = models.FindUserByEmailAndAudience(ts.API.db, c.email, ts.Config.JWT.Aud) diff --git a/internal/api/resend_test.go b/internal/api/resend_test.go index 122415dd7..83c58c4e4 100644 --- a/internal/api/resend_test.go +++ b/internal/api/resend_test.go @@ -128,6 +128,8 @@ func (ts *ResendTestSuite) TestResendSuccess() { u.EmailChangeSentAt = &now u.EmailChangeTokenNew = "123456" require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new test user") + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.ConfirmationToken, models.ConfirmationToken)) + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.EmailChange, u.EmailChangeTokenNew, models.EmailChangeTokenNew)) phoneUser, err := models.NewUser("1234567890", "", "password", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error creating test user model") @@ -135,6 +137,7 @@ func (ts *ResendTestSuite) TestResendSuccess() { phoneUser.EmailChangeSentAt = &now phoneUser.EmailChangeTokenNew = "123456" require.NoError(ts.T(), ts.API.db.Create(phoneUser), "Error saving new test user") + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, phoneUser.ID, phoneUser.EmailChange, phoneUser.EmailChangeTokenNew, models.EmailChangeTokenNew)) emailUser, err := models.NewUser("", "bar@example.com", "password", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error creating test user model") @@ -142,6 +145,7 @@ func (ts *ResendTestSuite) TestResendSuccess() { phoneUser.PhoneChangeSentAt = &now phoneUser.PhoneChangeToken = "123456" require.NoError(ts.T(), ts.API.db.Create(emailUser), "Error saving new test user") + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, phoneUser.ID, phoneUser.PhoneChange, phoneUser.PhoneChangeToken, models.PhoneChangeToken)) cases := []struct { desc string diff --git a/internal/api/signup_test.go b/internal/api/signup_test.go index 36b8feb66..3f4783261 100644 --- a/internal/api/signup_test.go +++ b/internal/api/signup_test.go @@ -4,13 +4,14 @@ import ( "bytes" "encoding/json" "fmt" - mail "github.com/supabase/auth/internal/mailer" "net/http" "net/http/httptest" "net/url" "testing" "time" + mail "github.com/supabase/auth/internal/mailer" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -127,6 +128,7 @@ func (ts *SignupTestSuite) TestVerifySignup() { user.ConfirmationSentAt = &now require.NoError(ts.T(), err) require.NoError(ts.T(), ts.API.db.Create(user)) + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, user.ID, user.GetEmail(), user.ConfirmationToken, models.ConfirmationToken)) // Find test user u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) diff --git a/internal/api/verify_test.go b/internal/api/verify_test.go index 1b4c64efd..73386bebe 100644 --- a/internal/api/verify_test.go +++ b/internal/api/verify_test.go @@ -21,26 +21,6 @@ import ( "github.com/supabase/auth/internal/models" ) -type VerifyVariant int - -const ( - VerifyWithoutOTT VerifyVariant = iota - VerifyWithOTT -) - -func (v VerifyVariant) String() string { - switch v { - case VerifyWithoutOTT: - return "WithoutOTT" - - case VerifyWithOTT: - return "WithOTT" - - default: - panic("VerifyVariant: unreachable code") - } -} - type VerifyTestSuite struct { suite.Suite API *API @@ -69,21 +49,6 @@ func (ts *VerifyTestSuite) SetupTest() { require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new test user") } -func (ts *VerifyTestSuite) VerifyWithVariants(fn func(variant VerifyVariant)) { - variants := []VerifyVariant{ - VerifyWithoutOTT, - VerifyWithOTT, - } - - for _, v := range variants { - variant := v - - ts.Run(variant.String(), func() { - fn(variant) - }) - } -} - func (ts *VerifyTestSuite) TestVerifyPasswordRecovery() { // modify config so we don't hit rate limit from requesting recovery twice in 60s ts.Config.SMTP.MaxFrequency = 60 @@ -117,60 +82,54 @@ func (ts *VerifyTestSuite) TestVerifyPasswordRecovery() { }, } - ts.VerifyWithVariants(func(variant VerifyVariant) { - for _, c := range cases { - ts.Run(c.desc, func() { - // Reset user - u.EmailConfirmedAt = nil - require.NoError(ts.T(), ts.API.db.Update(u)) - require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID)) - - // Request body - var buffer bytes.Buffer - require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body)) + for _, c := range cases { + ts.Run(c.desc, func() { + // Reset user + u.EmailConfirmedAt = nil + require.NoError(ts.T(), ts.API.db.Update(u)) + require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID)) - // Setup request - req := httptest.NewRequest(http.MethodPost, "http://localhost/recover", &buffer) - req.Header.Set("Content-Type", "application/json") + // Request body + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body)) - // Setup response recorder - w := httptest.NewRecorder() - ts.API.handler.ServeHTTP(w, req) - assert.Equal(ts.T(), http.StatusOK, w.Code) + // Setup request + req := httptest.NewRequest(http.MethodPost, "http://localhost/recover", &buffer) + req.Header.Set("Content-Type", "application/json") - u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) - require.NoError(ts.T(), err) + // Setup response recorder + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + assert.Equal(ts.T(), http.StatusOK, w.Code) - assert.WithinDuration(ts.T(), time.Now(), *u.RecoverySentAt, 1*time.Second) - assert.False(ts.T(), u.IsConfirmed()) + u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) + require.NoError(ts.T(), err) - recoveryToken := u.RecoveryToken + assert.WithinDuration(ts.T(), time.Now(), *u.RecoverySentAt, 1*time.Second) + assert.False(ts.T(), u.IsConfirmed()) - if variant == VerifyWithoutOTT { - require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID)) - } + recoveryToken := u.RecoveryToken - reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s", mail.RecoveryVerification, recoveryToken) - req = httptest.NewRequest(http.MethodGet, reqURL, nil) + reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s", mail.RecoveryVerification, recoveryToken) + req = httptest.NewRequest(http.MethodGet, reqURL, nil) - w = httptest.NewRecorder() - ts.API.handler.ServeHTTP(w, req) - assert.Equal(ts.T(), http.StatusSeeOther, w.Code) + w = httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + assert.Equal(ts.T(), http.StatusSeeOther, w.Code) - u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) - require.NoError(ts.T(), err) - assert.True(ts.T(), u.IsConfirmed()) + u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + assert.True(ts.T(), u.IsConfirmed()) - if c.isPKCE { - rURL, _ := w.Result().Location() + if c.isPKCE { + rURL, _ := w.Result().Location() - f, err := url.ParseQuery(rURL.RawQuery) - require.NoError(ts.T(), err) - assert.NotEmpty(ts.T(), f.Get("code")) - } - }) - } - }) + f, err := url.ParseQuery(rURL.RawQuery) + require.NoError(ts.T(), err) + assert.NotEmpty(ts.T(), f.Get("code")) + } + }) + } } func (ts *VerifyTestSuite) TestVerifySecureEmailChange() { @@ -208,118 +167,112 @@ func (ts *VerifyTestSuite) TestVerifySecureEmailChange() { }, } - ts.VerifyWithVariants(func(variant VerifyVariant) { - for _, c := range cases { - u, err := models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) - require.NoError(ts.T(), err) - - // reset user - u.EmailChangeSentAt = nil - u.EmailChangeTokenCurrent = "" - u.EmailChangeTokenNew = "" - require.NoError(ts.T(), ts.API.db.Update(u)) - require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID)) - - // Request body - var buffer bytes.Buffer - require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body)) - - // Setup request - req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer) - req.Header.Set("Content-Type", "application/json") - - // Generate access token for request and a mock session - var token string - session, err := models.NewSession(u.ID, nil) - require.NoError(ts.T(), err) - require.NoError(ts.T(), ts.API.db.Create(session)) - - token, _, err = ts.API.generateAccessToken(req, ts.API.db, u, &session.ID, models.MagicLink) - require.NoError(ts.T(), err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - - // Setup response recorder - w := httptest.NewRecorder() - ts.API.handler.ServeHTTP(w, req) - assert.Equal(ts.T(), http.StatusOK, w.Code) - - u, err = models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) - require.NoError(ts.T(), err) - - currentTokenHash := u.EmailChangeTokenCurrent - newTokenHash := u.EmailChangeTokenNew - - if variant == VerifyWithoutOTT { - require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID)) - } - - u, err = models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) - require.NoError(ts.T(), err) - - assert.WithinDuration(ts.T(), time.Now(), *u.EmailChangeSentAt, 1*time.Second) - assert.False(ts.T(), u.IsConfirmed()) - - // Verify new email - reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s", mail.EmailChangeVerification, newTokenHash) - req = httptest.NewRequest(http.MethodGet, reqURL, nil) - - w = httptest.NewRecorder() - ts.API.handler.ServeHTTP(w, req) - - require.Equal(ts.T(), http.StatusSeeOther, w.Code) - urlVal, err := url.Parse(w.Result().Header.Get("Location")) - ts.Require().NoError(err, "redirect url parse failed") - var v url.Values - if !c.isPKCE { - v, err = url.ParseQuery(urlVal.Fragment) - ts.Require().NoError(err) - ts.Require().NotEmpty(v.Get("message")) - } else if c.isPKCE { - v, err = url.ParseQuery(urlVal.RawQuery) - ts.Require().NoError(err) - ts.Require().NotEmpty(v.Get("message")) - - v, err = url.ParseQuery(urlVal.Fragment) - ts.Require().NoError(err) - ts.Require().NotEmpty(v.Get("message")) - } - - u, err = models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) - require.NoError(ts.T(), err) - assert.Equal(ts.T(), singleConfirmation, u.EmailChangeConfirmStatus) - - // Verify old email - reqURL = fmt.Sprintf("http://localhost/verify?type=%s&token=%s", mail.EmailChangeVerification, currentTokenHash) - req = httptest.NewRequest(http.MethodGet, reqURL, nil) + for _, c := range cases { + u, err := models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + + // reset user + u.EmailChangeSentAt = nil + u.EmailChangeTokenCurrent = "" + u.EmailChangeTokenNew = "" + require.NoError(ts.T(), ts.API.db.Update(u)) + require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID)) + + // Request body + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body)) + + // Setup request + req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer) + req.Header.Set("Content-Type", "application/json") + + // Generate access token for request and a mock session + var token string + session, err := models.NewSession(u.ID, nil) + require.NoError(ts.T(), err) + require.NoError(ts.T(), ts.API.db.Create(session)) + + token, _, err = ts.API.generateAccessToken(req, ts.API.db, u, &session.ID, models.MagicLink) + require.NoError(ts.T(), err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + // Setup response recorder + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + assert.Equal(ts.T(), http.StatusOK, w.Code) + + u, err = models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + + currentTokenHash := u.EmailChangeTokenCurrent + newTokenHash := u.EmailChangeTokenNew + + u, err = models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + + assert.WithinDuration(ts.T(), time.Now(), *u.EmailChangeSentAt, 1*time.Second) + assert.False(ts.T(), u.IsConfirmed()) + + // Verify new email + reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s", mail.EmailChangeVerification, newTokenHash) + req = httptest.NewRequest(http.MethodGet, reqURL, nil) + + w = httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + require.Equal(ts.T(), http.StatusSeeOther, w.Code) + urlVal, err := url.Parse(w.Result().Header.Get("Location")) + ts.Require().NoError(err, "redirect url parse failed") + var v url.Values + if !c.isPKCE { + v, err = url.ParseQuery(urlVal.Fragment) + ts.Require().NoError(err) + ts.Require().NotEmpty(v.Get("message")) + } else if c.isPKCE { + v, err = url.ParseQuery(urlVal.RawQuery) + ts.Require().NoError(err) + ts.Require().NotEmpty(v.Get("message")) + + v, err = url.ParseQuery(urlVal.Fragment) + ts.Require().NoError(err) + ts.Require().NotEmpty(v.Get("message")) + } - w = httptest.NewRecorder() - ts.API.handler.ServeHTTP(w, req) - require.Equal(ts.T(), http.StatusSeeOther, w.Code) - - urlVal, err = url.Parse(w.Header().Get("Location")) - ts.Require().NoError(err, "redirect url parse failed") - if !c.isPKCE { - v, err = url.ParseQuery(urlVal.Fragment) - ts.Require().NoError(err) - ts.Require().NotEmpty(v.Get("access_token")) - ts.Require().NotEmpty(v.Get("expires_in")) - ts.Require().NotEmpty(v.Get("refresh_token")) - } else if c.isPKCE { - v, err = url.ParseQuery(urlVal.RawQuery) - ts.Require().NoError(err) - ts.Require().NotEmpty(v.Get("code")) - } + u, err = models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + assert.Equal(ts.T(), singleConfirmation, u.EmailChangeConfirmStatus) + + // Verify old email + reqURL = fmt.Sprintf("http://localhost/verify?type=%s&token=%s", mail.EmailChangeVerification, currentTokenHash) + req = httptest.NewRequest(http.MethodGet, reqURL, nil) + + w = httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusSeeOther, w.Code) + + urlVal, err = url.Parse(w.Header().Get("Location")) + ts.Require().NoError(err, "redirect url parse failed") + if !c.isPKCE { + v, err = url.ParseQuery(urlVal.Fragment) + ts.Require().NoError(err) + ts.Require().NotEmpty(v.Get("access_token")) + ts.Require().NotEmpty(v.Get("expires_in")) + ts.Require().NotEmpty(v.Get("refresh_token")) + } else if c.isPKCE { + v, err = url.ParseQuery(urlVal.RawQuery) + ts.Require().NoError(err) + ts.Require().NotEmpty(v.Get("code")) + } - // user's email should've been updated to newEmail - u, err = models.FindUserByEmailAndAudience(ts.API.db, c.newEmail, ts.Config.JWT.Aud) - require.NoError(ts.T(), err) - require.Equal(ts.T(), zeroConfirmation, u.EmailChangeConfirmStatus) + // user's email should've been updated to newEmail + u, err = models.FindUserByEmailAndAudience(ts.API.db, c.newEmail, ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + require.Equal(ts.T(), zeroConfirmation, u.EmailChangeConfirmStatus) - // Reset confirmation status after each test - u.EmailConfirmedAt = nil - require.NoError(ts.T(), ts.API.db.Update(u)) - } - }) + // Reset confirmation status after each test + u.EmailConfirmedAt = nil + require.NoError(ts.T(), ts.API.db.Update(u)) + } } func (ts *VerifyTestSuite) TestExpiredConfirmationToken() { @@ -332,6 +285,7 @@ func (ts *VerifyTestSuite) TestExpiredConfirmationToken() { sentTime := time.Now().Add(-48 * time.Hour) u.ConfirmationSentAt = &sentTime require.NoError(ts.T(), ts.API.db.Update(u)) + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.ConfirmationToken, models.ConfirmationToken)) // Setup request reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s", mail.SignupVerification, u.ConfirmationToken) @@ -363,6 +317,8 @@ func (ts *VerifyTestSuite) TestInvalidOtp() { u.PhoneChangeToken = "123456" u.PhoneChangeSentAt = &sentTime require.NoError(ts.T(), ts.API.db.Update(u)) + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.ConfirmationToken, models.ConfirmationToken)) + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.PhoneChange, u.PhoneChangeToken, models.PhoneChangeToken)) type ResponseBody struct { Code int `json:"code"` @@ -685,6 +641,7 @@ func (ts *VerifyTestSuite) TestVerifySignupWithRedirectURLContainedPath() { sendTime := time.Now().Add(time.Hour) u.ConfirmationSentAt = &sendTime require.NoError(ts.T(), ts.API.db.Update(u)) + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.ConfirmationToken, models.ConfirmationToken)) reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s&redirect_to=%s", "signup", u.ConfirmationToken, redirectURL) req := httptest.NewRequest(http.MethodGet, reqURL, nil) @@ -705,13 +662,10 @@ func (ts *VerifyTestSuite) TestVerifySignupWithRedirectURLContainedPath() { func (ts *VerifyTestSuite) TestVerifyPKCEOTP() { u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) - u.ConfirmationToken = "pkce_confirmation_token" - u.RecoveryToken = "pkce_recovery_token" t := time.Now() u.ConfirmationSentAt = &t u.RecoverySentAt = &t u.EmailChangeSentAt = &t - require.NoError(ts.T(), ts.API.db.Update(u)) cases := []struct { @@ -720,10 +674,10 @@ func (ts *VerifyTestSuite) TestVerifyPKCEOTP() { authenticationMethod models.AuthenticationMethod }{ { - desc: "Verify banned user on signup", + desc: "Verify user on signup", payload: &VerifyParams{ Type: "signup", - Token: u.ConfirmationToken, + Token: "pkce_confirmation_token", }, authenticationMethod: models.EmailSignup, }, @@ -731,7 +685,7 @@ func (ts *VerifyTestSuite) TestVerifyPKCEOTP() { desc: "Verify magiclink", payload: &VerifyParams{ Type: "magiclink", - Token: u.RecoveryToken, + Token: "pkce_recovery_token", }, authenticationMethod: models.MagicLink, }, @@ -739,8 +693,16 @@ func (ts *VerifyTestSuite) TestVerifyPKCEOTP() { for _, c := range cases { ts.Run(c.desc, func() { var buffer bytes.Buffer + // since the test user is the same, the tokens are being cleared after each successful verification attempt + // so we create them on each run + if c.payload.Type == "signup" { + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), c.payload.Token, models.ConfirmationToken)) + } else if c.payload.Type == "magiclink" { + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), c.payload.Token, models.RecoveryToken)) + } + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.payload)) - codeChallenge := "codechallengecodechallengcodechallengcodechallengcodechallenge" + c.payload.Type + codeChallenge := "codechallengecodechallengcodechallengcodechallengcodechallenge" flowState := models.NewFlowState(c.authenticationMethod.String(), codeChallenge, models.SHA256, c.authenticationMethod, &u.ID) require.NoError(ts.T(), ts.API.db.Create(flowState)) @@ -780,6 +742,10 @@ func (ts *VerifyTestSuite) TestVerifyBannedUser() { t = time.Now().Add(24 * time.Hour) u.BannedUntil = &t require.NoError(ts.T(), ts.API.db.Update(u)) + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.ConfirmationToken, models.ConfirmationToken)) + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.RecoveryToken, models.RecoveryToken)) + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.EmailChangeTokenCurrent, models.EmailChangeTokenCurrent)) + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.EmailChangeTokenNew, models.EmailChangeTokenNew)) cases := []struct { desc string @@ -980,48 +946,42 @@ func (ts *VerifyTestSuite) TestVerifyValidOtp() { }, } - ts.VerifyWithVariants(func(variant VerifyVariant) { - for _, caseItem := range cases { - c := caseItem - ts.Run(c.desc, func() { - // create user - require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID)) - - u.ConfirmationSentAt = &c.sentTime - u.RecoverySentAt = &c.sentTime - u.EmailChangeSentAt = &c.sentTime - u.PhoneChangeSentAt = &c.sentTime - - u.ConfirmationToken = c.expected.tokenHash - u.RecoveryToken = c.expected.tokenHash - u.EmailChangeTokenNew = c.expected.tokenHash - u.PhoneChangeToken = c.expected.tokenHash - - require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", u.ConfirmationToken, models.ConfirmationToken)) - require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", u.RecoveryToken, models.RecoveryToken)) - require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", u.EmailChangeTokenNew, models.EmailChangeTokenNew)) - require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", u.PhoneChangeToken, models.PhoneChangeToken)) - - if variant == VerifyWithoutOTT { - require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID)) - } - - require.NoError(ts.T(), ts.API.db.Update(u)) - - var buffer bytes.Buffer - require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body)) - - // Setup request - req := httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer) - req.Header.Set("Content-Type", "application/json") - - // Setup response recorder - w := httptest.NewRecorder() - ts.API.handler.ServeHTTP(w, req) - assert.Equal(ts.T(), c.expected.code, w.Code) - }) - } - }) + for _, caseItem := range cases { + c := caseItem + ts.Run(c.desc, func() { + // create user + require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID)) + + u.ConfirmationSentAt = &c.sentTime + u.RecoverySentAt = &c.sentTime + u.EmailChangeSentAt = &c.sentTime + u.PhoneChangeSentAt = &c.sentTime + + u.ConfirmationToken = c.expected.tokenHash + u.RecoveryToken = c.expected.tokenHash + u.EmailChangeTokenNew = c.expected.tokenHash + u.PhoneChangeToken = c.expected.tokenHash + + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", u.ConfirmationToken, models.ConfirmationToken)) + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", u.RecoveryToken, models.RecoveryToken)) + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", u.EmailChangeTokenNew, models.EmailChangeTokenNew)) + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", u.PhoneChangeToken, models.PhoneChangeToken)) + + require.NoError(ts.T(), ts.API.db.Update(u)) + + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body)) + + // Setup request + req := httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer) + req.Header.Set("Content-Type", "application/json") + + // Setup response recorder + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + assert.Equal(ts.T(), c.expected.code, w.Code) + }) + } } func (ts *VerifyTestSuite) TestSecureEmailChangeWithTokenHash() { @@ -1066,47 +1026,42 @@ func (ts *VerifyTestSuite) TestSecureEmailChangeWithTokenHash() { }, } - ts.VerifyWithVariants(func(variant VerifyVariant) { - for _, c := range cases { - ts.Run(c.desc, func() { - // Set the corresponding email change tokens - u.EmailChangeTokenCurrent = currentEmailChangeToken - u.EmailChangeTokenNew = newEmailChangeToken - require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID)) - - if variant == VerifyWithOTT { - require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", currentEmailChangeToken, models.EmailChangeTokenCurrent)) - require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", newEmailChangeToken, models.EmailChangeTokenNew)) - } - - currentTime := time.Now() - u.EmailChangeSentAt = ¤tTime - require.NoError(ts.T(), ts.API.db.Update(u)) - - var buffer bytes.Buffer - require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.firstVerificationBody)) - - // Setup request - req := httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer) - req.Header.Set("Content-Type", "application/json") - - // Setup response recorder - w := httptest.NewRecorder() - ts.API.handler.ServeHTTP(w, req) - require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.secondVerificationBody)) - - // Setup second request - req = httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer) - req.Header.Set("Content-Type", "application/json") - - // Setup second response recorder - w = httptest.NewRecorder() - ts.API.handler.ServeHTTP(w, req) - assert.Equal(ts.T(), c.expectedStatus, w.Code) - }) + for _, c := range cases { + ts.Run(c.desc, func() { + // Set the corresponding email change tokens + u.EmailChangeTokenCurrent = currentEmailChangeToken + u.EmailChangeTokenNew = newEmailChangeToken + require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID)) - } - }) + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", currentEmailChangeToken, models.EmailChangeTokenCurrent)) + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", newEmailChangeToken, models.EmailChangeTokenNew)) + + currentTime := time.Now() + u.EmailChangeSentAt = ¤tTime + require.NoError(ts.T(), ts.API.db.Update(u)) + + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.firstVerificationBody)) + + // Setup request + req := httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer) + req.Header.Set("Content-Type", "application/json") + + // Setup response recorder + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.secondVerificationBody)) + + // Setup second request + req = httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer) + req.Header.Set("Content-Type", "application/json") + + // Setup second response recorder + w = httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + assert.Equal(ts.T(), c.expectedStatus, w.Code) + }) + } } func (ts *VerifyTestSuite) TestPrepRedirectURL() { diff --git a/internal/models/connection.go b/internal/models/connection.go index 95fe87415..80acccc57 100644 --- a/internal/models/connection.go +++ b/internal/models/connection.go @@ -48,6 +48,7 @@ func TruncateAll(conn *storage.Connection) error { (&pop.Model{Value: SAMLProvider{}}).TableName(), (&pop.Model{Value: SAMLRelayState{}}).TableName(), (&pop.Model{Value: FlowState{}}).TableName(), + (&pop.Model{Value: OneTimeToken{}}).TableName(), } for _, tableName := range tables { diff --git a/internal/models/one_time_token.go b/internal/models/one_time_token.go index 18417af84..c5a204902 100644 --- a/internal/models/one_time_token.go +++ b/internal/models/one_time_token.go @@ -178,99 +178,57 @@ func FindOneTimeToken(tx *storage.Connection, tokenHash string, tokenTypes ...On // FindUserByConfirmationToken finds users with the matching confirmation token. func FindUserByConfirmationOrRecoveryToken(tx *storage.Connection, token string) (*User, error) { ott, err := FindOneTimeToken(tx, token, ConfirmationToken, RecoveryToken) - if err != nil && !IsNotFoundError(err) { + if err != nil { return nil, err } - if ott == nil { - user, err := findUser(tx, "(confirmation_token = ? or recovery_token = ?) and is_sso_user = false", token, token) - if err != nil { - if IsNotFoundError(err) { - return nil, ConfirmationOrRecoveryTokenNotFoundError{} - } else { - return nil, err - } - } - - return user, nil - } - return FindUserByID(tx, ott.UserID) } // FindUserByConfirmationToken finds users with the matching confirmation token. func FindUserByConfirmationToken(tx *storage.Connection, token string) (*User, error) { ott, err := FindOneTimeToken(tx, token, ConfirmationToken) - if err != nil && !IsNotFoundError(err) { + if err != nil { return nil, err } - if ott == nil { - user, err := findUser(tx, "confirmation_token = ? and is_sso_user = false", token) - if err != nil { - if IsNotFoundError(err) { - return nil, ConfirmationTokenNotFoundError{} - } else { - return nil, err - } - } - - return user, nil - } - return FindUserByID(tx, ott.UserID) } // FindUserByRecoveryToken finds a user with the matching recovery token. func FindUserByRecoveryToken(tx *storage.Connection, token string) (*User, error) { ott, err := FindOneTimeToken(tx, token, RecoveryToken) - if err != nil && !IsNotFoundError(err) { + if err != nil { return nil, err } - if ott == nil { - return findUser(tx, "recovery_token = ? and is_sso_user = false", token) - } - return FindUserByID(tx, ott.UserID) } // FindUserByEmailChangeToken finds a user with the matching email change token. func FindUserByEmailChangeToken(tx *storage.Connection, token string) (*User, error) { ott, err := FindOneTimeToken(tx, token, EmailChangeTokenCurrent, EmailChangeTokenNew) - if err != nil && !IsNotFoundError(err) { + if err != nil { return nil, err } - if ott == nil { - return findUser(tx, "is_sso_user = false and (email_change_token_current = ? or email_change_token_new = ?)", token, token) - } - return FindUserByID(tx, ott.UserID) } // FindUserByEmailChangeCurrentAndAudience finds a user with the matching email change and audience. func FindUserByEmailChangeCurrentAndAudience(tx *storage.Connection, email, token, aud string) (*User, error) { ott, err := FindOneTimeToken(tx, token, EmailChangeTokenCurrent) - if err != nil && !IsNotFoundError(err) { + if err != nil { return nil, err } if ott == nil { ott, err = FindOneTimeToken(tx, "pkce_"+token, EmailChangeTokenCurrent) - if err != nil && !IsNotFoundError(err) { + if err != nil { return nil, err } } - if ott == nil { - return findUser( - tx, - "instance_id = ? and LOWER(email) = ? and aud = ? and is_sso_user = false and (email_change_token_current = 'pkce_' || ? or email_change_token_current = ?)", - uuid.Nil, strings.ToLower(email), aud, token, token, - ) - } - user, err := FindUserByID(tx, ott.UserID) if err != nil { return nil, err @@ -297,14 +255,6 @@ func FindUserByEmailChangeNewAndAudience(tx *storage.Connection, email, token, a } } - if ott == nil { - return findUser( - tx, - "instance_id = ? and LOWER(email_change) = ? and aud = ? and is_sso_user = false and (email_change_token_new = 'pkce_' || ? or email_change_token_new = ?)", - uuid.Nil, strings.ToLower(email), aud, token, token, - ) - } - user, err := FindUserByID(tx, ott.UserID) if err != nil { return nil, err diff --git a/internal/models/user_test.go b/internal/models/user_test.go index 3b3438608..6c915f6af 100644 --- a/internal/models/user_test.go +++ b/internal/models/user_test.go @@ -83,8 +83,10 @@ func (ts *UserTestSuite) TestUpdateUserMetadata() { func (ts *UserTestSuite) TestFindUserByConfirmationToken() { u := ts.createUser() + tokenHash := "test_confirmation_token" + require.NoError(ts.T(), CreateOneTimeToken(ts.db, u.ID, "relates_to not used", tokenHash, ConfirmationToken)) - n, err := FindUserByConfirmationToken(ts.db, u.ConfirmationToken) + n, err := FindUserByConfirmationToken(ts.db, tokenHash) require.NoError(ts.T(), err) require.Equal(ts.T(), u.ID, n.ID) } @@ -136,14 +138,11 @@ func (ts *UserTestSuite) TestFindUserByID() { func (ts *UserTestSuite) TestFindUserByRecoveryToken() { u := ts.createUser() - u.RecoveryToken = "asdf" + tokenHash := "test_recovery_token" + require.NoError(ts.T(), CreateOneTimeToken(ts.db, u.ID, "relates_to not used", tokenHash, RecoveryToken)) - err := ts.db.Update(u) + n, err := FindUserByRecoveryToken(ts.db, tokenHash) require.NoError(ts.T(), err) - - n, err := FindUserByRecoveryToken(ts.db, u.RecoveryToken) - require.NoError(ts.T(), err) - require.Equal(ts.T(), u.ID, n.ID) } From 72614a1fce27888f294772b512f8e31c55a36d87 Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Wed, 22 May 2024 11:05:29 +0200 Subject: [PATCH 007/118] feat: new timeout writer implementation (#1584) #1529 introduced timeout middleware, but it appears from working in the wild it has some race conditions that are not particularly helpful. This PR rewrites the implementation to get rid of race conditions, at the expense of slightly higher RAM usage. It follows the implementation of `http.TimeoutHandler` closely. --------- Co-authored-by: Kang Ming --- internal/api/api.go | 2 +- internal/api/middleware.go | 138 ++++++++++++--------- internal/api/middleware_test.go | 23 +++- internal/api/verify.go | 21 ++-- internal/api/verify_test.go | 210 ++++++++++++++++---------------- 5 files changed, 224 insertions(+), 170 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index 51d443331..8613a05dc 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -102,7 +102,7 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne r.UseBypass(recoverer) if globalConfig.API.MaxRequestDuration > 0 { - r.UseBypass(api.timeoutMiddleware(globalConfig.API.MaxRequestDuration)) + r.UseBypass(timeoutMiddleware(globalConfig.API.MaxRequestDuration)) } // request tracing should be added only when tracing or metrics is enabled diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 8360af0a0..5db550bf8 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "context" "encoding/json" "fmt" @@ -8,9 +9,9 @@ import ( "net/url" "strings" "sync" - "sync/atomic" "time" + "github.com/sirupsen/logrus" "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/observability" "github.com/supabase/auth/internal/security" @@ -263,95 +264,122 @@ func (a *API) databaseCleanup(cleanup *models.Cleanup) func(http.Handler) http.H } } -// timeoutResponseWriter is a http.ResponseWriter that prevents subsequent -// writes after the context contained in it has exceeded the deadline. If a -// partial write occurs before the deadline is exceeded, but the writing is not -// complete it will allow further writes. +// timeoutResponseWriter is a http.ResponseWriter that queues up a response +// body to be sent if the serving completes before the context has exceeded its +// deadline. type timeoutResponseWriter struct { - ctx context.Context - w http.ResponseWriter - wrote int32 - mu sync.Mutex + sync.Mutex + + header http.Header + wroteHeader bool + snapHeader http.Header // snapshot of the header at the time WriteHeader was called + statusCode int + buf bytes.Buffer } func (t *timeoutResponseWriter) Header() http.Header { - t.mu.Lock() - defer t.mu.Unlock() - return t.w.Header() + t.Lock() + defer t.Unlock() + + return t.header } func (t *timeoutResponseWriter) Write(bytes []byte) (int, error) { - t.mu.Lock() - defer t.mu.Unlock() - if t.ctx.Err() == context.DeadlineExceeded { - if atomic.LoadInt32(&t.wrote) == 0 { - return 0, context.DeadlineExceeded - } + t.Lock() + defer t.Unlock() - // writing started before the deadline exceeded, but the - // deadline came in the middle, so letting the writes go - // through + if !t.wroteHeader { + t.WriteHeader(http.StatusOK) } - t.wrote = 1 - - return t.w.Write(bytes) + return t.buf.Write(bytes) } func (t *timeoutResponseWriter) WriteHeader(statusCode int) { - t.mu.Lock() - defer t.mu.Unlock() - if t.ctx.Err() == context.DeadlineExceeded { - if atomic.LoadInt32(&t.wrote) == 0 { - return - } + t.Lock() + defer t.Unlock() + + if t.wroteHeader { + // ignore multiple calls to WriteHeader + // once WriteHeader has been called once, a snapshot of the header map is taken + // and saved in snapHeader to be used in finallyWrite + return + } + t.statusCode = statusCode + t.wroteHeader = true + t.snapHeader = t.header.Clone() +} + +func (t *timeoutResponseWriter) finallyWrite(w http.ResponseWriter) { + t.Lock() + defer t.Unlock() - // writing started before the deadline exceeded, but the - // deadline came in the middle, so letting the writes go - // through + dst := w.Header() + for k, vv := range t.snapHeader { + dst[k] = vv } - t.wrote = 1 + if !t.wroteHeader { + t.statusCode = http.StatusOK + } - t.w.WriteHeader(statusCode) + w.WriteHeader(t.statusCode) + if _, err := w.Write(t.buf.Bytes()); err != nil { + logrus.WithError(err).Warn("Write failed") + } } -func (a *API) timeoutMiddleware(timeout time.Duration) func(http.Handler) http.Handler { +func timeoutMiddleware(timeout time.Duration) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), timeout) defer cancel() timeoutWriter := &timeoutResponseWriter{ - w: w, - ctx: ctx, + header: make(http.Header), } + panicChan := make(chan any, 1) + serverDone := make(chan struct{}) go func() { - <-ctx.Done() + defer func() { + if p := recover(); p != nil { + panicChan <- p + } + }() + next.ServeHTTP(timeoutWriter, r.WithContext(ctx)) + close(serverDone) + }() + + select { + case p := <-panicChan: + panic(p) + + case <-serverDone: + timeoutWriter.finallyWrite(w) + + case <-ctx.Done(): err := ctx.Err() if err == context.DeadlineExceeded { - timeoutWriter.mu.Lock() - defer timeoutWriter.mu.Unlock() - if timeoutWriter.wrote == 0 { - // writer wasn't written to, so we're sending the error payload - - httpError := &HTTPError{ - HTTPStatus: http.StatusGatewayTimeout, - ErrorCode: ErrorCodeRequestTimeout, - Message: "Processing this request timed out, please retry after a moment.", - } + httpError := &HTTPError{ + HTTPStatus: http.StatusGatewayTimeout, + ErrorCode: ErrorCodeRequestTimeout, + Message: "Processing this request timed out, please retry after a moment.", + } - httpError = httpError.WithInternalError(err) + httpError = httpError.WithInternalError(err) - HandleResponseError(httpError, w, r) - } - } - }() + HandleResponseError(httpError, w, r) + } else { + // unrecognized context error, so we should wait for the server to finish + // and write out the response + <-serverDone - next.ServeHTTP(timeoutWriter, r.WithContext(ctx)) + timeoutWriter.finallyWrite(w) + } + } }) } } diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go index 8c91f0258..a9d908c32 100644 --- a/internal/api/middleware_test.go +++ b/internal/api/middleware_test.go @@ -319,7 +319,7 @@ func (ts *MiddlewareTestSuite) TestTimeoutMiddleware() { req := httptest.NewRequest(http.MethodGet, "http://localhost", nil) w := httptest.NewRecorder() - timeoutHandler := ts.API.timeoutMiddleware(ts.Config.API.MaxRequestDuration) + timeoutHandler := timeoutMiddleware(ts.Config.API.MaxRequestDuration) slowHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Sleep for 1 second to simulate a slow handler which should trigger the timeout @@ -335,3 +335,24 @@ func (ts *MiddlewareTestSuite) TestTimeoutMiddleware() { require.Equal(ts.T(), float64(504), data["code"]) require.NotNil(ts.T(), data["msg"]) } + +func TestTimeoutResponseWriter(t *testing.T) { + // timeoutResponseWriter should exhitbit a similar behavior as http.ResponseWriter + req := httptest.NewRequest(http.MethodGet, "http://localhost", nil) + w1 := httptest.NewRecorder() + w2 := httptest.NewRecorder() + + timeoutHandler := timeoutMiddleware(time.Second * 10) + + redirectHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // tries to redirect twice + http.Redirect(w, r, "http://localhost:3001/#message=first_message", http.StatusSeeOther) + + // overwrites the first + http.Redirect(w, r, "http://localhost:3001/second", http.StatusSeeOther) + }) + timeoutHandler(redirectHandler).ServeHTTP(w1, req) + redirectHandler.ServeHTTP(w2, req) + + require.Equal(t, w1.Result(), w2.Result()) +} diff --git a/internal/api/verify.go b/internal/api/verify.go index 8a857f685..5badfc77e 100644 --- a/internal/api/verify.go +++ b/internal/api/verify.go @@ -125,6 +125,7 @@ func (a *API) verifyGet(w http.ResponseWriter, r *http.Request, params *VerifyPa err error token *AccessTokenResponse authCode string + rurl string ) grantParams.FillGrantParams(r) @@ -138,6 +139,7 @@ func (a *API) verifyGet(w http.ResponseWriter, r *http.Request, params *VerifyPa return err } } + err = db.Transaction(func(tx *storage.Connection) error { var terr error user, terr = a.verifyTokenHash(tx, params) @@ -152,12 +154,11 @@ func (a *API) verifyGet(w http.ResponseWriter, r *http.Request, params *VerifyPa case mail.EmailChangeVerification: user, terr = a.emailChangeVerify(r, tx, params, user) if user == nil && terr == nil { - // when double confirmation is required - rurl, err := a.prepRedirectURL(singleConfirmationAccepted, params.RedirectTo, flowType) - if err != nil { - return err + // only one OTP is confirmed at this point, so we return early and ask the user to confirm the second OTP + rurl, terr = a.prepRedirectURL(singleConfirmationAccepted, params.RedirectTo, flowType) + if terr != nil { + return terr } - http.Redirect(w, r, rurl, http.StatusSeeOther) return nil } default: @@ -198,15 +199,17 @@ func (a *API) verifyGet(w http.ResponseWriter, r *http.Request, params *VerifyPa if err != nil { var herr *HTTPError if errors.As(err, &herr) { - rurl, err := a.prepErrorRedirectURL(herr, r, params.RedirectTo, flowType) + rurl, err = a.prepErrorRedirectURL(herr, r, params.RedirectTo, flowType) if err != nil { return err } - http.Redirect(w, r, rurl, http.StatusSeeOther) - return nil } } - rurl := params.RedirectTo + if rurl != "" { + http.Redirect(w, r, rurl, http.StatusSeeOther) + return nil + } + rurl = params.RedirectTo if isImplicitFlow(flowType) && token != nil { q := url.Values{} q.Set("type", params.Type) diff --git a/internal/api/verify_test.go b/internal/api/verify_test.go index 73386bebe..8d818b43a 100644 --- a/internal/api/verify_test.go +++ b/internal/api/verify_test.go @@ -168,110 +168,112 @@ func (ts *VerifyTestSuite) TestVerifySecureEmailChange() { } for _, c := range cases { - u, err := models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) - require.NoError(ts.T(), err) - - // reset user - u.EmailChangeSentAt = nil - u.EmailChangeTokenCurrent = "" - u.EmailChangeTokenNew = "" - require.NoError(ts.T(), ts.API.db.Update(u)) - require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID)) - - // Request body - var buffer bytes.Buffer - require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body)) - - // Setup request - req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer) - req.Header.Set("Content-Type", "application/json") - - // Generate access token for request and a mock session - var token string - session, err := models.NewSession(u.ID, nil) - require.NoError(ts.T(), err) - require.NoError(ts.T(), ts.API.db.Create(session)) - - token, _, err = ts.API.generateAccessToken(req, ts.API.db, u, &session.ID, models.MagicLink) - require.NoError(ts.T(), err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - - // Setup response recorder - w := httptest.NewRecorder() - ts.API.handler.ServeHTTP(w, req) - assert.Equal(ts.T(), http.StatusOK, w.Code) - - u, err = models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) - require.NoError(ts.T(), err) - - currentTokenHash := u.EmailChangeTokenCurrent - newTokenHash := u.EmailChangeTokenNew - - u, err = models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) - require.NoError(ts.T(), err) - - assert.WithinDuration(ts.T(), time.Now(), *u.EmailChangeSentAt, 1*time.Second) - assert.False(ts.T(), u.IsConfirmed()) - - // Verify new email - reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s", mail.EmailChangeVerification, newTokenHash) - req = httptest.NewRequest(http.MethodGet, reqURL, nil) - - w = httptest.NewRecorder() - ts.API.handler.ServeHTTP(w, req) - - require.Equal(ts.T(), http.StatusSeeOther, w.Code) - urlVal, err := url.Parse(w.Result().Header.Get("Location")) - ts.Require().NoError(err, "redirect url parse failed") - var v url.Values - if !c.isPKCE { - v, err = url.ParseQuery(urlVal.Fragment) - ts.Require().NoError(err) - ts.Require().NotEmpty(v.Get("message")) - } else if c.isPKCE { - v, err = url.ParseQuery(urlVal.RawQuery) - ts.Require().NoError(err) - ts.Require().NotEmpty(v.Get("message")) - - v, err = url.ParseQuery(urlVal.Fragment) - ts.Require().NoError(err) - ts.Require().NotEmpty(v.Get("message")) - } - - u, err = models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) - require.NoError(ts.T(), err) - assert.Equal(ts.T(), singleConfirmation, u.EmailChangeConfirmStatus) - - // Verify old email - reqURL = fmt.Sprintf("http://localhost/verify?type=%s&token=%s", mail.EmailChangeVerification, currentTokenHash) - req = httptest.NewRequest(http.MethodGet, reqURL, nil) - - w = httptest.NewRecorder() - ts.API.handler.ServeHTTP(w, req) - require.Equal(ts.T(), http.StatusSeeOther, w.Code) - - urlVal, err = url.Parse(w.Header().Get("Location")) - ts.Require().NoError(err, "redirect url parse failed") - if !c.isPKCE { - v, err = url.ParseQuery(urlVal.Fragment) - ts.Require().NoError(err) - ts.Require().NotEmpty(v.Get("access_token")) - ts.Require().NotEmpty(v.Get("expires_in")) - ts.Require().NotEmpty(v.Get("refresh_token")) - } else if c.isPKCE { - v, err = url.ParseQuery(urlVal.RawQuery) - ts.Require().NoError(err) - ts.Require().NotEmpty(v.Get("code")) - } - - // user's email should've been updated to newEmail - u, err = models.FindUserByEmailAndAudience(ts.API.db, c.newEmail, ts.Config.JWT.Aud) - require.NoError(ts.T(), err) - require.Equal(ts.T(), zeroConfirmation, u.EmailChangeConfirmStatus) - - // Reset confirmation status after each test - u.EmailConfirmedAt = nil - require.NoError(ts.T(), ts.API.db.Update(u)) + ts.Run(c.desc, func() { + u, err := models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + + // reset user + u.EmailChangeSentAt = nil + u.EmailChangeTokenCurrent = "" + u.EmailChangeTokenNew = "" + require.NoError(ts.T(), ts.API.db.Update(u)) + require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID)) + + // Request body + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body)) + + // Setup request + req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer) + req.Header.Set("Content-Type", "application/json") + + // Generate access token for request and a mock session + var token string + session, err := models.NewSession(u.ID, nil) + require.NoError(ts.T(), err) + require.NoError(ts.T(), ts.API.db.Create(session)) + + token, _, err = ts.API.generateAccessToken(req, ts.API.db, u, &session.ID, models.MagicLink) + require.NoError(ts.T(), err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + // Setup response recorder + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + assert.Equal(ts.T(), http.StatusOK, w.Code) + + u, err = models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + + currentTokenHash := u.EmailChangeTokenCurrent + newTokenHash := u.EmailChangeTokenNew + + u, err = models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + + assert.WithinDuration(ts.T(), time.Now(), *u.EmailChangeSentAt, 1*time.Second) + assert.False(ts.T(), u.IsConfirmed()) + + // Verify new email + reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s", mail.EmailChangeVerification, newTokenHash) + req = httptest.NewRequest(http.MethodGet, reqURL, nil) + + w = httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + require.Equal(ts.T(), http.StatusSeeOther, w.Code) + urlVal, err := url.Parse(w.Result().Header.Get("Location")) + ts.Require().NoError(err, "redirect url parse failed") + var v url.Values + if !c.isPKCE { + v, err = url.ParseQuery(urlVal.Fragment) + ts.Require().NoError(err) + ts.Require().NotEmpty(v.Get("message")) + } else if c.isPKCE { + v, err = url.ParseQuery(urlVal.RawQuery) + ts.Require().NoError(err) + ts.Require().NotEmpty(v.Get("message")) + + v, err = url.ParseQuery(urlVal.Fragment) + ts.Require().NoError(err) + ts.Require().NotEmpty(v.Get("message")) + } + + u, err = models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + assert.Equal(ts.T(), singleConfirmation, u.EmailChangeConfirmStatus) + + // Verify old email + reqURL = fmt.Sprintf("http://localhost/verify?type=%s&token=%s", mail.EmailChangeVerification, currentTokenHash) + req = httptest.NewRequest(http.MethodGet, reqURL, nil) + + w = httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusSeeOther, w.Code) + + urlVal, err = url.Parse(w.Header().Get("Location")) + ts.Require().NoError(err, "redirect url parse failed") + if !c.isPKCE { + v, err = url.ParseQuery(urlVal.Fragment) + ts.Require().NoError(err) + ts.Require().NotEmpty(v.Get("access_token")) + ts.Require().NotEmpty(v.Get("expires_in")) + ts.Require().NotEmpty(v.Get("refresh_token")) + } else if c.isPKCE { + v, err = url.ParseQuery(urlVal.RawQuery) + ts.Require().NoError(err) + ts.Require().NotEmpty(v.Get("code")) + } + + // user's email should've been updated to newEmail + u, err = models.FindUserByEmailAndAudience(ts.API.db, c.newEmail, ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + require.Equal(ts.T(), zeroConfirmation, u.EmailChangeConfirmStatus) + + // Reset confirmation status after each test + u.EmailConfirmedAt = nil + require.NoError(ts.T(), ts.API.db.Update(u)) + }) } } From b954a485096cddfd1eef4d582034a99eff95fa6f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 May 2024 13:07:35 +0200 Subject: [PATCH 008/118] chore(master): release 2.152.0 (#1574) :robot: I have created a release *beep* *boop* --- ## [2.152.0](https://github.com/supabase/auth/compare/v2.151.0...v2.152.0) (2024-05-22) ### Features * new timeout writer implementation ([#1584](https://github.com/supabase/auth/issues/1584)) ([72614a1](https://github.com/supabase/auth/commit/72614a1fce27888f294772b512f8e31c55a36d87)) * remove legacy lookup in users for one_time_tokens (phase II) ([#1569](https://github.com/supabase/auth/issues/1569)) ([39ca026](https://github.com/supabase/auth/commit/39ca026035f6c61d206d31772c661b326c2a424c)) * update chi version ([#1581](https://github.com/supabase/auth/issues/1581)) ([c64ae3d](https://github.com/supabase/auth/commit/c64ae3dd775e8fb3022239252c31b4ee73893237)) * update openapi spec with identity and is_anonymous fields ([#1573](https://github.com/supabase/auth/issues/1573)) ([86a79df](https://github.com/supabase/auth/commit/86a79df9ecfcf09fda0b8e07afbc41154fbb7d9d)) ### Bug Fixes * improve logging structure ([#1583](https://github.com/supabase/auth/issues/1583)) ([c22fc15](https://github.com/supabase/auth/commit/c22fc15d2a8383e95a2364f383dfa7dce5f5df88)) * sms verify should update is_anonymous field ([#1580](https://github.com/supabase/auth/issues/1580)) ([e5f98cb](https://github.com/supabase/auth/commit/e5f98cb9e24ecebb0b7dc88c495fd456cc73fcba)) * use api_external_url domain as localname ([#1575](https://github.com/supabase/auth/issues/1575)) ([ed2b490](https://github.com/supabase/auth/commit/ed2b4907244281e4c54aaef74b1f4c8a8e3d97c9)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f18970426..8dd17bbc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [2.152.0](https://github.com/supabase/auth/compare/v2.151.0...v2.152.0) (2024-05-22) + + +### Features + +* new timeout writer implementation ([#1584](https://github.com/supabase/auth/issues/1584)) ([72614a1](https://github.com/supabase/auth/commit/72614a1fce27888f294772b512f8e31c55a36d87)) +* remove legacy lookup in users for one_time_tokens (phase II) ([#1569](https://github.com/supabase/auth/issues/1569)) ([39ca026](https://github.com/supabase/auth/commit/39ca026035f6c61d206d31772c661b326c2a424c)) +* update chi version ([#1581](https://github.com/supabase/auth/issues/1581)) ([c64ae3d](https://github.com/supabase/auth/commit/c64ae3dd775e8fb3022239252c31b4ee73893237)) +* update openapi spec with identity and is_anonymous fields ([#1573](https://github.com/supabase/auth/issues/1573)) ([86a79df](https://github.com/supabase/auth/commit/86a79df9ecfcf09fda0b8e07afbc41154fbb7d9d)) + + +### Bug Fixes + +* improve logging structure ([#1583](https://github.com/supabase/auth/issues/1583)) ([c22fc15](https://github.com/supabase/auth/commit/c22fc15d2a8383e95a2364f383dfa7dce5f5df88)) +* sms verify should update is_anonymous field ([#1580](https://github.com/supabase/auth/issues/1580)) ([e5f98cb](https://github.com/supabase/auth/commit/e5f98cb9e24ecebb0b7dc88c495fd456cc73fcba)) +* use api_external_url domain as localname ([#1575](https://github.com/supabase/auth/issues/1575)) ([ed2b490](https://github.com/supabase/auth/commit/ed2b4907244281e4c54aaef74b1f4c8a8e3d97c9)) + ## [2.151.0](https://github.com/supabase/auth/compare/v2.150.1...v2.151.0) (2024-05-06) From 93f52fc42aef9c083418db4789256cba20ef93c0 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Thu, 30 May 2024 12:05:40 +0800 Subject: [PATCH 009/118] chore: remove unused args in functions (#1594) ## What kind of change does this PR introduce? * clean up unused args in various functions --- internal/api/hooks.go | 4 ++-- internal/api/hooks_test.go | 16 +++++++--------- internal/api/identity_test.go | 6 +++--- internal/api/otp.go | 2 +- internal/api/phone.go | 3 +-- internal/api/phone_test.go | 4 +--- internal/api/reauthenticate.go | 2 +- internal/api/resend.go | 4 ++-- internal/api/signup.go | 2 +- internal/api/user.go | 2 +- 10 files changed, 20 insertions(+), 25 deletions(-) diff --git a/internal/api/hooks.go b/internal/api/hooks.go index a2f693200..c66a94b5f 100644 --- a/internal/api/hooks.go +++ b/internal/api/hooks.go @@ -77,7 +77,7 @@ func (a *API) runPostgresHook(ctx context.Context, tx *storage.Connection, hookC return response, nil } -func (a *API) runHTTPHook(r *http.Request, hookConfig conf.ExtensibilityPointConfiguration, input, output any) ([]byte, error) { +func (a *API) runHTTPHook(r *http.Request, hookConfig conf.ExtensibilityPointConfiguration, input any) ([]byte, error) { ctx := r.Context() client := http.Client{ Timeout: DefaultHTTPHookTimeout, @@ -351,7 +351,7 @@ func (a *API) runHook(r *http.Request, conn *storage.Connection, hookConfig conf var err error switch strings.ToLower(scheme) { case "http", "https": - response, err = a.runHTTPHook(r, hookConfig, input, output) + response, err = a.runHTTPHook(r, hookConfig, input) case "pg-functions": response, err = a.runPostgresHook(ctx, conn, hookConfig, input, output) default: diff --git a/internal/api/hooks_test.go b/internal/api/hooks_test.go index df8e93e71..d645ef41b 100644 --- a/internal/api/hooks_test.go +++ b/internal/api/hooks_test.go @@ -6,6 +6,8 @@ import ( "testing" "errors" + "net/http/httptest" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -13,7 +15,6 @@ import ( "github.com/supabase/auth/internal/hooks" "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/storage" - "net/http/httptest" "gopkg.in/h2non/gock.v1" ) @@ -105,13 +106,13 @@ func (ts *HooksTestSuite) TestRunHTTPHook() { } - var output hooks.SendSMSOutput req, _ := http.NewRequest("POST", ts.Config.Hook.SendSMS.URI, nil) - body, err := ts.API.runHTTPHook(req, ts.Config.Hook.SendSMS, &input, &output) + body, err := ts.API.runHTTPHook(req, ts.Config.Hook.SendSMS, &input) if !tc.expectError { require.NoError(ts.T(), err) if body != nil { + var output hooks.SendSMSOutput require.NoError(ts.T(), json.Unmarshal(body, &output)) require.True(ts.T(), output.Success) } @@ -149,15 +150,14 @@ func (ts *HooksTestSuite) TestShouldRetryWithRetryAfterHeader() { Reply(http.StatusOK). JSON(successOutput).SetHeader("content-type", "application/json") - var output hooks.SendSMSOutput - // Simulate the original HTTP request which triggered the hook req, err := http.NewRequest("POST", "http://localhost:9998/otp", nil) require.NoError(ts.T(), err) - body, err := ts.API.runHTTPHook(req, ts.Config.Hook.SendSMS, &input, &output) + body, err := ts.API.runHTTPHook(req, ts.Config.Hook.SendSMS, &input) require.NoError(ts.T(), err) + var output hooks.SendSMSOutput err = json.Unmarshal(body, &output) require.NoError(ts.T(), err, "Unmarshal should not fail") require.True(ts.T(), output.Success, "Expected success on retry") @@ -184,12 +184,10 @@ func (ts *HooksTestSuite) TestShouldReturnErrorForNonJSONContentType() { Reply(http.StatusOK). SetHeader("content-type", "text/plain") - var output hooks.SendSMSOutput - req, err := http.NewRequest("POST", "http://localhost:9999/otp", nil) require.NoError(ts.T(), err) - _, err = ts.API.runHTTPHook(req, ts.Config.Hook.SendSMS, &input, &output) + _, err = ts.API.runHTTPHook(req, ts.Config.Hook.SendSMS, &input) require.Error(ts.T(), err, "Expected an error due to wrong content type") require.Contains(ts.T(), err.Error(), "Invalid JSON response.") diff --git a/internal/api/identity_test.go b/internal/api/identity_test.go index 89b92b3e0..999559ec6 100644 --- a/internal/api/identity_test.go +++ b/internal/api/identity_test.go @@ -134,7 +134,7 @@ func (ts *IdentityTestSuite) TestUnlinkIdentityError() { for _, c := range cases { ts.Run(c.desc, func() { - token := ts.generateAccessTokenAndSession(context.Background(), c.user, models.PasswordGrant) + token := ts.generateAccessTokenAndSession(c.user) req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("/user/identities/%s", c.identityId), nil) require.NoError(ts.T(), err) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) @@ -183,7 +183,7 @@ func (ts *IdentityTestSuite) TestUnlinkIdentity() { identity, err := models.FindIdentityByIdAndProvider(ts.API.db, u.ID.String(), c.provider) require.NoError(ts.T(), err) - token := ts.generateAccessTokenAndSession(context.Background(), u, models.PasswordGrant) + token := ts.generateAccessTokenAndSession(u) req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("/user/identities/%s", identity.ID), nil) require.NoError(ts.T(), err) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) @@ -214,7 +214,7 @@ func (ts *IdentityTestSuite) TestUnlinkIdentity() { } -func (ts *IdentityTestSuite) generateAccessTokenAndSession(ctx context.Context, u *models.User, authenticationMethod models.AuthenticationMethod) string { +func (ts *IdentityTestSuite) generateAccessTokenAndSession(u *models.User) string { s, err := models.NewSession(u.ID, nil) require.NoError(ts.T(), err) require.NoError(ts.T(), ts.API.db.Create(s)) diff --git a/internal/api/otp.go b/internal/api/otp.go index a2ea95429..d0e3d6f18 100644 --- a/internal/api/otp.go +++ b/internal/api/otp.go @@ -195,7 +195,7 @@ func (a *API) SmsOtp(w http.ResponseWriter, r *http.Request) error { if terr != nil { return internalServerError("Unable to get SMS provider").WithInternalError(err) } - mID, serr := a.sendPhoneConfirmation(ctx, r, tx, user, params.Phone, phoneConfirmationOtp, smsProvider, params.Channel) + mID, serr := a.sendPhoneConfirmation(r, tx, user, params.Phone, phoneConfirmationOtp, smsProvider, params.Channel) if serr != nil { return badRequestError(ErrorCodeSMSSendFailed, "Error sending sms OTP: %v", serr).WithInternalError(serr) } diff --git a/internal/api/phone.go b/internal/api/phone.go index 83caec4af..2147a959b 100644 --- a/internal/api/phone.go +++ b/internal/api/phone.go @@ -2,7 +2,6 @@ package api import ( "bytes" - "context" "net/http" "regexp" "strings" @@ -44,7 +43,7 @@ func formatPhoneNumber(phone string) string { } // sendPhoneConfirmation sends an otp to the user's phone number -func (a *API) sendPhoneConfirmation(ctx context.Context, r *http.Request, tx *storage.Connection, user *models.User, phone, otpType string, smsProvider sms_provider.SmsProvider, channel string) (string, error) { +func (a *API) sendPhoneConfirmation(r *http.Request, tx *storage.Connection, user *models.User, phone, otpType string, smsProvider sms_provider.SmsProvider, channel string) (string, error) { config := a.config var token *string diff --git a/internal/api/phone_test.go b/internal/api/phone_test.go index b532022ec..38daa4941 100644 --- a/internal/api/phone_test.go +++ b/internal/api/phone_test.go @@ -112,9 +112,7 @@ func doTestSendPhoneConfirmation(ts *PhoneTestSuite, useTestOTP bool) { ts.Run(c.desc, func() { provider := &TestSmsProvider{} - ctx := req.Context() - - _, err = ts.API.sendPhoneConfirmation(ctx, req, ts.API.db, u, "123456789", c.otpType, provider, sms_provider.SMSProvider) + _, err = ts.API.sendPhoneConfirmation(req, ts.API.db, u, "123456789", c.otpType, provider, sms_provider.SMSProvider) require.Equal(ts.T(), c.expected, err) u, err = models.FindUserByPhoneAndAudience(ts.API.db, "123456789", ts.Config.JWT.Aud) require.NoError(ts.T(), err) diff --git a/internal/api/reauthenticate.go b/internal/api/reauthenticate.go index 89e434514..cf86d102f 100644 --- a/internal/api/reauthenticate.go +++ b/internal/api/reauthenticate.go @@ -48,7 +48,7 @@ func (a *API) Reauthenticate(w http.ResponseWriter, r *http.Request) error { if terr != nil { return internalServerError("Failed to get SMS provider").WithInternalError(terr) } - mID, err := a.sendPhoneConfirmation(ctx, r, tx, user, phone, phoneReauthenticationOtp, smsProvider, sms_provider.SMSProvider) + mID, err := a.sendPhoneConfirmation(r, tx, user, phone, phoneReauthenticationOtp, smsProvider, sms_provider.SMSProvider) if err != nil { return err } diff --git a/internal/api/resend.go b/internal/api/resend.go index 4093d3405..b9e16df51 100644 --- a/internal/api/resend.go +++ b/internal/api/resend.go @@ -131,7 +131,7 @@ func (a *API) Resend(w http.ResponseWriter, r *http.Request) error { if terr != nil { return terr } - mID, terr := a.sendPhoneConfirmation(ctx, r, tx, user, params.Phone, phoneConfirmationOtp, smsProvider, sms_provider.SMSProvider) + mID, terr := a.sendPhoneConfirmation(r, tx, user, params.Phone, phoneConfirmationOtp, smsProvider, sms_provider.SMSProvider) if terr != nil { return terr } @@ -143,7 +143,7 @@ func (a *API) Resend(w http.ResponseWriter, r *http.Request) error { if terr != nil { return terr } - mID, terr := a.sendPhoneConfirmation(ctx, r, tx, user, user.PhoneChange, phoneChangeVerification, smsProvider, sms_provider.SMSProvider) + mID, terr := a.sendPhoneConfirmation(r, tx, user, user.PhoneChange, phoneChangeVerification, smsProvider, sms_provider.SMSProvider) if terr != nil { return terr } diff --git a/internal/api/signup.go b/internal/api/signup.go index 7584719a8..75c287cff 100644 --- a/internal/api/signup.go +++ b/internal/api/signup.go @@ -274,7 +274,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { if terr != nil { return internalServerError("Unable to get SMS provider").WithInternalError(terr) } - if _, terr := a.sendPhoneConfirmation(ctx, r, tx, user, params.Phone, phoneConfirmationOtp, smsProvider, params.Channel); terr != nil { + if _, terr := a.sendPhoneConfirmation(r, tx, user, params.Phone, phoneConfirmationOtp, smsProvider, params.Channel); terr != nil { return unprocessableEntityError(ErrorCodeSMSSendFailed, "Error sending confirmation sms: %v", terr).WithInternalError(terr) } } diff --git a/internal/api/user.go b/internal/api/user.go index f2ace729e..10fbc93d2 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -223,7 +223,7 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error { if terr != nil { return internalServerError("Error finding SMS provider").WithInternalError(terr) } - if _, terr := a.sendPhoneConfirmation(ctx, r, tx, user, params.Phone, phoneChangeVerification, smsProvider, params.Channel); terr != nil { + if _, terr := a.sendPhoneConfirmation(r, tx, user, params.Phone, phoneChangeVerification, smsProvider, params.Channel); terr != nil { return internalServerError("Error sending phone change otp").WithInternalError(terr) } } From 6c9fbd4bd5623c729906fca7857ab508166a3056 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Thu, 30 May 2024 16:42:33 +0800 Subject: [PATCH 010/118] fix: deadlock issue with timeout middleware write (#1595) --- internal/api/middleware.go | 4 ---- internal/api/saml_test.go | 2 ++ 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 5db550bf8..4338ced3e 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -288,10 +288,6 @@ func (t *timeoutResponseWriter) Write(bytes []byte) (int, error) { t.Lock() defer t.Unlock() - if !t.wroteHeader { - t.WriteHeader(http.StatusOK) - } - return t.buf.Write(bytes) } diff --git a/internal/api/saml_test.go b/internal/api/saml_test.go index e06f660aa..fa6580c9d 100644 --- a/internal/api/saml_test.go +++ b/internal/api/saml_test.go @@ -2,6 +2,7 @@ package api import ( tst "testing" + "time" "encoding/xml" "net/http" @@ -17,6 +18,7 @@ func TestSAMLMetadataWithAPI(t *tst.T) { config.API.ExternalURL = "https://projectref.supabase.co/auth/v1/" config.SAML.Enabled = true config.SAML.PrivateKey = "MIIEowIBAAKCAQEAszrVveMQcSsa0Y+zN1ZFb19cRS0jn4UgIHTprW2tVBmO2PABzjY3XFCfx6vPirMAPWBYpsKmXrvm1tr0A6DZYmA8YmJd937VUQ67fa6DMyppBYTjNgGEkEhmKuszvF3MARsIKCGtZqUrmS7UG4404wYxVppnr2EYm3RGtHlkYsXu20MBqSDXP47bQP+PkJqC3BuNGk3xt5UHl2FSFpTHelkI6lBynw16B+lUT1F96SERNDaMqi/TRsZdGe5mB/29ngC/QBMpEbRBLNRir5iUevKS7Pn4aph9Qjaxx/97siktK210FJT23KjHpgcUfjoQ6BgPBTLtEeQdRyDuc/CgfwIDAQABAoIBAGYDWOEpupQPSsZ4mjMnAYJwrp4ZISuMpEqVAORbhspVeb70bLKonT4IDcmiexCg7cQBcLQKGpPVM4CbQ0RFazXZPMVq470ZDeWDEyhoCfk3bGtdxc1Zc9CDxNMs6FeQs6r1beEZug6weG5J/yRn/qYxQife3qEuDMl+lzfl2EN3HYVOSnBmdt50dxRuX26iW3nqqbMRqYn9OHuJ1LvRRfYeyVKqgC5vgt/6Tf7DAJwGe0dD7q08byHV8DBZ0pnMVU0bYpf1GTgMibgjnLjK//EVWafFHtN+RXcjzGmyJrk3+7ZyPUpzpDjO21kpzUQLrpEkkBRnmg6bwHnSrBr8avECgYEA3pq1PTCAOuLQoIm1CWR9/dhkbJQiKTJevlWV8slXQLR50P0WvI2RdFuSxlWmA4xZej8s4e7iD3MYye6SBsQHygOVGc4efvvEZV8/XTlDdyj7iLVGhnEmu2r7AFKzy8cOvXx0QcLg+zNd7vxZv/8D3Qj9Jje2LjLHKM5n/dZ3RzUCgYEAzh5Lo2anc4WN8faLGt7rPkGQF+7/18ImQE11joHWa3LzAEy7FbeOGpE/vhOv5umq5M/KlWFIRahMEQv4RusieHWI19ZLIP+JwQFxWxS+cPp3xOiGcquSAZnlyVSxZ//dlVgaZq2o2MfrxECcovRlaknl2csyf+HjFFwKlNxHm2MCgYAr//R3BdEy0oZeVRndo2lr9YvUEmu2LOihQpWDCd0fQw0ZDA2kc28eysL2RROte95r1XTvq6IvX5a0w11FzRWlDpQ4J4/LlcQ6LVt+98SoFwew+/PWuyLmxLycUbyMOOpm9eSc4wJJZNvaUzMCSkvfMtmm5jgyZYMMQ9A2Ul/9SQKBgB9mfh9mhBwVPIqgBJETZMMXOdxrjI5SBYHGSyJqpT+5Q0vIZLfqPrvNZOiQFzwWXPJ+tV4Mc/YorW3rZOdo6tdvEGnRO6DLTTEaByrY/io3/gcBZXoSqSuVRmxleqFdWWRnB56c1hwwWLqNHU+1671FhL6pNghFYVK4suP6qu4BAoGBAMk+VipXcIlD67mfGrET/xDqiWWBZtgTzTMjTpODhDY1GZck1eb4CQMP5j5V3gFJ4cSgWDJvnWg8rcz0unz/q4aeMGl1rah5WNDWj1QKWMS6vJhMHM/rqN1WHWR0ZnV83svYgtg0zDnQKlLujqW4JmGXLMU7ur6a+e6lpa1fvLsP" + config.API.MaxRequestDuration = 5 * time.Second require.NoError(t, config.ApplyDefaults()) require.NoError(t, config.SAML.PopulateFields(config.API.ExternalURL)) From 0ef7eb30619d4c365e06a94a79b9cb0333d792da Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Thu, 30 May 2024 12:42:30 +0200 Subject: [PATCH 011/118] fix: call write header in write if not written (#1598) If `Write` is called and `WriteHeader` was not previously called, the `WriteHeader` needs to be set to StatusOK. --- internal/api/middleware.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 4338ced3e..291eba5e8 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -288,6 +288,10 @@ func (t *timeoutResponseWriter) Write(bytes []byte) (int, error) { t.Lock() defer t.Unlock() + if !t.wroteHeader { + t.writeHeaderLocked(http.StatusOK) + } + return t.buf.Write(bytes) } @@ -295,12 +299,17 @@ func (t *timeoutResponseWriter) WriteHeader(statusCode int) { t.Lock() defer t.Unlock() + t.writeHeaderLocked(statusCode) +} + +func (t *timeoutResponseWriter) writeHeaderLocked(statusCode int) { if t.wroteHeader { // ignore multiple calls to WriteHeader // once WriteHeader has been called once, a snapshot of the header map is taken // and saved in snapHeader to be used in finallyWrite return } + t.statusCode = statusCode t.wroteHeader = true t.snapHeader = t.header.Clone() From b3527190560381fafe9ba2fae4adc3b73703024a Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Fri, 31 May 2024 14:41:17 +0200 Subject: [PATCH 012/118] feat: add SAML specific external URL config (#1599) Adds a SAML-specific external URL config, which allows the advertised SAML metadata to be different than the one defined with the API external URL. This is useful in projects that use proxies or custom domains which can be very disruptive with SAML as a new connection with the IDP needs to be established. By configuring `GOTRUE_SAML_EXTERNAL_URL` to the URL before the custom domain was set up, Auth will advertise the correct metadata. --- internal/api/saml.go | 22 ++++++++++++++++++---- internal/conf/saml.go | 9 +++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/internal/api/saml.go b/internal/api/saml.go index d936ff2fa..def4e3912 100644 --- a/internal/api/saml.go +++ b/internal/api/saml.go @@ -14,10 +14,24 @@ import ( // getSAMLServiceProvider generates a new service provider object with the // (optionally) provided descriptor (metadata) for the identity provider. func (a *API) getSAMLServiceProvider(identityProvider *saml.EntityDescriptor, idpInitiated bool) *saml.ServiceProvider { - externalURL, err := url.ParseRequestURI(a.config.API.ExternalURL) - if err != nil { - // this should not fail as a.config should have been validated using #Validate() - panic(err) + var externalURL *url.URL + + if a.config.SAML.ExternalURL != "" { + url, err := url.ParseRequestURI(a.config.SAML.ExternalURL) + if err != nil { + // this should not fail as a.config should have been validated using #Validate() + panic(err) + } + + externalURL = url + } else { + url, err := url.ParseRequestURI(a.config.API.ExternalURL) + if err != nil { + // this should not fail as a.config should have been validated using #Validate() + panic(err) + } + + externalURL = url } if !strings.HasSuffix(externalURL.Path, "/") { diff --git a/internal/conf/saml.go b/internal/conf/saml.go index 88045e941..246868ed6 100644 --- a/internal/conf/saml.go +++ b/internal/conf/saml.go @@ -23,6 +23,8 @@ type SAMLConfiguration struct { RSAPublicKey *rsa.PublicKey `json:"-"` Certificate *x509.Certificate `json:"-"` + ExternalURL string `json:"external_url,omitempty" split_words:"true"` + RateLimitAssertion float64 `default:"15" split_words:"true"` } @@ -54,6 +56,13 @@ func (c *SAMLConfiguration) Validate() error { if c.RelayStateValidityPeriod < 0 { return errors.New("SAML RelayState validity period should be a positive duration") } + + if c.ExternalURL != "" { + _, err := url.ParseRequestURI(c.ExternalURL) + if err != nil { + return err + } + } } return nil From 55409f797bea55068a3fafdddd6cfdb78feba1b4 Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Fri, 31 May 2024 14:42:41 +0200 Subject: [PATCH 013/118] feat: add support for verifying argon2i and argon2id passwords (#1597) Adds support for verifying passwords hashed with Argon2i and Argon2id using Go's [standard library](https://pkg.go.dev/golang.org/x/crypto/argon2). --- go.mod | 6 +- go.sum | 6 ++ internal/crypto/password.go | 113 +++++++++++++++++++++++++++++++ internal/crypto/password_test.go | 21 ++++++ 4 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 internal/crypto/password_test.go diff --git a/go.mod b/go.mod index 2feeaebfb..925307980 100644 --- a/go.mod +++ b/go.mod @@ -29,7 +29,7 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.6.1 github.com/stretchr/testify v1.8.4 - golang.org/x/crypto v0.21.0 + golang.org/x/crypto v0.23.0 golang.org/x/oauth2 v0.7.0 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df ) @@ -135,8 +135,8 @@ require ( golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb golang.org/x/net v0.23.0 // indirect golang.org/x/sync v0.1.0 // indirect - golang.org/x/sys v0.18.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect golang.org/x/time v0.0.0-20220411224347-583f2d630306 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect diff --git a/go.sum b/go.sum index 2e5cb69e0..79981598f 100644 --- a/go.sum +++ b/go.sum @@ -573,6 +573,8 @@ golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -729,6 +731,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -749,6 +753,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.0.0-20160926182426-711ca1cb8763/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/internal/crypto/password.go b/internal/crypto/password.go index d494eaab4..6341e4d2d 100644 --- a/internal/crypto/password.go +++ b/internal/crypto/password.go @@ -2,12 +2,18 @@ package crypto import ( "context" + "crypto/subtle" + "encoding/base64" "errors" "fmt" + "regexp" + "strconv" + "strings" "github.com/supabase/auth/internal/observability" "go.opentelemetry.io/otel/attribute" + "golang.org/x/crypto/argon2" "golang.org/x/crypto/bcrypt" ) @@ -42,10 +48,117 @@ var ( compareHashAndPasswordCompletedCounter = observability.ObtainMetricCounter("gotrue_compare_hash_and_password_completed", "Number of completed CompareHashAndPassword hashing attempts") ) +var ErrArgon2MismatchedHashAndPassword = errors.New("crypto: argon2 hash and password mismatch") + +// argon2HashRegexp https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md#argon2-encoding +var argon2HashRegexp = regexp.MustCompile("^[$](?Pargon2(d|i|id))[$]v=(?P(16|19))[$]m=(?P[0-9]+),t=(?P[0-9]+),p=(?P

[0-9]+)(,keyid=(?P[^,]+))?(,data=(?P[^$]+))?[$](?P[^$]+)[$](?P.+)$") + +func compareHashAndPasswordArgon2(ctx context.Context, hash, password string) error { + submatch := argon2HashRegexp.FindStringSubmatchIndex(hash) + + if submatch == nil { + return errors.New("crypto: incorrect argon2 hash format") + } + + alg := string(argon2HashRegexp.ExpandString(nil, "$alg", hash, submatch)) + v := string(argon2HashRegexp.ExpandString(nil, "$v", hash, submatch)) + m := string(argon2HashRegexp.ExpandString(nil, "$m", hash, submatch)) + t := string(argon2HashRegexp.ExpandString(nil, "$t", hash, submatch)) + p := string(argon2HashRegexp.ExpandString(nil, "$p", hash, submatch)) + keyid := string(argon2HashRegexp.ExpandString(nil, "$keyid", hash, submatch)) + data := string(argon2HashRegexp.ExpandString(nil, "$data", hash, submatch)) + saltB64 := string(argon2HashRegexp.ExpandString(nil, "$salt", hash, submatch)) + hashB64 := string(argon2HashRegexp.ExpandString(nil, "$hash", hash, submatch)) + + if alg != "argon2i" && alg != "argon2id" { + return fmt.Errorf("crypto: argon2 hash uses unsupported algorithm %q only argon2i and argon2id supported", alg) + } + + if v != "19" { + return fmt.Errorf("crypto: argon2 hash uses unsupported version %q only %d is supported", v, argon2.Version) + } + + if data != "" { + return fmt.Errorf("crypto: argon2 hashes with the data parameter not supported") + } + + if keyid != "" { + return fmt.Errorf("crypto: argon2 hashes with the keyid parameter not supported") + } + + memory, err := strconv.ParseUint(m, 10, 32) + if err != nil { + return fmt.Errorf("crypto: argon2 hash has invalid m parameter %q %w", m, err) + } + + time, err := strconv.ParseUint(t, 10, 32) + if err != nil { + return fmt.Errorf("crypto: argon2 hash has invalid t parameter %q %w", t, err) + } + + threads, err := strconv.ParseUint(p, 10, 8) + if err != nil { + return fmt.Errorf("crypto: argon2 hash has invalid p parameter %q %w", p, err) + } + + rawHash, err := base64.RawStdEncoding.DecodeString(hashB64) + if err != nil { + return fmt.Errorf("crypto: argon2 hash has invalid base64 in the hash section %w", err) + } + + salt, err := base64.RawStdEncoding.DecodeString(saltB64) + if err != nil { + return fmt.Errorf("crypto: argon2 hash has invalid base64 in the salt section %w", err) + } + + var match bool + var derivedKey []byte + + attributes := []attribute.KeyValue{ + attribute.String("alg", alg), + attribute.String("v", v), + attribute.Int64("m", int64(memory)), + attribute.Int64("t", int64(time)), + attribute.Int("p", int(threads)), + attribute.Int("len", len(rawHash)), + } + + compareHashAndPasswordSubmittedCounter.Add(ctx, 1, attributes...) + defer func() { + attributes = append(attributes, attribute.Bool( + "match", + match, + )) + + compareHashAndPasswordCompletedCounter.Add(ctx, 1, attributes...) + }() + + switch alg { + case "argon2i": + derivedKey = argon2.Key([]byte(password), salt, uint32(time), uint32(memory)*1024, uint8(threads), uint32(len(rawHash))) + + case "argon2id": + derivedKey = argon2.IDKey([]byte(password), salt, uint32(time), uint32(memory)*1024, uint8(threads), uint32(len(rawHash))) + } + + match = subtle.ConstantTimeCompare(derivedKey, rawHash) == 0 + + if !match { + return ErrArgon2MismatchedHashAndPassword + } + + return nil +} + // CompareHashAndPassword compares the hash and // password, returns nil if equal otherwise an error. Context can be used to // cancel the hashing if the algorithm supports it. func CompareHashAndPassword(ctx context.Context, hash, password string) error { + if strings.HasPrefix(hash, "$argon2") { + return compareHashAndPasswordArgon2(ctx, hash, password) + } + + // assume bcrypt hashCost, err := bcrypt.Cost([]byte(hash)) if err != nil { return err diff --git a/internal/crypto/password_test.go b/internal/crypto/password_test.go new file mode 100644 index 000000000..c3091975b --- /dev/null +++ b/internal/crypto/password_test.go @@ -0,0 +1,21 @@ +package crypto + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestArgon2(t *testing.T) { + // all of these hash the `test` string with various parameters + + examples := []string{ + "$argon2i$v=19$m=16,t=2,p=1$bGJRWThNOHJJTVBSdHl2dQ$NfEnUOuUpb7F2fQkgFUG4g", + "$argon2id$v=19$m=32,t=3,p=2$SFVpOWJ0eXhjRzVkdGN1RQ$RXnb8rh7LaDcn07xsssqqulZYXOM/EUCEFMVcAcyYVk", + } + + for _, example := range examples { + assert.NoError(t, CompareHashAndPassword(context.Background(), example, "test")) + } +} From 526268311844467664e89c8329e5aaee817dbbaf Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Mon, 3 Jun 2024 17:43:24 +0800 Subject: [PATCH 014/118] fix: improve token OIDC logging (#1606) ## What kind of change does this PR introduce? * Currently, when the "Unacceptable audience in id_token" error is returned, it doesn't log the audience claim from the id token, which makes it hard to debug. The audience claim from the id token is now logged as well when this error is returned. * Adds a basic test for the generic id token oidc `getProvider()` method, since we currently have 0 coverage for this file * The test also uncovered a possible nil pointer panic in the case of the generic OIDC provider being returned since in the generic case, the `oauthConfig` will be nil. Rather than returning the `oauthConfig`, we only need to return the `skipNonceCheck` property since we only check for that. --- internal/api/token_oidc.go | 25 +++++++----- internal/api/token_oidc_test.go | 69 +++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 10 deletions(-) create mode 100644 internal/api/token_oidc_test.go diff --git a/internal/api/token_oidc.go b/internal/api/token_oidc.go index 1c728bf86..7b0a8155b 100644 --- a/internal/api/token_oidc.go +++ b/internal/api/token_oidc.go @@ -24,7 +24,7 @@ type IdTokenGrantParams struct { Issuer string `json:"issuer"` } -func (p *IdTokenGrantParams) getProvider(ctx context.Context, config *conf.GlobalConfiguration, r *http.Request) (*oidc.Provider, *conf.OAuthProviderConfiguration, string, []string, error) { +func (p *IdTokenGrantParams) getProvider(ctx context.Context, config *conf.GlobalConfiguration, r *http.Request) (*oidc.Provider, bool, string, []string, error) { log := observability.GetLogEntry(r).Entry var cfg *conf.OAuthProviderConfiguration @@ -54,7 +54,7 @@ func (p *IdTokenGrantParams) getProvider(ctx context.Context, config *conf.Globa if issuer == "" || !provider.IsAzureIssuer(issuer) { detectedIssuer, err := provider.DetectAzureIDTokenIssuer(ctx, p.IdToken) if err != nil { - return nil, nil, "", nil, badRequestError(ErrorCodeValidationFailed, "Unable to detect issuer in ID token for Azure provider").WithInternalError(err) + return nil, false, "", nil, badRequestError(ErrorCodeValidationFailed, "Unable to detect issuer in ID token for Azure provider").WithInternalError(err) } issuer = detectedIssuer } @@ -95,20 +95,25 @@ func (p *IdTokenGrantParams) getProvider(ctx context.Context, config *conf.Globa } if !allowed { - return nil, nil, "", nil, badRequestError(ErrorCodeValidationFailed, fmt.Sprintf("Custom OIDC provider %q not allowed", p.Provider)) + return nil, false, "", nil, badRequestError(ErrorCodeValidationFailed, fmt.Sprintf("Custom OIDC provider %q not allowed", p.Provider)) + } + + cfg = &conf.OAuthProviderConfiguration{ + Enabled: true, + SkipNonceCheck: false, } } - if cfg != nil && !cfg.Enabled { - return nil, nil, "", nil, badRequestError(ErrorCodeProviderDisabled, fmt.Sprintf("Provider (issuer %q) is not enabled", issuer)) + if !cfg.Enabled { + return nil, false, "", nil, badRequestError(ErrorCodeProviderDisabled, fmt.Sprintf("Provider (issuer %q) is not enabled", issuer)) } oidcProvider, err := oidc.NewProvider(ctx, issuer) if err != nil { - return nil, nil, "", nil, err + return nil, false, "", nil, err } - return oidcProvider, cfg, providerType, acceptableClientIDs, nil + return oidcProvider, cfg.SkipNonceCheck, providerType, acceptableClientIDs, nil } // IdTokenGrant implements the id_token grant type flow @@ -131,7 +136,7 @@ func (a *API) IdTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.R return oauthError("invalid request", "provider or client_id and issuer required") } - oidcProvider, oauthConfig, providerType, acceptableClientIDs, err := params.getProvider(ctx, config, r) + oidcProvider, skipNonceCheck, providerType, acceptableClientIDs, err := params.getProvider(ctx, config, r) if err != nil { return err } @@ -179,10 +184,10 @@ func (a *API) IdTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.R } if !correctAudience { - return oauthError("invalid request", "Unacceptable audience in id_token") + return oauthError("invalid request", fmt.Sprintf("Unacceptable audience in id_token: %v", idToken.Audience)) } - if !oauthConfig.SkipNonceCheck { + if !skipNonceCheck { tokenHasNonce := idToken.Nonce != "" paramsHasNonce := params.Nonce != "" diff --git a/internal/api/token_oidc_test.go b/internal/api/token_oidc_test.go new file mode 100644 index 000000000..1eab99ebd --- /dev/null +++ b/internal/api/token_oidc_test.go @@ -0,0 +1,69 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "github.com/supabase/auth/internal/conf" +) + +type TokenOIDCTestSuite struct { + suite.Suite + API *API + Config *conf.GlobalConfiguration +} + +func TestTokenOIDC(t *testing.T) { + api, config, err := setupAPIForTest() + require.NoError(t, err) + + ts := &TokenOIDCTestSuite{ + API: api, + Config: config, + } + defer api.db.Close() + + suite.Run(t, ts) +} + +func SetupTestOIDCProvider(ts *TokenOIDCTestSuite) *httptest.Server { + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/openid-configuration": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"issuer":"` + server.URL + `","authorization_endpoint":"` + server.URL + `/authorize","token_endpoint":"` + server.URL + `/token","jwks_uri":"` + server.URL + `/jwks"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + return server +} + +func (ts *TokenOIDCTestSuite) TestGetProvider() { + server := SetupTestOIDCProvider(ts) + defer server.Close() + + params := &IdTokenGrantParams{ + IdToken: "test-id-token", + AccessToken: "test-access-token", + Nonce: "test-nonce", + Provider: server.URL, + ClientID: "test-client-id", + Issuer: server.URL, + } + + ts.Config.External.AllowedIdTokenIssuers = []string{server.URL} + + req := httptest.NewRequest(http.MethodPost, "http://localhost", nil) + oidcProvider, skipNonceCheck, providerType, acceptableClientIds, err := params.getProvider(context.Background(), ts.Config, req) + require.NoError(ts.T(), err) + require.NotNil(ts.T(), oidcProvider) + require.False(ts.T(), skipNonceCheck) + require.Equal(ts.T(), params.Provider, providerType) + require.NotEmpty(ts.T(), acceptableClientIds) +} From 53e223abdf29f4abcad13f99baf00daedcb00c3f Mon Sep 17 00:00:00 2001 From: Rodrigo Mansueli Date: Tue, 4 Jun 2024 02:02:54 -0300 Subject: [PATCH 015/118] feat: make the email client explicity set the format to be HTML (#1149) ## What kind of change does this PR introduce? This makes the emails to be explicitly set to be HTML formatted instead of plain text which helper older clients that cannot figure this out themselves. ## What is the current behavior? Email messages are sent as plain/text. ## What is the new behavior? Feel free to include screenshots if it includes visual changes. ## Additional context Add any other context or screenshots. --- internal/mailer/mailer.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go index 02dc9898b..ff19239d8 100644 --- a/internal/mailer/mailer.go +++ b/internal/mailer/mailer.go @@ -46,8 +46,12 @@ type EmailData struct { func NewMailer(globalConfig *conf.GlobalConfiguration) Mailer { mail := gomail.NewMessage() - // so that messages are not grouped under each other - mail.SetHeader("Message-ID", fmt.Sprintf("<%s@gotrue-mailer>", uuid.Must(uuid.NewV4()).String())) + mail.SetHeaders(map[string][]string{ + // Make the emails explicitly set to be HTML formatted (to cover older email clients) + "Content-Type": {"text/html; charset=utf-8"}, + // so that messages are not grouped under each other + "Message-ID": {fmt.Sprintf("<%s@gotrue-mailer>", uuid.Must(uuid.NewV4()).String())}, + }) from := mail.FormatAddress(globalConfig.SMTP.AdminEmail, globalConfig.SMTP.SenderName) u, _ := url.ParseRequestURI(globalConfig.API.ExternalURL) From e3ebffb10662d4b369478b7da8235ef127e1f7d4 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Tue, 4 Jun 2024 13:17:45 +0800 Subject: [PATCH 016/118] chore: bump alpine and go versions (#1607) ## What kind of change does this PR introduce? * Bump alpine version to v3.20.0 to resolve an openssl vuln * Bump go to v1.22 --- .github/workflows/test.yml | 4 ++-- Dockerfile | 4 ++-- go.mod | 4 +--- go.sum | 5 ----- 4 files changed, 5 insertions(+), 12 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bc3baf1fe..779e8d697 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,13 +5,13 @@ on: push: branches: - master - tags: ['*'] + tags: ["*"] jobs: test: strategy: matrix: - go-version: [1.21.x] + go-version: [1.22.x] runs-on: ubuntu-20.04 services: postgres: diff --git a/Dockerfile b/Dockerfile index 7d39cb1fc..18e2f8c59 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.21-alpine as build +FROM golang:1.22.3-alpine3.20 as build ENV GO111MODULE=on ENV CGO_ENABLED=0 ENV GOOS=linux @@ -17,7 +17,7 @@ COPY . /go/src/github.com/supabase/auth # Make sure you change the RELEASE_VERSION value before publishing an image. RUN RELEASE_VERSION=unspecified make build -FROM alpine:3.17 +FROM alpine:3.20 RUN adduser -D -u 1000 supabase RUN apk add --no-cache ca-certificates diff --git a/go.mod b/go.mod index 925307980..02c99607d 100644 --- a/go.mod +++ b/go.mod @@ -147,6 +147,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -go 1.21.0 - -toolchain go1.21.6 +go 1.22.3 diff --git a/go.sum b/go.sum index 79981598f..20fa41a75 100644 --- a/go.sum +++ b/go.sum @@ -571,8 +571,6 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -729,8 +727,6 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= @@ -751,7 +747,6 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= From cbcab16cfe5d20e8435e9f11eff569bd9a73a590 Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Tue, 4 Jun 2024 15:31:47 +0200 Subject: [PATCH 017/118] ci: upgrade local dockerfile go version (#1608) Local Dockerfile needs to use go 1.22.3 too. --- Dockerfile.dev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.dev b/Dockerfile.dev index 1ccac5b31..d2733aa18 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM golang:1.21-alpine +FROM golang:1.22.3-alpine3.20 ENV GO111MODULE=on ENV CGO_ENABLED=0 ENV GOOS=linux From 5894d9e41e7681512a9904ad47082a705e948c98 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Tue, 4 Jun 2024 16:29:35 +0200 Subject: [PATCH 018/118] fix: update contributing to use v1.22 (#1609) ## What kind of change does this PR introduce? as per title --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c188c173..96091c145 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,11 +59,11 @@ Therefore, to contribute to Auth you will need to install these tools. ### Install Tools -- Install [Go](https://go.dev) 1.21 +- Install [Go](https://go.dev) 1.22 ```terminal # Via Homebrew on OSX -brew install go@1.21 +brew install go@1.22 # Set the GOPATH environment variable in the ~/.zshrc file export GOPATH="$HOME/go" From dd16b959461fbb0e84a438d35dc9d9f2bf323d6d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 6 Jun 2024 11:43:46 +0800 Subject: [PATCH 019/118] chore(master): release 2.153.0 (#1596) :robot: I have created a release *beep* *boop* --- ## [2.153.0](https://github.com/supabase/auth/compare/v2.152.0...v2.153.0) (2024-06-04) ### Features * add SAML specific external URL config ([#1599](https://github.com/supabase/auth/issues/1599)) ([b352719](https://github.com/supabase/auth/commit/b3527190560381fafe9ba2fae4adc3b73703024a)) * add support for verifying argon2i and argon2id passwords ([#1597](https://github.com/supabase/auth/issues/1597)) ([55409f7](https://github.com/supabase/auth/commit/55409f797bea55068a3fafdddd6cfdb78feba1b4)) * make the email client explicity set the format to be HTML ([#1149](https://github.com/supabase/auth/issues/1149)) ([53e223a](https://github.com/supabase/auth/commit/53e223abdf29f4abcad13f99baf00daedcb00c3f)) ### Bug Fixes * call write header in write if not written ([#1598](https://github.com/supabase/auth/issues/1598)) ([0ef7eb3](https://github.com/supabase/auth/commit/0ef7eb30619d4c365e06a94a79b9cb0333d792da)) * deadlock issue with timeout middleware write ([#1595](https://github.com/supabase/auth/issues/1595)) ([6c9fbd4](https://github.com/supabase/auth/commit/6c9fbd4bd5623c729906fca7857ab508166a3056)) * improve token OIDC logging ([#1606](https://github.com/supabase/auth/issues/1606)) ([5262683](https://github.com/supabase/auth/commit/526268311844467664e89c8329e5aaee817dbbaf)) * update contributing to use v1.22 ([#1609](https://github.com/supabase/auth/issues/1609)) ([5894d9e](https://github.com/supabase/auth/commit/5894d9e41e7681512a9904ad47082a705e948c98)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dd17bbc2..4315f21cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [2.153.0](https://github.com/supabase/auth/compare/v2.152.0...v2.153.0) (2024-06-04) + + +### Features + +* add SAML specific external URL config ([#1599](https://github.com/supabase/auth/issues/1599)) ([b352719](https://github.com/supabase/auth/commit/b3527190560381fafe9ba2fae4adc3b73703024a)) +* add support for verifying argon2i and argon2id passwords ([#1597](https://github.com/supabase/auth/issues/1597)) ([55409f7](https://github.com/supabase/auth/commit/55409f797bea55068a3fafdddd6cfdb78feba1b4)) +* make the email client explicity set the format to be HTML ([#1149](https://github.com/supabase/auth/issues/1149)) ([53e223a](https://github.com/supabase/auth/commit/53e223abdf29f4abcad13f99baf00daedcb00c3f)) + + +### Bug Fixes + +* call write header in write if not written ([#1598](https://github.com/supabase/auth/issues/1598)) ([0ef7eb3](https://github.com/supabase/auth/commit/0ef7eb30619d4c365e06a94a79b9cb0333d792da)) +* deadlock issue with timeout middleware write ([#1595](https://github.com/supabase/auth/issues/1595)) ([6c9fbd4](https://github.com/supabase/auth/commit/6c9fbd4bd5623c729906fca7857ab508166a3056)) +* improve token OIDC logging ([#1606](https://github.com/supabase/auth/issues/1606)) ([5262683](https://github.com/supabase/auth/commit/526268311844467664e89c8329e5aaee817dbbaf)) +* update contributing to use v1.22 ([#1609](https://github.com/supabase/auth/issues/1609)) ([5894d9e](https://github.com/supabase/auth/commit/5894d9e41e7681512a9904ad47082a705e948c98)) + ## [2.152.0](https://github.com/supabase/auth/compare/v2.151.0...v2.152.0) (2024-05-22) From 4f9994bf792c3887f2f45910b11a9c19ee3a896b Mon Sep 17 00:00:00 2001 From: Will Matz <39891237+william-matz@users.noreply.github.com> Date: Thu, 6 Jun 2024 09:20:36 -0400 Subject: [PATCH 020/118] feat: use largest avatar from spotify instead (#1210) Extracts the largest avatar returned by Spotify, instead of the first avatar in the list. Fixes: - #1209 --- internal/api/provider/spotify.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/api/provider/spotify.go b/internal/api/provider/spotify.go index 57fe8c0f8..ebd226fc6 100644 --- a/internal/api/provider/spotify.go +++ b/internal/api/provider/spotify.go @@ -86,8 +86,17 @@ func (g spotifyProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*U var avatarURL string + // Spotify returns a list of avatars, we want to use the largest one if len(u.Avatars) >= 1 { - avatarURL = u.Avatars[0].Url + largestAvatar := u.Avatars[0] + + for _, avatar := range u.Avatars { + if avatar.Height * avatar.Width > largestAvatar.Height * largestAvatar.Width { + largestAvatar = avatar + } + } + + avatarURL = largestAvatar.Url } data.Metadata = &Claims{ From f9c13c0ad5c556bede49d3e0f6e5f58ca26161c3 Mon Sep 17 00:00:00 2001 From: Lasha <72510037+LashaJini@users.noreply.github.com> Date: Thu, 6 Jun 2024 17:55:37 +0400 Subject: [PATCH 021/118] feat: add max length check for email (#1508) ## What kind of change does this PR introduce? feature: add max length check for email. ## What is the current behavior? Currently, email length is only checked on db side. Email has max length 255 characters, when user sends (>255 characters) large email to `/admin/users` endpoint, db is doing unnecessary queries. ![Screenshot from 2024-03-30 02-40-54](https://github.com/supabase/auth/assets/72510037/10a36b08-5112-4737-9c3a-b9e01c7ccc10) ## What is the new behavior? Code returns early if user enters large email. There will be no db queries. ![Screenshot from 2024-03-30 02-44-31](https://github.com/supabase/auth/assets/72510037/735a4e79-561f-412a-b536-6dac3aa6f339) --- internal/api/mail.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/api/mail.go b/internal/api/mail.go index 86c31f56a..30f358ad2 100644 --- a/internal/api/mail.go +++ b/internal/api/mail.go @@ -550,6 +550,9 @@ func validateEmail(email string) (string, error) { if email == "" { return "", badRequestError(ErrorCodeValidationFailed, "An email address is required") } + if len(email) > 255 { + return "", badRequestError(ErrorCodeValidationFailed, "An email address is too long") + } if err := checkmail.ValidateFormat(email); err != nil { return "", badRequestError(ErrorCodeValidationFailed, "Unable to validate email address: "+err.Error()) } From fa90764847f625d11bc52d3b3f3a0c40c6548eb0 Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Thu, 6 Jun 2024 18:41:16 +0200 Subject: [PATCH 022/118] ci: re-format files (#1611) Re-formats the files from PR #1209. --- internal/api/provider/spotify.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/api/provider/spotify.go b/internal/api/provider/spotify.go index ebd226fc6..e6d2f383c 100644 --- a/internal/api/provider/spotify.go +++ b/internal/api/provider/spotify.go @@ -89,13 +89,13 @@ func (g spotifyProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*U // Spotify returns a list of avatars, we want to use the largest one if len(u.Avatars) >= 1 { largestAvatar := u.Avatars[0] - + for _, avatar := range u.Avatars { - if avatar.Height * avatar.Width > largestAvatar.Height * largestAvatar.Width { + if avatar.Height*avatar.Width > largestAvatar.Height*largestAvatar.Width { largestAvatar = avatar } } - + avatarURL = largestAvatar.Url } From cdd13adec02eb0c9401bc55a2915c1005d50dea1 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Fri, 7 Jun 2024 13:54:21 +0200 Subject: [PATCH 023/118] feat: upgrade otel to v1.26 (#1585) ## What kind of change does this PR introduce? Spiritual successor to #1305 where we attempt to bump the OTEL version from v1.10.0 to v1.26.0 which has a few breaking changes ## How this was tested We used a local setup with honeycomb with tracing and metrics enabled. We then triggered - a cleanup to test `gotrue_cleanup_affected_rows` - a few password sign ins to test `gotrue_compare_hash_and_password_submitted` - Checked if `gotrue_running` was present - Checked that DB traces were still present DB Trace: CleanShot 2024-05-23 at 20 46 02@2x Metrics: CleanShot 2024-05-23 at 20 41 51@2x --- go.mod | 77 ++- go.sum | 575 +++------------------- internal/api/middleware.go | 3 +- internal/api/opentelemetry-tracer_test.go | 12 +- internal/crypto/password.go | 13 +- internal/models/cleanup.go | 28 +- internal/observability/metrics.go | 136 +++-- internal/observability/request-tracing.go | 28 +- 8 files changed, 219 insertions(+), 653 deletions(-) diff --git a/go.mod b/go.mod index 02c99607d..3911c8a69 100644 --- a/go.mod +++ b/go.mod @@ -28,9 +28,9 @@ require ( github.com/sethvargo/go-password v0.2.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.6.1 - github.com/stretchr/testify v1.8.4 - golang.org/x/crypto v0.23.0 - golang.org/x/oauth2 v0.7.0 + github.com/stretchr/testify v1.9.0 + golang.org/x/crypto v0.21.0 + golang.org/x/oauth2 v0.17.0 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df ) @@ -40,25 +40,23 @@ require ( github.com/gobuffalo/nulls v0.4.2 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + golang.org/x/mod v0.9.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect ) require ( - github.com/XSAM/otelsql v0.16.0 + github.com/XSAM/otelsql v0.26.0 github.com/bombsimon/logrusr/v3 v3.0.0 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.36.0 - go.opentelemetry.io/contrib/instrumentation/runtime v0.35.0 - go.opentelemetry.io/otel v1.10.0 - go.opentelemetry.io/otel/exporters/otlp/otlpmetric v0.31.0 - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.31.0 - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.31.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.10.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.10.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.10.0 - go.opentelemetry.io/otel/exporters/prometheus v0.31.0 - go.opentelemetry.io/otel/metric v0.32.0 - go.opentelemetry.io/otel/sdk v1.10.0 - go.opentelemetry.io/otel/sdk/metric v0.31.0 - go.opentelemetry.io/otel/trace v1.10.0 + go.opentelemetry.io/contrib/instrumentation/runtime v0.45.0 + go.opentelemetry.io/otel v1.26.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 + go.opentelemetry.io/otel/metric v1.26.0 + go.opentelemetry.io/otel/sdk v1.26.0 + go.opentelemetry.io/otel/sdk/metric v1.26.0 + go.opentelemetry.io/otel/trace v1.26.0 gopkg.in/h2non/gock.v1 v1.1.2 ) @@ -74,6 +72,10 @@ require ( github.com/supabase/hibp v0.0.0-20231124125943-d225752ae869 github.com/supabase/mailme v0.2.0 github.com/xeipuuv/gojsonschema v1.2.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.26.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.26.0 + go.opentelemetry.io/otel/exporters/prometheus v0.48.0 ) require ( @@ -81,13 +83,13 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/beevik/etree v1.1.0 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/cenkalti/backoff/v4 v4.1.3 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/crewjam/httperr v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fatih/color v1.13.0 // indirect - github.com/felixge/httpsnoop v1.0.3 // indirect - github.com/go-logr/logr v1.2.3 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/gobuffalo/envy v1.10.2 // indirect @@ -98,10 +100,10 @@ require ( github.com/gobuffalo/plush/v4 v4.1.18 // indirect github.com/gobuffalo/tags/v3 v3.1.4 // indirect github.com/golang-jwt/jwt/v4 v4.4.3 // indirect - github.com/golang/protobuf v1.5.3 // indirect - github.com/google/uuid v1.3.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 // indirect github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect github.com/inconshreveable/mousetrap v1.0.1 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect @@ -116,31 +118,28 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.16 // indirect github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_golang v1.12.2 // indirect - github.com/prometheus/client_model v0.2.0 // indirect - github.com/prometheus/common v0.32.1 // indirect - github.com/prometheus/procfs v0.7.3 // indirect - github.com/rogpeppe/go-internal v1.9.0 // indirect + github.com/prometheus/client_golang v1.19.0 + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.48.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/russellhaering/goxmldsig v1.3.0 // indirect github.com/sergi/go-diff v1.2.0 // indirect github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d // indirect github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/stretchr/objx v0.5.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.10.0 // indirect - go.opentelemetry.io/proto/otlp v0.19.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + go.opentelemetry.io/proto/otlp v1.2.0 // indirect golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb golang.org/x/net v0.23.0 // indirect - golang.org/x/sync v0.1.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect + golang.org/x/sync v0.6.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.0.0-20220411224347-583f2d630306 // indirect - google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect - google.golang.org/grpc v1.56.3 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/grpc v1.63.2 // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 20fa41a75..fdfb47a9b 100644 --- a/go.sum +++ b/go.sum @@ -1,56 +1,15 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/XSAM/otelsql v0.16.0 h1:pOqeHGYCJmP5ezW0OvAGA+zzdgW/sV8nLHTxVnPgiXU= -github.com/XSAM/otelsql v0.16.0/go.mod h1:DpO7NCSeqQdr23nU0yapjR3jGx2OdO/PihPRG+/PV0Y= +github.com/XSAM/otelsql v0.26.0 h1:UhAGVBD34Ctbh2aYcm/JAdL+6T6ybrP+YMWYkHqCdmo= +github.com/XSAM/otelsql v0.26.0/go.mod h1:5ciw61eMSh+RtTPN8spvPEPLJpAErZw8mFFPNfYiaxA= github.com/aaronarduino/goqrsvg v0.0.0-20220419053939-17e843f1dd40 h1:uz4N2yHL4MF8vZX+36n+tcxeUf8D/gL4aJkyouhDw4A= github.com/aaronarduino/goqrsvg v0.0.0-20220419053939-17e843f1dd40/go.mod h1:dytw+5qs+pdi61fO/S4OmXR7AuEq/HvNCuG03KxQHT4= github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= @@ -59,10 +18,6 @@ github.com/badoux/checkmail v0.0.0-20170203135005-d0a759655d62 h1:vMqcPzLT1/mbYe github.com/badoux/checkmail v0.0.0-20170203135005-d0a759655d62/go.mod h1:r5ZalvRl3tXevRNJkwIB6DC4DD3DMjIlY9NEU1XGoaQ= github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= -github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= -github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.10.0 h1:ePXTeiPEazB5+opbv5fr8umg2R/1NlzgDsyepwsSr88= @@ -74,25 +29,10 @@ github.com/bombsimon/logrusr/v3 v3.0.0 h1:tcAoLfuAhKP9npBxWzSdpsvKPQt1XV02nSf2lZ github.com/bombsimon/logrusr/v3 v3.0.0/go.mod h1:PksPPgSFEL2I52pla2glgCyyd2OqOHAnFF5E+g8Ixco= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4= -github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= -github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/coreos/go-oidc/v3 v3.6.0 h1:AKVxfYw1Gmkn/w96z0DbT/B/xFnzTd3MkZvWLjF4n/o= @@ -113,36 +53,21 @@ github.com/deepmap/oapi-codegen v1.12.4 h1:pPmn6qI9MuOtCz82WY2Xaw46EQjgvxednXXrP github.com/deepmap/oapi-codegen v1.12.4/go.mod h1:3lgHGMu6myQ2vqbbTXH2H1o4eXFTGnFiDaOaKKl5yas= github.com/didip/tollbooth/v5 v5.1.1 h1:QpKFg56jsbNuQ6FFj++Z1gn2fbBsvAc1ZPLUaDOYW5k= github.com/didip/tollbooth/v5 v5.1.1/go.mod h1:d9rzwOULswrD3YIrAQmP3bfjxab32Df4IaO6+D25l9g= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= -github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= -github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= -github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= -github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= @@ -182,83 +107,27 @@ github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRx github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI= github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU= github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= -github.com/golang/glog v1.1.0 h1:/d3pCKDPWNnvIWe0vVUpNP32qc8U3PDVxySP/y360qE= -github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0 h1:BZHcxBETFHIdVyhyEfOvn/RdU/QGdLI4y34qQGjGWO0= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 h1:/c3QmbOGMGTOumP2iT/rCwB7b0QDGLKzqOmktBjT+Is= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= @@ -321,16 +190,7 @@ github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= @@ -338,8 +198,6 @@ github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= @@ -376,29 +234,19 @@ github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= -github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/microcosm-cc/bluemonday v1.0.20/go.mod h1:yfBmMi8mxvaZut3Yytv+jTXRY8mxyjJ0/kQBTElld50= github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58= github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs= github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 h1:j2kD3MT1z4PXCiUllUJF9mWUESr9TWKS7iEKsQ/IipM= github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450/go.mod h1:skjdDftzkFALcuGzYSklqYd8gvat6F1gZJ4YPVbkZpM= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= github.com/patrickmn/go-cache v0.0.0-20170418232947-7ac151875ffb/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -406,34 +254,20 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/otp v1.3.0 h1:oJV/SkzR33anKXwQU3Of42rL4wbrffP4uvUf1SvS5Xs= github.com/pquerna/otp v1.3.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= -github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_golang v1.12.2 h1:51L9cDoUHVrXx4zWYlcLQIZ+d+VXHgqnYKkIuq4g/34= -github.com/prometheus/client_golang v1.12.2/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= -github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= -github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= -github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4= -github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU= -github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= +github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= +github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/rs/cors v1.9.0 h1:l9HGsTsHJcvW14Nk7J9KFz8bzeAWXn3CG6bgt7LsrAE= github.com/rs/cors v1.9.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= @@ -452,10 +286,8 @@ github.com/sethvargo/go-password v0.2.0/go.mod h1:Ym4Mr9JXLBycr02MFuVQ/0JHidNetS github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= @@ -463,7 +295,6 @@ github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d h1:yKm7XZV6j9 github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e h1:qpG93cPwA5f7s/ZPBJnGOYQNK/vKsaDaseuKT5Asee8= github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -475,8 +306,9 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -486,8 +318,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/supabase/hibp v0.0.0-20231124125943-d225752ae869 h1:VDuRtwen5Z7QQ5ctuHUse4wAv/JozkKZkdic5vUV4Lg= github.com/supabase/hibp v0.0.0-20231124125943-d225752ae869/go.mod h1:eHX5nlSMSnyPjUrbYzeqrA8snCe2SKyfizKjU3dkfOw= github.com/supabase/mailme v0.2.0 h1:39LHZ4+YOeqoN4MiuncPBC3JarExAa0flmokM24qHNU= @@ -500,56 +332,43 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.36.0 h1:qZ3KzA4qPzLBDtQyPk4ydjlg8zvXbNysnFHaVMKJbVo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.36.0/go.mod h1:14Oo79mRwusSI02L0EfG3Gp1uF3+1wSL+D4zDysxyqs= -go.opentelemetry.io/contrib/instrumentation/runtime v0.35.0 h1:VvinIr6FpeUnW0ATkV+NBPT+bMkytiqRuA6Hj+t4Ylc= -go.opentelemetry.io/contrib/instrumentation/runtime v0.35.0/go.mod h1:7iORGR19PYmn3eq0kEI4qAVdzeXcZiqYyS2pGUnVXXU= -go.opentelemetry.io/otel v1.10.0 h1:Y7DTJMR6zs1xkS/upamJYk0SxxN4C9AqRd77jmZnyY4= -go.opentelemetry.io/otel v1.10.0/go.mod h1:NbvWjCthWHKBEUMpf0/v8ZRZlni86PpGFEMA9pnQSnQ= -go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.10.0 h1:TaB+1rQhddO1sF71MpZOZAuSPW1klK2M8XxfrBMfK7Y= -go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.10.0/go.mod h1:78XhIg8Ht9vR4tbLNUhXsiOnE2HOuSeKAiAcoVQEpOY= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric v0.31.0 h1:H0+xwv4shKw0gfj/ZqR13qO2N/dBQogB1OcRjJjV39Y= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric v0.31.0/go.mod h1:nkenGD8vcvs0uN6WhR90ZVHQlgDsRmXicnNadMnk+XQ= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.31.0 h1:BaQ2xM5cPmldVCMvbLoy5tcLUhXCtIhItDYBNw83B7Y= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.31.0/go.mod h1:VRr8tlXQEsTdesDCh0qBe2iKDWhpi3ZqDYw6VlZ8MhI= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.31.0 h1:MuEG0gG27QZQrqhNl0f7vQ5Nl03OQfFeDAqWkGt+1zM= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.31.0/go.mod h1:52qtPFDDaa0FaSyyzPnxWMehx2SZv0xuobTlNEZA2JA= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.10.0 h1:pDDYmo0QadUPal5fwXoY1pmMpFcdyhXOmL5drCrI3vU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.10.0/go.mod h1:Krqnjl22jUJ0HgMzw5eveuCvFDXY4nSYb4F8t5gdrag= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.10.0 h1:KtiUEhQmj/Pa874bVYKGNVdq8NPKiacPbaRRtgXi+t4= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.10.0/go.mod h1:OfUCyyIiDvNXHWpcWgbF+MWvqPZiNa3YDEnivcnYsV0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.10.0 h1:S8DedULB3gp93Rh+9Z+7NTEv+6Id/KYS7LDyipZ9iCE= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.10.0/go.mod h1:5WV40MLWwvWlGP7Xm8g3pMcg0pKOUY609qxJn8y7LmM= -go.opentelemetry.io/otel/exporters/prometheus v0.31.0 h1:jwtnOGBM8dIty5AVZ+9ZCzZexCea3aVKmUfZAQcHqxs= -go.opentelemetry.io/otel/exporters/prometheus v0.31.0/go.mod h1:QarXIB8L79IwIPoNgG3A6zNvBgVmcppeFogV1d8612s= -go.opentelemetry.io/otel/metric v0.32.0 h1:lh5KMDB8xlMM4kwE38vlZJ3rZeiWrjw3As1vclfC01k= -go.opentelemetry.io/otel/metric v0.32.0/go.mod h1:PVDNTt297p8ehm949jsIzd+Z2bIZJYQQG/uuHTeWFHY= -go.opentelemetry.io/otel/sdk v1.10.0 h1:jZ6K7sVn04kk/3DNUdJ4mqRlGDiXAVuIG+MMENpTNdY= -go.opentelemetry.io/otel/sdk v1.10.0/go.mod h1:vO06iKzD5baltJz1zarxMCNHFpUlUiOy4s65ECtn6kE= -go.opentelemetry.io/otel/sdk/metric v0.31.0 h1:2sZx4R43ZMhJdteKAlKoHvRgrMp53V1aRxvEf5lCq8Q= -go.opentelemetry.io/otel/sdk/metric v0.31.0/go.mod h1:fl0SmNnX9mN9xgU6OLYLMBMrNAsaZQi7qBwprwO3abk= -go.opentelemetry.io/otel/trace v1.10.0 h1:npQMbR8o7mum8uF95yFbOEJffhs1sbCOfDh8zAJiH5E= -go.opentelemetry.io/otel/trace v1.10.0/go.mod h1:Sij3YYczqAdz+EhmGhE6TpTxUO5/F/AzrK+kxfGqySM= -go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= -go.opentelemetry.io/proto/otlp v0.19.0 h1:IVN6GR+mhC4s5yfcTbmzHYODqvWAp3ZedA2SJPI1Nnw= -go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 h1:Xs2Ncz0gNihqu9iosIZ5SkBbWo5T8JhhLJFMQL1qmLI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0/go.mod h1:vy+2G/6NvVMpwGX/NyLqcC41fxepnuKHk16E6IZUcJc= +go.opentelemetry.io/contrib/instrumentation/runtime v0.45.0 h1:2JydY5UiDpqvj2p7sO9bgHuhTy4hgTZ0ymehdq/Ob0Q= +go.opentelemetry.io/contrib/instrumentation/runtime v0.45.0/go.mod h1:ch3a5QxOqVWxas4CzjCFFOOQe+7HgAXC/N1oVxS9DK4= +go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs= +go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.26.0 h1:+hm+I+KigBy3M24/h1p/NHkUx/evbLH0PNcjpMyCHc4= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.26.0/go.mod h1:NjC8142mLvvNT6biDpaMjyz78kyEHIwAJlSX0N9P5KI= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.26.0 h1:HGZWGmCVRCVyAs2GQaiHQPbDHo+ObFWeUEOd+zDnp64= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.26.0/go.mod h1:SaH+v38LSCHddyk7RGlU9uZyQoRrKao6IBnJw6Kbn+c= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0 h1:3d+S281UTjM+AbF31XSOYn1qXn3BgIdWl8HNEpx08Jk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0/go.mod h1:0+KuTDyKL4gjKCF75pHOX4wuzYDUZYfAQdSu43o+Z2I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= +go.opentelemetry.io/otel/exporters/prometheus v0.48.0 h1:sBQe3VNGUjY9IKWQC6z2lNqa5iGbDSxhs60ABwK4y0s= +go.opentelemetry.io/otel/exporters/prometheus v0.48.0/go.mod h1:DtrbMzoZWwQHyrQmCfLam5DZbnmorsGbOtTbYHycU5o= +go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30= +go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4= +go.opentelemetry.io/otel/sdk v1.26.0 h1:Y7bumHf5tAiDlRYFmGqetNcLaVUZmh4iYfmGxtmz7F8= +go.opentelemetry.io/otel/sdk v1.26.0/go.mod h1:0p8MXpqLeJ0pzcszQQN4F0S5FVjBLgypeGSngLsmirs= +go.opentelemetry.io/otel/sdk/metric v1.26.0 h1:cWSks5tfriHPdWFnl+qpX3P681aAYqlZHcAyHw5aU9Y= +go.opentelemetry.io/otel/sdk/metric v1.26.0/go.mod h1:ClMFFknnThJCksebJwz7KIyEDHO+nTB6gK8obLy8RyE= +go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA= +go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0= +go.opentelemetry.io/proto/otlp v1.2.0 h1:pVeZGk7nXDC9O2hncA6nHldxEjm6LByfA2aN8IOkz94= +go.opentelemetry.io/proto/otlp v1.2.0/go.mod h1:gGpR8txAl5M03pDhMC79G6SdqNV26naRm/KDsgaHD8A= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= -go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA= -go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= +go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= @@ -557,11 +376,9 @@ go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9E go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -571,76 +388,25 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb h1:PaBZQdo+iSDyHT053FjUCgZQ/9uqVwPOcl7KSWhKn6w= golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs= +golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20161007143504-f4b625ec9b21/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= @@ -649,76 +415,31 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g= -golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ= +golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -727,8 +448,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -736,71 +457,29 @@ golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.0.0-20160926182426-711ca1cb8763/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220411224347-583f2d630306 h1:+gHMid33q6pen7kv9xvT+JRinntgeXO2AeZVd0AWD3w= golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= @@ -810,97 +489,20 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A= -google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= -google.golang.org/grpc v1.56.3 h1:8I4C0Yq1EjstUzUJzpcRVbuYA2mODtEmpWiQoN/b2nc= -google.golang.org/grpc v1.56.3/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de h1:F6qOa9AZTYJXOUEr4jDysRDLrm4PHePlge4v4TGAlxY= +google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:VUhTRKeHn9wwcdrk73nvdC9gF178Tzhmt/qyaFcPLSo= +google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de h1:jFNzHPIeuzhdRwVhbZdiym9q0ory/xY3sA+v2wPg8I0= +google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:5iCWqnniDlqZHrd3neWVTOwvh/v6s3232omMecelax8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= +google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -914,12 +516,8 @@ gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkp gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -928,14 +526,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 291eba5e8..d4e3068eb 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -16,6 +16,7 @@ import ( "github.com/supabase/auth/internal/observability" "github.com/supabase/auth/internal/security" "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" "github.com/didip/tollbooth/v5" "github.com/didip/tollbooth/v5/limiter" @@ -113,7 +114,7 @@ func (a *API) limitEmailOrPhoneSentHandler() middlewareHandler { emailRateLimitCounter.Add( req.Context(), 1, - attribute.String("path", req.URL.Path), + metric.WithAttributeSet(attribute.NewSet(attribute.String("path", req.URL.Path))), ) return c, tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, "Email rate limit exceeded") } diff --git a/internal/api/opentelemetry-tracer_test.go b/internal/api/opentelemetry-tracer_test.go index 0abcf869e..4aeddce5a 100644 --- a/internal/api/opentelemetry-tracer_test.go +++ b/internal/api/opentelemetry-tracer_test.go @@ -14,7 +14,7 @@ import ( "go.opentelemetry.io/otel/attribute" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace/tracetest" - semconv "go.opentelemetry.io/otel/semconv/v1.10.0" + semconv "go.opentelemetry.io/otel/semconv/v1.25.0" ) type OpenTelemetryTracerTestSuite struct { @@ -78,22 +78,16 @@ func (ts *OpenTelemetryTracerTestSuite) TestOpenTelemetryTracer_Spans() { method1 := getAttribute(attributes1, semconv.HTTPMethodKey) assert.Equal(ts.T(), "POST", method1.AsString()) url1 := getAttribute(attributes1, semconv.HTTPTargetKey) - assert.Equal(ts.T(), "http://localhost/something1", url1.AsString()) + assert.Equal(ts.T(), "/something1", url1.AsString()) statusCode1 := getAttribute(attributes1, semconv.HTTPStatusCodeKey) assert.Equal(ts.T(), int64(404), statusCode1.AsInt64()) - userAgent1 := getAttribute(attributes1, semconv.HTTPUserAgentKey) - assert.Equal(ts.T(), "stripped", userAgent1.AsString()) - attributes2 := spans[1].Attributes() method2 := getAttribute(attributes2, semconv.HTTPMethodKey) assert.Equal(ts.T(), "GET", method2.AsString()) url2 := getAttribute(attributes2, semconv.HTTPTargetKey) - assert.Equal(ts.T(), "http://localhost/something2", url2.AsString()) + assert.Equal(ts.T(), "/something2", url2.AsString()) statusCode2 := getAttribute(attributes2, semconv.HTTPStatusCodeKey) assert.Equal(ts.T(), int64(404), statusCode2.AsInt64()) - - userAgent2 := getAttribute(attributes2, semconv.HTTPUserAgentKey) - assert.Equal(ts.T(), "stripped", userAgent2.AsString()) } } diff --git a/internal/crypto/password.go b/internal/crypto/password.go index 6341e4d2d..554daccaa 100644 --- a/internal/crypto/password.go +++ b/internal/crypto/password.go @@ -12,6 +12,7 @@ import ( "github.com/supabase/auth/internal/observability" "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" "golang.org/x/crypto/argon2" "golang.org/x/crypto/bcrypt" @@ -123,14 +124,14 @@ func compareHashAndPasswordArgon2(ctx context.Context, hash, password string) er attribute.Int("len", len(rawHash)), } - compareHashAndPasswordSubmittedCounter.Add(ctx, 1, attributes...) + compareHashAndPasswordSubmittedCounter.Add(ctx, 1, metric.WithAttributes(attributes...)) defer func() { attributes = append(attributes, attribute.Bool( "match", match, )) - compareHashAndPasswordCompletedCounter.Add(ctx, 1, attributes...) + compareHashAndPasswordCompletedCounter.Add(ctx, 1, metric.WithAttributes(attributes...)) }() switch alg { @@ -169,14 +170,14 @@ func CompareHashAndPassword(ctx context.Context, hash, password string) error { attribute.Int("bcrypt_cost", hashCost), } - compareHashAndPasswordSubmittedCounter.Add(ctx, 1, attributes...) + compareHashAndPasswordSubmittedCounter.Add(ctx, 1, metric.WithAttributes(attributes...)) defer func() { attributes = append(attributes, attribute.Bool( "match", !errors.Is(err, bcrypt.ErrMismatchedHashAndPassword), )) - compareHashAndPasswordCompletedCounter.Add(ctx, 1, attributes...) + compareHashAndPasswordCompletedCounter.Add(ctx, 1, metric.WithAttributes(attributes...)) }() err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) @@ -207,8 +208,8 @@ func GenerateFromPassword(ctx context.Context, password string) (string, error) attribute.Int("bcrypt_cost", hashCost), } - generateFromPasswordSubmittedCounter.Add(ctx, 1, attributes...) - defer generateFromPasswordCompletedCounter.Add(ctx, 1, attributes...) + generateFromPasswordSubmittedCounter.Add(ctx, 1, metric.WithAttributes(attributes...)) + defer generateFromPasswordCompletedCounter.Add(ctx, 1, metric.WithAttributes(attributes...)) hash, err := bcrypt.GenerateFromPassword([]byte(password), hashCost) if err != nil { diff --git a/internal/models/cleanup.go b/internal/models/cleanup.go index 9da6363eb..46d4c2bdd 100644 --- a/internal/models/cleanup.go +++ b/internal/models/cleanup.go @@ -1,14 +1,14 @@ package models import ( + "context" "fmt" + "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/metric" "sync/atomic" - "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/attribute" - metricglobal "go.opentelemetry.io/otel/metric/global" - metricinstrument "go.opentelemetry.io/otel/metric/instrument" - otelasyncint64instrument "go.opentelemetry.io/otel/metric/instrument/asyncint64" "github.com/supabase/auth/internal/conf" "github.com/supabase/auth/internal/observability" @@ -24,7 +24,7 @@ type Cleanup struct { // cleanupAffectedRows tracks an OpenTelemetry metric on the total number of // cleaned up rows. - cleanupAffectedRows otelasyncint64instrument.Counter + cleanupAffectedRows atomic.Int64 } func NewCleanup(config *conf.GlobalConfiguration) *Cleanup { @@ -79,16 +79,21 @@ func NewCleanup(config *conf.GlobalConfiguration) *Cleanup { c.cleanupStatements = append(c.cleanupStatements, fmt.Sprintf("delete from %q where id in (select %q.id as id from %q, %q where %q.session_id = %q.id and %q.refreshed_at is null and %q.revoked is false and %q.updated_at + interval '%d seconds' < now() - interval '24 hours' limit 100 for update skip locked)", tableSessions, tableSessions, tableSessions, tableRefreshTokens, tableRefreshTokens, tableSessions, tableSessions, tableRefreshTokens, tableRefreshTokens, inactivitySeconds)) } - cleanupAffectedRows, err := metricglobal.Meter("gotrue").AsyncInt64().Counter( + meter := otel.Meter("gotrue") + + _, err := meter.Int64ObservableCounter( "gotrue_cleanup_affected_rows", - metricinstrument.WithDescription("Number of affected rows from cleaning up stale entities"), + metric.WithDescription("Number of affected rows from cleaning up stale entities"), + metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error { + o.Observe(c.cleanupAffectedRows.Load()) + return nil + }), ) + if err != nil { logrus.WithError(err).Error("unable to get gotrue.gotrue_cleanup_rows counter metric") } - c.cleanupAffectedRows = cleanupAffectedRows - return c } @@ -120,10 +125,7 @@ func (c *Cleanup) Clean(db *storage.Connection) (int, error) { }); err != nil { return affectedRows, err } - - if c.cleanupAffectedRows != nil { - c.cleanupAffectedRows.Observe(ctx, int64(affectedRows)) - } + c.cleanupAffectedRows.Add(int64(affectedRows)) return affectedRows, nil } diff --git a/internal/observability/metrics.go b/internal/observability/metrics.go index cfe8f2c8e..b3632aa8e 100644 --- a/internal/observability/metrics.go +++ b/internal/observability/metrics.go @@ -11,50 +11,38 @@ import ( "github.com/sirupsen/logrus" "github.com/supabase/auth/internal/conf" - "go.opentelemetry.io/otel/exporters/otlp/otlpmetric" + "github.com/prometheus/client_golang/prometheus/promhttp" + "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" "go.opentelemetry.io/otel/exporters/prometheus" "go.opentelemetry.io/otel/metric" - metricglobal "go.opentelemetry.io/otel/metric/global" - metricinstrument "go.opentelemetry.io/otel/metric/instrument" - basicmetriccontroller "go.opentelemetry.io/otel/sdk/metric/controller/basic" - exportmetricaggregation "go.opentelemetry.io/otel/sdk/metric/export/aggregation" - basicmetricprocessor "go.opentelemetry.io/otel/sdk/metric/processor/basic" - simplemetricselector "go.opentelemetry.io/otel/sdk/metric/selector/simple" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" otelruntimemetrics "go.opentelemetry.io/contrib/instrumentation/runtime" ) func Meter(instrumentationName string, opts ...metric.MeterOption) metric.Meter { - return metricglobal.Meter(instrumentationName, opts...) + return otel.Meter(instrumentationName, opts...) } -func ObtainMetricCounter(name, desc string) metricCounter { - counter, err := Meter("gotrue").SyncInt64().Counter(name, metricinstrument.WithDescription(desc)) +func ObtainMetricCounter(name, desc string) metric.Int64Counter { + counter, err := Meter("gotrue").Int64Counter(name, metric.WithDescription(desc)) if err != nil { panic(err) } - return counter } func enablePrometheusMetrics(ctx context.Context, mc *conf.MetricsConfig) error { - controller := basicmetriccontroller.New( - basicmetricprocessor.NewFactory( - simplemetricselector.NewWithHistogramDistribution(), - exportmetricaggregation.CumulativeTemporalitySelector(), - basicmetricprocessor.WithMemory(true), // pushes all metrics, not only the collected ones - ), - basicmetriccontroller.WithResource(openTelemetryResource()), - ) - - exporter, err := prometheus.New(prometheus.Config{}, controller) + exporter, err := prometheus.New() if err != nil { return err } - metricglobal.SetMeterProvider(exporter.MeterProvider()) + provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(exporter)) + + otel.SetMeterProvider(provider) cleanupWaitGroup.Add(1) go func() { @@ -63,7 +51,7 @@ func enablePrometheusMetrics(ctx context.Context, mc *conf.MetricsConfig) error server := &http.Server{ Addr: addr, - Handler: exporter, + Handler: promhttp.Handler(), BaseContext: func(net.Listener) context.Context { return baseContext }, @@ -97,62 +85,67 @@ func enablePrometheusMetrics(ctx context.Context, mc *conf.MetricsConfig) error } func enableOpenTelemetryMetrics(ctx context.Context, mc *conf.MetricsConfig) error { - var ( - err error - metricExporter *otlpmetric.Exporter - ) - switch mc.ExporterProtocol { case "grpc": - metricExporter, err = otlpmetricgrpc.New(ctx) + metricExporter, err := otlpmetricgrpc.New(ctx) if err != nil { return err } + meterProvider := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExporter)), + ) + + otel.SetMeterProvider(meterProvider) + + cleanupWaitGroup.Add(1) + go func() { + defer cleanupWaitGroup.Done() + + <-ctx.Done() + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + + if err := metricExporter.Shutdown(shutdownCtx); err != nil { + logrus.WithError(err).Error("unable to gracefully shut down OpenTelemetry metric exporter") + } else { + logrus.Info("OpenTelemetry metric exporter shut down") + } + }() case "http/protobuf": - metricExporter, err = otlpmetrichttp.New(ctx) + metricExporter, err := otlpmetrichttp.New(ctx) if err != nil { return err } + meterProvider := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExporter)), + ) - default: // http/json for example - return fmt.Errorf("unsupported OpenTelemetry exporter protocol %q", mc.ExporterProtocol) - } - - controller := basicmetriccontroller.New( - basicmetricprocessor.NewFactory( - simplemetricselector.NewWithHistogramDistribution(), - metricExporter, - ), - basicmetriccontroller.WithExporter(metricExporter), - basicmetriccontroller.WithResource(openTelemetryResource()), - ) - - metricglobal.SetMeterProvider(controller) + otel.SetMeterProvider(meterProvider) - cleanupWaitGroup.Add(1) - go func() { - defer cleanupWaitGroup.Done() + cleanupWaitGroup.Add(1) + go func() { + defer cleanupWaitGroup.Done() - <-ctx.Done() + <-ctx.Done() - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer shutdownCancel() + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() - if err := metricExporter.Shutdown(shutdownCtx); err != nil { - logrus.WithError(err).Error("unable to gracefully shut down OpenTelemetry metric exporter") - } else { - logrus.Info("OpenTelemetry metric exporter shut down") - } - }() + if err := metricExporter.Shutdown(shutdownCtx); err != nil { + logrus.WithError(err).Error("unable to gracefully shut down OpenTelemetry metric exporter") + } else { + logrus.Info("OpenTelemetry metric exporter shut down") + } + }() - if err := controller.Start(ctx); err != nil { - logrus.WithError(err).Error("unable to start pushing OpenTelemetry metrics") - } else { - logrus.Info("OpenTelemetry metrics exporter started") + default: // http/json for example + return fmt.Errorf("unsupported OpenTelemetry exporter protocol %q", mc.ExporterProtocol) } - + logrus.Info("OpenTelemetry metrics exporter started") return nil + } var ( @@ -190,26 +183,19 @@ func ConfigureMetrics(ctx context.Context, mc *conf.MetricsConfig) error { logrus.Info("Go runtime metrics collection started") } - meter := metricglobal.Meter("gotrue") - running, err := meter.AsyncInt64().Gauge( + meter := otel.Meter("gotrue") + _, err := meter.Int64ObservableGauge( "gotrue_running", - metricinstrument.WithDescription("Whether GoTrue is running (always 1)"), + metric.WithDescription("Whether GoTrue is running (always 1)"), + metric.WithInt64Callback(func(_ context.Context, obsrv metric.Int64Observer) error { + obsrv.Observe(int64(1)) + return nil + }), ) if err != nil { logrus.WithError(err).Error("unable to get gotrue.gotrue_running gague metric") return } - - if err := meter.RegisterCallback( - []metricinstrument.Asynchronous{ - running, - }, - func(ctx context.Context) { - running.Observe(ctx, 1) - }, - ); err != nil { - logrus.WithError(err).Error("unable to register gotrue.running gague metric") - } }) return err diff --git a/internal/observability/request-tracing.go b/internal/observability/request-tracing.go index aefa994db..e8ee61bc1 100644 --- a/internal/observability/request-tracing.go +++ b/internal/observability/request-tracing.go @@ -1,16 +1,15 @@ package observability import ( - "context" "net/http" "github.com/go-chi/chi/v5" "github.com/sirupsen/logrus" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" - metricglobal "go.opentelemetry.io/otel/metric/global" - metricinstrument "go.opentelemetry.io/otel/metric/instrument" - semconv "go.opentelemetry.io/otel/semconv/v1.12.0" + "go.opentelemetry.io/otel/metric" + semconv "go.opentelemetry.io/otel/semconv/v1.25.0" "go.opentelemetry.io/otel/trace" ) @@ -80,14 +79,10 @@ func (w *interceptingResponseWriter) Header() http.Header { return w.writer.Header() } -type metricCounter interface { - Add(ctx context.Context, incr int64, attrs ...attribute.KeyValue) -} - // countStatusCodesSafely counts the number of HTTP status codes per route that // occurred while GoTrue was running. If it is not able to identify the route // via chi.RouteContext(ctx).RoutePattern() it counts with a noroute attribute. -func countStatusCodesSafely(w *interceptingResponseWriter, r *http.Request, counter metricCounter) { +func countStatusCodesSafely(w *interceptingResponseWriter, r *http.Request, counter metric.Int64Counter) { if counter == nil { return } @@ -95,12 +90,12 @@ func countStatusCodesSafely(w *interceptingResponseWriter, r *http.Request, coun defer func() { if rec := recover(); rec != nil { logrus.WithField("error", rec).Error("unable to count status codes safely, metrics may be off") - counter.Add( r.Context(), 1, - attribute.Bool("noroute", true), - attribute.Int("code", w.statusCode), + metric.WithAttributes( + attribute.Bool("noroute", true), + attribute.Int("code", w.statusCode)), ) } }() @@ -113,8 +108,7 @@ func countStatusCodesSafely(w *interceptingResponseWriter, r *http.Request, coun counter.Add( ctx, 1, - attribute.Int("code", w.statusCode), - routePattern, + metric.WithAttributes(attribute.Int("code", w.statusCode), routePattern), ) } @@ -122,10 +116,10 @@ func countStatusCodesSafely(w *interceptingResponseWriter, r *http.Request, coun // in. Supports Chi routers, so this should be one of the first middlewares on // the router. func RequestTracing() func(http.Handler) http.Handler { - meter := metricglobal.Meter("gotrue") - statusCodes, err := meter.SyncInt64().Counter( + meter := otel.Meter("gotrue") + statusCodes, err := meter.Int64Counter( "http_status_codes", - metricinstrument.WithDescription("Number of returned HTTP status codes"), + metric.WithDescription("Number of returned HTTP status codes"), ) if err != nil { logrus.WithError(err).Error("unable to get gotrue.http_status_codes counter metric") From 357bda23cb2abd12748df80a9d27288aa548534d Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Wed, 12 Jun 2024 17:52:32 +0800 Subject: [PATCH 024/118] fix: define search path in auth functions (#1616) ## What kind of change does this PR introduce? * Set search_path to empty string in all auth functions --- .../20240612114525_set_search_path.up.sql | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 migrations/20240612114525_set_search_path.up.sql diff --git a/migrations/20240612114525_set_search_path.up.sql b/migrations/20240612114525_set_search_path.up.sql new file mode 100644 index 000000000..5d6ff2081 --- /dev/null +++ b/migrations/20240612114525_set_search_path.up.sql @@ -0,0 +1,43 @@ +-- set the search_path to an empty string to force fully qualified names in the function +do $$ +begin + -- auth.uid() function + create or replace function auth.uid() + returns uuid + set search_path to '' + as $func$ + select nullif(current_setting('request.jwt.claim.sub', true), '')::uuid; + $func$ language sql stable; + + -- auth.role() function + create or replace function {{ index .Options "Namespace" }}.role() + returns text + set search_path to '' + as $func$ + select nullif(current_setting('request.jwt.claim.role', true), '')::text; + $func$ language sql stable; + + -- auth.email() function + create or replace function {{ index .Options "Namespace" }}.email() + returns text + set search_path to '' + as $func$ + select + coalesce( + current_setting('request.jwt.claim.email', true), + (current_setting('request.jwt.claims', true)::jsonb ->> 'email') + )::text + $func$ language sql stable; + + -- auth.jwt() function + create or replace function {{ index .Options "Namespace" }}.jwt() + returns jsonb + set search_path to '' + as $func$ + select + coalesce( + nullif(current_setting('request.jwt.claim', true), ''), + nullif(current_setting('request.jwt.claims', true), '') + )::jsonb; + $func$ language sql stable; +end $$; From e4a475820b2dc1f985bd37df15a8ab9e781626f5 Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Wed, 12 Jun 2024 13:33:43 +0200 Subject: [PATCH 025/118] feat: encrypt sensitive columns (#1593) Adds support for encrypting sensitive columns like the MFA secret and password hash. The goal with this encryption mechanism is to add yet another layer of security on top of the database permissions provided by Postgres. In the event that the database leaks or is accessed by malicious users or the database permissions are incorrectly defined, the encryption key would also be required to inspect this sensitive data. Encryption is done using AES-GCM-256. Strings that are encrypted are converted into a JSON string with this shape: ```json { "key_id": "key identifier used for encryption", "alg": "aes-gcm-hkdf", "nonce": "GCM 12 byte nonce", "data": "Base64 standard encoding of the ciphertext" } ``` As AES-GCM must not be used more than 2^32 times with a single symmetric key, and this is not that much -- imagine serving 100m users -- then this means that all users can only add 42 passwords or MFA verification factors before running into this hard limit. To fix this, a symmetric key is derived using [HKDF](https://datatracker.ietf.org/doc/html/rfc5869) with SHA256 such that the symmetric key is used together with the object ID (for passwords - the user ID, for TOTP secrets - the factor ID). This way there's a separate AES-GCM key per object, and additionally gives the security property that a malicious actor with write permissions to the database cannot swap passwords / TOTP secrets from Malice's account to Target's account. They would need to also change the UUIDs of these objects, which is likely to be hard. To turn on encryption the following configs need to be added: `GOTRUE_SECURITY_DB_ENCRYPTION_ENCRYPT=true` -- that turns on encryption for new objects. `GOTRUE_SECURITY_DB_ENCRYPTION_ENCRYPTION_KEY_ID=key-id` -- ID of the encryption key, allowing to rotate keys easily. `GOTRUE_SECURITY_DB_ENCRYPTION_ENCRYPTION_KEY=key` -- Base64 URL encoding of a 256 bit AES key Once encryption has been turned on, in order to have the rows be readable **for ever** this config must be provided with all past and future keys: `GOTRUE_SECURITY_DB_ENCRYPTION_DECRYPTION_KEYS=key-id:key` -- A map of key IDs and Base64 URL key encodings of the keys. To retire keys, you should just move the old key to the decryption keys map, and advertise the new encryption key ID. On each successful sign in with password, or any MFA verification attempt, the latest key will be used to re-encrypt the column. This also applies for the non-encrypted-to-encrypted case. --- hack/test.env | 4 + internal/api/admin.go | 3 +- internal/api/admin_test.go | 14 +++- internal/api/mfa.go | 57 +++++++++++--- internal/api/mfa_test.go | 23 +++--- internal/api/token.go | 19 ++++- internal/api/user.go | 15 +++- internal/api/user_test.go | 15 +++- internal/api/verify.go | 4 +- internal/conf/configuration.go | 61 ++++++++++++++- internal/crypto/crypto.go | 138 +++++++++++++++++++++++++++++++++ internal/crypto/crypto_test.go | 34 ++++++++ internal/models/factor.go | 33 +++++++- internal/models/factor_test.go | 3 +- internal/models/user.go | 29 ++++++- internal/models/user_test.go | 4 +- 16 files changed, 413 insertions(+), 43 deletions(-) create mode 100644 internal/crypto/crypto_test.go diff --git a/hack/test.env b/hack/test.env index f4f3d0e6e..409940314 100644 --- a/hack/test.env +++ b/hack/test.env @@ -114,3 +114,7 @@ GOTRUE_SAML_ENABLED="true" GOTRUE_SAML_PRIVATE_KEY="MIIEowIBAAKCAQEAszrVveMQcSsa0Y+zN1ZFb19cRS0jn4UgIHTprW2tVBmO2PABzjY3XFCfx6vPirMAPWBYpsKmXrvm1tr0A6DZYmA8YmJd937VUQ67fa6DMyppBYTjNgGEkEhmKuszvF3MARsIKCGtZqUrmS7UG4404wYxVppnr2EYm3RGtHlkYsXu20MBqSDXP47bQP+PkJqC3BuNGk3xt5UHl2FSFpTHelkI6lBynw16B+lUT1F96SERNDaMqi/TRsZdGe5mB/29ngC/QBMpEbRBLNRir5iUevKS7Pn4aph9Qjaxx/97siktK210FJT23KjHpgcUfjoQ6BgPBTLtEeQdRyDuc/CgfwIDAQABAoIBAGYDWOEpupQPSsZ4mjMnAYJwrp4ZISuMpEqVAORbhspVeb70bLKonT4IDcmiexCg7cQBcLQKGpPVM4CbQ0RFazXZPMVq470ZDeWDEyhoCfk3bGtdxc1Zc9CDxNMs6FeQs6r1beEZug6weG5J/yRn/qYxQife3qEuDMl+lzfl2EN3HYVOSnBmdt50dxRuX26iW3nqqbMRqYn9OHuJ1LvRRfYeyVKqgC5vgt/6Tf7DAJwGe0dD7q08byHV8DBZ0pnMVU0bYpf1GTgMibgjnLjK//EVWafFHtN+RXcjzGmyJrk3+7ZyPUpzpDjO21kpzUQLrpEkkBRnmg6bwHnSrBr8avECgYEA3pq1PTCAOuLQoIm1CWR9/dhkbJQiKTJevlWV8slXQLR50P0WvI2RdFuSxlWmA4xZej8s4e7iD3MYye6SBsQHygOVGc4efvvEZV8/XTlDdyj7iLVGhnEmu2r7AFKzy8cOvXx0QcLg+zNd7vxZv/8D3Qj9Jje2LjLHKM5n/dZ3RzUCgYEAzh5Lo2anc4WN8faLGt7rPkGQF+7/18ImQE11joHWa3LzAEy7FbeOGpE/vhOv5umq5M/KlWFIRahMEQv4RusieHWI19ZLIP+JwQFxWxS+cPp3xOiGcquSAZnlyVSxZ//dlVgaZq2o2MfrxECcovRlaknl2csyf+HjFFwKlNxHm2MCgYAr//R3BdEy0oZeVRndo2lr9YvUEmu2LOihQpWDCd0fQw0ZDA2kc28eysL2RROte95r1XTvq6IvX5a0w11FzRWlDpQ4J4/LlcQ6LVt+98SoFwew+/PWuyLmxLycUbyMOOpm9eSc4wJJZNvaUzMCSkvfMtmm5jgyZYMMQ9A2Ul/9SQKBgB9mfh9mhBwVPIqgBJETZMMXOdxrjI5SBYHGSyJqpT+5Q0vIZLfqPrvNZOiQFzwWXPJ+tV4Mc/YorW3rZOdo6tdvEGnRO6DLTTEaByrY/io3/gcBZXoSqSuVRmxleqFdWWRnB56c1hwwWLqNHU+1671FhL6pNghFYVK4suP6qu4BAoGBAMk+VipXcIlD67mfGrET/xDqiWWBZtgTzTMjTpODhDY1GZck1eb4CQMP5j5V3gFJ4cSgWDJvnWg8rcz0unz/q4aeMGl1rah5WNDWj1QKWMS6vJhMHM/rqN1WHWR0ZnV83svYgtg0zDnQKlLujqW4JmGXLMU7ur6a+e6lpa1fvLsP" GOTRUE_MAX_VERIFIED_FACTORS=10 GOTRUE_SMS_TEST_OTP_VALID_UNTIL="" +GOTRUE_SECURITY_DB_ENCRYPTION_ENCRYPT=true +GOTRUE_SECURITY_DB_ENCRYPTION_ENCRYPTION_KEY_ID=abc +GOTRUE_SECURITY_DB_ENCRYPTION_ENCRYPTION_KEY=pwFoiPyybQMqNmYVN0gUnpbfpGQV2sDv9vp0ZAxi_Y4 +GOTRUE_SECURITY_DB_ENCRYPTION_DECRYPTION_KEYS=abc:pwFoiPyybQMqNmYVN0gUnpbfpGQV2sDv9vp0ZAxi_Y4 diff --git a/internal/api/admin.go b/internal/api/admin.go index dfb5bf8fa..053a75d35 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -134,6 +134,7 @@ func (a *API) adminUserGet(w http.ResponseWriter, r *http.Request) error { func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) + config := a.config user := getUser(ctx) adminUser := getAdminUser(ctx) params, err := a.getAdminParams(r) @@ -175,7 +176,7 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error { return err } - if err := user.SetPassword(ctx, password); err != nil { + if err := user.SetPassword(ctx, password, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil { return err } } diff --git a/internal/api/admin_test.go b/internal/api/admin_test.go index c57659414..fa046045e 100644 --- a/internal/api/admin_test.go +++ b/internal/api/admin_test.go @@ -350,7 +350,10 @@ func (ts *AdminTestSuite) TestAdminUserCreate() { expectedPassword = fmt.Sprintf("%v", c.params["password"]) } - assert.Equal(ts.T(), c.expected["isAuthenticated"], u.Authenticate(context.Background(), expectedPassword)) + isAuthenticated, _, err := u.Authenticate(context.Background(), expectedPassword, ts.API.config.Security.DBEncryption.DecryptionKeys, ts.API.config.Security.DBEncryption.Encrypt, ts.API.config.Security.DBEncryption.EncryptionKeyID) + require.NoError(ts.T(), err) + + assert.Equal(ts.T(), c.expected["isAuthenticated"], isAuthenticated) // remove created user after each case require.NoError(ts.T(), ts.API.db.Destroy(u)) @@ -726,7 +729,8 @@ func (ts *AdminTestSuite) TestAdminUserDeleteFactor() { require.NoError(ts.T(), err, "Error making new user") require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user") - f := models.NewFactor(u, "testSimpleName", models.TOTP, models.FactorStateVerified, "secretkey") + f := models.NewFactor(u, "testSimpleName", models.TOTP, models.FactorStateVerified) + require.NoError(ts.T(), f.SetSecret("secretkey", ts.Config.Security.DBEncryption.Encrypt, ts.Config.Security.DBEncryption.EncryptionKeyID, ts.Config.Security.DBEncryption.EncryptionKey)) require.NoError(ts.T(), ts.API.db.Create(f), "Error saving new test factor") // Setup request @@ -749,7 +753,8 @@ func (ts *AdminTestSuite) TestAdminUserGetFactors() { require.NoError(ts.T(), err, "Error making new user") require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user") - f := models.NewFactor(u, "testSimpleName", models.TOTP, models.FactorStateUnverified, "secretkey") + f := models.NewFactor(u, "testSimpleName", models.TOTP, models.FactorStateUnverified) + require.NoError(ts.T(), f.SetSecret("secretkey", ts.Config.Security.DBEncryption.Encrypt, ts.Config.Security.DBEncryption.EncryptionKeyID, ts.Config.Security.DBEncryption.EncryptionKey)) require.NoError(ts.T(), ts.API.db.Create(f), "Error saving new test factor") // Setup request @@ -770,7 +775,8 @@ func (ts *AdminTestSuite) TestAdminUserUpdateFactor() { require.NoError(ts.T(), err, "Error making new user") require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user") - f := models.NewFactor(u, "testSimpleName", models.TOTP, models.FactorStateUnverified, "secretkey") + f := models.NewFactor(u, "testSimpleName", models.TOTP, models.FactorStateUnverified) + require.NoError(ts.T(), f.SetSecret("secretkey", ts.Config.Security.DBEncryption.Encrypt, ts.Config.Security.DBEncryption.EncryptionKeyID, ts.Config.Security.DBEncryption.EncryptionKey)) require.NoError(ts.T(), ts.API.db.Create(f), "Error saving new test factor") var cases = []struct { diff --git a/internal/api/mfa.go b/internal/api/mfa.go index 07221fc2e..d2e8295f7 100644 --- a/internal/api/mfa.go +++ b/internal/api/mfa.go @@ -11,6 +11,7 @@ import ( "github.com/boombuler/barcode/qr" "github.com/gofrs/uuid" "github.com/pquerna/otp/totp" + "github.com/supabase/auth/internal/crypto" "github.com/supabase/auth/internal/hooks" "github.com/supabase/auth/internal/metering" "github.com/supabase/auth/internal/models" @@ -63,6 +64,7 @@ func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error { user := getUser(ctx) session := getSession(ctx) config := a.config + db := a.db.WithContext(ctx) if session == nil || user == nil { return internalServerError("A valid session and a registered user are required to enroll a factor") @@ -92,7 +94,7 @@ func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error { factorCount := len(factors) numVerifiedFactors := 0 - if err := models.DeleteExpiredFactors(a.db, config.MFA.FactorExpiryDuration); err != nil { + if err := models.DeleteExpiredFactors(db, config.MFA.FactorExpiryDuration); err != nil { return err } @@ -132,9 +134,12 @@ func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error { } svgData.End() - factor := models.NewFactor(user, params.FriendlyName, params.FactorType, models.FactorStateUnverified, key.Secret()) + factor := models.NewFactor(user, params.FriendlyName, params.FactorType, models.FactorStateUnverified) + if err := factor.SetSecret(key.Secret(), config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil { + return err + } - err = a.db.Transaction(func(tx *storage.Connection) error { + err = db.Transaction(func(tx *storage.Connection) error { if terr := tx.Create(factor); terr != nil { pgErr := utilities.NewPostgresError(terr) if pgErr.IsUniqueConstraintViolated() { @@ -161,7 +166,7 @@ func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error { TOTP: TOTPObject{ // See: https://css-tricks.com/probably-dont-base64-svg/ QRCode: buf.String(), - Secret: factor.Secret, + Secret: key.Secret(), URI: key.URL(), }, }) @@ -170,13 +175,14 @@ func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error { func (a *API) ChallengeFactor(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() config := a.config + db := a.db.WithContext(ctx) user := getUser(ctx) factor := getFactor(ctx) ipAddress := utilities.GetIPAddress(r) challenge := models.NewChallenge(factor, ipAddress) - if err := a.db.Transaction(func(tx *storage.Connection) error { + if err := db.Transaction(func(tx *storage.Connection) error { if terr := tx.Create(challenge); terr != nil { return terr } @@ -203,6 +209,7 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { user := getUser(ctx) factor := getFactor(ctx) config := a.config + db := a.db.WithContext(ctx) params := &VerifyFactorParams{} if err := retrieveRequestParams(r, params); err != nil { @@ -214,7 +221,7 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { return internalServerError(InvalidFactorOwnerErrorMessage) } - challenge, err := models.FindChallengeByID(a.db, params.ChallengeID) + challenge, err := models.FindChallengeByID(db, params.ChallengeID) if err != nil && models.IsNotFoundError(err) { return notFoundError(ErrorCodeMFAFactorNotFound, "MFA factor with the provided challenge ID not found") } else if err != nil { @@ -226,13 +233,18 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { } if challenge.HasExpired(config.MFA.ChallengeExpiryDuration) { - if err := a.db.Destroy(challenge); err != nil { + if err := db.Destroy(challenge); err != nil { return internalServerError("Database error deleting challenge").WithInternalError(err) } return unprocessableEntityError(ErrorCodeMFAChallengeExpired, "MFA challenge %v has expired, verify against another challenge or create a new challenge.", challenge.ID) } - valid := totp.Validate(params.Code, factor.Secret) + secret, shouldReEncrypt, err := factor.GetSecret(config.Security.DBEncryption.DecryptionKeys, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID) + if err != nil { + return internalServerError("Database error verifying MFA TOTP secret").WithInternalError(err) + } + + valid := totp.Validate(params.Code, secret) if config.Hook.MFAVerificationAttempt.Enabled { input := hooks.MFAVerificationAttemptInput{ @@ -248,7 +260,7 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { } if output.Decision == hooks.HookRejection { - if err := models.Logout(a.db, user.ID); err != nil { + if err := models.Logout(db, user.ID); err != nil { return err } @@ -259,12 +271,22 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { return forbiddenError(ErrorCodeMFAVerificationRejected, output.Message) } } + if !valid { + if shouldReEncrypt && config.Security.DBEncryption.Encrypt { + if err := factor.SetSecret(secret, true, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil { + return err + } + + if err := db.UpdateOnly(factor, "secret"); err != nil { + return err + } + } return unprocessableEntityError(ErrorCodeMFAVerificationFailed, "Invalid TOTP code entered") } var token *AccessTokenResponse - err = a.db.Transaction(func(tx *storage.Connection) error { + err = db.Transaction(func(tx *storage.Connection) error { var terr error if terr = models.NewAuditLogEntry(r, tx, user, models.VerifyFactorAction, r.RemoteAddr, map[string]interface{}{ "factor_id": factor.ID, @@ -280,6 +302,17 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { return terr } } + if shouldReEncrypt && config.Security.DBEncryption.Encrypt { + es, terr := crypto.NewEncryptedString(factor.ID.String(), []byte(secret), config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey) + if terr != nil { + return terr + } + + factor.Secret = es.String() + if terr := tx.UpdateOnly(factor, "secret"); terr != nil { + return terr + } + } user, terr = models.FindUserByID(tx, user.ID) if terr != nil { return terr @@ -316,6 +349,8 @@ func (a *API) UnenrollFactor(w http.ResponseWriter, r *http.Request) error { user := getUser(ctx) factor := getFactor(ctx) session := getSession(ctx) + db := a.db.WithContext(ctx) + if factor == nil || session == nil || user == nil { return internalServerError("A valid session and factor are required to unenroll a factor") } @@ -327,7 +362,7 @@ func (a *API) UnenrollFactor(w http.ResponseWriter, r *http.Request) error { return internalServerError(InvalidFactorOwnerErrorMessage) } - err = a.db.Transaction(func(tx *storage.Connection) error { + err = db.Transaction(func(tx *storage.Connection) error { var terr error if terr := tx.Destroy(factor); terr != nil { return terr diff --git a/internal/api/mfa_test.go b/internal/api/mfa_test.go index 991cc52f9..63f813249 100644 --- a/internal/api/mfa_test.go +++ b/internal/api/mfa_test.go @@ -2,7 +2,6 @@ package api import ( "bytes" - "context" "encoding/json" "fmt" "net/http" @@ -14,15 +13,15 @@ import ( "github.com/gofrs/uuid" "database/sql" + "github.com/pkg/errors" "github.com/pquerna/otp" "github.com/supabase/auth/internal/conf" + "github.com/supabase/auth/internal/crypto" "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/storage" "github.com/supabase/auth/internal/utilities" - "github.com/jackc/pgx/v4" - "github.com/pquerna/otp/totp" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -62,7 +61,8 @@ func (ts *MFATestSuite) SetupTest() { require.NoError(ts.T(), err, "Error creating test user model") require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new test user") // Create Factor - f := models.NewFactor(u, "test_factor", models.TOTP, models.FactorStateUnverified, "secretkey") + f := models.NewFactor(u, "test_factor", models.TOTP, models.FactorStateUnverified) + require.NoError(ts.T(), f.SetSecret("secretkey", ts.Config.Security.DBEncryption.Encrypt, ts.Config.Security.DBEncryption.EncryptionKeyID, ts.Config.Security.DBEncryption.EncryptionKey)) require.NoError(ts.T(), ts.API.db.Create(f), "Error saving new test factor") // Create corresponding session s, err := models.NewSession(u.ID, &f.ID) @@ -482,14 +482,19 @@ func ServeAuthenticatedRequest(ts *MFATestSuite, method, path, token string, buf func performVerifyFlow(ts *MFATestSuite, challengeID, factorID uuid.UUID, token string, requireStatusOK bool) *httptest.ResponseRecorder { var buffer bytes.Buffer - conn, err := pgx.Connect(context.Background(), ts.API.db.URL()) + factor, err := models.FindFactorByFactorID(ts.API.db, factorID) require.NoError(ts.T(), err) + require.NotNil(ts.T(), factor) - defer conn.Close(context.Background()) + totpSecret := factor.Secret - var totpSecret string - err = conn.QueryRow(context.Background(), "select secret from mfa_factors where id=$1", factorID).Scan(&totpSecret) - require.NoError(ts.T(), err) + if es := crypto.ParseEncryptedString(factor.Secret); es != nil { + secret, err := es.Decrypt(factor.ID.String(), ts.API.config.Security.DBEncryption.DecryptionKeys) + require.NoError(ts.T(), err) + require.NotNil(ts.T(), secret) + + totpSecret = string(secret) + } code, err := totp.GenerateCode(totpSecret, time.Now().UTC()) require.NoError(ts.T(), err) diff --git a/internal/api/token.go b/internal/api/token.go index 542c68edf..11af2883f 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -145,7 +145,10 @@ func (a *API) ResourceOwnerPasswordGrant(ctx context.Context, w http.ResponseWri return oauthError("invalid_grant", InvalidLoginMessage) } - isValidPassword := user.Authenticate(ctx, params.Password) + isValidPassword, shouldReEncrypt, err := user.Authenticate(ctx, params.Password, config.Security.DBEncryption.DecryptionKeys, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID) + if err != nil { + return err + } var weakPasswordError *WeakPasswordError if isValidPassword { @@ -156,6 +159,20 @@ func (a *API) ResourceOwnerPasswordGrant(ctx context.Context, w http.ResponseWri observability.GetLogEntry(r).Entry.WithError(err).Warn("Password strength check on sign-in failed") } } + + if shouldReEncrypt { + if err := user.SetPassword(ctx, params.Password, true, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil { + return err + } + + // directly change this in the database without + // calling user.UpdatePassword() because this + // is not a password change, just encryption + // change in the database + if err := db.UpdateOnly(user, "encrypted_password"); err != nil { + return err + } + } } if config.Hook.PasswordVerificationAttempt.Enabled { diff --git a/internal/api/user.go b/internal/api/user.go index 10fbc93d2..33c35aa57 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -153,12 +153,23 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error { password := *params.Password if password != "" { - if user.EncryptedPassword != "" && user.Authenticate(ctx, password) { + isSamePassword := false + + if user.EncryptedPassword != "" { + auth, _, err := user.Authenticate(ctx, password, config.Security.DBEncryption.DecryptionKeys, false, "") + if err != nil { + return err + } + + isSamePassword = auth + } + + if isSamePassword { return unprocessableEntityError(ErrorCodeSamePassword, "New password should be different from the old password.") } } - if err := user.SetPassword(ctx, password); err != nil { + if err := user.SetPassword(ctx, password, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil { return err } } diff --git a/internal/api/user_test.go b/internal/api/user_test.go index ac97d9c24..8272bb87e 100644 --- a/internal/api/user_test.go +++ b/internal/api/user_test.go @@ -310,7 +310,10 @@ func (ts *UserTestSuite) TestUserUpdatePassword() { u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) - require.Equal(ts.T(), c.expected.isAuthenticated, u.Authenticate(context.Background(), c.newPassword)) + isAuthenticated, _, err := u.Authenticate(context.Background(), c.newPassword, ts.API.config.Security.DBEncryption.DecryptionKeys, ts.API.config.Security.DBEncryption.Encrypt, ts.API.config.Security.DBEncryption.EncryptionKeyID) + require.NoError(ts.T(), err) + + require.Equal(ts.T(), c.expected.isAuthenticated, isAuthenticated) }) } } @@ -369,7 +372,10 @@ func (ts *UserTestSuite) TestUserUpdatePasswordNoReauthenticationRequired() { u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) - require.Equal(ts.T(), c.expected.isAuthenticated, u.Authenticate(context.Background(), c.newPassword)) + isAuthenticated, _, err := u.Authenticate(context.Background(), c.newPassword, ts.API.config.Security.DBEncryption.DecryptionKeys, ts.API.config.Security.DBEncryption.Encrypt, ts.API.config.Security.DBEncryption.EncryptionKeyID) + require.NoError(ts.T(), err) + + require.Equal(ts.T(), c.expected.isAuthenticated, isAuthenticated) }) } } @@ -424,7 +430,10 @@ func (ts *UserTestSuite) TestUserUpdatePasswordReauthentication() { u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) - require.True(ts.T(), u.Authenticate(context.Background(), "newpass")) + isAuthenticated, _, err := u.Authenticate(context.Background(), "newpass", ts.Config.Security.DBEncryption.DecryptionKeys, ts.Config.Security.DBEncryption.Encrypt, ts.Config.Security.DBEncryption.EncryptionKeyID) + require.NoError(ts.T(), err) + + require.True(ts.T(), isAuthenticated) require.Empty(ts.T(), u.ReauthenticationToken) require.Nil(ts.T(), u.ReauthenticationSentAt) } diff --git a/internal/api/verify.go b/internal/api/verify.go index 5badfc77e..91df8bc21 100644 --- a/internal/api/verify.go +++ b/internal/api/verify.go @@ -304,6 +304,8 @@ func (a *API) verifyPost(w http.ResponseWriter, r *http.Request, params *VerifyP } func (a *API) signupVerify(r *http.Request, ctx context.Context, conn *storage.Connection, user *models.User) (*models.User, error) { + config := a.config + if user.EncryptedPassword == "" && user.InvitedAt != nil { // sign them up with temporary password, and require application // to present the user with a password set form @@ -313,7 +315,7 @@ func (a *API) signupVerify(r *http.Request, ctx context.Context, conn *storage.C panic(err) } - if err := user.SetPassword(ctx, password); err != nil { + if err := user.SetPassword(ctx, password, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil { return nil, err } } diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 2c8212abd..99f0f1879 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -2,6 +2,7 @@ package conf import ( "bytes" + "encoding/base64" "errors" "fmt" "net/url" @@ -421,16 +422,74 @@ func (c *CaptchaConfiguration) Validate() error { return nil } +// DatabaseEncryptionConfiguration configures Auth to encrypt certain columns. +// Once Encrypt is set to true, data will start getting encrypted with the +// provided encryption key. Setting it to false just stops encryption from +// going on further, but DecryptionKeys would have to contain the same key so +// the encrypted data remains accessible. +type DatabaseEncryptionConfiguration struct { + Encrypt bool `json:"encrypt"` + + EncryptionKeyID string `json:"encryption_key_id" split_words:"true"` + EncryptionKey string `json:"-" split_words:"true"` + + DecryptionKeys map[string]string `json:"-" split_words:"true"` +} + +func (c *DatabaseEncryptionConfiguration) Validate() error { + if c.Encrypt { + if c.EncryptionKeyID == "" { + return errors.New("conf: encryption key ID must be specified") + } + + decodedKey, err := base64.RawURLEncoding.DecodeString(c.EncryptionKey) + if err != nil { + return err + } + + if len(decodedKey) != 256/8 { + return errors.New("conf: encryption key is not 256 bits") + } + + if c.DecryptionKeys == nil || c.DecryptionKeys[c.EncryptionKeyID] == "" { + return errors.New("conf: encryption key must also be present in decryption keys") + } + } + + for id, key := range c.DecryptionKeys { + decodedKey, err := base64.RawURLEncoding.DecodeString(key) + if err != nil { + return err + } + + if len(decodedKey) != 256/8 { + return fmt.Errorf("conf: decryption key with ID %q must be 256 bits", id) + } + } + + return nil +} + type SecurityConfiguration struct { Captcha CaptchaConfiguration `json:"captcha"` RefreshTokenRotationEnabled bool `json:"refresh_token_rotation_enabled" split_words:"true" default:"true"` RefreshTokenReuseInterval int `json:"refresh_token_reuse_interval" split_words:"true"` UpdatePasswordRequireReauthentication bool `json:"update_password_require_reauthentication" split_words:"true"` ManualLinkingEnabled bool `json:"manual_linking_enabled" split_words:"true" default:"false"` + + DBEncryption DatabaseEncryptionConfiguration `json:"database_encryption" split_words:"true"` } func (c *SecurityConfiguration) Validate() error { - return c.Captcha.Validate() + if err := c.Captcha.Validate(); err != nil { + return err + } + + if err := c.DBEncryption.Validate(); err != nil { + return err + } + + return nil } func loadEnvironment(filename string) error { diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index 590d1ba4d..be6a2b5df 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -1,9 +1,12 @@ package crypto import ( + "crypto/aes" + "crypto/cipher" "crypto/rand" "crypto/sha256" "encoding/base64" + "encoding/json" "fmt" "io" "math" @@ -14,6 +17,7 @@ import ( "github.com/gofrs/uuid" standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go" + "golang.org/x/crypto/hkdf" "github.com/pkg/errors" ) @@ -69,3 +73,137 @@ func GenerateSignatures(secrets []string, msgID uuid.UUID, currentTime time.Time } return signatureList, nil } + +type EncryptedString struct { + KeyID string `json:"key_id"` + Algorithm string `json:"alg"` + Data []byte `json:"data"` + Nonce []byte `json:"nonce,omitempty"` +} + +func (es *EncryptedString) IsValid() bool { + return es.KeyID != "" && len(es.Data) > 0 && len(es.Nonce) > 0 && es.Algorithm == "aes-gcm-hkdf" +} + +// ShouldReEncrypt tells you if the value encrypted needs to be encrypted again with a newer key. +func (es *EncryptedString) ShouldReEncrypt(encryptionKeyID string) bool { + return es.KeyID != encryptionKeyID +} + +func (es *EncryptedString) Decrypt(id string, decryptionKeys map[string]string) ([]byte, error) { + decryptionKey := decryptionKeys[es.KeyID] + + if decryptionKey == "" { + return nil, fmt.Errorf("crypto: decryption key with name %q does not exist", es.KeyID) + } + + key, err := deriveSymmetricKey(id, es.KeyID, decryptionKey) + if err != nil { + return nil, err + } + + aes, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + cipher, err := cipher.NewGCM(aes) + if err != nil { + return nil, err + } + + decrypted, err := cipher.Open(nil, es.Nonce, es.Data, nil) + if err != nil { + return nil, err + } + + return decrypted, nil +} + +func ParseEncryptedString(str string) *EncryptedString { + if !strings.HasPrefix(str, "{") { + return nil + } + + var es EncryptedString + + if err := json.Unmarshal([]byte(str), &es); err != nil { + return nil + } + + if !es.IsValid() { + return nil + } + + return &es +} + +func (es *EncryptedString) String() string { + out, err := json.Marshal(es) + if err != nil { + panic(err) + } + + return string(out) +} + +func deriveSymmetricKey(id, keyID, keyBase64URL string) ([]byte, error) { + hkdfKey, err := base64.RawURLEncoding.DecodeString(keyBase64URL) + if err != nil { + return nil, err + } + + if len(hkdfKey) != 256/8 { + return nil, fmt.Errorf("crypto: key with ID %q is not 256 bits", keyID) + } + + // Since we use AES-GCM here, the same symmetric key *must not be used + // more than* 2^32 times. But, that's not that much. Suppose a system + // with 100 million users, then a user can only change their password + // 42 times. To prevent this, the actual symmetric key is derived by + // using HKDF using the encryption key and the "ID" of the object + // containing the encryption string. Ideally this ID is a UUID. This + // has the added benefit that the encrypted string is bound to that + // specific object, and can't accidentally be "moved" to other objects + // without changing their ID to the original one. + + keyReader := hkdf.New(sha256.New, hkdfKey, nil, []byte(id)) + key := make([]byte, 256/8) + + if _, err := io.ReadFull(keyReader, key); err != nil { + panic(err) + } + + return key, nil +} + +func NewEncryptedString(id string, data []byte, keyID string, keyBase64URL string) (*EncryptedString, error) { + key, err := deriveSymmetricKey(id, keyID, keyBase64URL) + if err != nil { + return nil, err + } + + aes, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + cipher, err := cipher.NewGCM(aes) + if err != nil { + panic(err) + } + + es := EncryptedString{ + KeyID: keyID, + Algorithm: "aes-gcm-hkdf", + Nonce: make([]byte, 12), + } + + if _, err := io.ReadFull(rand.Reader, es.Nonce); err != nil { + panic(err) + } + + es.Data = cipher.Seal(nil, es.Nonce, data, nil) + + return &es, nil +} diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go new file mode 100644 index 000000000..b677b918d --- /dev/null +++ b/internal/crypto/crypto_test.go @@ -0,0 +1,34 @@ +package crypto + +import ( + "testing" + + "github.com/gofrs/uuid" + "github.com/stretchr/testify/assert" +) + +func TestEncryptedString(t *testing.T) { + id := uuid.Must(uuid.NewV4()).String() + + es, err := NewEncryptedString(id, []byte("data"), "key-id", "pwFoiPyybQMqNmYVN0gUnpbfpGQV2sDv9vp0ZAxi_Y4") + assert.NoError(t, err) + + assert.Equal(t, es.KeyID, "key-id") + assert.Equal(t, es.Algorithm, "aes-gcm-hkdf") + assert.Len(t, es.Data, 20) + assert.Len(t, es.Nonce, 12) + + dec := ParseEncryptedString(es.String()) + + assert.NotNil(t, dec) + assert.Equal(t, dec.Algorithm, "aes-gcm-hkdf") + assert.Len(t, dec.Data, 20) + assert.Len(t, dec.Nonce, 12) + + decrypted, err := dec.Decrypt(id, map[string]string{ + "key-id": "pwFoiPyybQMqNmYVN0gUnpbfpGQV2sDv9vp0ZAxi_Y4", + }) + + assert.NoError(t, err) + assert.Equal(t, []byte("data"), decrypted) +} diff --git a/internal/models/factor.go b/internal/models/factor.go index 265733564..53fddc260 100644 --- a/internal/models/factor.go +++ b/internal/models/factor.go @@ -9,6 +9,7 @@ import ( "github.com/gobuffalo/pop/v6" "github.com/gofrs/uuid" "github.com/pkg/errors" + "github.com/supabase/auth/internal/crypto" "github.com/supabase/auth/internal/storage" ) @@ -127,20 +128,46 @@ func (Factor) TableName() string { return tableName } -func NewFactor(user *User, friendlyName string, factorType string, state FactorState, secret string) *Factor { +func NewFactor(user *User, friendlyName string, factorType string, state FactorState) *Factor { id := uuid.Must(uuid.NewV4()) factor := &Factor{ - UserID: user.ID, ID: id, + UserID: user.ID, Status: state.String(), FriendlyName: friendlyName, - Secret: secret, FactorType: factorType, } return factor } +func (f *Factor) SetSecret(secret string, encrypt bool, encryptionKeyID, encryptionKey string) error { + f.Secret = secret + if encrypt { + es, err := crypto.NewEncryptedString(f.ID.String(), []byte(secret), encryptionKeyID, encryptionKey) + if err != nil { + return err + } + + f.Secret = es.String() + } + + return nil +} + +func (f *Factor) GetSecret(decryptionKeys map[string]string, encrypt bool, encryptionKeyID string) (string, bool, error) { + if es := crypto.ParseEncryptedString(f.Secret); es != nil { + bytes, err := es.Decrypt(f.ID.String(), decryptionKeys) + if err != nil { + return "", false, err + } + + return string(bytes), encrypt && es.ShouldReEncrypt(encryptionKeyID), nil + } + + return f.Secret, encrypt, nil +} + func FindFactorByFactorID(conn *storage.Connection, factorID uuid.UUID) (*Factor, error) { var factor Factor err := conn.Find(&factor, factorID) diff --git a/internal/models/factor_test.go b/internal/models/factor_test.go index c6d1f4a70..1ca782ce6 100644 --- a/internal/models/factor_test.go +++ b/internal/models/factor_test.go @@ -37,7 +37,8 @@ func (ts *FactorTestSuite) SetupTest() { require.NoError(ts.T(), err) require.NoError(ts.T(), ts.db.Create(user)) - factor := NewFactor(user, "asimplename", TOTP, FactorStateUnverified, "topsecret") + factor := NewFactor(user, "asimplename", TOTP, FactorStateUnverified) + require.NoError(ts.T(), factor.SetSecret("topsecret", false, "", "")) require.NoError(ts.T(), ts.db.Create(factor)) ts.TestFactor = factor } diff --git a/internal/models/user.go b/internal/models/user.go index 270484e08..721819eab 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -283,7 +283,7 @@ func (u *User) SetPhone(tx *storage.Connection, phone string) error { return tx.UpdateOnly(u, "phone") } -func (u *User) SetPassword(ctx context.Context, password string) error { +func (u *User) SetPassword(ctx context.Context, password string, encrypt bool, encryptionKeyID, encryptionKey string) error { if password == "" { u.EncryptedPassword = "" return nil @@ -295,6 +295,14 @@ func (u *User) SetPassword(ctx context.Context, password string) error { } u.EncryptedPassword = pw + if encrypt { + es, err := crypto.NewEncryptedString(u.ID.String(), []byte(pw), encryptionKeyID, encryptionKey) + if err != nil { + return err + } + + u.EncryptedPassword = es.String() + } return nil } @@ -332,9 +340,22 @@ func (u *User) UpdatePassword(tx *storage.Connection, sessionID *uuid.UUID) erro } // Authenticate a user from a password -func (u *User) Authenticate(ctx context.Context, password string) bool { - err := crypto.CompareHashAndPassword(ctx, u.EncryptedPassword, password) - return err == nil +func (u *User) Authenticate(ctx context.Context, password string, decryptionKeys map[string]string, encrypt bool, encryptionKeyID string) (bool, bool, error) { + hash := u.EncryptedPassword + + es := crypto.ParseEncryptedString(u.EncryptedPassword) + if es != nil { + h, err := es.Decrypt(u.ID.String(), decryptionKeys) + if err != nil { + return false, false, err + } + + hash = string(h) + } + + compareErr := crypto.CompareHashAndPassword(ctx, hash, password) + + return compareErr == nil, encrypt && (es == nil || es.ShouldReEncrypt(encryptionKeyID)), nil } // ConfirmReauthentication resets the reauthentication token diff --git a/internal/models/user_test.go b/internal/models/user_test.go index 6c915f6af..011cf28f0 100644 --- a/internal/models/user_test.go +++ b/internal/models/user_test.go @@ -372,9 +372,9 @@ func (ts *UserTestSuite) TestSetPasswordTooLong() { require.NoError(ts.T(), err) require.NoError(ts.T(), ts.db.Create(user)) - err = user.SetPassword(ts.db.Context(), strings.Repeat("a", crypto.MaxPasswordLength+1)) + err = user.SetPassword(ts.db.Context(), strings.Repeat("a", crypto.MaxPasswordLength+1), false, "", "") require.Error(ts.T(), err) - err = user.SetPassword(ts.db.Context(), strings.Repeat("a", crypto.MaxPasswordLength)) + err = user.SetPassword(ts.db.Context(), strings.Repeat("a", crypto.MaxPasswordLength), false, "", "") require.NoError(ts.T(), err) } From cd7b1919fca99585be0ce842b51ca2a10b926714 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Wed, 12 Jun 2024 15:54:26 +0200 Subject: [PATCH 026/118] ci: fix doubling of rc version identifier (#1618) ## What kind of change does this PR introduce? See: https://github.com/supabase/ssr/pull/13/files as per title --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8540046a7..2ad87dee4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,7 +53,7 @@ jobs: # Use git describe tags to identify the number of commits the branch # is ahead of the most recent non-release-candidate tag, which is # part of the rc. value. - RELEASE_VERSION=$MAIN_RELEASE_VERSION-rc.$(node -e "console.log('$(git describe --tags --exclude *rc*)'.split('-')[1])") + RELEASE_VERSION=$MAIN_RELEASE_VERSION-rc.$(node -e "console.log('$(git describe --tags --exclude rc*)'.split('-')[1])") # release-please only ignores releases that have a form like [A-Z0-9], so prefixing with rc RELEASE_NAME="rc$RELEASE_VERSION" From bb992519cdf7578dc02cd7de55e2e6aa09b4c0f3 Mon Sep 17 00:00:00 2001 From: Zach Hawtof Date: Wed, 12 Jun 2024 14:09:26 -0400 Subject: [PATCH 027/118] feat: add support for Slack OAuth V2 (#1591) ## What kind of change does this PR introduce? - Updates the Slack OAuth provider with the new Sign In With Slack V2. - Creates a test for Slack, improving test coverage - Moves the old Slack provider to slack_legacy. Some users might still rely on this provider after the creation of legacy apps is disallowed on June 4th. ## What is the current behavior? Fixes #1294 Current behavior uses the original Slack OAuth V1 which is sunsetting June 4th according to [the changelog](https://api.slack.com/changelog/2024-04-discontinuing-new-creation-of-classic-slack-apps-and-custom-bots) ## What is the new behavior? New behavior now leverages the new [Sign In With Slack](https://api.slack.com/authentication/sign-in-with-slack) (SIWS) on OAuth V2 for Slack authentication. ## Additional context A ticket should be created for ending support on slack_legacy. --------- Co-authored-by: Kang Ming --- CONTRIBUTING.md | 1 + hack/test.env | 8 ++ internal/api/external.go | 2 + internal/api/external_slack_oidc_test.go | 33 ++++++++ internal/api/provider/slack.go | 4 +- internal/api/provider/slack_oidc.go | 99 ++++++++++++++++++++++++ internal/api/settings.go | 2 + internal/api/settings_test.go | 2 + internal/conf/configuration.go | 1 + 9 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 internal/api/external_slack_oidc_test.go create mode 100644 internal/api/provider/slack_oidc.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 96091c145..f65eca299 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -253,6 +253,7 @@ To see the current settings, make a request to `http://localhost:9999/settings` "facebook": false, "spotify": false, "slack": false, + "slack_oidc": false, "twitch": true, "twitter": false, "email": true, diff --git a/hack/test.env b/hack/test.env index 409940314..35e4b61c8 100644 --- a/hack/test.env +++ b/hack/test.env @@ -60,6 +60,10 @@ GOTRUE_EXTERNAL_LINKEDIN_ENABLED=true GOTRUE_EXTERNAL_LINKEDIN_CLIENT_ID=testclientid GOTRUE_EXTERNAL_LINKEDIN_SECRET=testsecret GOTRUE_EXTERNAL_LINKEDIN_REDIRECT_URI=https://identity.services.netlify.com/callback +GOTRUE_EXTERNAL_LINKEDIN_OIDC_ENABLED=true +GOTRUE_EXTERNAL_LINKEDIN_OIDC_CLIENT_ID=testclientid +GOTRUE_EXTERNAL_LINKEDIN_OIDC_SECRET=testsecret +GOTRUE_EXTERNAL_LINKEDIN_OIDC_REDIRECT_URI=https://identity.services.netlify.com/callback GOTRUE_EXTERNAL_GITLAB_ENABLED=true GOTRUE_EXTERNAL_GITLAB_CLIENT_ID=testclientid GOTRUE_EXTERNAL_GITLAB_SECRET=testsecret @@ -80,6 +84,10 @@ GOTRUE_EXTERNAL_SLACK_ENABLED=true GOTRUE_EXTERNAL_SLACK_CLIENT_ID=testclientid GOTRUE_EXTERNAL_SLACK_SECRET=testsecret GOTRUE_EXTERNAL_SLACK_REDIRECT_URI=https://identity.services.netlify.com/callback +GOTRUE_EXTERNAL_SLACK_OIDC_ENABLED=true +GOTRUE_EXTERNAL_SLACK_OIDC_CLIENT_ID=testclientid +GOTRUE_EXTERNAL_SLACK_OIDC_SECRET=testsecret +GOTRUE_EXTERNAL_SLACK_OIDC_REDIRECT_URI=https://identity.services.netlify.com/callback GOTRUE_EXTERNAL_WORKOS_ENABLED=true GOTRUE_EXTERNAL_WORKOS_CLIENT_ID=testclientid GOTRUE_EXTERNAL_WORKOS_SECRET=testsecret diff --git a/internal/api/external.go b/internal/api/external.go index cf1736f03..a8048fb26 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -558,6 +558,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide return provider.NewSpotifyProvider(config.External.Spotify, scopes) case "slack": return provider.NewSlackProvider(config.External.Slack, scopes) + case "slack_oidc": + return provider.NewSlackOIDCProvider(config.External.SlackOIDC, scopes) case "twitch": return provider.NewTwitchProvider(config.External.Twitch, scopes) case "twitter": diff --git a/internal/api/external_slack_oidc_test.go b/internal/api/external_slack_oidc_test.go new file mode 100644 index 000000000..9090581d0 --- /dev/null +++ b/internal/api/external_slack_oidc_test.go @@ -0,0 +1,33 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "net/url" + + jwt "github.com/golang-jwt/jwt" +) + +func (ts *ExternalTestSuite) TestSignupExternalSlackOIDC() { + req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=slack_oidc", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.Require().Equal(http.StatusFound, w.Code) + u, err := url.Parse(w.Header().Get("Location")) + ts.Require().NoError(err, "redirect url parse failed") + q := u.Query() + ts.Equal(ts.Config.External.Slack.RedirectURI, q.Get("redirect_uri")) + ts.Equal(ts.Config.External.Slack.ClientID, []string{q.Get("client_id")}) + ts.Equal("code", q.Get("response_type")) + ts.Equal("profile email openid", q.Get("scope")) + + claims := ExternalProviderClaims{} + p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { + return []byte(ts.Config.JWT.Secret), nil + }) + ts.Require().NoError(err) + + ts.Equal("slack_oidc", claims.Provider) + ts.Equal(ts.Config.SiteURL, claims.SiteURL) +} diff --git a/internal/api/provider/slack.go b/internal/api/provider/slack.go index efe318813..40377b0aa 100644 --- a/internal/api/provider/slack.go +++ b/internal/api/provider/slack.go @@ -23,7 +23,7 @@ type slackUser struct { TeamID string `json:"https://slack.com/team_id"` } -// NewSlackProvider creates a Slack account provider. +// NewSlackProvider creates a Slack account provider with Legacy Slack OAuth. func NewSlackProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) { if err := ext.ValidateOAuth(); err != nil { return nil, err @@ -71,7 +71,7 @@ func (g slackProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*Use if u.Email != "" { data.Emails = []Email{{ Email: u.Email, - Verified: true, // Slack dosen't provide data on if email is verified. + Verified: true, // Slack doesn't provide data on if email is verified. Primary: true, }} } diff --git a/internal/api/provider/slack_oidc.go b/internal/api/provider/slack_oidc.go new file mode 100644 index 000000000..3c7a5eb62 --- /dev/null +++ b/internal/api/provider/slack_oidc.go @@ -0,0 +1,99 @@ +package provider + +import ( + "context" + "strings" + + "github.com/supabase/auth/internal/conf" + "golang.org/x/oauth2" +) + +const defaultSlackOIDCApiBase = "slack.com" + +type slackOIDCProvider struct { + *oauth2.Config + APIPath string +} + +type slackOIDCUser struct { + ID string `json:"https://slack.com/user_id"` + TeamID string `json:"https://slack.com/team_id"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + AvatarURL string `json:"picture"` +} + +// NewSlackOIDCProvider creates a Slack account provider with Sign in with Slack. +func NewSlackOIDCProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) { + if err := ext.ValidateOAuth(); err != nil { + return nil, err + } + + apiPath := chooseHost(ext.URL, defaultSlackOIDCApiBase) + "/api" + authPath := chooseHost(ext.URL, defaultSlackOIDCApiBase) + "/openid" + + // these are required scopes for slack's OIDC flow + // see https://api.slack.com/authentication/sign-in-with-slack#implementation + oauthScopes := []string{ + "profile", + "email", + "openid", + } + + if scopes != "" { + oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...) + } + + return &slackOIDCProvider{ + Config: &oauth2.Config{ + ClientID: ext.ClientID[0], + ClientSecret: ext.Secret, + Endpoint: oauth2.Endpoint{ + AuthURL: authPath + "/connect/authorize", + TokenURL: apiPath + "/openid.connect.token", + }, + Scopes: oauthScopes, + RedirectURL: ext.RedirectURI, + }, + APIPath: apiPath, + }, nil +} + +func (g slackOIDCProvider) GetOAuthToken(code string) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code) +} + +func (g slackOIDCProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { + var u slackOIDCUser + if err := makeRequest(ctx, tok, g.Config, g.APIPath+"/openid.connect.userInfo", &u); err != nil { + return nil, err + } + + data := &UserProvidedData{} + if u.Email != "" { + data.Emails = []Email{{ + Email: u.Email, + // email_verified is returned as part of the response + // see: https://api.slack.com/authentication/sign-in-with-slack#response + Verified: u.EmailVerified, + Primary: true, + }} + } + + data.Metadata = &Claims{ + Issuer: g.APIPath, + Subject: u.ID, + Name: u.Name, + Picture: u.AvatarURL, + CustomClaims: map[string]interface{}{ + "https://slack.com/team_id": u.TeamID, + }, + + // To be deprecated + AvatarURL: u.AvatarURL, + FullName: u.Name, + ProviderId: u.ID, + } + return data, nil +} diff --git a/internal/api/settings.go b/internal/api/settings.go index 9ea93edb7..16817db10 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -21,6 +21,7 @@ type ProviderSettings struct { Notion bool `json:"notion"` Spotify bool `json:"spotify"` Slack bool `json:"slack"` + SlackOIDC bool `json:"slack_oidc"` WorkOS bool `json:"workos"` Twitch bool `json:"twitch"` Twitter bool `json:"twitter"` @@ -62,6 +63,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error { Notion: config.External.Notion.Enabled, Spotify: config.External.Spotify.Enabled, Slack: config.External.Slack.Enabled, + SlackOIDC: config.External.SlackOIDC.Enabled, Twitch: config.External.Twitch.Enabled, Twitter: config.External.Twitter.Enabled, WorkOS: config.External.WorkOS.Enabled, diff --git a/internal/api/settings_test.go b/internal/api/settings_test.go index 42a5d9784..767bcf784 100644 --- a/internal/api/settings_test.go +++ b/internal/api/settings_test.go @@ -35,10 +35,12 @@ func TestSettings_DefaultProviders(t *testing.T) { require.True(t, p.Notion) require.True(t, p.Spotify) require.True(t, p.Slack) + require.True(t, p.SlackOIDC) require.True(t, p.Google) require.True(t, p.Kakao) require.True(t, p.Keycloak) require.True(t, p.Linkedin) + require.True(t, p.LinkedinOIDC) require.True(t, p.GitHub) require.True(t, p.GitLab) require.True(t, p.Twitch) diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 99f0f1879..d3ba720a0 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -298,6 +298,7 @@ type ProviderConfiguration struct { LinkedinOIDC OAuthProviderConfiguration `json:"linkedin_oidc" envconfig:"LINKEDIN_OIDC"` Spotify OAuthProviderConfiguration `json:"spotify"` Slack OAuthProviderConfiguration `json:"slack"` + SlackOIDC OAuthProviderConfiguration `json:"slack_oidc" envconfig:"SLACK_OIDC"` Twitter OAuthProviderConfiguration `json:"twitter"` Twitch OAuthProviderConfiguration `json:"twitch"` WorkOS OAuthProviderConfiguration `json:"workos"` From 28967aa4b5db2363cc581c9da0d64e974eb7b64c Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Thu, 13 Jun 2024 02:14:36 +0800 Subject: [PATCH 028/118] fix: enable rls & update grants for auth tables (#1617) ## What kind of change does this PR introduce? * Previously, users need to grant [these permissions](https://supabase.com/docs/guides/database/database-advisors?lint=0002_auth_users_exposed#security-invoker-view-with-rls-on-authusers) to create views with RLS for tables in the auth schema. * This also unblocks our efforts to revoke `supabase_auth_admin` membership from `postgres` to prevent cases where the `auth.schema_migrations` table is accidentally truncated by the user - causing migrations to be rerun unnecessarily. * Bug fix, feature, docs update, ... ## What is the current behavior? Please link any relevant issues here. ## What is the new behavior? Feel free to include screenshots if it includes visual changes. ## Additional context Add any other context or screenshots. --- ...0612123726_enable_rls_update_grants.up.sql | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 migrations/20240612123726_enable_rls_update_grants.up.sql diff --git a/migrations/20240612123726_enable_rls_update_grants.up.sql b/migrations/20240612123726_enable_rls_update_grants.up.sql new file mode 100644 index 000000000..9201e8496 --- /dev/null +++ b/migrations/20240612123726_enable_rls_update_grants.up.sql @@ -0,0 +1,36 @@ +do $$ begin + -- enable RLS policy on auth tables + alter table {{ index .Options "Namespace" }}.schema_migrations enable row level security; + alter table {{ index .Options "Namespace" }}.instances enable row level security; + alter table {{ index .Options "Namespace" }}.users enable row level security; + alter table {{ index .Options "Namespace" }}.audit_log_entries enable row level security; + alter table {{ index .Options "Namespace" }}.saml_relay_states enable row level security; + alter table {{ index .Options "Namespace" }}.refresh_tokens enable row level security; + alter table {{ index .Options "Namespace" }}.mfa_factors enable row level security; + alter table {{ index .Options "Namespace" }}.sessions enable row level security; + alter table {{ index .Options "Namespace" }}.sso_providers enable row level security; + alter table {{ index .Options "Namespace" }}.sso_domains enable row level security; + alter table {{ index .Options "Namespace" }}.mfa_challenges enable row level security; + alter table {{ index .Options "Namespace" }}.mfa_amr_claims enable row level security; + alter table {{ index .Options "Namespace" }}.saml_providers enable row level security; + alter table {{ index .Options "Namespace" }}.flow_state enable row level security; + alter table {{ index .Options "Namespace" }}.identities enable row level security; + alter table {{ index .Options "Namespace" }}.one_time_tokens enable row level security; + -- allow postgres role to select from auth tables and allow it to grant select to other roles + grant select on {{ index .Options "Namespace" }}.schema_migrations to postgres with grant option; + grant select on {{ index .Options "Namespace" }}.instances to postgres with grant option; + grant select on {{ index .Options "Namespace" }}.users to postgres with grant option; + grant select on {{ index .Options "Namespace" }}.audit_log_entries to postgres with grant option; + grant select on {{ index .Options "Namespace" }}.saml_relay_states to postgres with grant option; + grant select on {{ index .Options "Namespace" }}.refresh_tokens to postgres with grant option; + grant select on {{ index .Options "Namespace" }}.mfa_factors to postgres with grant option; + grant select on {{ index .Options "Namespace" }}.sessions to postgres with grant option; + grant select on {{ index .Options "Namespace" }}.sso_providers to postgres with grant option; + grant select on {{ index .Options "Namespace" }}.sso_domains to postgres with grant option; + grant select on {{ index .Options "Namespace" }}.mfa_challenges to postgres with grant option; + grant select on {{ index .Options "Namespace" }}.mfa_amr_claims to postgres with grant option; + grant select on {{ index .Options "Namespace" }}.saml_providers to postgres with grant option; + grant select on {{ index .Options "Namespace" }}.flow_state to postgres with grant option; + grant select on {{ index .Options "Namespace" }}.identities to postgres with grant option; + grant select on {{ index .Options "Namespace" }}.one_time_tokens to postgres with grant option; +end $$; From 3cd00ee288b6c88577f07b4e2cf1e959ae172404 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Jun 2024 16:41:07 +0200 Subject: [PATCH 029/118] chore(master): release 2.154.0 (#1610) :robot: I have created a release *beep* *boop* --- ## [2.154.0](https://github.com/supabase/auth/compare/v2.153.0...v2.154.0) (2024-06-12) ### Features * add max length check for email ([#1508](https://github.com/supabase/auth/issues/1508)) ([f9c13c0](https://github.com/supabase/auth/commit/f9c13c0ad5c556bede49d3e0f6e5f58ca26161c3)) * add support for Slack OAuth V2 ([#1591](https://github.com/supabase/auth/issues/1591)) ([bb99251](https://github.com/supabase/auth/commit/bb992519cdf7578dc02cd7de55e2e6aa09b4c0f3)) * encrypt sensitive columns ([#1593](https://github.com/supabase/auth/issues/1593)) ([e4a4758](https://github.com/supabase/auth/commit/e4a475820b2dc1f985bd37df15a8ab9e781626f5)) * upgrade otel to v1.26 ([#1585](https://github.com/supabase/auth/issues/1585)) ([cdd13ad](https://github.com/supabase/auth/commit/cdd13adec02eb0c9401bc55a2915c1005d50dea1)) * use largest avatar from spotify instead ([#1210](https://github.com/supabase/auth/issues/1210)) ([4f9994b](https://github.com/supabase/auth/commit/4f9994bf792c3887f2f45910b11a9c19ee3a896b)), closes [#1209](https://github.com/supabase/auth/issues/1209) ### Bug Fixes * define search path in auth functions ([#1616](https://github.com/supabase/auth/issues/1616)) ([357bda2](https://github.com/supabase/auth/commit/357bda23cb2abd12748df80a9d27288aa548534d)) * enable rls & update grants for auth tables ([#1617](https://github.com/supabase/auth/issues/1617)) ([28967aa](https://github.com/supabase/auth/commit/28967aa4b5db2363cc581c9da0d64e974eb7b64c)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4315f21cb..f64d3be31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [2.154.0](https://github.com/supabase/auth/compare/v2.153.0...v2.154.0) (2024-06-12) + + +### Features + +* add max length check for email ([#1508](https://github.com/supabase/auth/issues/1508)) ([f9c13c0](https://github.com/supabase/auth/commit/f9c13c0ad5c556bede49d3e0f6e5f58ca26161c3)) +* add support for Slack OAuth V2 ([#1591](https://github.com/supabase/auth/issues/1591)) ([bb99251](https://github.com/supabase/auth/commit/bb992519cdf7578dc02cd7de55e2e6aa09b4c0f3)) +* encrypt sensitive columns ([#1593](https://github.com/supabase/auth/issues/1593)) ([e4a4758](https://github.com/supabase/auth/commit/e4a475820b2dc1f985bd37df15a8ab9e781626f5)) +* upgrade otel to v1.26 ([#1585](https://github.com/supabase/auth/issues/1585)) ([cdd13ad](https://github.com/supabase/auth/commit/cdd13adec02eb0c9401bc55a2915c1005d50dea1)) +* use largest avatar from spotify instead ([#1210](https://github.com/supabase/auth/issues/1210)) ([4f9994b](https://github.com/supabase/auth/commit/4f9994bf792c3887f2f45910b11a9c19ee3a896b)), closes [#1209](https://github.com/supabase/auth/issues/1209) + + +### Bug Fixes + +* define search path in auth functions ([#1616](https://github.com/supabase/auth/issues/1616)) ([357bda2](https://github.com/supabase/auth/commit/357bda23cb2abd12748df80a9d27288aa548534d)) +* enable rls & update grants for auth tables ([#1617](https://github.com/supabase/auth/issues/1617)) ([28967aa](https://github.com/supabase/auth/commit/28967aa4b5db2363cc581c9da0d64e974eb7b64c)) + ## [2.153.0](https://github.com/supabase/auth/compare/v2.152.0...v2.153.0) (2024-06-04) From f5c6fcd9c3fee0f793f96880a8caebc5b5cb0916 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Mon, 17 Jun 2024 17:01:15 +0800 Subject: [PATCH 030/118] fix: admin user update should update is_anonymous field (#1623) ## What kind of change does this PR introduce? * Fixes #1578 --- internal/api/admin.go | 29 +++++++++--- internal/api/anonymous_test.go | 84 ++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 6 deletions(-) diff --git a/internal/api/admin.go b/internal/api/admin.go index 053a75d35..1cda8e264 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -214,8 +214,9 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error { // if the user doesn't have an existing email // then updating the user's email should create a new email identity i, terr := a.createNewIdentity(tx, user, "email", structs.Map(provider.Claims{ - Subject: user.ID.String(), - Email: params.Email, + Subject: user.ID.String(), + Email: params.Email, + EmailVerified: params.EmailConfirm, })) if terr != nil { return terr @@ -224,11 +225,19 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error { } else { // update the existing email identity if terr := identity.UpdateIdentityData(tx, map[string]interface{}{ - "email": params.Email, + "email": params.Email, + "email_verified": params.EmailConfirm, }); terr != nil { return terr } } + if user.IsAnonymous && params.EmailConfirm { + user.IsAnonymous = false + if terr := tx.UpdateOnly(user, "is_anonymous"); terr != nil { + return terr + } + } + if terr := user.SetEmail(tx, params.Email); terr != nil { return terr } @@ -241,8 +250,9 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error { // if the user doesn't have an existing phone // then updating the user's phone should create a new phone identity identity, terr := a.createNewIdentity(tx, user, "phone", structs.Map(provider.Claims{ - Subject: user.ID.String(), - Phone: params.Phone, + Subject: user.ID.String(), + Phone: params.Phone, + PhoneVerified: params.PhoneConfirm, })) if terr != nil { return terr @@ -251,11 +261,18 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error { } else { // update the existing phone identity if terr := identity.UpdateIdentityData(tx, map[string]interface{}{ - "phone": params.Phone, + "phone": params.Phone, + "phone_verified": params.PhoneConfirm, }); terr != nil { return terr } } + if user.IsAnonymous && params.PhoneConfirm { + user.IsAnonymous = false + if terr := tx.UpdateOnly(user, "is_anonymous"); terr != nil { + return terr + } + } if terr := user.SetPhone(tx, params.Phone); terr != nil { return terr } diff --git a/internal/api/anonymous_test.go b/internal/api/anonymous_test.go index fdee4cc07..92877cf19 100644 --- a/internal/api/anonymous_test.go +++ b/internal/api/anonymous_test.go @@ -8,6 +8,8 @@ import ( "net/http/httptest" "testing" + "github.com/gofrs/uuid" + jwt "github.com/golang-jwt/jwt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -222,3 +224,85 @@ func (ts *AnonymousTestSuite) TestRateLimitAnonymousSignups() { ts.API.handler.ServeHTTP(w, req) assert.Equal(ts.T(), http.StatusBadRequest, w.Code) } + +func (ts *AnonymousTestSuite) TestAdminUpdateAnonymousUser() { + claims := &AccessTokenClaims{ + Role: "supabase_admin", + } + adminJwt, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(ts.Config.JWT.Secret)) + require.NoError(ts.T(), err) + + u1, err := models.NewUser("", "", "", ts.Config.JWT.Aud, nil) + require.NoError(ts.T(), err) + u1.IsAnonymous = true + require.NoError(ts.T(), ts.API.db.Create(u1)) + + u2, err := models.NewUser("", "", "", ts.Config.JWT.Aud, nil) + require.NoError(ts.T(), err) + u2.IsAnonymous = true + require.NoError(ts.T(), ts.API.db.Create(u2)) + + cases := []struct { + desc string + userId uuid.UUID + body map[string]interface{} + expected map[string]interface{} + expectedIdentities int + }{ + { + desc: "update anonymous user with email and email confirm true", + userId: u1.ID, + body: map[string]interface{}{ + "email": "foo@example.com", + "email_confirm": true, + }, + expected: map[string]interface{}{ + "email": "foo@example.com", + "is_anonymous": false, + }, + expectedIdentities: 1, + }, + { + desc: "update anonymous user with email and email confirm false", + userId: u2.ID, + body: map[string]interface{}{ + "email": "bar@example.com", + "email_confirm": false, + }, + expected: map[string]interface{}{ + "email": "bar@example.com", + "is_anonymous": true, + }, + expectedIdentities: 1, + }, + } + + for _, c := range cases { + ts.Run(c.desc, func() { + // Request body + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body)) + + req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/admin/users/%s", c.userId), &buffer) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", adminJwt)) + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var data models.User + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data)) + + require.NotNil(ts.T(), data) + require.Len(ts.T(), data.Identities, c.expectedIdentities) + + actual := map[string]interface{}{ + "email": data.GetEmail(), + "is_anonymous": data.IsAnonymous, + } + + require.Equal(ts.T(), c.expected, actual) + }) + } +} From 06464c013571253d1f18f7ae5e840826c4bd84a7 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Mon, 17 Jun 2024 17:02:13 +0800 Subject: [PATCH 031/118] fix: add ip based limiter (#1622) ## What kind of change does this PR introduce? * Adds ip-based rate limiting on all endpoints that send OTPs either through email or phone with the config `GOTRUE_RATE_LIMIT_OTP` * IP-based rate limiting should always come before the shared limiter, so as to prevent the quota of the shared limiter from being consumed too quickly by the same ip-address --- internal/api/api.go | 55 +++++++++++-- internal/api/middleware_test.go | 134 ++++++++++++++++++++++++++++++++ internal/conf/configuration.go | 1 + 3 files changed, 183 insertions(+), 7 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index 8613a05dc..6e49e8a19 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -136,9 +136,14 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne r.With(sharedLimiter).With(api.requireAdminCredentials).Post("/invite", api.Invite) r.With(sharedLimiter).With(api.verifyCaptcha).Route("/signup", func(r *router) { // rate limit per hour - limiter := tollbooth.NewLimiter(api.config.RateLimitAnonymousUsers/(60*60), &limiter.ExpirableOptions{ + limitAnonymousSignIns := tollbooth.NewLimiter(api.config.RateLimitAnonymousUsers/(60*60), &limiter.ExpirableOptions{ DefaultExpirationTTL: time.Hour, }).SetBurst(int(api.config.RateLimitAnonymousUsers)).SetMethods([]string{"POST"}) + + limitSignups := tollbooth.NewLimiter(api.config.RateLimitOtp/(60*5), &limiter.ExpirableOptions{ + DefaultExpirationTTL: time.Hour, + }).SetBurst(30) + r.Post("/", func(w http.ResponseWriter, r *http.Request) error { params := &SignupParams{} if err := retrieveRequestParams(r, params); err != nil { @@ -148,19 +153,50 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne if !api.config.External.AnonymousUsers.Enabled { return unprocessableEntityError(ErrorCodeAnonymousProviderDisabled, "Anonymous sign-ins are disabled") } - if _, err := api.limitHandler(limiter)(w, r); err != nil { + if _, err := api.limitHandler(limitAnonymousSignIns)(w, r); err != nil { return err } return api.SignupAnonymously(w, r) } + + // apply ip-based rate limiting on otps + if _, err := api.limitHandler(limitSignups)(w, r); err != nil { + return err + } + // apply shared rate limiting on email / phone + if _, err := sharedLimiter(w, r); err != nil { + return err + } return api.Signup(w, r) }) }) - r.With(sharedLimiter).With(api.verifyCaptcha).With(api.requireEmailProvider).Post("/recover", api.Recover) - r.With(sharedLimiter).With(api.verifyCaptcha).Post("/resend", api.Resend) - r.With(sharedLimiter).With(api.verifyCaptcha).Post("/magiclink", api.MagicLink) + r.With(api.limitHandler( + // Allow requests at the specified rate per 5 minutes + tollbooth.NewLimiter(api.config.RateLimitOtp/(60*5), &limiter.ExpirableOptions{ + DefaultExpirationTTL: time.Hour, + }).SetBurst(30), + )).With(sharedLimiter).With(api.verifyCaptcha).With(api.requireEmailProvider).Post("/recover", api.Recover) - r.With(sharedLimiter).With(api.verifyCaptcha).Post("/otp", api.Otp) + r.With(api.limitHandler( + // Allow requests at the specified rate per 5 minutes + tollbooth.NewLimiter(api.config.RateLimitOtp/(60*5), &limiter.ExpirableOptions{ + DefaultExpirationTTL: time.Hour, + }).SetBurst(30), + )).With(sharedLimiter).With(api.verifyCaptcha).Post("/resend", api.Resend) + + r.With(api.limitHandler( + // Allow requests at the specified rate per 5 minutes + tollbooth.NewLimiter(api.config.RateLimitOtp/(60*5), &limiter.ExpirableOptions{ + DefaultExpirationTTL: time.Hour, + }).SetBurst(30), + )).With(sharedLimiter).With(api.verifyCaptcha).Post("/magiclink", api.MagicLink) + + r.With(api.limitHandler( + // Allow requests at the specified rate per 5 minutes + tollbooth.NewLimiter(api.config.RateLimitOtp/(60*5), &limiter.ExpirableOptions{ + DefaultExpirationTTL: time.Hour, + }).SetBurst(30), + )).With(sharedLimiter).With(api.verifyCaptcha).Post("/otp", api.Otp) r.With(api.limitHandler( // Allow requests at the specified rate per 5 minutes. @@ -187,7 +223,12 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne r.With(api.requireAuthentication).Route("/user", func(r *router) { r.Get("/", api.UserGet) - r.With(sharedLimiter).Put("/", api.UserUpdate) + r.With(api.limitHandler( + // Allow requests at the specified rate per 5 minutes + tollbooth.NewLimiter(api.config.RateLimitOtp/(60*5), &limiter.ExpirableOptions{ + DefaultExpirationTTL: time.Hour, + }).SetBurst(30), + )).With(sharedLimiter).Put("/", api.UserUpdate) r.Route("/identities", func(r *router) { r.Use(api.requireManualLinkingEnabled) diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go index a9d908c32..4d0e327f3 100644 --- a/internal/api/middleware_test.go +++ b/internal/api/middleware_test.go @@ -11,6 +11,8 @@ import ( "testing" "time" + "github.com/didip/tollbooth/v5" + "github.com/didip/tollbooth/v5/limiter" jwt "github.com/golang-jwt/jwt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -356,3 +358,135 @@ func TestTimeoutResponseWriter(t *testing.T) { require.Equal(t, w1.Result(), w2.Result()) } + +func (ts *MiddlewareTestSuite) TestLimitHandler() { + ts.Config.RateLimitHeader = "X-Rate-Limit" + lmt := tollbooth.NewLimiter(5, &limiter.ExpirableOptions{ + DefaultExpirationTTL: time.Hour, + }) + + okHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + b, _ := json.Marshal(map[string]interface{}{"message": "ok"}) + w.Write([]byte(b)) + }) + + for i := 0; i < 5; i++ { + req := httptest.NewRequest(http.MethodGet, "http://localhost", nil) + req.Header.Add(ts.Config.RateLimitHeader, "0.0.0.0") + w := httptest.NewRecorder() + ts.API.limitHandler(lmt).handler(okHandler).ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var data map[string]interface{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data)) + require.Equal(ts.T(), "ok", data["message"]) + } + + // 6th request should fail and return a rate limit exceeded error + req := httptest.NewRequest(http.MethodGet, "http://localhost", nil) + req.Header.Add(ts.Config.RateLimitHeader, "0.0.0.0") + w := httptest.NewRecorder() + ts.API.limitHandler(lmt).handler(okHandler).ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusTooManyRequests, w.Code) +} + +func (ts *MiddlewareTestSuite) TestLimitHandlerWithSharedLimiter() { + // setup config for shared limiter and ip-based limiter to work + ts.Config.RateLimitHeader = "X-Rate-Limit" + ts.Config.External.Email.Enabled = true + ts.Config.External.Phone.Enabled = true + ts.Config.Mailer.Autoconfirm = false + ts.Config.Sms.Autoconfirm = false + + ipBasedLimiter := func(max float64) *limiter.Limiter { + return tollbooth.NewLimiter(max, &limiter.ExpirableOptions{ + DefaultExpirationTTL: time.Hour, + }) + } + + okHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + cases := []struct { + desc string + sharedLimiterConfig *conf.GlobalConfiguration + ipBasedLimiterConfig float64 + body map[string]interface{} + expectedErrorCode string + }{ + { + desc: "Exceed ip-based rate limit before shared limiter", + sharedLimiterConfig: &conf.GlobalConfiguration{ + RateLimitEmailSent: 10, + RateLimitSmsSent: 10, + }, + ipBasedLimiterConfig: 1, + body: map[string]interface{}{ + "email": "foo@example.com", + }, + expectedErrorCode: ErrorCodeOverRequestRateLimit, + }, + { + desc: "Exceed email shared limiter", + sharedLimiterConfig: &conf.GlobalConfiguration{ + RateLimitEmailSent: 1, + RateLimitSmsSent: 1, + }, + ipBasedLimiterConfig: 10, + body: map[string]interface{}{ + "email": "foo@example.com", + }, + expectedErrorCode: ErrorCodeOverEmailSendRateLimit, + }, + { + desc: "Exceed sms shared limiter", + sharedLimiterConfig: &conf.GlobalConfiguration{ + RateLimitEmailSent: 1, + RateLimitSmsSent: 1, + }, + ipBasedLimiterConfig: 10, + body: map[string]interface{}{ + "phone": "123456789", + }, + expectedErrorCode: ErrorCodeOverSMSSendRateLimit, + }, + } + + for _, c := range cases { + ts.Run(c.desc, func() { + ts.Config.RateLimitEmailSent = c.sharedLimiterConfig.RateLimitEmailSent + ts.Config.RateLimitSmsSent = c.sharedLimiterConfig.RateLimitSmsSent + lmt := ts.API.limitHandler(ipBasedLimiter(c.ipBasedLimiterConfig)) + sharedLimiter := ts.API.limitEmailOrPhoneSentHandler() + + // get the minimum amount to reach the threshold just before the rate limit is exceeded + threshold := min(c.sharedLimiterConfig.RateLimitEmailSent, c.sharedLimiterConfig.RateLimitSmsSent, c.ipBasedLimiterConfig) + for i := 0; i < int(threshold); i++ { + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body)) + req := httptest.NewRequest(http.MethodPost, "http://localhost", &buffer) + req.Header.Add(ts.Config.RateLimitHeader, "0.0.0.0") + + w := httptest.NewRecorder() + lmt.handler(sharedLimiter.handler(okHandler)).ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + } + + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body)) + req := httptest.NewRequest(http.MethodPost, "http://localhost", &buffer) + req.Header.Add(ts.Config.RateLimitHeader, "0.0.0.0") + + // check if the rate limit is exceeded with the expected error code + w := httptest.NewRecorder() + lmt.handler(sharedLimiter.handler(okHandler)).ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusTooManyRequests, w.Code) + + var data map[string]interface{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data)) + require.Equal(ts.T(), c.expectedErrorCode, data["error_code"]) + }) + } +} diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index d3ba720a0..35024ea52 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -223,6 +223,7 @@ type GlobalConfiguration struct { RateLimitTokenRefresh float64 `split_words:"true" default:"150"` RateLimitSso float64 `split_words:"true" default:"30"` RateLimitAnonymousUsers float64 `split_words:"true" default:"30"` + RateLimitOtp float64 `split_words:"true" default:"30"` SiteURL string `json:"site_url" split_words:"true" required:"true"` URIAllowList []string `json:"uri_allow_list" split_words:"true"` From bca0ea72831c890df5a5bf7207a80f5f8e594179 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Jun 2024 02:44:20 +0200 Subject: [PATCH 032/118] chore(master): release 2.154.1 (#1624) :robot: I have created a release *beep* *boop* --- ## [2.154.1](https://github.com/supabase/auth/compare/v2.154.0...v2.154.1) (2024-06-17) ### Bug Fixes * add ip based limiter ([#1622](https://github.com/supabase/auth/issues/1622)) ([06464c0](https://github.com/supabase/auth/commit/06464c013571253d1f18f7ae5e840826c4bd84a7)) * admin user update should update is_anonymous field ([#1623](https://github.com/supabase/auth/issues/1623)) ([f5c6fcd](https://github.com/supabase/auth/commit/f5c6fcd9c3fee0f793f96880a8caebc5b5cb0916)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f64d3be31..1f3599fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [2.154.1](https://github.com/supabase/auth/compare/v2.154.0...v2.154.1) (2024-06-17) + + +### Bug Fixes + +* add ip based limiter ([#1622](https://github.com/supabase/auth/issues/1622)) ([06464c0](https://github.com/supabase/auth/commit/06464c013571253d1f18f7ae5e840826c4bd84a7)) +* admin user update should update is_anonymous field ([#1623](https://github.com/supabase/auth/issues/1623)) ([f5c6fcd](https://github.com/supabase/auth/commit/f5c6fcd9c3fee0f793f96880a8caebc5b5cb0916)) + ## [2.154.0](https://github.com/supabase/auth/compare/v2.153.0...v2.154.0) (2024-06-12) From 930aa3edb633823d4510c2aff675672df06f1211 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Thu, 20 Jun 2024 10:12:38 +0200 Subject: [PATCH 033/118] fix: publish to ghcr.io/supabase/auth (#1626) ## What kind of change does this PR introduce? Fix #1625 --- .github/workflows/publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c92959778..133163f1c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -25,6 +25,7 @@ jobs: supabase/gotrue public.ecr.aws/supabase/gotrue ghcr.io/supabase/gotrue + ghcr.io/supabase/auth 436098097459.dkr.ecr.us-east-1.amazonaws.com/gotrue 646182064048.dkr.ecr.us-east-1.amazonaws.com/gotrue supabase/auth From e81c25d19551fdebfc5197d96bc220ddb0f8227b Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Thu, 20 Jun 2024 13:54:37 +0200 Subject: [PATCH 034/118] fix: update MaxFrequency error message to reflect number of seconds (#1540) ## What kind of change does this PR introduce? Currently we use a constant value on number of seconds left before a developer can send a follow up email confirmation. This can prove confusing if developer has a custom setting for `MaxFrequency` on `Sms` or `SMTP` We change some core email related routes to show the exact number of seconds. This includes `signup`, `magic_link` and `email_change` The rest will follow should this change roll out smoothly. Tested the three flows locally by triggering the max frequency limit and checking that the number of seconds show up as expected. --- internal/api/errors.go | 7 +++++++ internal/api/magic_link.go | 2 +- internal/api/signup.go | 5 +---- internal/api/user.go | 2 +- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/internal/api/errors.go b/internal/api/errors.go index 2d40a53f4..e821409a7 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "runtime/debug" + "time" "github.com/pkg/errors" "github.com/supabase/auth/internal/observability" @@ -312,3 +313,9 @@ func HandleResponseError(err error, w http.ResponseWriter, r *http.Request) { } } } + +func generateFrequencyLimitErrorMessage(timeStamp *time.Time, maxFrequency time.Duration) string { + now := time.Now() + left := timeStamp.Add(maxFrequency).Sub(now) / time.Second + return fmt.Sprintf("For security purposes, you can only request this after %d seconds.", left) +} diff --git a/internal/api/magic_link.go b/internal/api/magic_link.go index e197d72f6..eeabafd39 100644 --- a/internal/api/magic_link.go +++ b/internal/api/magic_link.go @@ -142,7 +142,7 @@ func (a *API) MagicLink(w http.ResponseWriter, r *http.Request) error { }) if err != nil { if errors.Is(err, MaxFrequencyLimitError) { - return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, "For security purposes, you can only request this once every 60 seconds") + return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, generateFrequencyLimitErrorMessage(user.RecoverySentAt, config.SMTP.MaxFrequency)) } return internalServerError("Error sending magic link").WithInternalError(err) } diff --git a/internal/api/signup.go b/internal/api/signup.go index 75c287cff..b396178db 100644 --- a/internal/api/signup.go +++ b/internal/api/signup.go @@ -2,7 +2,6 @@ package api import ( "context" - "fmt" "net/http" "time" @@ -246,9 +245,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { } if terr = a.sendConfirmation(r, tx, user, flowType); terr != nil { if errors.Is(terr, MaxFrequencyLimitError) { - now := time.Now() - left := user.ConfirmationSentAt.Add(config.SMTP.MaxFrequency).Sub(now) / time.Second - return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, fmt.Sprintf("For security purposes, you can only request this after %d seconds.", left)) + return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, generateFrequencyLimitErrorMessage(user.ConfirmationSentAt, config.SMTP.MaxFrequency)) } return internalServerError("Error sending confirmation mail").WithInternalError(terr) } diff --git a/internal/api/user.go b/internal/api/user.go index 33c35aa57..e76b77453 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -214,7 +214,7 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error { } if terr = a.sendEmailChange(r, tx, user, params.Email, flowType); terr != nil { if errors.Is(terr, MaxFrequencyLimitError) { - return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, "For security purposes, you can only request this once every 60 seconds") + return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, generateFrequencyLimitErrorMessage(user.EmailChangeSentAt, config.SMTP.MaxFrequency)) } return internalServerError("Error sending change email").WithInternalError(terr) } From 155e87ef8129366d665968f64d1fc66676d07e16 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Mon, 24 Jun 2024 17:41:07 +0200 Subject: [PATCH 035/118] fix: revert define search path in auth functions (#1634) Reverts supabase/auth#1616 Follow up to #1633 - more context there and in this discussion: https://supabase.slack.com/archives/C07A55TKL3S/p1719237535404369 --- .../20240612114525_set_search_path.up.sql | 43 ------------------- 1 file changed, 43 deletions(-) delete mode 100644 migrations/20240612114525_set_search_path.up.sql diff --git a/migrations/20240612114525_set_search_path.up.sql b/migrations/20240612114525_set_search_path.up.sql deleted file mode 100644 index 5d6ff2081..000000000 --- a/migrations/20240612114525_set_search_path.up.sql +++ /dev/null @@ -1,43 +0,0 @@ --- set the search_path to an empty string to force fully qualified names in the function -do $$ -begin - -- auth.uid() function - create or replace function auth.uid() - returns uuid - set search_path to '' - as $func$ - select nullif(current_setting('request.jwt.claim.sub', true), '')::uuid; - $func$ language sql stable; - - -- auth.role() function - create or replace function {{ index .Options "Namespace" }}.role() - returns text - set search_path to '' - as $func$ - select nullif(current_setting('request.jwt.claim.role', true), '')::text; - $func$ language sql stable; - - -- auth.email() function - create or replace function {{ index .Options "Namespace" }}.email() - returns text - set search_path to '' - as $func$ - select - coalesce( - current_setting('request.jwt.claim.email', true), - (current_setting('request.jwt.claims', true)::jsonb ->> 'email') - )::text - $func$ language sql stable; - - -- auth.jwt() function - create or replace function {{ index .Options "Namespace" }}.jwt() - returns jsonb - set search_path to '' - as $func$ - select - coalesce( - nullif(current_setting('request.jwt.claim', true), ''), - nullif(current_setting('request.jwt.claims', true), '') - )::jsonb; - $func$ language sql stable; -end $$; From 32afa7e5ade6609055f44d8f09aaef69eeaed051 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 24 Jun 2024 17:44:17 +0200 Subject: [PATCH 036/118] chore(master): release 2.154.2 (#1628) :robot: I have created a release *beep* *boop* --- ## [2.154.2](https://github.com/supabase/auth/compare/v2.154.1...v2.154.2) (2024-06-24) ### Bug Fixes * publish to ghcr.io/supabase/auth ([#1626](https://github.com/supabase/auth/issues/1626)) ([930aa3e](https://github.com/supabase/auth/commit/930aa3edb633823d4510c2aff675672df06f1211)), closes [#1625](https://github.com/supabase/auth/issues/1625) * revert define search path in auth functions ([#1634](https://github.com/supabase/auth/issues/1634)) ([155e87e](https://github.com/supabase/auth/commit/155e87ef8129366d665968f64d1fc66676d07e16)) * update MaxFrequency error message to reflect number of seconds ([#1540](https://github.com/supabase/auth/issues/1540)) ([e81c25d](https://github.com/supabase/auth/commit/e81c25d19551fdebfc5197d96bc220ddb0f8227b)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f3599fe1..c708a5779 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [2.154.2](https://github.com/supabase/auth/compare/v2.154.1...v2.154.2) (2024-06-24) + + +### Bug Fixes + +* publish to ghcr.io/supabase/auth ([#1626](https://github.com/supabase/auth/issues/1626)) ([930aa3e](https://github.com/supabase/auth/commit/930aa3edb633823d4510c2aff675672df06f1211)), closes [#1625](https://github.com/supabase/auth/issues/1625) +* revert define search path in auth functions ([#1634](https://github.com/supabase/auth/issues/1634)) ([155e87e](https://github.com/supabase/auth/commit/155e87ef8129366d665968f64d1fc66676d07e16)) +* update MaxFrequency error message to reflect number of seconds ([#1540](https://github.com/supabase/auth/issues/1540)) ([e81c25d](https://github.com/supabase/auth/commit/e81c25d19551fdebfc5197d96bc220ddb0f8227b)) + ## [2.154.1](https://github.com/supabase/auth/compare/v2.154.0...v2.154.1) (2024-06-17) From d8b47f9d3f0dc8f97ad1de49e45f452ebc726481 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Wed, 26 Jun 2024 18:03:47 +0800 Subject: [PATCH 037/118] fix: improve mfa verify logs (#1635) ## What kind of change does this PR introduce? * Upgrade the totp library to the latest version * Improve logging when mfa verification fails by returning the validation error internally as well as logging the code used --- go.mod | 2 +- go.sum | 2 ++ internal/api/mfa.go | 11 +++++++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 3911c8a69..35ed75024 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/mitchellh/mapstructure v1.1.2 github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 github.com/pkg/errors v0.9.1 - github.com/pquerna/otp v1.3.0 + github.com/pquerna/otp v1.4.0 github.com/rs/cors v1.9.0 github.com/sebest/xff v0.0.0-20160910043805-6c115e0ffa35 github.com/sethvargo/go-password v0.2.0 diff --git a/go.sum b/go.sum index fdfb47a9b..0c289a276 100644 --- a/go.sum +++ b/go.sum @@ -254,6 +254,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/otp v1.3.0 h1:oJV/SkzR33anKXwQU3Of42rL4wbrffP4uvUf1SvS5Xs= github.com/pquerna/otp v1.3.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= +github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= diff --git a/internal/api/mfa.go b/internal/api/mfa.go index d2e8295f7..df70c6b51 100644 --- a/internal/api/mfa.go +++ b/internal/api/mfa.go @@ -5,11 +5,13 @@ import ( "fmt" "net/http" "net/url" + "time" "github.com/aaronarduino/goqrsvg" svg "github.com/ajstarks/svgo" "github.com/boombuler/barcode/qr" "github.com/gofrs/uuid" + "github.com/pquerna/otp" "github.com/pquerna/otp/totp" "github.com/supabase/auth/internal/crypto" "github.com/supabase/auth/internal/hooks" @@ -244,7 +246,12 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { return internalServerError("Database error verifying MFA TOTP secret").WithInternalError(err) } - valid := totp.Validate(params.Code, secret) + valid, verr := totp.ValidateCustom(params.Code, secret, time.Now().UTC(), totp.ValidateOpts{ + Period: 30, + Skew: 1, + Digits: otp.DigitsSix, + Algorithm: otp.AlgorithmSHA1, + }) if config.Hook.MFAVerificationAttempt.Enabled { input := hooks.MFAVerificationAttemptInput{ @@ -282,7 +289,7 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { return err } } - return unprocessableEntityError(ErrorCodeMFAVerificationFailed, "Invalid TOTP code entered") + return unprocessableEntityError(ErrorCodeMFAVerificationFailed, "Invalid TOTP code entered").WithInternalError(verr) } var token *AccessTokenResponse From bbecbd61a46b0c528b1191f48d51f166c06f4b16 Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Thu, 27 Jun 2024 15:07:11 +0200 Subject: [PATCH 038/118] fix: use pointer for `user.EncryptedPassword` (#1637) Makes sure that `NULL` values in the `auth.users.encrypted_password` column are not met with SQL serialization errors. --- internal/api/invite_test.go | 2 +- internal/api/user.go | 2 +- internal/api/verify.go | 4 ++-- internal/models/user.go | 33 ++++++++++++++++++++++++--------- 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/internal/api/invite_test.go b/internal/api/invite_test.go index 1d502adc8..864463d10 100644 --- a/internal/api/invite_test.go +++ b/internal/api/invite_test.go @@ -207,7 +207,7 @@ func (ts *InviteTestSuite) TestVerifyInvite() { now := time.Now() user.InvitedAt = &now user.ConfirmationSentAt = &now - user.EncryptedPassword = "" + user.EncryptedPassword = nil user.ConfirmationToken = crypto.GenerateTokenHash(c.email, c.requestBody["token"].(string)) require.NoError(ts.T(), err) require.NoError(ts.T(), ts.API.db.Create(user)) diff --git a/internal/api/user.go b/internal/api/user.go index e76b77453..6fd8d34e5 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -155,7 +155,7 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error { if password != "" { isSamePassword := false - if user.EncryptedPassword != "" { + if user.HasPassword() { auth, _, err := user.Authenticate(ctx, password, config.Security.DBEncryption.DecryptionKeys, false, "") if err != nil { return err diff --git a/internal/api/verify.go b/internal/api/verify.go index 91df8bc21..1040ce7c6 100644 --- a/internal/api/verify.go +++ b/internal/api/verify.go @@ -306,7 +306,7 @@ func (a *API) verifyPost(w http.ResponseWriter, r *http.Request, params *VerifyP func (a *API) signupVerify(r *http.Request, ctx context.Context, conn *storage.Connection, user *models.User) (*models.User, error) { config := a.config - if user.EncryptedPassword == "" && user.InvitedAt != nil { + if !user.HasPassword() && user.InvitedAt != nil { // sign them up with temporary password, and require application // to present the user with a password set form password, err := password.Generate(64, 10, 0, false, true) @@ -322,7 +322,7 @@ func (a *API) signupVerify(r *http.Request, ctx context.Context, conn *storage.C err := conn.Transaction(func(tx *storage.Connection) error { var terr error - if user.EncryptedPassword == "" && user.InvitedAt != nil { + if !user.HasPassword() && user.InvitedAt != nil { if terr = user.UpdatePassword(tx, nil); terr != nil { return internalServerError("Error storing password").WithInternalError(terr) } diff --git a/internal/models/user.go b/internal/models/user.go index 721819eab..0d074562a 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -25,7 +25,7 @@ type User struct { Email storage.NullString `json:"email" db:"email"` IsSSOUser bool `json:"-" db:"is_sso_user"` - EncryptedPassword string `json:"-" db:"encrypted_password"` + EncryptedPassword *string `json:"-" db:"encrypted_password"` EmailConfirmedAt *time.Time `json:"email_confirmed_at,omitempty" db:"email_confirmed_at"` InvitedAt *time.Time `json:"invited_at,omitempty" db:"invited_at"` @@ -95,7 +95,7 @@ func NewUser(phone, email, password, aud string, userData map[string]interface{} Email: storage.NullString(strings.ToLower(email)), Phone: storage.NullString(phone), UserMetaData: userData, - EncryptedPassword: passwordHash, + EncryptedPassword: &passwordHash, } return user, nil } @@ -106,6 +106,16 @@ func (User) TableName() string { return tableName } +func (u *User) HasPassword() bool { + var pwd string + + if u.EncryptedPassword != nil { + pwd = *u.EncryptedPassword + } + + return pwd != "" +} + // BeforeSave is invoked before the user is saved to the database func (u *User) BeforeSave(tx *pop.Connection) error { if u.EmailConfirmedAt != nil && u.EmailConfirmedAt.IsZero() { @@ -285,7 +295,7 @@ func (u *User) SetPhone(tx *storage.Connection, phone string) error { func (u *User) SetPassword(ctx context.Context, password string, encrypt bool, encryptionKeyID, encryptionKey string) error { if password == "" { - u.EncryptedPassword = "" + u.EncryptedPassword = nil return nil } @@ -294,14 +304,15 @@ func (u *User) SetPassword(ctx context.Context, password string, encrypt bool, e return err } - u.EncryptedPassword = pw + u.EncryptedPassword = &pw if encrypt { es, err := crypto.NewEncryptedString(u.ID.String(), []byte(pw), encryptionKeyID, encryptionKey) if err != nil { return err } - u.EncryptedPassword = es.String() + encryptedPassword := es.String() + u.EncryptedPassword = &encryptedPassword } return nil @@ -341,9 +352,13 @@ func (u *User) UpdatePassword(tx *storage.Connection, sessionID *uuid.UUID) erro // Authenticate a user from a password func (u *User) Authenticate(ctx context.Context, password string, decryptionKeys map[string]string, encrypt bool, encryptionKeyID string) (bool, bool, error) { - hash := u.EncryptedPassword + if u.EncryptedPassword == nil { + return false, false, nil + } + + hash := *u.EncryptedPassword - es := crypto.ParseEncryptedString(u.EncryptedPassword) + es := crypto.ParseEncryptedString(hash) if es != nil { h, err := es.Decrypt(u.ID.String(), decryptionKeys) if err != nil { @@ -719,7 +734,7 @@ func (u *User) UpdateBannedUntil(tx *storage.Connection) error { func (u *User) RemoveUnconfirmedIdentities(tx *storage.Connection, identity *Identity) error { if identity.Provider != "email" && identity.Provider != "phone" { // user is unconfirmed so the password should be reset - u.EncryptedPassword = "" + u.EncryptedPassword = nil if terr := tx.UpdateOnly(u, "encrypted_password"); terr != nil { return terr } @@ -755,7 +770,7 @@ func (u *User) SoftDeleteUser(tx *storage.Connection) error { u.Phone = storage.NullString(obfuscatePhone(u, u.GetPhone())) u.EmailChange = obfuscateEmail(u, u.EmailChange) u.PhoneChange = obfuscatePhone(u, u.PhoneChange) - u.EncryptedPassword = "" + u.EncryptedPassword = nil u.ConfirmationToken = "" u.RecoveryToken = "" u.EmailChangeTokenCurrent = "" From 2cb97f080fa4695766985cc4792d09476534be68 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Fri, 28 Jun 2024 00:22:20 +0800 Subject: [PATCH 039/118] fix: upgrade golang-jwt to v5 (#1639) --- go.mod | 4 ++-- go.sum | 4 ++-- internal/api/admin_test.go | 2 +- internal/api/anonymous_test.go | 2 +- internal/api/audit_test.go | 4 ++-- internal/api/auth.go | 4 ++-- internal/api/auth_test.go | 16 ++++++++-------- internal/api/context.go | 2 +- internal/api/external.go | 8 ++++---- internal/api/external_apple_test.go | 4 ++-- internal/api/external_azure_test.go | 4 ++-- internal/api/external_bitbucket_test.go | 4 ++-- internal/api/external_discord_test.go | 4 ++-- internal/api/external_facebook_test.go | 4 ++-- internal/api/external_figma_test.go | 4 ++-- internal/api/external_fly_test.go | 4 ++-- internal/api/external_github_test.go | 4 ++-- internal/api/external_gitlab_test.go | 4 ++-- internal/api/external_google_test.go | 4 ++-- internal/api/external_kakao_test.go | 4 ++-- internal/api/external_keycloak_test.go | 4 ++-- internal/api/external_linkedin_test.go | 4 ++-- internal/api/external_notion_test.go | 4 ++-- internal/api/external_slack_oidc_test.go | 4 ++-- internal/api/external_twitch_test.go | 4 ++-- internal/api/external_workos_test.go | 8 ++++---- internal/api/external_zoom_test.go | 4 ++-- internal/api/helpers.go | 8 ++++++-- internal/api/identity.go | 3 ++- internal/api/invite_test.go | 4 ++-- internal/api/mail_test.go | 2 +- internal/api/middleware.go | 4 ++-- internal/api/middleware_test.go | 2 +- internal/api/provider/oidc.go | 10 +++++----- internal/api/sso_test.go | 2 +- internal/api/token.go | 16 ++++++++-------- internal/api/user.go | 3 ++- internal/hooks/auth_hooks.go | 6 +++--- 38 files changed, 94 insertions(+), 88 deletions(-) diff --git a/go.mod b/go.mod index 35ed75024..15c9a6651 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,6 @@ require ( github.com/gobuffalo/validate/v3 v3.3.3 // indirect github.com/gobwas/glob v0.2.3 github.com/gofrs/uuid v4.3.1+incompatible - github.com/golang-jwt/jwt v3.2.2+incompatible github.com/jackc/pgconn v1.14.3 github.com/jackc/pgerrcode v0.0.0-20201024163028-a0d42d470451 github.com/jackc/pgproto3/v2 v2.3.3 // indirect @@ -38,6 +37,7 @@ require ( github.com/bits-and-blooms/bitset v1.10.0 // indirect github.com/go-jose/go-jose/v3 v3.0.3 // indirect github.com/gobuffalo/nulls v0.4.2 // indirect + github.com/jackc/pgx/v4 v4.18.2 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect golang.org/x/mod v0.9.0 // indirect @@ -67,7 +67,7 @@ require ( github.com/fatih/structs v1.1.0 github.com/go-chi/chi/v5 v5.0.12 github.com/gobuffalo/pop/v6 v6.1.1 - github.com/jackc/pgx/v4 v4.18.2 + github.com/golang-jwt/jwt/v5 v5.2.1 github.com/standard-webhooks/standard-webhooks/libraries v0.0.0-20240303152453-e0e82adf1721 github.com/supabase/hibp v0.0.0-20231124125943-d225752ae869 github.com/supabase/mailme v0.2.0 diff --git a/go.sum b/go.sum index 0c289a276..66067a406 100644 --- a/go.sum +++ b/go.sum @@ -107,10 +107,10 @@ github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRx github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI= github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= -github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU= github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= diff --git a/internal/api/admin_test.go b/internal/api/admin_test.go index fa046045e..135616c1f 100644 --- a/internal/api/admin_test.go +++ b/internal/api/admin_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" diff --git a/internal/api/anonymous_test.go b/internal/api/anonymous_test.go index 92877cf19..6ab2bae04 100644 --- a/internal/api/anonymous_test.go +++ b/internal/api/anonymous_test.go @@ -9,7 +9,7 @@ import ( "testing" "github.com/gofrs/uuid" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" diff --git a/internal/api/audit_test.go b/internal/api/audit_test.go index 8779ab678..c8e992ead 100644 --- a/internal/api/audit_test.go +++ b/internal/api/audit_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -58,7 +58,7 @@ func (ts *AuditTestSuite) makeSuperAdmin(email string) string { token, _, err = ts.API.generateAccessToken(req, ts.API.db, u, &session.ID, models.PasswordGrant) require.NoError(ts.T(), err, "Error generating access token") - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) _, err = p.Parse(token, func(token *jwt.Token) (interface{}, error) { return []byte(ts.Config.JWT.Secret), nil }) diff --git a/internal/api/auth.go b/internal/api/auth.go index c167d8212..4e63715a1 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -7,7 +7,7 @@ import ( "strings" "github.com/gofrs/uuid" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/storage" ) @@ -75,7 +75,7 @@ func (a *API) parseJWTClaims(bearer string, r *http.Request) (context.Context, e ctx := r.Context() config := a.config - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) token, err := p.ParseWithClaims(bearer, &AccessTokenClaims{}, func(token *jwt.Token) (interface{}, error) { return []byte(config.JWT.Secret), nil }) diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 700d27af1..35a64be92 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -6,7 +6,7 @@ import ( "testing" "github.com/gofrs/uuid" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "github.com/supabase/auth/internal/conf" @@ -91,7 +91,7 @@ func (ts *AuthTestSuite) TestMaybeLoadUserOrSession() { { Desc: "Missing Subject Claim", UserJwtClaims: &AccessTokenClaims{ - StandardClaims: jwt.StandardClaims{ + RegisteredClaims: jwt.RegisteredClaims{ Subject: "", }, Role: "authenticated", @@ -102,7 +102,7 @@ func (ts *AuthTestSuite) TestMaybeLoadUserOrSession() { { Desc: "Valid Subject Claim", UserJwtClaims: &AccessTokenClaims{ - StandardClaims: jwt.StandardClaims{ + RegisteredClaims: jwt.RegisteredClaims{ Subject: u.ID.String(), }, Role: "authenticated", @@ -113,7 +113,7 @@ func (ts *AuthTestSuite) TestMaybeLoadUserOrSession() { { Desc: "Invalid Subject Claim", UserJwtClaims: &AccessTokenClaims{ - StandardClaims: jwt.StandardClaims{ + RegisteredClaims: jwt.RegisteredClaims{ Subject: "invalid-subject-claim", }, Role: "authenticated", @@ -124,7 +124,7 @@ func (ts *AuthTestSuite) TestMaybeLoadUserOrSession() { { Desc: "Empty Session ID Claim", UserJwtClaims: &AccessTokenClaims{ - StandardClaims: jwt.StandardClaims{ + RegisteredClaims: jwt.RegisteredClaims{ Subject: u.ID.String(), }, Role: "authenticated", @@ -136,7 +136,7 @@ func (ts *AuthTestSuite) TestMaybeLoadUserOrSession() { { Desc: "Invalid Session ID Claim", UserJwtClaims: &AccessTokenClaims{ - StandardClaims: jwt.StandardClaims{ + RegisteredClaims: jwt.RegisteredClaims{ Subject: u.ID.String(), }, Role: "authenticated", @@ -148,7 +148,7 @@ func (ts *AuthTestSuite) TestMaybeLoadUserOrSession() { { Desc: "Valid Session ID Claim", UserJwtClaims: &AccessTokenClaims{ - StandardClaims: jwt.StandardClaims{ + RegisteredClaims: jwt.RegisteredClaims{ Subject: u.ID.String(), }, Role: "authenticated", @@ -161,7 +161,7 @@ func (ts *AuthTestSuite) TestMaybeLoadUserOrSession() { { Desc: "Session ID doesn't exist", UserJwtClaims: &AccessTokenClaims{ - StandardClaims: jwt.StandardClaims{ + RegisteredClaims: jwt.RegisteredClaims{ Subject: u.ID.String(), }, Role: "authenticated", diff --git a/internal/api/context.go b/internal/api/context.go index b357299a6..3047f3dd6 100644 --- a/internal/api/context.go +++ b/internal/api/context.go @@ -4,7 +4,7 @@ import ( "context" "net/url" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" "github.com/supabase/auth/internal/models" ) diff --git a/internal/api/external.go b/internal/api/external.go index a8048fb26..ef6032d9a 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -12,7 +12,7 @@ import ( "github.com/fatih/structs" "github.com/gofrs/uuid" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" "github.com/sirupsen/logrus" "github.com/supabase/auth/internal/api/provider" "github.com/supabase/auth/internal/models" @@ -89,8 +89,8 @@ func (a *API) GetExternalProviderRedirectURL(w http.ResponseWriter, r *http.Requ claims := ExternalProviderClaims{ AuthMicroserviceClaims: AuthMicroserviceClaims{ - StandardClaims: jwt.StandardClaims{ - ExpiresAt: time.Now().Add(5 * time.Minute).Unix(), + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)), }, SiteURL: config.SiteURL, InstanceID: uuid.Nil.String(), @@ -481,7 +481,7 @@ func (a *API) processInvite(r *http.Request, tx *storage.Connection, userData *p func (a *API) loadExternalState(ctx context.Context, state string) (context.Context, error) { config := a.config claims := ExternalProviderClaims{} - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) _, err := p.ParseWithClaims(state, &claims, func(token *jwt.Token) (interface{}, error) { return []byte(config.JWT.Secret), nil }) diff --git a/internal/api/external_apple_test.go b/internal/api/external_apple_test.go index 0413e2bda..5a0b4970c 100644 --- a/internal/api/external_apple_test.go +++ b/internal/api/external_apple_test.go @@ -5,7 +5,7 @@ import ( "net/http/httptest" "net/url" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" ) func (ts *ExternalTestSuite) TestSignupExternalApple() { @@ -22,7 +22,7 @@ func (ts *ExternalTestSuite) TestSignupExternalApple() { ts.Equal("email name", q.Get("scope")) claims := ExternalProviderClaims{} - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { return []byte(ts.Config.JWT.Secret), nil }) diff --git a/internal/api/external_azure_test.go b/internal/api/external_azure_test.go index e940ba9f6..aac124c78 100644 --- a/internal/api/external_azure_test.go +++ b/internal/api/external_azure_test.go @@ -15,7 +15,7 @@ import ( "time" "github.com/coreos/go-oidc/v3/oidc" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" "github.com/supabase/auth/internal/api/provider" ) @@ -116,7 +116,7 @@ func (ts *ExternalTestSuite) TestSignupExternalAzure() { ts.Equal("openid", q.Get("scope")) claims := ExternalProviderClaims{} - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { return []byte(ts.Config.JWT.Secret), nil }) diff --git a/internal/api/external_bitbucket_test.go b/internal/api/external_bitbucket_test.go index fad66456f..66b3bd4df 100644 --- a/internal/api/external_bitbucket_test.go +++ b/internal/api/external_bitbucket_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "net/url" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" ) const ( @@ -27,7 +27,7 @@ func (ts *ExternalTestSuite) TestSignupExternalBitbucket() { ts.Equal("account email", q.Get("scope")) claims := ExternalProviderClaims{} - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { return []byte(ts.Config.JWT.Secret), nil }) diff --git a/internal/api/external_discord_test.go b/internal/api/external_discord_test.go index eeae84e13..7b6be8d34 100644 --- a/internal/api/external_discord_test.go +++ b/internal/api/external_discord_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "net/url" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" ) const ( @@ -29,7 +29,7 @@ func (ts *ExternalTestSuite) TestSignupExternalDiscord() { ts.Equal("email identify", q.Get("scope")) claims := ExternalProviderClaims{} - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { return []byte(ts.Config.JWT.Secret), nil }) diff --git a/internal/api/external_facebook_test.go b/internal/api/external_facebook_test.go index c484218f8..c1864bb9c 100644 --- a/internal/api/external_facebook_test.go +++ b/internal/api/external_facebook_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "net/url" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" ) const ( @@ -29,7 +29,7 @@ func (ts *ExternalTestSuite) TestSignupExternalFacebook() { ts.Equal("email", q.Get("scope")) claims := ExternalProviderClaims{} - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { return []byte(ts.Config.JWT.Secret), nil }) diff --git a/internal/api/external_figma_test.go b/internal/api/external_figma_test.go index 45e403759..ccd59b1ff 100644 --- a/internal/api/external_figma_test.go +++ b/internal/api/external_figma_test.go @@ -11,7 +11,7 @@ import ( "net/url" "time" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/require" "github.com/supabase/auth/internal/models" ) @@ -30,7 +30,7 @@ func (ts *ExternalTestSuite) TestSignupExternalFigma() { ts.Equal("file_read", q.Get("scope")) claims := ExternalProviderClaims{} - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { return []byte(ts.Config.JWT.Secret), nil }) diff --git a/internal/api/external_fly_test.go b/internal/api/external_fly_test.go index b8ddf1a6d..cf357c97b 100644 --- a/internal/api/external_fly_test.go +++ b/internal/api/external_fly_test.go @@ -11,7 +11,7 @@ import ( "net/url" "time" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/require" "github.com/supabase/auth/internal/models" ) @@ -30,7 +30,7 @@ func (ts *ExternalTestSuite) TestSignupExternalFly() { ts.Equal("read", q.Get("scope")) claims := ExternalProviderClaims{} - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { return []byte(ts.Config.JWT.Secret), nil }) diff --git a/internal/api/external_github_test.go b/internal/api/external_github_test.go index ca6df9543..7b9d31e89 100644 --- a/internal/api/external_github_test.go +++ b/internal/api/external_github_test.go @@ -11,7 +11,7 @@ import ( "net/url" "time" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/require" "github.com/supabase/auth/internal/models" ) @@ -30,7 +30,7 @@ func (ts *ExternalTestSuite) TestSignupExternalGithub() { ts.Equal("user:email", q.Get("scope")) claims := ExternalProviderClaims{} - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { return []byte(ts.Config.JWT.Secret), nil }) diff --git a/internal/api/external_gitlab_test.go b/internal/api/external_gitlab_test.go index 1a32655cf..5a14a0a2b 100644 --- a/internal/api/external_gitlab_test.go +++ b/internal/api/external_gitlab_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "net/url" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" ) const ( @@ -29,7 +29,7 @@ func (ts *ExternalTestSuite) TestSignupExternalGitlab() { ts.Equal("read_user", q.Get("scope")) claims := ExternalProviderClaims{} - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { return []byte(ts.Config.JWT.Secret), nil }) diff --git a/internal/api/external_google_test.go b/internal/api/external_google_test.go index 8458be5d0..7b3b6d156 100644 --- a/internal/api/external_google_test.go +++ b/internal/api/external_google_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "net/url" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/require" "github.com/supabase/auth/internal/api/provider" ) @@ -34,7 +34,7 @@ func (ts *ExternalTestSuite) TestSignupExternalGoogle() { ts.Equal("email profile", q.Get("scope")) claims := ExternalProviderClaims{} - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { return []byte(ts.Config.JWT.Secret), nil }) diff --git a/internal/api/external_kakao_test.go b/internal/api/external_kakao_test.go index cd2bd2b29..729f723a7 100644 --- a/internal/api/external_kakao_test.go +++ b/internal/api/external_kakao_test.go @@ -8,7 +8,7 @@ import ( "net/url" "time" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/require" "github.com/supabase/auth/internal/api/provider" "github.com/supabase/auth/internal/models" @@ -27,7 +27,7 @@ func (ts *ExternalTestSuite) TestSignupExternalKakao() { ts.Equal("code", q.Get("response_type")) claims := ExternalProviderClaims{} - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { return []byte(ts.Config.JWT.Secret), nil }) diff --git a/internal/api/external_keycloak_test.go b/internal/api/external_keycloak_test.go index 2562664a4..a0952eaac 100644 --- a/internal/api/external_keycloak_test.go +++ b/internal/api/external_keycloak_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "net/url" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" ) const ( @@ -28,7 +28,7 @@ func (ts *ExternalTestSuite) TestSignupExternalKeycloak() { ts.Equal("profile email", q.Get("scope")) claims := ExternalProviderClaims{} - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { return []byte(ts.Config.JWT.Secret), nil }) diff --git a/internal/api/external_linkedin_test.go b/internal/api/external_linkedin_test.go index 27449dd3a..fe49932e5 100644 --- a/internal/api/external_linkedin_test.go +++ b/internal/api/external_linkedin_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "net/url" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" ) const ( @@ -30,7 +30,7 @@ func (ts *ExternalTestSuite) TestSignupExternalLinkedin() { ts.Equal("r_emailaddress r_liteprofile", q.Get("scope")) claims := ExternalProviderClaims{} - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { return []byte(ts.Config.JWT.Secret), nil }) diff --git a/internal/api/external_notion_test.go b/internal/api/external_notion_test.go index 1680b14c2..268e4492b 100644 --- a/internal/api/external_notion_test.go +++ b/internal/api/external_notion_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "net/url" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" ) const ( @@ -28,7 +28,7 @@ func (ts *ExternalTestSuite) TestSignupExternalNotion() { ts.Equal("code", q.Get("response_type")) claims := ExternalProviderClaims{} - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { return []byte(ts.Config.JWT.Secret), nil }) diff --git a/internal/api/external_slack_oidc_test.go b/internal/api/external_slack_oidc_test.go index 9090581d0..acd2e784d 100644 --- a/internal/api/external_slack_oidc_test.go +++ b/internal/api/external_slack_oidc_test.go @@ -5,7 +5,7 @@ import ( "net/http/httptest" "net/url" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" ) func (ts *ExternalTestSuite) TestSignupExternalSlackOIDC() { @@ -22,7 +22,7 @@ func (ts *ExternalTestSuite) TestSignupExternalSlackOIDC() { ts.Equal("profile email openid", q.Get("scope")) claims := ExternalProviderClaims{} - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { return []byte(ts.Config.JWT.Secret), nil }) diff --git a/internal/api/external_twitch_test.go b/internal/api/external_twitch_test.go index 775c44ef8..694a5ff68 100644 --- a/internal/api/external_twitch_test.go +++ b/internal/api/external_twitch_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "net/url" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" ) const ( @@ -28,7 +28,7 @@ func (ts *ExternalTestSuite) TestSignupExternalTwitch() { ts.Equal("user:read:email", q.Get("scope")) claims := ExternalProviderClaims{} - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { return []byte(ts.Config.JWT.Secret), nil }) diff --git a/internal/api/external_workos_test.go b/internal/api/external_workos_test.go index 05d3175cb..eedd5b00e 100644 --- a/internal/api/external_workos_test.go +++ b/internal/api/external_workos_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "net/url" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" ) const ( @@ -31,7 +31,7 @@ func (ts *ExternalTestSuite) TestSignupExternalWorkOSWithConnection() { ts.Equal(connection, q.Get("connection")) claims := ExternalProviderClaims{} - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { return []byte(ts.Config.JWT.Secret), nil }) @@ -57,7 +57,7 @@ func (ts *ExternalTestSuite) TestSignupExternalWorkOSWithOrganization() { ts.Equal(organization, q.Get("organization")) claims := ExternalProviderClaims{} - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { return []byte(ts.Config.JWT.Secret), nil }) @@ -83,7 +83,7 @@ func (ts *ExternalTestSuite) TestSignupExternalWorkOSWithProvider() { ts.Equal(provider, q.Get("provider")) claims := ExternalProviderClaims{} - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { return []byte(ts.Config.JWT.Secret), nil }) diff --git a/internal/api/external_zoom_test.go b/internal/api/external_zoom_test.go index cac2c647f..ea3f15c4d 100644 --- a/internal/api/external_zoom_test.go +++ b/internal/api/external_zoom_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "net/url" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" ) const ( @@ -28,7 +28,7 @@ func (ts *ExternalTestSuite) TestSignupExternalZoom() { ts.Equal("code", q.Get("response_type")) claims := ExternalProviderClaims{} - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { return []byte(ts.Config.JWT.Secret), nil }) diff --git a/internal/api/helpers.go b/internal/api/helpers.go index bcdce1416..58ac82d08 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -36,8 +36,12 @@ func (a *API) requestAud(ctx context.Context, r *http.Request) string { // Then check the token claims := getClaims(ctx) - if claims != nil && claims.Audience != "" { - return claims.Audience + + if claims != nil { + aud, _ := claims.GetAudience() + if len(aud) != 0 { + return aud[0] + } } // Finally, return the default if none of the above methods are successful diff --git a/internal/api/identity.go b/internal/api/identity.go index 7acf7c25c..69d3f854f 100644 --- a/internal/api/identity.go +++ b/internal/api/identity.go @@ -27,7 +27,8 @@ func (a *API) DeleteIdentity(w http.ResponseWriter, r *http.Request) error { } aud := a.requestAud(ctx, r) - if aud != claims.Audience { + audienceFromClaims, _ := claims.GetAudience() + if len(audienceFromClaims) == 0 || aud != audienceFromClaims[0] { return forbiddenError(ErrorCodeUnexpectedAudience, "Token audience doesn't match request audience") } diff --git a/internal/api/invite_test.go b/internal/api/invite_test.go index 864463d10..ff0baca6a 100644 --- a/internal/api/invite_test.go +++ b/internal/api/invite_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -70,7 +70,7 @@ func (ts *InviteTestSuite) makeSuperAdmin(email string) string { require.NoError(ts.T(), err, "Error generating access token") - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) _, err = p.Parse(token, func(token *jwt.Token) (interface{}, error) { return []byte(ts.Config.JWT.Secret), nil }) diff --git a/internal/api/mail_test.go b/internal/api/mail_test.go index 6063a9a6b..90608a13a 100644 --- a/internal/api/mail_test.go +++ b/internal/api/mail_test.go @@ -10,7 +10,7 @@ import ( "testing" "github.com/gobwas/glob" - "github.com/golang-jwt/jwt" + "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "github.com/supabase/auth/internal/conf" diff --git a/internal/api/middleware.go b/internal/api/middleware.go index d4e3068eb..8caa5d885 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -20,13 +20,13 @@ import ( "github.com/didip/tollbooth/v5" "github.com/didip/tollbooth/v5/limiter" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" ) type FunctionHooks map[string][]string type AuthMicroserviceClaims struct { - jwt.StandardClaims + jwt.RegisteredClaims SiteURL string `json:"site_url"` InstanceID string `json:"id"` FunctionHooks FunctionHooks `json:"function_hooks"` diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go index 4d0e327f3..eb8c5da3b 100644 --- a/internal/api/middleware_test.go +++ b/internal/api/middleware_test.go @@ -13,7 +13,7 @@ import ( "github.com/didip/tollbooth/v5" "github.com/didip/tollbooth/v5/limiter" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" diff --git a/internal/api/provider/oidc.go b/internal/api/provider/oidc.go index 01f7ab7e6..51deaeff3 100644 --- a/internal/api/provider/oidc.go +++ b/internal/api/provider/oidc.go @@ -8,7 +8,7 @@ import ( "time" "github.com/coreos/go-oidc/v3/oidc" - "github.com/golang-jwt/jwt" + "github.com/golang-jwt/jwt/v5" ) type ParseIDTokenOptions struct { @@ -120,7 +120,7 @@ func parseGoogleIDToken(token *oidc.IDToken) (*oidc.IDToken, *UserProvidedData, } type AppleIDTokenClaims struct { - jwt.StandardClaims + jwt.RegisteredClaims Email string `json:"email"` @@ -165,7 +165,7 @@ func parseAppleIDToken(token *oidc.IDToken) (*oidc.IDToken, *UserProvidedData, e } type LinkedinIDTokenClaims struct { - jwt.StandardClaims + jwt.RegisteredClaims Email string `json:"email"` EmailVerified string `json:"email_verified"` @@ -210,7 +210,7 @@ func parseLinkedinIDToken(token *oidc.IDToken) (*oidc.IDToken, *UserProvidedData } type AzureIDTokenClaims struct { - jwt.StandardClaims + jwt.RegisteredClaims Email string `json:"email"` Name string `json:"name"` @@ -315,7 +315,7 @@ func parseAzureIDToken(token *oidc.IDToken) (*oidc.IDToken, *UserProvidedData, e } type KakaoIDTokenClaims struct { - jwt.StandardClaims + jwt.RegisteredClaims Email string `json:"email"` Nickname string `json:"nickname"` diff --git a/internal/api/sso_test.go b/internal/api/sso_test.go index 5fc46b2d0..bae1bebf3 100644 --- a/internal/api/sso_test.go +++ b/internal/api/sso_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - jwt "github.com/golang-jwt/jwt" + jwt "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "github.com/supabase/auth/internal/conf" diff --git a/internal/api/token.go b/internal/api/token.go index 11af2883f..0d03d4fd4 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -11,7 +11,7 @@ import ( "fmt" "github.com/gofrs/uuid" - "github.com/golang-jwt/jwt" + "github.com/golang-jwt/jwt/v5" "github.com/xeipuuv/gojsonschema" "github.com/supabase/auth/internal/conf" @@ -24,7 +24,7 @@ import ( // AccessTokenClaims is a struct thats used for JWT claims type AccessTokenClaims struct { - jwt.StandardClaims + jwt.RegisteredClaims Email string `json:"email"` Phone string `json:"phone"` AppMetaData map[string]interface{} `json:"app_metadata"` @@ -324,14 +324,14 @@ func (a *API) generateAccessToken(r *http.Request, tx *storage.Connection, user } issuedAt := time.Now().UTC() - expiresAt := issuedAt.Add(time.Second * time.Duration(config.JWT.Exp)).Unix() + expiresAt := issuedAt.Add(time.Second * time.Duration(config.JWT.Exp)) claims := &hooks.AccessTokenClaims{ - StandardClaims: jwt.StandardClaims{ + RegisteredClaims: jwt.RegisteredClaims{ Subject: user.ID.String(), - Audience: user.Aud, - IssuedAt: issuedAt.Unix(), - ExpiresAt: expiresAt, + Audience: []string{user.Aud}, + IssuedAt: jwt.NewNumericDate(issuedAt), + ExpiresAt: jwt.NewNumericDate(expiresAt), Issuer: config.JWT.Issuer, }, Email: user.GetEmail(), @@ -380,7 +380,7 @@ func (a *API) generateAccessToken(r *http.Request, tx *storage.Connection, user return "", 0, err } - return signed, expiresAt, nil + return signed, expiresAt.Unix(), nil } func (a *API) issueRefreshToken(r *http.Request, conn *storage.Connection, user *models.User, authenticationMethod models.AuthenticationMethod, grantParams models.GrantParams) (*AccessTokenResponse, error) { diff --git a/internal/api/user.go b/internal/api/user.go index 6fd8d34e5..960322c94 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -66,7 +66,8 @@ func (a *API) UserGet(w http.ResponseWriter, r *http.Request) error { } aud := a.requestAud(ctx, r) - if aud != claims.Audience { + audienceFromClaims, _ := claims.GetAudience() + if len(audienceFromClaims) == 0 || aud != audienceFromClaims[0] { return badRequestError(ErrorCodeValidationFailed, "Token audience doesn't match request audience") } diff --git a/internal/hooks/auth_hooks.go b/internal/hooks/auth_hooks.go index 711334429..c11b91b5e 100644 --- a/internal/hooks/auth_hooks.go +++ b/internal/hooks/auth_hooks.go @@ -2,7 +2,7 @@ package hooks import ( "github.com/gofrs/uuid" - "github.com/golang-jwt/jwt" + "github.com/golang-jwt/jwt/v5" "github.com/supabase/auth/internal/mailer" "github.com/supabase/auth/internal/models" ) @@ -43,7 +43,7 @@ const MinimumViableTokenSchema = `{ "type": "object", "properties": { "aud": { - "type": "string" + "type": "array" }, "exp": { "type": "integer" @@ -98,7 +98,7 @@ const MinimumViableTokenSchema = `{ // AccessTokenClaims is a struct thats used for JWT claims type AccessTokenClaims struct { - jwt.StandardClaims + jwt.RegisteredClaims Email string `json:"email"` Phone string `json:"phone"` AppMetaData map[string]interface{} `json:"app_metadata"` From 97b8f1ba3f615d192c2952977415b96f2576bf46 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Tue, 2 Jul 2024 22:35:49 +0200 Subject: [PATCH 040/118] fix: return proper error message when invalid email_change token is passed in (#1643) ## What kind of change does this PR introduce? Currently, when `SECURE_EMAIL_CHANGE` is enabled and a dev invokes `verifyOtp({type:'email_change', token:'', email:''})` an internalServerError and panic is returned. After this change a 403 is returned as expected. This is because when an invalid `email_change` OTP is submitted [an access on ott.UserID (which is nil) is made ](https://github.com/supabase/auth/compare/j0/prevent_panic_on_email_change?expand=1#diff-55a81f8dc53ee75ff592eec9c5e1fcb665f653d7593de7e9c8b8dadf7159fe83L258) --- internal/api/verify_test.go | 16 ++++++++++++++++ internal/models/one_time_token.go | 6 ++++++ 2 files changed, 22 insertions(+) diff --git a/internal/api/verify_test.go b/internal/api/verify_test.go index 8d818b43a..73ef6f768 100644 --- a/internal/api/verify_test.go +++ b/internal/api/verify_test.go @@ -318,9 +318,14 @@ func (ts *VerifyTestSuite) TestInvalidOtp() { u.PhoneChange = "22222222" u.PhoneChangeToken = "123456" u.PhoneChangeSentAt = &sentTime + u.EmailChange = "test@gmail.com" + u.EmailChangeTokenNew = "123456" + u.EmailChangeTokenCurrent = "123456" require.NoError(ts.T(), ts.API.db.Update(u)) require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.ConfirmationToken, models.ConfirmationToken)) require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.PhoneChange, u.PhoneChangeToken, models.PhoneChangeToken)) + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.EmailChangeTokenCurrent, models.EmailChangeTokenCurrent)) + require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.EmailChange, u.EmailChangeTokenNew, models.EmailChangeTokenNew)) type ResponseBody struct { Code int `json:"code"` @@ -378,6 +383,16 @@ func (ts *VerifyTestSuite) TestInvalidOtp() { }, expected: expectedResponse, }, + { + desc: "Invalid Email Change", + sentTime: time.Now(), + body: map[string]interface{}{ + "type": mail.EmailChangeVerification, + "token": "invalid_otp", + "email": u.GetEmail(), + }, + expected: expectedResponse, + }, } for _, caseItem := range cases { @@ -814,6 +829,7 @@ func (ts *VerifyTestSuite) TestVerifyBannedUser() { } func (ts *VerifyTestSuite) TestVerifyValidOtp() { + ts.Config.Mailer.SecureEmailChangeEnabled = true u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) u.EmailChange = "new@example.com" diff --git a/internal/models/one_time_token.go b/internal/models/one_time_token.go index c5a204902..3077647a5 100644 --- a/internal/models/one_time_token.go +++ b/internal/models/one_time_token.go @@ -228,6 +228,9 @@ func FindUserByEmailChangeCurrentAndAudience(tx *storage.Connection, email, toke return nil, err } } + if ott == nil { + return nil, err + } user, err := FindUserByID(tx, ott.UserID) if err != nil { @@ -254,6 +257,9 @@ func FindUserByEmailChangeNewAndAudience(tx *storage.Connection, email, token, a return nil, err } } + if ott == nil { + return nil, err + } user, err := FindUserByID(tx, ott.UserID) if err != nil { From 3f70d9d8974d0e9c437c51e1312ad17ce9056ec9 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Wed, 3 Jul 2024 12:12:33 -0700 Subject: [PATCH 041/118] fix: invited users should have a temporary password generated (#1644) ## What kind of change does this PR introduce? * Fixes a bug in the boolean condition where an invited user fails to have their password updated to a temporary password on invite confirmation --- internal/api/verify.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/api/verify.go b/internal/api/verify.go index 1040ce7c6..c0b78d680 100644 --- a/internal/api/verify.go +++ b/internal/api/verify.go @@ -306,6 +306,7 @@ func (a *API) verifyPost(w http.ResponseWriter, r *http.Request, params *VerifyP func (a *API) signupVerify(r *http.Request, ctx context.Context, conn *storage.Connection, user *models.User) (*models.User, error) { config := a.config + shouldUpdatePassword := false if !user.HasPassword() && user.InvitedAt != nil { // sign them up with temporary password, and require application // to present the user with a password set form @@ -318,11 +319,12 @@ func (a *API) signupVerify(r *http.Request, ctx context.Context, conn *storage.C if err := user.SetPassword(ctx, password, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil { return nil, err } + shouldUpdatePassword = true } err := conn.Transaction(func(tx *storage.Connection) error { var terr error - if !user.HasPassword() && user.InvitedAt != nil { + if shouldUpdatePassword { if terr = user.UpdatePassword(tx, nil); terr != nil { return internalServerError("Error storing password").WithInternalError(terr) } From 20d59f10b601577683d05bcd7d2128ff4bc462a0 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Wed, 3 Jul 2024 12:13:22 -0700 Subject: [PATCH 042/118] feat: add `password_hash` and `id` fields to admin create user (#1641) ## What kind of change does this PR introduce? * Add a `password_hash` field to admin create user, which allows an admin to create a user with a given password hash (argon2 or bcrypt) * Add an `id` field to admin create user, which allows an admin to create a user with a custom id * To prevent someone from creating a bunch of users with a high bcrypt hashing cost, we opt to rehash the password with the default cost (10) on subsequent sign-in. ## What is the current behavior? * Only plaintext passwords are allowed, which will subsequently be hashed internally ## What is the new behavior? Example request using the bcrypt hash of "test": ```bash $ curl -X POST 'http://localhost:9999/admin/users' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{"email": "foo@example.com", "password_hash": "$2y$10$SXEz2HeT8PUIGQXo9yeUIem8KzNxgG0d7o/.eGj2rj8KbRgAuRVlq"}' ``` Example request using a custom id: ```bash $ curl -X POST 'http://localhost:9999/admin/users' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{"id": "2a8813c2-bda7-47f0-94a6-49fcfdf61a70", "email": "foo@example.com"}' ``` Feel free to include screenshots if it includes visual changes. ## Additional context Add any other context or screenshots. --- internal/api/admin.go | 65 ++++++++++++++------ internal/api/admin_test.go | 112 ++++++++++++++++++++++++++++++++--- internal/api/token.go | 2 +- internal/api/user.go | 2 +- internal/api/user_test.go | 6 +- internal/crypto/password.go | 73 +++++++++++++++-------- internal/models/user.go | 44 +++++++++++++- internal/models/user_test.go | 92 ++++++++++++++++++++++++++++ 8 files changed, 340 insertions(+), 56 deletions(-) diff --git a/internal/api/admin.go b/internal/api/admin.go index 1cda8e264..ecd9c2053 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "net/http" - "strings" "time" "github.com/fatih/structs" @@ -18,11 +17,13 @@ import ( ) type AdminUserParams struct { + Id string `json:"id"` Aud string `json:"aud"` Role string `json:"role"` Email string `json:"email"` Phone string `json:"phone"` Password *string `json:"password"` + PasswordHash string `json:"password_hash"` EmailConfirm bool `json:"email_confirm"` PhoneConfirm bool `json:"phone_confirm"` UserMetaData map[string]interface{} `json:"user_metadata"` @@ -156,6 +157,7 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error { } } + var banDuration *time.Duration if params.BanDuration != "" { duration := time.Duration(0) if params.BanDuration != "none" { @@ -164,9 +166,7 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error { return badRequestError(ErrorCodeValidationFailed, "invalid format for ban duration: %v", err) } } - if terr := user.Ban(a.db, duration); terr != nil { - return terr - } + banDuration = &duration } if params.Password != nil { @@ -291,6 +291,12 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error { } } + if banDuration != nil { + if terr := user.Ban(tx, *banDuration); terr != nil { + return terr + } + } + if terr := models.NewAuditLogEntry(r, tx, adminUser, models.UserModifiedAction, "", map[string]interface{}{ "user_id": user.ID, "user_email": user.Email, @@ -356,7 +362,11 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error { providers = append(providers, "phone") } - if params.Password == nil || *params.Password == "" { + if params.Password != nil && params.PasswordHash != "" { + return badRequestError(ErrorCodeValidationFailed, "Only a password or a password hash should be provided") + } + + if (params.Password == nil || *params.Password == "") && params.PasswordHash == "" { password, err := password.Generate(64, 10, 0, false, true) if err != nil { return internalServerError("Error generating password").WithInternalError(err) @@ -364,11 +374,28 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error { params.Password = &password } - user, err := models.NewUser(params.Phone, params.Email, *params.Password, aud, params.UserMetaData) + var user *models.User + if params.PasswordHash != "" { + user, err = models.NewUserWithPasswordHash(params.Phone, params.Email, params.PasswordHash, aud, params.UserMetaData) + } else { + user, err = models.NewUser(params.Phone, params.Email, *params.Password, aud, params.UserMetaData) + } + if err != nil { return internalServerError("Error creating user").WithInternalError(err) } + if params.Id != "" { + customId, err := uuid.FromString(params.Id) + if err != nil { + return badRequestError(ErrorCodeValidationFailed, "ID must conform to the uuid v4 format") + } + if customId == uuid.Nil { + return badRequestError(ErrorCodeValidationFailed, "ID cannot be a nil uuid") + } + user.ID = customId + } + user.AppMetaData = map[string]interface{}{ // TODO: Deprecate "provider" field // default to the first provider in the providers slice @@ -376,6 +403,18 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error { "providers": providers, } + var banDuration *time.Duration + if params.BanDuration != "" { + duration := time.Duration(0) + if params.BanDuration != "none" { + duration, err = time.ParseDuration(params.BanDuration) + if err != nil { + return badRequestError(ErrorCodeValidationFailed, "invalid format for ban duration: %v", err) + } + } + banDuration = &duration + } + err = db.Transaction(func(tx *storage.Connection) error { if terr := tx.Create(user); terr != nil { return terr @@ -442,15 +481,8 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error { } } - if params.BanDuration != "" { - duration := time.Duration(0) - if params.BanDuration != "none" { - duration, err = time.ParseDuration(params.BanDuration) - if err != nil { - return badRequestError(ErrorCodeValidationFailed, "invalid format for ban duration: %v", err) - } - } - if terr := user.Ban(a.db, duration); terr != nil { + if banDuration != nil { + if terr := user.Ban(tx, *banDuration); terr != nil { return terr } } @@ -459,9 +491,6 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error { }) if err != nil { - if strings.Contains("invalid format for ban duration", err.Error()) { - return err - } return internalServerError("Database error creating new user").WithInternalError(err) } diff --git a/internal/api/admin_test.go b/internal/api/admin_test.go index 135616c1f..e1b2c0328 100644 --- a/internal/api/admin_test.go +++ b/internal/api/admin_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/gofrs/uuid" jwt "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -244,6 +245,7 @@ func (ts *AdminTestSuite) TestAdminUserCreate() { "isAuthenticated": true, "provider": "phone", "providers": []string{"phone"}, + "password": "test1", }, }, { @@ -259,6 +261,7 @@ func (ts *AdminTestSuite) TestAdminUserCreate() { "isAuthenticated": true, "provider": "email", "providers": []string{"email", "phone"}, + "password": "test1", }, }, { @@ -288,6 +291,7 @@ func (ts *AdminTestSuite) TestAdminUserCreate() { "isAuthenticated": false, "provider": "email", "providers": []string{"email"}, + "password": "", }, }, { @@ -304,6 +308,39 @@ func (ts *AdminTestSuite) TestAdminUserCreate() { "isAuthenticated": true, "provider": "email", "providers": []string{"email"}, + "password": "test1", + }, + }, + { + desc: "With password hash", + params: map[string]interface{}{ + "email": "test5@example.com", + "password_hash": "$2y$10$SXEz2HeT8PUIGQXo9yeUIem8KzNxgG0d7o/.eGj2rj8KbRgAuRVlq", + }, + expected: map[string]interface{}{ + "email": "test5@example.com", + "phone": "", + "isAuthenticated": true, + "provider": "email", + "providers": []string{"email"}, + "password": "test", + }, + }, + { + desc: "With custom id", + params: map[string]interface{}{ + "id": "fc56ab41-2010-4870-a9b9-767c1dc573fb", + "email": "test6@example.com", + "password": "test", + }, + expected: map[string]interface{}{ + "id": "fc56ab41-2010-4870-a9b9-767c1dc573fb", + "email": "test6@example.com", + "phone": "", + "isAuthenticated": true, + "provider": "email", + "providers": []string{"email"}, + "password": "test", }, }, } @@ -345,15 +382,18 @@ func (ts *AdminTestSuite) TestAdminUserCreate() { } } - var expectedPassword string - if _, ok := c.params["password"]; ok { - expectedPassword = fmt.Sprintf("%v", c.params["password"]) + if _, ok := c.expected["password"]; ok { + expectedPassword := fmt.Sprintf("%v", c.expected["password"]) + isAuthenticated, _, err := u.Authenticate(context.Background(), ts.API.db, expectedPassword, ts.API.config.Security.DBEncryption.DecryptionKeys, ts.API.config.Security.DBEncryption.Encrypt, ts.API.config.Security.DBEncryption.EncryptionKeyID) + require.NoError(ts.T(), err) + require.Equal(ts.T(), c.expected["isAuthenticated"], isAuthenticated) } - isAuthenticated, _, err := u.Authenticate(context.Background(), expectedPassword, ts.API.config.Security.DBEncryption.DecryptionKeys, ts.API.config.Security.DBEncryption.Encrypt, ts.API.config.Security.DBEncryption.EncryptionKeyID) - require.NoError(ts.T(), err) - - assert.Equal(ts.T(), c.expected["isAuthenticated"], isAuthenticated) + if id, ok := c.expected["id"]; ok { + uid, err := uuid.FromString(id.(string)) + require.NoError(ts.T(), err) + require.Equal(ts.T(), uid, data.ID) + } // remove created user after each case require.NoError(ts.T(), ts.API.db.Destroy(u)) @@ -820,5 +860,63 @@ func (ts *AdminTestSuite) TestAdminUserUpdateFactor() { require.Equal(ts.T(), c.ExpectedCode, w.Code) }) } +} + +func (ts *AdminTestSuite) TestAdminUserCreateValidationErrors() { + cases := []struct { + desc string + params map[string]interface{} + }{ + { + desc: "create user without email and phone", + params: map[string]interface{}{ + "password": "test_password", + }, + }, + { + desc: "create user with password and password hash", + params: map[string]interface{}{ + "email": "test@example.com", + "password": "test_password", + "password_hash": "$2y$10$Tk6yEdmTbb/eQ/haDMaCsuCsmtPVprjHMcij1RqiJdLGPDXnL3L1a", + }, + }, + { + desc: "invalid ban duration", + params: map[string]interface{}{ + "email": "test@example.com", + "ban_duration": "never", + }, + }, + { + desc: "custom id is nil", + params: map[string]interface{}{ + "id": "00000000-0000-0000-0000-000000000000", + "email": "test@example.com", + }, + }, + { + desc: "bad id format", + params: map[string]interface{}{ + "id": "bad_uuid_format", + "email": "test@example.com", + }, + }, + } + for _, c := range cases { + ts.Run(c.desc, func() { + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.params)) + req := httptest.NewRequest(http.MethodPost, "/admin/users", &buffer) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token)) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusBadRequest, w.Code, w) + data := map[string]interface{}{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data)) + require.Equal(ts.T(), data["error_code"], ErrorCodeValidationFailed) + }) + + } } diff --git a/internal/api/token.go b/internal/api/token.go index 0d03d4fd4..22c42e9b1 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -145,7 +145,7 @@ func (a *API) ResourceOwnerPasswordGrant(ctx context.Context, w http.ResponseWri return oauthError("invalid_grant", InvalidLoginMessage) } - isValidPassword, shouldReEncrypt, err := user.Authenticate(ctx, params.Password, config.Security.DBEncryption.DecryptionKeys, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID) + isValidPassword, shouldReEncrypt, err := user.Authenticate(ctx, db, params.Password, config.Security.DBEncryption.DecryptionKeys, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID) if err != nil { return err } diff --git a/internal/api/user.go b/internal/api/user.go index 960322c94..4cb1fc1d9 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -157,7 +157,7 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error { isSamePassword := false if user.HasPassword() { - auth, _, err := user.Authenticate(ctx, password, config.Security.DBEncryption.DecryptionKeys, false, "") + auth, _, err := user.Authenticate(ctx, db, password, config.Security.DBEncryption.DecryptionKeys, false, "") if err != nil { return err } diff --git a/internal/api/user_test.go b/internal/api/user_test.go index 8272bb87e..af9cfec37 100644 --- a/internal/api/user_test.go +++ b/internal/api/user_test.go @@ -310,7 +310,7 @@ func (ts *UserTestSuite) TestUserUpdatePassword() { u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) - isAuthenticated, _, err := u.Authenticate(context.Background(), c.newPassword, ts.API.config.Security.DBEncryption.DecryptionKeys, ts.API.config.Security.DBEncryption.Encrypt, ts.API.config.Security.DBEncryption.EncryptionKeyID) + isAuthenticated, _, err := u.Authenticate(context.Background(), ts.API.db, c.newPassword, ts.API.config.Security.DBEncryption.DecryptionKeys, ts.API.config.Security.DBEncryption.Encrypt, ts.API.config.Security.DBEncryption.EncryptionKeyID) require.NoError(ts.T(), err) require.Equal(ts.T(), c.expected.isAuthenticated, isAuthenticated) @@ -372,7 +372,7 @@ func (ts *UserTestSuite) TestUserUpdatePasswordNoReauthenticationRequired() { u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) - isAuthenticated, _, err := u.Authenticate(context.Background(), c.newPassword, ts.API.config.Security.DBEncryption.DecryptionKeys, ts.API.config.Security.DBEncryption.Encrypt, ts.API.config.Security.DBEncryption.EncryptionKeyID) + isAuthenticated, _, err := u.Authenticate(context.Background(), ts.API.db, c.newPassword, ts.API.config.Security.DBEncryption.DecryptionKeys, ts.API.config.Security.DBEncryption.Encrypt, ts.API.config.Security.DBEncryption.EncryptionKeyID) require.NoError(ts.T(), err) require.Equal(ts.T(), c.expected.isAuthenticated, isAuthenticated) @@ -430,7 +430,7 @@ func (ts *UserTestSuite) TestUserUpdatePasswordReauthentication() { u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) - isAuthenticated, _, err := u.Authenticate(context.Background(), "newpass", ts.Config.Security.DBEncryption.DecryptionKeys, ts.Config.Security.DBEncryption.Encrypt, ts.Config.Security.DBEncryption.EncryptionKeyID) + isAuthenticated, _, err := u.Authenticate(context.Background(), ts.API.db, "newpass", ts.Config.Security.DBEncryption.DecryptionKeys, ts.Config.Security.DBEncryption.Encrypt, ts.Config.Security.DBEncryption.EncryptionKeyID) require.NoError(ts.T(), err) require.True(ts.T(), isAuthenticated) diff --git a/internal/crypto/password.go b/internal/crypto/password.go index 554daccaa..dca101450 100644 --- a/internal/crypto/password.go +++ b/internal/crypto/password.go @@ -32,6 +32,8 @@ const ( // BCrypt hashed passwords have a 72 character limit MaxPasswordLength = 72 + + Argon2Prefix = "$argon2" ) // PasswordHashCost is the current pasword hashing cost @@ -54,11 +56,23 @@ var ErrArgon2MismatchedHashAndPassword = errors.New("crypto: argon2 hash and pas // argon2HashRegexp https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md#argon2-encoding var argon2HashRegexp = regexp.MustCompile("^[$](?Pargon2(d|i|id))[$]v=(?P(16|19))[$]m=(?P[0-9]+),t=(?P[0-9]+),p=(?P

[0-9]+)(,keyid=(?P[^,]+))?(,data=(?P[^$]+))?[$](?P[^$]+)[$](?P.+)$") -func compareHashAndPasswordArgon2(ctx context.Context, hash, password string) error { +type Argon2HashInput struct { + alg string + v string + memory uint64 + time uint64 + threads uint64 + keyid string + data string + salt []byte + rawHash []byte +} + +func ParseArgon2Hash(hash string) (*Argon2HashInput, error) { submatch := argon2HashRegexp.FindStringSubmatchIndex(hash) if submatch == nil { - return errors.New("crypto: incorrect argon2 hash format") + return nil, errors.New("crypto: incorrect argon2 hash format") } alg := string(argon2HashRegexp.ExpandString(nil, "$alg", hash, submatch)) @@ -72,58 +86,68 @@ func compareHashAndPasswordArgon2(ctx context.Context, hash, password string) er hashB64 := string(argon2HashRegexp.ExpandString(nil, "$hash", hash, submatch)) if alg != "argon2i" && alg != "argon2id" { - return fmt.Errorf("crypto: argon2 hash uses unsupported algorithm %q only argon2i and argon2id supported", alg) + return nil, fmt.Errorf("crypto: argon2 hash uses unsupported algorithm %q only argon2i and argon2id supported", alg) } if v != "19" { - return fmt.Errorf("crypto: argon2 hash uses unsupported version %q only %d is supported", v, argon2.Version) + return nil, fmt.Errorf("crypto: argon2 hash uses unsupported version %q only %d is supported", v, argon2.Version) } if data != "" { - return fmt.Errorf("crypto: argon2 hashes with the data parameter not supported") + return nil, fmt.Errorf("crypto: argon2 hashes with the data parameter not supported") } if keyid != "" { - return fmt.Errorf("crypto: argon2 hashes with the keyid parameter not supported") + return nil, fmt.Errorf("crypto: argon2 hashes with the keyid parameter not supported") } memory, err := strconv.ParseUint(m, 10, 32) if err != nil { - return fmt.Errorf("crypto: argon2 hash has invalid m parameter %q %w", m, err) + return nil, fmt.Errorf("crypto: argon2 hash has invalid m parameter %q %w", m, err) } time, err := strconv.ParseUint(t, 10, 32) if err != nil { - return fmt.Errorf("crypto: argon2 hash has invalid t parameter %q %w", t, err) + return nil, fmt.Errorf("crypto: argon2 hash has invalid t parameter %q %w", t, err) } threads, err := strconv.ParseUint(p, 10, 8) if err != nil { - return fmt.Errorf("crypto: argon2 hash has invalid p parameter %q %w", p, err) + return nil, fmt.Errorf("crypto: argon2 hash has invalid p parameter %q %w", p, err) } rawHash, err := base64.RawStdEncoding.DecodeString(hashB64) if err != nil { - return fmt.Errorf("crypto: argon2 hash has invalid base64 in the hash section %w", err) + return nil, fmt.Errorf("crypto: argon2 hash has invalid base64 in the hash section %w", err) } salt, err := base64.RawStdEncoding.DecodeString(saltB64) if err != nil { - return fmt.Errorf("crypto: argon2 hash has invalid base64 in the salt section %w", err) + return nil, fmt.Errorf("crypto: argon2 hash has invalid base64 in the salt section %w", err) } - var match bool - var derivedKey []byte + input := Argon2HashInput{alg, v, memory, time, threads, keyid, data, salt, rawHash} + + return &input, nil +} + +func compareHashAndPasswordArgon2(ctx context.Context, hash, password string) error { + input, err := ParseArgon2Hash(hash) + if err != nil { + return err + } attributes := []attribute.KeyValue{ - attribute.String("alg", alg), - attribute.String("v", v), - attribute.Int64("m", int64(memory)), - attribute.Int64("t", int64(time)), - attribute.Int("p", int(threads)), - attribute.Int("len", len(rawHash)), + attribute.String("alg", input.alg), + attribute.String("v", input.v), + attribute.Int64("m", int64(input.memory)), + attribute.Int64("t", int64(input.time)), + attribute.Int("p", int(input.threads)), + attribute.Int("len", len(input.rawHash)), } + var match bool + var derivedKey []byte compareHashAndPasswordSubmittedCounter.Add(ctx, 1, metric.WithAttributes(attributes...)) defer func() { attributes = append(attributes, attribute.Bool( @@ -134,15 +158,15 @@ func compareHashAndPasswordArgon2(ctx context.Context, hash, password string) er compareHashAndPasswordCompletedCounter.Add(ctx, 1, metric.WithAttributes(attributes...)) }() - switch alg { + switch input.alg { case "argon2i": - derivedKey = argon2.Key([]byte(password), salt, uint32(time), uint32(memory)*1024, uint8(threads), uint32(len(rawHash))) + derivedKey = argon2.Key([]byte(password), input.salt, uint32(input.time), uint32(input.memory)*1024, uint8(input.threads), uint32(len(input.rawHash))) case "argon2id": - derivedKey = argon2.IDKey([]byte(password), salt, uint32(time), uint32(memory)*1024, uint8(threads), uint32(len(rawHash))) + derivedKey = argon2.IDKey([]byte(password), input.salt, uint32(input.time), uint32(input.memory)*1024, uint8(input.threads), uint32(len(input.rawHash))) } - match = subtle.ConstantTimeCompare(derivedKey, rawHash) == 0 + match = subtle.ConstantTimeCompare(derivedKey, input.rawHash) == 0 if !match { return ErrArgon2MismatchedHashAndPassword @@ -155,7 +179,7 @@ func compareHashAndPasswordArgon2(ctx context.Context, hash, password string) er // password, returns nil if equal otherwise an error. Context can be used to // cancel the hashing if the algorithm supports it. func CompareHashAndPassword(ctx context.Context, hash, password string) error { - if strings.HasPrefix(hash, "$argon2") { + if strings.HasPrefix(hash, Argon2Prefix) { return compareHashAndPasswordArgon2(ctx, hash, password) } @@ -181,7 +205,6 @@ func CompareHashAndPassword(ctx context.Context, hash, password string) error { }() err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) - return err } diff --git a/internal/models/user.go b/internal/models/user.go index 0d074562a..3c871e543 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -14,6 +14,7 @@ import ( "github.com/pkg/errors" "github.com/supabase/auth/internal/crypto" "github.com/supabase/auth/internal/storage" + "golang.org/x/crypto/bcrypt" ) // User respresents a registered user with email/password authentication @@ -71,6 +72,31 @@ type User struct { DONTUSEINSTANCEID uuid.UUID `json:"-" db:"instance_id"` } +func NewUserWithPasswordHash(phone, email, passwordHash, aud string, userData map[string]interface{}) (*User, error) { + if strings.HasPrefix(passwordHash, crypto.Argon2Prefix) { + _, err := crypto.ParseArgon2Hash(passwordHash) + if err != nil { + return nil, err + } + } else { + // verify that the hash is a bcrypt hash + _, err := bcrypt.Cost([]byte(passwordHash)) + if err != nil { + return nil, err + } + } + id := uuid.Must(uuid.NewV4()) + user := &User{ + ID: id, + Aud: aud, + Email: storage.NullString(strings.ToLower(email)), + Phone: storage.NullString(phone), + UserMetaData: userData, + EncryptedPassword: &passwordHash, + } + return user, nil +} + // NewUser initializes a new user from an email, password and user data. func NewUser(phone, email, password, aud string, userData map[string]interface{}) (*User, error) { passwordHash := "" @@ -351,7 +377,7 @@ func (u *User) UpdatePassword(tx *storage.Connection, sessionID *uuid.UUID) erro } // Authenticate a user from a password -func (u *User) Authenticate(ctx context.Context, password string, decryptionKeys map[string]string, encrypt bool, encryptionKeyID string) (bool, bool, error) { +func (u *User) Authenticate(ctx context.Context, tx *storage.Connection, password string, decryptionKeys map[string]string, encrypt bool, encryptionKeyID string) (bool, bool, error) { if u.EncryptedPassword == nil { return false, false, nil } @@ -370,6 +396,22 @@ func (u *User) Authenticate(ctx context.Context, password string, decryptionKeys compareErr := crypto.CompareHashAndPassword(ctx, hash, password) + if !strings.HasPrefix(hash, crypto.Argon2Prefix) { + // check if cost exceeds default cost or is too low + cost, err := bcrypt.Cost([]byte(hash)) + if err != nil { + return compareErr == nil, false, err + } + + if cost > bcrypt.DefaultCost || cost == bcrypt.MinCost { + // don't bother with encrypting the password in Authenticate + // since it's handled separately + if err := u.SetPassword(ctx, password, false, "", ""); err != nil { + return compareErr == nil, false, err + } + } + } + return compareErr == nil, encrypt && (es == nil || es.ShouldReEncrypt(encryptionKeyID)), nil } diff --git a/internal/models/user_test.go b/internal/models/user_test.go index 011cf28f0..47d16178d 100644 --- a/internal/models/user_test.go +++ b/internal/models/user_test.go @@ -1,6 +1,7 @@ package models import ( + "context" "strings" "testing" @@ -11,6 +12,7 @@ import ( "github.com/supabase/auth/internal/crypto" "github.com/supabase/auth/internal/storage" "github.com/supabase/auth/internal/storage/test" + "golang.org/x/crypto/bcrypt" ) const modelsTestConfig = "../../hack/test.env" @@ -378,3 +380,93 @@ func (ts *UserTestSuite) TestSetPasswordTooLong() { err = user.SetPassword(ts.db.Context(), strings.Repeat("a", crypto.MaxPasswordLength), false, "", "") require.NoError(ts.T(), err) } + +func (ts *UserTestSuite) TestNewUserWithPasswordHashSuccess() { + cases := []struct { + desc string + hash string + }{ + { + desc: "Valid bcrypt hash", + hash: "$2y$10$SXEz2HeT8PUIGQXo9yeUIem8KzNxgG0d7o/.eGj2rj8KbRgAuRVlq", + }, + { + desc: "Valid argon2i hash", + hash: "$argon2i$v=19$m=16,t=2,p=1$bGJRWThNOHJJTVBSdHl2dQ$NfEnUOuUpb7F2fQkgFUG4g", + }, + { + desc: "Valid argon2id hash", + hash: "$argon2id$v=19$m=32,t=3,p=2$SFVpOWJ0eXhjRzVkdGN1RQ$RXnb8rh7LaDcn07xsssqqulZYXOM/EUCEFMVcAcyYVk", + }, + } + + for _, c := range cases { + ts.Run(c.desc, func() { + u, err := NewUserWithPasswordHash("", "", c.hash, "", nil) + require.NoError(ts.T(), err) + require.NotNil(ts.T(), u) + }) + } +} + +func (ts *UserTestSuite) TestNewUserWithPasswordHashFailure() { + cases := []struct { + desc string + hash string + }{ + { + desc: "Invalid argon2i hash", + hash: "$argon2id$test", + }, + { + desc: "Invalid bcrypt hash", + hash: "plaintest_password", + }, + } + + for _, c := range cases { + ts.Run(c.desc, func() { + u, err := NewUserWithPasswordHash("", "", c.hash, "", nil) + require.Error(ts.T(), err) + require.Nil(ts.T(), u) + }) + } +} + +func (ts *UserTestSuite) TestAuthenticate() { + // every case uses "test" as the password + cases := []struct { + desc string + hash string + expectedHashCost int + }{ + { + desc: "Invalid bcrypt hash cost of 11", + hash: "$2y$11$4lH57PU7bGATpRcx93vIoObH3qDmft/pytbOzDG9/1WsyNmN5u4di", + expectedHashCost: bcrypt.MinCost, + }, + { + desc: "Valid bcrypt hash cost of 10", + hash: "$2y$10$va66S4MxFrH6G6L7BzYl0.QgcYgvSr/F92gc.3botlz7bG4p/g/1i", + expectedHashCost: bcrypt.DefaultCost, + }, + } + + for _, c := range cases { + ts.Run(c.desc, func() { + u, err := NewUserWithPasswordHash("", "", c.hash, "", nil) + require.NoError(ts.T(), err) + require.NoError(ts.T(), ts.db.Create(u)) + require.NotNil(ts.T(), u) + + isAuthenticated, _, err := u.Authenticate(context.Background(), ts.db, "test", nil, false, "") + require.NoError(ts.T(), err) + require.True(ts.T(), isAuthenticated) + + // check hash cost + hashCost, err := bcrypt.Cost([]byte(*u.EncryptedPassword)) + require.NoError(ts.T(), err) + require.Equal(ts.T(), c.expectedHashCost, hashCost) + }) + } +} From 85361b7aa533381ae14acf92d5c0efbe2e218997 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 3 Jul 2024 22:10:34 +0200 Subject: [PATCH 043/118] chore(master): release 2.155.0 (#1640) :robot: I have created a release *beep* *boop* --- ## [2.155.0](https://github.com/supabase/auth/compare/v2.154.2...v2.155.0) (2024-07-03) ### Features * add `password_hash` and `id` fields to admin create user ([#1641](https://github.com/supabase/auth/issues/1641)) ([20d59f1](https://github.com/supabase/auth/commit/20d59f10b601577683d05bcd7d2128ff4bc462a0)) ### Bug Fixes * improve mfa verify logs ([#1635](https://github.com/supabase/auth/issues/1635)) ([d8b47f9](https://github.com/supabase/auth/commit/d8b47f9d3f0dc8f97ad1de49e45f452ebc726481)) * invited users should have a temporary password generated ([#1644](https://github.com/supabase/auth/issues/1644)) ([3f70d9d](https://github.com/supabase/auth/commit/3f70d9d8974d0e9c437c51e1312ad17ce9056ec9)) * upgrade golang-jwt to v5 ([#1639](https://github.com/supabase/auth/issues/1639)) ([2cb97f0](https://github.com/supabase/auth/commit/2cb97f080fa4695766985cc4792d09476534be68)) * use pointer for `user.EncryptedPassword` ([#1637](https://github.com/supabase/auth/issues/1637)) ([bbecbd6](https://github.com/supabase/auth/commit/bbecbd61a46b0c528b1191f48d51f166c06f4b16)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c708a5779..62fc5a48c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [2.155.0](https://github.com/supabase/auth/compare/v2.154.2...v2.155.0) (2024-07-03) + + +### Features + +* add `password_hash` and `id` fields to admin create user ([#1641](https://github.com/supabase/auth/issues/1641)) ([20d59f1](https://github.com/supabase/auth/commit/20d59f10b601577683d05bcd7d2128ff4bc462a0)) + + +### Bug Fixes + +* improve mfa verify logs ([#1635](https://github.com/supabase/auth/issues/1635)) ([d8b47f9](https://github.com/supabase/auth/commit/d8b47f9d3f0dc8f97ad1de49e45f452ebc726481)) +* invited users should have a temporary password generated ([#1644](https://github.com/supabase/auth/issues/1644)) ([3f70d9d](https://github.com/supabase/auth/commit/3f70d9d8974d0e9c437c51e1312ad17ce9056ec9)) +* upgrade golang-jwt to v5 ([#1639](https://github.com/supabase/auth/issues/1639)) ([2cb97f0](https://github.com/supabase/auth/commit/2cb97f080fa4695766985cc4792d09476534be68)) +* use pointer for `user.EncryptedPassword` ([#1637](https://github.com/supabase/auth/issues/1637)) ([bbecbd6](https://github.com/supabase/auth/commit/bbecbd61a46b0c528b1191f48d51f166c06f4b16)) + ## [2.154.2](https://github.com/supabase/auth/compare/v2.154.1...v2.154.2) (2024-06-24) From 3c8d7656431ac4b2e80726b7c37adb8f0c778495 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Thu, 4 Jul 2024 00:28:13 -0700 Subject: [PATCH 044/118] fix: return proper error if sms rate limit is exceeded (#1647) ## What kind of change does this PR introduce? * Fixes #1629 * Previously, rate limit errors were being returned as an internal server error, which is incorrect. This PR checks for the underlying error type returned and ensures that rate limit errors are surfaced appropriately. ## What is the current behavior? Please link any relevant issues here. ## What is the new behavior? Feel free to include screenshots if it includes visual changes. ## Additional context Add any other context or screenshots. --- internal/api/user.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/api/user.go b/internal/api/user.go index 4cb1fc1d9..f456db3fd 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -236,6 +236,9 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error { return internalServerError("Error finding SMS provider").WithInternalError(terr) } if _, terr := a.sendPhoneConfirmation(r, tx, user, params.Phone, phoneChangeVerification, smsProvider, params.Channel); terr != nil { + if errors.Is(terr, MaxFrequencyLimitError) { + return tooManyRequestsError(ErrorCodeOverSMSSendRateLimit, generateFrequencyLimitErrorMessage(user.PhoneChangeSentAt, config.Sms.MaxFrequency)) + } return internalServerError("Error sending phone change otp").WithInternalError(terr) } } From 42c1d4526b98203664d4a22c23014ecd0b4951f9 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Thu, 4 Jul 2024 11:44:32 -0700 Subject: [PATCH 045/118] fix: check for empty aud string (#1649) ## What kind of change does this PR introduce? * Fixes an issue where an empty string set as the `aud` claim will cause some methods to fail ## What is the current behavior? Please link any relevant issues here. ## What is the new behavior? Feel free to include screenshots if it includes visual changes. ## Additional context Add any other context or screenshots. --- internal/api/helpers.go | 2 +- internal/api/helpers_test.go | 77 ++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/internal/api/helpers.go b/internal/api/helpers.go index 58ac82d08..485a3870c 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -39,7 +39,7 @@ func (a *API) requestAud(ctx context.Context, r *http.Request) string { if claims != nil { aud, _ := claims.GetAudience() - if len(aud) != 0 { + if len(aud) != 0 && aud[0] != "" { return aud[0] } } diff --git a/internal/api/helpers_test.go b/internal/api/helpers_test.go index 84a4846e6..29070e81d 100644 --- a/internal/api/helpers_test.go +++ b/internal/api/helpers_test.go @@ -1,10 +1,15 @@ package api import ( + "fmt" + "net/http" + "net/http/httptest" "strconv" "testing" + "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/conf" ) func TestIsValidCodeChallenge(t *testing.T) { @@ -72,3 +77,75 @@ func TestIsValidPKCEParams(t *testing.T) { }) } } + +func TestRequestAud(ts *testing.T) { + mockAPI := API{ + config: &conf.GlobalConfiguration{ + JWT: conf.JWTConfiguration{ + Aud: "authenticated", + Secret: "test-secret", + }, + }, + } + + cases := []struct { + desc string + headers map[string]string + payload map[string]interface{} + expectedAud string + }{ + { + desc: "Valid audience slice", + headers: map[string]string{ + audHeaderName: "my_custom_aud", + }, + payload: map[string]interface{}{ + "aud": "authenticated", + }, + expectedAud: "my_custom_aud", + }, + { + desc: "Valid custom audience", + payload: map[string]interface{}{ + "aud": "my_custom_aud", + }, + expectedAud: "my_custom_aud", + }, + { + desc: "Invalid audience", + payload: map[string]interface{}{ + "aud": "", + }, + expectedAud: mockAPI.config.JWT.Aud, + }, + { + desc: "Missing audience", + payload: map[string]interface{}{ + "sub": "d6044b6e-b0ec-4efe-a055-0d2d6ff1dbd8", + }, + expectedAud: mockAPI.config.JWT.Aud, + }, + } + + for _, c := range cases { + ts.Run(c.desc, func(t *testing.T) { + claims := jwt.MapClaims(c.payload) + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString([]byte(mockAPI.config.JWT.Secret)) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", fmt.Sprintf("Bearer: %s", signed)) + for k, v := range c.headers { + req.Header.Set(k, v) + } + + // set the token in the request context for requestAud + ctx, err := mockAPI.parseJWTClaims(signed, req) + require.NoError(t, err) + aud := mockAPI.requestAud(ctx, req) + require.Equal(t, c.expectedAud, aud) + }) + } + +} From a5185058e72509b0781e0eb59910ecdbb8676fee Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Thu, 4 Jul 2024 14:47:09 -0700 Subject: [PATCH 046/118] fix: apply mailer autoconfirm config to update user email (#1646) ## What kind of change does this PR introduce? * `GOTRUE_MAILER_AUTOCONFIRM` setting should be respected in the update email flow via `PUT /user` ## What is the current behavior? * When `GOTRUE_MAILER_AUTOCONFIRM=true`, updating a user's email still sends an email and requires user confirmation * Difficult for anonymous users to upgrade to a permanent user seamlessly without requiring email confirmation * Fixes #1619 ## What is the new behavior? * When `GOTRUE_MAILER_AUTOCONFIRM=true`, updating a user's email will not require email confirmation. ## Additional context Add any other context or screenshots. --- internal/api/user.go | 30 +++++++++++++++++++++--------- internal/api/user_test.go | 28 ++++++++++++++++++++++++++++ internal/api/verify.go | 5 ++++- 3 files changed, 53 insertions(+), 10 deletions(-) diff --git a/internal/api/user.go b/internal/api/user.go index f456db3fd..6fec273f9 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -8,6 +8,7 @@ import ( "github.com/gofrs/uuid" "github.com/supabase/auth/internal/api/sms_provider" + "github.com/supabase/auth/internal/mailer" "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/storage" ) @@ -205,19 +206,30 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error { } if params.Email != "" && params.Email != user.GetEmail() { - flowType := getFlowFromChallenge(params.CodeChallenge) - if isPKCEFlow(flowType) { - _, terr := generateFlowState(tx, models.EmailChange.String(), models.EmailChange, params.CodeChallengeMethod, params.CodeChallenge, &user.ID) - if terr != nil { + if config.Mailer.Autoconfirm { + user.EmailChange = params.Email + if _, terr := a.emailChangeVerify(r, tx, &VerifyParams{ + Type: mailer.EmailChangeVerification, + Email: params.Email, + }, user); terr != nil { return terr } - } - if terr = a.sendEmailChange(r, tx, user, params.Email, flowType); terr != nil { - if errors.Is(terr, MaxFrequencyLimitError) { - return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, generateFrequencyLimitErrorMessage(user.EmailChangeSentAt, config.SMTP.MaxFrequency)) + } else { + flowType := getFlowFromChallenge(params.CodeChallenge) + if isPKCEFlow(flowType) { + _, terr := generateFlowState(tx, models.EmailChange.String(), models.EmailChange, params.CodeChallengeMethod, params.CodeChallenge, &user.ID) + if terr != nil { + return terr + } + + } + if terr = a.sendEmailChange(r, tx, user, params.Email, flowType); terr != nil { + if errors.Is(terr, MaxFrequencyLimitError) { + return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, generateFrequencyLimitErrorMessage(user.EmailChangeSentAt, config.SMTP.MaxFrequency)) + } + return internalServerError("Error sending change email").WithInternalError(terr) } - return internalServerError("Error sending change email").WithInternalError(terr) } } diff --git a/internal/api/user_test.go b/internal/api/user_test.go index af9cfec37..cef28d607 100644 --- a/internal/api/user_test.go +++ b/internal/api/user_test.go @@ -84,6 +84,7 @@ func (ts *UserTestSuite) TestUserUpdateEmail() { desc string userData map[string]string isSecureEmailChangeEnabled bool + isMailerAutoconfirmEnabled bool expectedCode int }{ { @@ -93,6 +94,7 @@ func (ts *UserTestSuite) TestUserUpdateEmail() { "phone": "", }, isSecureEmailChangeEnabled: false, + isMailerAutoconfirmEnabled: false, expectedCode: http.StatusOK, }, { @@ -102,6 +104,7 @@ func (ts *UserTestSuite) TestUserUpdateEmail() { "phone": "234567890", }, isSecureEmailChangeEnabled: true, + isMailerAutoconfirmEnabled: false, expectedCode: http.StatusOK, }, { @@ -111,6 +114,7 @@ func (ts *UserTestSuite) TestUserUpdateEmail() { "phone": "", }, isSecureEmailChangeEnabled: false, + isMailerAutoconfirmEnabled: false, expectedCode: http.StatusOK, }, { @@ -120,6 +124,17 @@ func (ts *UserTestSuite) TestUserUpdateEmail() { "phone": "", }, isSecureEmailChangeEnabled: true, + isMailerAutoconfirmEnabled: false, + expectedCode: http.StatusOK, + }, + { + desc: "Update email with mailer autoconfirm enabled", + userData: map[string]string{ + "email": "bar@example.com", + "phone": "", + }, + isSecureEmailChangeEnabled: true, + isMailerAutoconfirmEnabled: true, expectedCode: http.StatusOK, }, } @@ -146,9 +161,22 @@ func (ts *UserTestSuite) TestUserUpdateEmail() { w := httptest.NewRecorder() ts.Config.Mailer.SecureEmailChangeEnabled = c.isSecureEmailChangeEnabled + ts.Config.Mailer.Autoconfirm = c.isMailerAutoconfirmEnabled ts.API.handler.ServeHTTP(w, req) require.Equal(ts.T(), c.expectedCode, w.Code) + var data models.User + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data)) + + if c.isMailerAutoconfirmEnabled { + require.Empty(ts.T(), data.EmailChange) + require.Equal(ts.T(), "new@example.com", data.GetEmail()) + require.Len(ts.T(), data.Identities, 1) + } else { + require.Equal(ts.T(), "new@example.com", data.EmailChange) + require.Len(ts.T(), data.Identities, 0) + } + // remove user after each case require.NoError(ts.T(), ts.API.db.Destroy(u)) }) diff --git a/internal/api/verify.go b/internal/api/verify.go index c0b78d680..97a13b93b 100644 --- a/internal/api/verify.go +++ b/internal/api/verify.go @@ -491,7 +491,10 @@ func (a *API) prepPKCERedirectURL(rurl, code string) (string, error) { func (a *API) emailChangeVerify(r *http.Request, conn *storage.Connection, params *VerifyParams, user *models.User) (*models.User, error) { config := a.config - if config.Mailer.SecureEmailChangeEnabled && user.EmailChangeConfirmStatus == zeroConfirmation && user.GetEmail() != "" { + if !config.Mailer.Autoconfirm && + config.Mailer.SecureEmailChangeEnabled && + user.EmailChangeConfirmStatus == zeroConfirmation && + user.GetEmail() != "" { err := conn.Transaction(func(tx *storage.Connection) error { currentOTT, terr := models.FindOneTimeToken(tx, params.TokenHash, models.EmailChangeTokenCurrent) if terr != nil && !models.IsNotFoundError(terr) { From 9c88ee55c61a9246f7f89df32c25e96a3000d3d2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 4 Jul 2024 14:48:34 -0700 Subject: [PATCH 047/118] chore(master): release 2.155.1 (#1648) :robot: I have created a release *beep* *boop* --- ## [2.155.1](https://github.com/supabase/auth/compare/v2.155.0...v2.155.1) (2024-07-04) ### Bug Fixes * apply mailer autoconfirm config to update user email ([#1646](https://github.com/supabase/auth/issues/1646)) ([a518505](https://github.com/supabase/auth/commit/a5185058e72509b0781e0eb59910ecdbb8676fee)) * check for empty aud string ([#1649](https://github.com/supabase/auth/issues/1649)) ([42c1d45](https://github.com/supabase/auth/commit/42c1d4526b98203664d4a22c23014ecd0b4951f9)) * return proper error if sms rate limit is exceeded ([#1647](https://github.com/supabase/auth/issues/1647)) ([3c8d765](https://github.com/supabase/auth/commit/3c8d7656431ac4b2e80726b7c37adb8f0c778495)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62fc5a48c..a82a5f385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [2.155.1](https://github.com/supabase/auth/compare/v2.155.0...v2.155.1) (2024-07-04) + + +### Bug Fixes + +* apply mailer autoconfirm config to update user email ([#1646](https://github.com/supabase/auth/issues/1646)) ([a518505](https://github.com/supabase/auth/commit/a5185058e72509b0781e0eb59910ecdbb8676fee)) +* check for empty aud string ([#1649](https://github.com/supabase/auth/issues/1649)) ([42c1d45](https://github.com/supabase/auth/commit/42c1d4526b98203664d4a22c23014ecd0b4951f9)) +* return proper error if sms rate limit is exceeded ([#1647](https://github.com/supabase/auth/issues/1647)) ([3c8d765](https://github.com/supabase/auth/commit/3c8d7656431ac4b2e80726b7c37adb8f0c778495)) + ## [2.155.0](https://github.com/supabase/auth/compare/v2.154.2...v2.155.0) (2024-07-03) From 33caaa9893f9343ac175d1c38742ba2e91f4dfc0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Jul 2024 15:24:18 -0700 Subject: [PATCH 048/118] chore(deps): bump github.com/rs/cors from 1.9.0 to 1.11.0 (#1650) Bumps [github.com/rs/cors](https://github.com/rs/cors) from 1.9.0 to 1.11.0.

Commits
  • 4c32059 Normalize allowed request headers and store them in a sorted set (fixes #170)...
  • 8d33ca4 Complete documentation; deprecate AllowOriginRequestFunc in favour of AllowOr...
  • af821ae Merge branch 'jub0bs-master'
  • 0bcf73f Update benchmark
  • eacc8e8 Fix skewed middleware benchmarks (#165)
  • 9297f15 Respect the documented precedence of options (#163)
  • 73f81b4 Fix readme benchmark rendering (#161)
  • e19471c Prevent empty Access-Control-Expose-Headers header (#160)
  • 20a76bd Update benchmark
  • 46855ae Remove travis build report from README
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/rs/cors&package-manager=go_modules&previous-version=1.9.0&new-version=1.11.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/supabase/auth/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 15c9a6651..cdea6cfac 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 github.com/pkg/errors v0.9.1 github.com/pquerna/otp v1.4.0 - github.com/rs/cors v1.9.0 + github.com/rs/cors v1.11.0 github.com/sebest/xff v0.0.0-20160910043805-6c115e0ffa35 github.com/sethvargo/go-password v0.2.0 github.com/sirupsen/logrus v1.9.3 diff --git a/go.sum b/go.sum index 66067a406..1cf79bda8 100644 --- a/go.sum +++ b/go.sum @@ -252,8 +252,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pquerna/otp v1.3.0 h1:oJV/SkzR33anKXwQU3Of42rL4wbrffP4uvUf1SvS5Xs= -github.com/pquerna/otp v1.3.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= @@ -270,8 +268,8 @@ github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6po github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= -github.com/rs/cors v1.9.0 h1:l9HGsTsHJcvW14Nk7J9KFz8bzeAWXn3CG6bgt7LsrAE= -github.com/rs/cors v1.9.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po= +github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= From 10ca9c806e4b67a371897f1b3f93c515764c4240 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Thu, 11 Jul 2024 02:17:38 -0700 Subject: [PATCH 049/118] fix: set rate limit log level to warn (#1652) --- internal/api/errors.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/api/errors.go b/internal/api/errors.go index e821409a7..308a85a0f 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -232,11 +232,14 @@ func HandleResponseError(err error, w http.ResponseWriter, r *http.Request) { } case *HTTPError: - if e.HTTPStatus >= http.StatusInternalServerError { + switch { + case e.HTTPStatus >= http.StatusInternalServerError: e.ErrorID = errorID // this will get us the stack trace too log.WithError(e.Cause()).Error(e.Error()) - } else { + case e.HTTPStatus == http.StatusTooManyRequests: + log.WithError(e.Cause()).Warn(e.Error()) + default: log.WithError(e.Cause()).Info(e.Error()) } From 4e6ef47184ce7bb7acb5e8c155d2537ec9b9fad2 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Thu, 11 Jul 2024 15:39:29 -0700 Subject: [PATCH 050/118] chore: add default tests for saml assertions (#1651) ## What kind of change does this PR introduce? * Add test cases to ensure that the default attribute is respected * If the attribute is missing, the default value in the mapping will be used * If the attribute is present, but the mapping doesn't specify the name, then the default value will be used * The name will always be respected over the default ## What is the current behavior? Please link any relevant issues here. ## What is the new behavior? Feel free to include screenshots if it includes visual changes. ## Additional context Add any other context or screenshots. --------- Co-authored-by: Joel Lee --- internal/api/samlassertion_test.go | 92 +++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/internal/api/samlassertion_test.go b/internal/api/samlassertion_test.go index 47992a214..62c3e52f4 100644 --- a/internal/api/samlassertion_test.go +++ b/internal/api/samlassertion_test.go @@ -150,6 +150,7 @@ func TestSAMLAssertionUserID(t *tst.T) { func TestSAMLAssertionProcessing(t *tst.T) { type spec struct { + desc string xml string mapping models.SAMLAttributeMapping expected map[string]interface{} @@ -157,6 +158,7 @@ func TestSAMLAssertionProcessing(t *tst.T) { examples := []spec{ { + desc: "valid attribute and mapping", xml: ` @@ -178,6 +180,7 @@ func TestSAMLAssertionProcessing(t *tst.T) { }, }, { + desc: "valid attributes, use first attribute found in Names", xml: ` @@ -205,6 +208,7 @@ func TestSAMLAssertionProcessing(t *tst.T) { }, }, { + desc: "valid groups attribute", xml: ` @@ -240,6 +244,92 @@ func TestSAMLAssertionProcessing(t *tst.T) { }, }, }, + { + desc: "missing attribute, use default value", + xml: ` + + + + someone@example.com + + + +`, + mapping: models.SAMLAttributeMapping{ + Keys: map[string]models.SAMLAttribute{ + "email": { + Name: "mail", + }, + "role": { + Name: "role", + Default: "member", + }, + }, + }, + expected: map[string]interface{}{ + "email": "someone@example.com", + "role": "member", + }, + }, + { + desc: "use default value even if attribute exists but is not specified in mapping", + xml: ` + + + + someone@example.com + + + admin + + + +`, + mapping: models.SAMLAttributeMapping{ + Keys: map[string]models.SAMLAttribute{ + "email": { + Name: "mail", + }, + "role": { + Default: "member", + }, + }, + }, + expected: map[string]interface{}{ + "email": "someone@example.com", + "role": "member", + }, + }, + { + desc: "use value in XML when attribute exists and is specified in mapping", + xml: ` + + + + someone@example.com + + + admin + + + +`, + mapping: models.SAMLAttributeMapping{ + Keys: map[string]models.SAMLAttribute{ + "email": { + Name: "mail", + }, + "role": { + Name: "role", + Default: "member", + }, + }, + }, + expected: map[string]interface{}{ + "email": "someone@example.com", + "role": "admin", + }, + }, } for i, example := range examples { @@ -252,6 +342,6 @@ func TestSAMLAssertionProcessing(t *tst.T) { result := assertion.Process(example.mapping) - require.Equal(t, result, example.expected, "example %d had different processing", i) + require.Equal(t, example.expected, result, "example %d had different processing", i) } } From bf5381a6b1c686955dc4e39fe5fb806ffd309563 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Fri, 12 Jul 2024 02:43:06 -0700 Subject: [PATCH 051/118] fix: omit empty string from name & use case-insensitive equality for comparing SAML attributes (#1654) ## What kind of change does this PR introduce? * Fixes an issue where the SAML mapping is incorrect when a `Default` is specified in the mapping and the `FriendlyName` in the SAML Assertion is an empty string * Use case-insensitive equality for mapping SAML attributes * Tested with a trial okta account ## What is the current behavior? * During the SAML assertion process, the `SAMLAttributeMapping` struct defaults the `Name` to an empty string. When combined with a SAML assertion that has an empty string set in the `FriendlyName`, this causes an incorrect mapping when a default mapping is provided. (see [test case](https://github.com/supabase/auth/pull/1654/files#diff-e708a1335880a92063e8b45f3a06eb96931ab0ee46b94cdf6bf5f566640f7a3cR247-R272) * Use case-sensitive equality for mapping SAML attributes Please link any relevant issues here. ## What is the new behavior? Feel free to include screenshots if it includes visual changes. ## Additional context Add any other context or screenshots. --- internal/api/samlassertion.go | 8 +- internal/api/samlassertion_test.go | 130 ++++++++++++++--------------- 2 files changed, 70 insertions(+), 68 deletions(-) diff --git a/internal/api/samlassertion.go b/internal/api/samlassertion.go index 75cdfdb4f..fdf932385 100644 --- a/internal/api/samlassertion.go +++ b/internal/api/samlassertion.go @@ -24,8 +24,7 @@ func (a *SAMLAssertion) Attribute(name string) []saml.AttributeValue { for _, stmt := range a.AttributeStatements { for _, attr := range stmt.Attributes { - // TODO: maybe this should be case-insentivite equality? - if attr.Name == name || attr.FriendlyName == name { + if strings.EqualFold(attr.Name, name) || strings.EqualFold(attr.FriendlyName, name) { values = append(values, attr.Values...) } } @@ -120,7 +119,10 @@ func (a *SAMLAssertion) Process(mapping models.SAMLAttributeMapping) map[string] ret := make(map[string]interface{}) for key, mapper := range mapping.Keys { - names := []string{mapper.Name} + names := []string{} + if mapper.Name != "" { + names = append(names, mapper.Name) + } names = append(names, mapper.Names...) setKey := false diff --git a/internal/api/samlassertion_test.go b/internal/api/samlassertion_test.go index 62c3e52f4..b7461b26d 100644 --- a/internal/api/samlassertion_test.go +++ b/internal/api/samlassertion_test.go @@ -160,14 +160,14 @@ func TestSAMLAssertionProcessing(t *tst.T) { { desc: "valid attribute and mapping", xml: ` - - - - someone@example.com - - - -`, + + + + someone@example.com + + + + `, mapping: models.SAMLAttributeMapping{ Keys: map[string]models.SAMLAttribute{ "email": { @@ -182,17 +182,17 @@ func TestSAMLAssertionProcessing(t *tst.T) { { desc: "valid attributes, use first attribute found in Names", xml: ` - - - - old-soap@example.com - - - soap@example.com - - - -`, + + + + old-soap@example.com + + + soap@example.com + + + + `, mapping: models.SAMLAttributeMapping{ Keys: map[string]models.SAMLAttribute{ "email": { @@ -210,18 +210,18 @@ func TestSAMLAssertionProcessing(t *tst.T) { { desc: "valid groups attribute", xml: ` - - - - group1 - group2 - - - soap@example.com - - - -`, + + + + group1 + group2 + + + soap@example.com + + + + `, mapping: models.SAMLAttributeMapping{ Keys: map[string]models.SAMLAttribute{ "email": { @@ -245,11 +245,11 @@ func TestSAMLAssertionProcessing(t *tst.T) { }, }, { - desc: "missing attribute, use default value", + desc: "missing attribute use default value", xml: ` - + someone@example.com @@ -258,10 +258,9 @@ func TestSAMLAssertionProcessing(t *tst.T) { mapping: models.SAMLAttributeMapping{ Keys: map[string]models.SAMLAttribute{ "email": { - Name: "mail", + Name: "email", }, "role": { - Name: "role", Default: "member", }, }, @@ -274,17 +273,17 @@ func TestSAMLAssertionProcessing(t *tst.T) { { desc: "use default value even if attribute exists but is not specified in mapping", xml: ` - - - - someone@example.com - - - admin - - - -`, + + + + someone@example.com + + + admin + + + + `, mapping: models.SAMLAttributeMapping{ Keys: map[string]models.SAMLAttribute{ "email": { @@ -303,17 +302,17 @@ func TestSAMLAssertionProcessing(t *tst.T) { { desc: "use value in XML when attribute exists and is specified in mapping", xml: ` - - - - someone@example.com - - - admin - - - -`, + + + + someone@example.com + + + admin + + + + `, mapping: models.SAMLAttributeMapping{ Keys: map[string]models.SAMLAttribute{ "email": { @@ -333,15 +332,16 @@ func TestSAMLAssertionProcessing(t *tst.T) { } for i, example := range examples { - rawAssertion := saml.Assertion{} - require.NoError(t, xml.Unmarshal([]byte(example.xml), &rawAssertion)) - - assertion := SAMLAssertion{ - &rawAssertion, - } + t.Run(example.desc, func(t *tst.T) { + rawAssertion := saml.Assertion{} + require.NoError(t, xml.Unmarshal([]byte(example.xml), &rawAssertion)) - result := assertion.Process(example.mapping) + assertion := SAMLAssertion{ + &rawAssertion, + } - require.Equal(t, example.expected, result, "example %d had different processing", i) + result := assertion.Process(example.mapping) + require.Equal(t, example.expected, result, "example %d had different processing", i) + }) } } From 5a6793ee8fce7a089750fe10b3b63bb0a19d6d21 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Fri, 12 Jul 2024 02:43:20 -0700 Subject: [PATCH 052/118] fix: improve session error logging (#1655) ## What kind of change does this PR introduce? * Improve logging for missing session * Helps provide better visibility for missing session errors encountered in https://github.com/orgs/supabase/discussions/23715 ## What is the current behavior? Please link any relevant issues here. ## What is the new behavior? Feel free to include screenshots if it includes visual changes. ## Additional context Add any other context or screenshots. --- internal/api/auth.go | 2 +- internal/api/auth_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/api/auth.go b/internal/api/auth.go index 4e63715a1..d6d079364 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -123,7 +123,7 @@ func (a *API) maybeLoadUserOrSession(ctx context.Context) (context.Context, erro session, err = models.FindSessionByID(db, sessionId, false) if err != nil { if models.IsNotFoundError(err) { - return ctx, forbiddenError(ErrorCodeSessionNotFound, "Session from session_id claim in JWT does not exist") + return ctx, forbiddenError(ErrorCodeSessionNotFound, "Session from session_id claim in JWT does not exist").WithInternalError(err).WithInternalMessage(fmt.Sprintf("session id (%s) doesn't exist", sessionId)) } return ctx, err } diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 35a64be92..3150628d9 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -167,7 +167,7 @@ func (ts *AuthTestSuite) TestMaybeLoadUserOrSession() { Role: "authenticated", SessionId: "73bf9ee0-9e8c-453b-b484-09cb93e2f341", }, - ExpectedError: forbiddenError(ErrorCodeSessionNotFound, "Session from session_id claim in JWT does not exist"), + ExpectedError: forbiddenError(ErrorCodeSessionNotFound, "Session from session_id claim in JWT does not exist").WithInternalError(models.SessionNotFoundError{}).WithInternalMessage("session id (73bf9ee0-9e8c-453b-b484-09cb93e2f341) doesn't exist"), ExpectedUser: u, ExpectedSession: nil, }, From ac619397892f7049351df76f86393a01f793e591 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 12 Jul 2024 11:44:30 +0200 Subject: [PATCH 053/118] chore(master): release 2.155.2 (#1653) :robot: I have created a release *beep* *boop* --- ## [2.155.2](https://github.com/supabase/auth/compare/v2.155.1...v2.155.2) (2024-07-12) ### Bug Fixes * improve session error logging ([#1655](https://github.com/supabase/auth/issues/1655)) ([5a6793e](https://github.com/supabase/auth/commit/5a6793ee8fce7a089750fe10b3b63bb0a19d6d21)) * omit empty string from name & use case-insensitive equality for comparing SAML attributes ([#1654](https://github.com/supabase/auth/issues/1654)) ([bf5381a](https://github.com/supabase/auth/commit/bf5381a6b1c686955dc4e39fe5fb806ffd309563)) * set rate limit log level to warn ([#1652](https://github.com/supabase/auth/issues/1652)) ([10ca9c8](https://github.com/supabase/auth/commit/10ca9c806e4b67a371897f1b3f93c515764c4240)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a82a5f385..e9ad3d484 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [2.155.2](https://github.com/supabase/auth/compare/v2.155.1...v2.155.2) (2024-07-12) + + +### Bug Fixes + +* improve session error logging ([#1655](https://github.com/supabase/auth/issues/1655)) ([5a6793e](https://github.com/supabase/auth/commit/5a6793ee8fce7a089750fe10b3b63bb0a19d6d21)) +* omit empty string from name & use case-insensitive equality for comparing SAML attributes ([#1654](https://github.com/supabase/auth/issues/1654)) ([bf5381a](https://github.com/supabase/auth/commit/bf5381a6b1c686955dc4e39fe5fb806ffd309563)) +* set rate limit log level to warn ([#1652](https://github.com/supabase/auth/issues/1652)) ([10ca9c8](https://github.com/supabase/auth/commit/10ca9c806e4b67a371897f1b3f93c515764c4240)) + ## [2.155.1](https://github.com/supabase/auth/compare/v2.155.0...v2.155.1) (2024-07-04) From 98d83245e40d606438eb0afdbf474276179fd91d Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Fri, 12 Jul 2024 09:20:55 -0700 Subject: [PATCH 054/118] fix: serialize jwt as string (#1657) ## What kind of change does this PR introduce? * serializes the jwt as a string rather than a slice of strings ## What is the current behavior? Please link any relevant issues here. ## What is the new behavior? Feel free to include screenshots if it includes visual changes. ## Additional context Add any other context or screenshots. --- internal/api/token.go | 4 +++- internal/hooks/auth_hooks.go | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/api/token.go b/internal/api/token.go index 22c42e9b1..3362faa93 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -329,7 +329,7 @@ func (a *API) generateAccessToken(r *http.Request, tx *storage.Connection, user claims := &hooks.AccessTokenClaims{ RegisteredClaims: jwt.RegisteredClaims{ Subject: user.ID.String(), - Audience: []string{user.Aud}, + Audience: jwt.ClaimStrings{user.Aud}, IssuedAt: jwt.NewNumericDate(issuedAt), ExpiresAt: jwt.NewNumericDate(expiresAt), Issuer: config.JWT.Issuer, @@ -375,6 +375,8 @@ func (a *API) generateAccessToken(r *http.Request, tx *storage.Connection, user token.Header["kid"] = config.JWT.KeyID } + // this serializes the aud claim was a string + jwt.MarshalSingleStringAsArray = false signed, err := token.SignedString([]byte(config.JWT.Secret)) if err != nil { return "", 0, err diff --git a/internal/hooks/auth_hooks.go b/internal/hooks/auth_hooks.go index c11b91b5e..315bff872 100644 --- a/internal/hooks/auth_hooks.go +++ b/internal/hooks/auth_hooks.go @@ -43,7 +43,7 @@ const MinimumViableTokenSchema = `{ "type": "object", "properties": { "aud": { - "type": "array" + "type": ["string", "array"] }, "exp": { "type": "integer" From 9ac3cffb9dd36958fd6f221765dcebcebf1a30d4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 12 Jul 2024 09:25:42 -0700 Subject: [PATCH 055/118] chore(master): release 2.155.3 (#1658) :robot: I have created a release *beep* *boop* --- ## [2.155.3](https://github.com/supabase/auth/compare/v2.155.2...v2.155.3) (2024-07-12) ### Bug Fixes * serialize jwt as string ([#1657](https://github.com/supabase/auth/issues/1657)) ([98d8324](https://github.com/supabase/auth/commit/98d83245e40d606438eb0afdbf474276179fd91d)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9ad3d484..23e68861c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [2.155.3](https://github.com/supabase/auth/compare/v2.155.2...v2.155.3) (2024-07-12) + + +### Bug Fixes + +* serialize jwt as string ([#1657](https://github.com/supabase/auth/issues/1657)) ([98d8324](https://github.com/supabase/auth/commit/98d83245e40d606438eb0afdbf474276179fd91d)) + ## [2.155.2](https://github.com/supabase/auth/compare/v2.155.1...v2.155.2) (2024-07-12) From f99286eaed505daf3db6f381265ef6024e7e36d2 Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Wed, 17 Jul 2024 17:41:29 +0200 Subject: [PATCH 056/118] fix: treat empty string as nil in `encrypted_password` (#1663) `Authenticate` should treat empty string as nil, as both mean the same thing. --- internal/api/token.go | 4 ++++ internal/models/user.go | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/internal/api/token.go b/internal/api/token.go index 3362faa93..f8db6f330 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -141,6 +141,10 @@ func (a *API) ResourceOwnerPasswordGrant(ctx context.Context, w http.ResponseWri return internalServerError("Database error querying schema").WithInternalError(err) } + if !user.HasPassword() { + return oauthError("invalid_grant", InvalidLoginMessage) + } + if user.IsBanned() { return oauthError("invalid_grant", InvalidLoginMessage) } diff --git a/internal/models/user.go b/internal/models/user.go index 3c871e543..12ac52816 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -384,6 +384,10 @@ func (u *User) Authenticate(ctx context.Context, tx *storage.Connection, passwor hash := *u.EncryptedPassword + if hash == "" { + return false, false, nil + } + es := crypto.ParseEncryptedString(hash) if es != nil { h, err := es.Decrypt(u.ID.String(), decryptionKeys) From 04752de2d244bb8938c473f5c1ea138e253d3a2a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 17 Jul 2024 09:21:33 -0700 Subject: [PATCH 057/118] chore(master): release 2.155.4 (#1664) :robot: I have created a release *beep* *boop* --- ## [2.155.4](https://github.com/supabase/auth/compare/v2.155.3...v2.155.4) (2024-07-17) ### Bug Fixes * treat empty string as nil in `encrypted_password` ([#1663](https://github.com/supabase/auth/issues/1663)) ([f99286e](https://github.com/supabase/auth/commit/f99286eaed505daf3db6f381265ef6024e7e36d2)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23e68861c..7823f9002 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [2.155.4](https://github.com/supabase/auth/compare/v2.155.3...v2.155.4) (2024-07-17) + + +### Bug Fixes + +* treat empty string as nil in `encrypted_password` ([#1663](https://github.com/supabase/auth/issues/1663)) ([f99286e](https://github.com/supabase/auth/commit/f99286eaed505daf3db6f381265ef6024e7e36d2)) + ## [2.155.3](https://github.com/supabase/auth/compare/v2.155.2...v2.155.3) (2024-07-12) From 1858c93bba6f5bc41e4c65489f12c1a0786a1f2b Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Thu, 18 Jul 2024 01:15:08 -0700 Subject: [PATCH 058/118] fix: check password max length in checkPasswordStrength (#1659) ## What kind of change does this PR introduce? * Check if password exceeds max length in `checkPasswordStrength` ## What is the current behavior? Please link any relevant issues here. ## What is the new behavior? Feel free to include screenshots if it includes visual changes. ## Additional context Add any other context or screenshots. --- internal/api/admin.go | 5 +++++ internal/api/password.go | 7 +++++++ internal/api/password_test.go | 16 +++++++++++++--- internal/crypto/password.go | 7 ------- internal/models/user_test.go | 13 ------------- 5 files changed, 25 insertions(+), 23 deletions(-) diff --git a/internal/api/admin.go b/internal/api/admin.go index ecd9c2053..f4b774ec7 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -9,11 +9,13 @@ import ( "github.com/fatih/structs" "github.com/go-chi/chi/v5" "github.com/gofrs/uuid" + "github.com/pkg/errors" "github.com/sethvargo/go-password/password" "github.com/supabase/auth/internal/api/provider" "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/observability" "github.com/supabase/auth/internal/storage" + "golang.org/x/crypto/bcrypt" ) type AdminUserParams struct { @@ -382,6 +384,9 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error { } if err != nil { + if errors.Is(err, bcrypt.ErrPasswordTooLong) { + return badRequestError(ErrorCodeValidationFailed, err.Error()) + } return internalServerError("Error creating user").WithInternalError(err) } diff --git a/internal/api/password.go b/internal/api/password.go index 680f396bd..73de368ed 100644 --- a/internal/api/password.go +++ b/internal/api/password.go @@ -8,6 +8,9 @@ import ( "github.com/sirupsen/logrus" ) +// BCrypt hashed passwords have a 72 character limit +const MaxPasswordLength = 72 + // WeakPasswordError encodes an error that a password does not meet strength // requirements. It is handled specially in errors.go as it gets transformed to // a HTTPError with a special weak_password field that encodes the Reasons @@ -24,6 +27,10 @@ func (e *WeakPasswordError) Error() string { func (a *API) checkPasswordStrength(ctx context.Context, password string) error { config := a.config + if len(password) > MaxPasswordLength { + return badRequestError(ErrorCodeValidationFailed, fmt.Sprintf("Password cannot be longer than %v characters", MaxPasswordLength)) + } + var messages, reasons []string if len(password) < config.Password.MinLength { diff --git a/internal/api/password_test.go b/internal/api/password_test.go index 07740440e..f95f6f6d5 100644 --- a/internal/api/password_test.go +++ b/internal/api/password_test.go @@ -85,6 +85,12 @@ func TestPasswordStrengthChecks(t *testing.T) { Password: "abc123", Reasons: nil, }, + { + MinLength: 6, + RequiredCharacters: []string{}, + Password: "zZgXb5gzyCNrV36qwbOSbKVQsVJd28mC1TwRpeB0y6sFNICJyjD6bILKJMsjyKDzBdaY5tmi8zY9BWJYmt3vULLmyafjIDLYjy8qhETu0mS2jj1uQBgSAzJn9Zjm8EFa", + Reasons: nil, + }, } for i, example := range examples { @@ -98,10 +104,14 @@ func TestPasswordStrengthChecks(t *testing.T) { } err := api.checkPasswordStrength(context.Background(), example.Password) - if example.Reasons == nil { + + switch e := err.(type) { + case *WeakPasswordError: + require.Equal(t, e.Reasons, example.Reasons, "Example %d failed with wrong reasons", i) + case *HTTPError: + require.Equal(t, e.ErrorCode, ErrorCodeValidationFailed, "Example %d failed with wrong error code", i) + default: require.NoError(t, err, "Example %d failed with error", i) - } else { - require.Equal(t, err.(*WeakPasswordError).Reasons, example.Reasons, "Example %d failed with wrong reasons", i) } } } diff --git a/internal/crypto/password.go b/internal/crypto/password.go index dca101450..bce145a45 100644 --- a/internal/crypto/password.go +++ b/internal/crypto/password.go @@ -30,9 +30,6 @@ const ( // useful for tests only. QuickHashCost HashCost = iota - // BCrypt hashed passwords have a 72 character limit - MaxPasswordLength = 72 - Argon2Prefix = "$argon2" ) @@ -214,10 +211,6 @@ func CompareHashAndPassword(ctx context.Context, hash, password string) error { func GenerateFromPassword(ctx context.Context, password string) (string, error) { var hashCost int - if len(password) > MaxPasswordLength { - return "", fmt.Errorf("password cannot be longer than %d characters", MaxPasswordLength) - } - switch PasswordHashCost { case QuickHashCost: hashCost = bcrypt.MinCost diff --git a/internal/models/user_test.go b/internal/models/user_test.go index 47d16178d..92b0858ce 100644 --- a/internal/models/user_test.go +++ b/internal/models/user_test.go @@ -2,7 +2,6 @@ package models import ( "context" - "strings" "testing" "github.com/stretchr/testify/assert" @@ -369,18 +368,6 @@ func (ts *UserTestSuite) TestUpdateUserEmailFailure() { require.Equal(ts.T(), primaryIdentity.GetEmail(), userA.GetEmail()) } -func (ts *UserTestSuite) TestSetPasswordTooLong() { - user, err := NewUser("", "", strings.Repeat("a", crypto.MaxPasswordLength), "", nil) - require.NoError(ts.T(), err) - require.NoError(ts.T(), ts.db.Create(user)) - - err = user.SetPassword(ts.db.Context(), strings.Repeat("a", crypto.MaxPasswordLength+1), false, "", "") - require.Error(ts.T(), err) - - err = user.SetPassword(ts.db.Context(), strings.Repeat("a", crypto.MaxPasswordLength), false, "", "") - require.NoError(ts.T(), err) -} - func (ts *UserTestSuite) TestNewUserWithPasswordHashSuccess() { cases := []struct { desc string From 7e67f3edbf81766df297a66f52a8e472583438c6 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Thu, 18 Jul 2024 08:02:16 -0700 Subject: [PATCH 059/118] fix: don't update attribute mapping if nil (#1665) --- internal/api/ssoadmin.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/internal/api/ssoadmin.go b/internal/api/ssoadmin.go index 72d88dbac..20fd8b9c5 100644 --- a/internal/api/ssoadmin.go +++ b/internal/api/ssoadmin.go @@ -349,10 +349,13 @@ func (a *API) adminSSOProvidersUpdate(w http.ResponseWriter, r *http.Request) er } } - updateAttributeMapping := !provider.SAMLProvider.AttributeMapping.Equal(¶ms.AttributeMapping) - if updateAttributeMapping { - modified = true - provider.SAMLProvider.AttributeMapping = params.AttributeMapping + updateAttributeMapping := false + if params.AttributeMapping.Keys != nil { + updateAttributeMapping = !provider.SAMLProvider.AttributeMapping.Equal(¶ms.AttributeMapping) + if updateAttributeMapping { + modified = true + provider.SAMLProvider.AttributeMapping = params.AttributeMapping + } } nameIDFormat := "" From 822fb93faab325ba3d4bb628dff43381d68d0b5d Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Fri, 19 Jul 2024 19:41:33 +0200 Subject: [PATCH 060/118] fix: refactor mfa models and add observability to loadFactor (#1669) ## What kind of change does this PR introduce? Makes two changes: - Ensures we track DB calls to loadFactor - Changes the interface for creating a challenge in attempt to make clear that Challenge should always have a Factor --- internal/api/admin.go | 6 ++++-- internal/api/mfa.go | 2 +- internal/api/mfa_test.go | 2 +- internal/models/challenge.go | 11 ----------- internal/models/factor.go | 10 ++++++++++ 5 files changed, 16 insertions(+), 15 deletions(-) diff --git a/internal/api/admin.go b/internal/api/admin.go index f4b774ec7..7df41fb65 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -70,6 +70,8 @@ func (a *API) loadUser(w http.ResponseWriter, r *http.Request) (context.Context, } func (a *API) loadFactor(w http.ResponseWriter, r *http.Request) (context.Context, error) { + ctx := r.Context() + db := a.db.WithContext(ctx) factorID, err := uuid.FromString(chi.URLParam(r, "factor_id")) if err != nil { return nil, notFoundError(ErrorCodeValidationFailed, "factor_id must be an UUID") @@ -77,14 +79,14 @@ func (a *API) loadFactor(w http.ResponseWriter, r *http.Request) (context.Contex observability.LogEntrySetField(r, "factor_id", factorID) - f, err := models.FindFactorByFactorID(a.db, factorID) + f, err := models.FindFactorByFactorID(db, factorID) if err != nil { if models.IsNotFoundError(err) { return nil, notFoundError(ErrorCodeMFAFactorNotFound, "Factor not found") } return nil, internalServerError("Database error loading factor").WithInternalError(err) } - return withFactor(r.Context(), f), nil + return withFactor(ctx, f), nil } func (a *API) getAdminParams(r *http.Request) (*AdminUserParams, error) { diff --git a/internal/api/mfa.go b/internal/api/mfa.go index df70c6b51..c15f4a3b4 100644 --- a/internal/api/mfa.go +++ b/internal/api/mfa.go @@ -182,7 +182,7 @@ func (a *API) ChallengeFactor(w http.ResponseWriter, r *http.Request) error { user := getUser(ctx) factor := getFactor(ctx) ipAddress := utilities.GetIPAddress(r) - challenge := models.NewChallenge(factor, ipAddress) + challenge := factor.CreateChallenge(ipAddress) if err := db.Transaction(func(tx *storage.Connection) error { if terr := tx.Create(challenge); terr != nil { diff --git a/internal/api/mfa_test.go b/internal/api/mfa_test.go index 63f813249..101f6c660 100644 --- a/internal/api/mfa_test.go +++ b/internal/api/mfa_test.go @@ -265,7 +265,7 @@ func (ts *MFATestSuite) TestMFAVerifyFactor() { req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) testIPAddress := utilities.GetIPAddress(req) - c := models.NewChallenge(f, testIPAddress) + c := f.CreateChallenge(testIPAddress) require.NoError(ts.T(), ts.API.db.Create(c), "Error saving new test challenge") if !v.validChallenge { // Set challenge creation so that it has expired in present time. diff --git a/internal/models/challenge.go b/internal/models/challenge.go index d52132aaa..c088f4b99 100644 --- a/internal/models/challenge.go +++ b/internal/models/challenge.go @@ -22,17 +22,6 @@ func (Challenge) TableName() string { return tableName } -func NewChallenge(factor *Factor, ipAddress string) *Challenge { - id := uuid.Must(uuid.NewV4()) - - challenge := &Challenge{ - ID: id, - FactorID: factor.ID, - IPAddress: ipAddress, - } - return challenge -} - func FindChallengeByID(conn *storage.Connection, challengeID uuid.UUID) (*Challenge, error) { var challenge Challenge err := conn.Find(&challenge, challengeID) diff --git a/internal/models/factor.go b/internal/models/factor.go index 53fddc260..6abd00080 100644 --- a/internal/models/factor.go +++ b/internal/models/factor.go @@ -187,6 +187,16 @@ func DeleteUnverifiedFactors(tx *storage.Connection, user *User) error { return nil } +func (f *Factor) CreateChallenge(ipAddress string) *Challenge { + id := uuid.Must(uuid.NewV4()) + challenge := &Challenge{ + ID: id, + FactorID: f.ID, + IPAddress: ipAddress, + } + return challenge +} + // UpdateFriendlyName changes the friendly name func (f *Factor) UpdateFriendlyName(tx *storage.Connection, friendlyName string) error { f.FriendlyName = friendlyName From ad7a77057e51419f64740b1e05f4cc4ff3fcc2a1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 19 Jul 2024 16:40:47 -0700 Subject: [PATCH 061/118] chore(master): release 2.155.5 (#1666) :robot: I have created a release *beep* *boop* --- ## [2.155.5](https://github.com/supabase/auth/compare/v2.155.4...v2.155.5) (2024-07-19) ### Bug Fixes * check password max length in checkPasswordStrength ([#1659](https://github.com/supabase/auth/issues/1659)) ([1858c93](https://github.com/supabase/auth/commit/1858c93bba6f5bc41e4c65489f12c1a0786a1f2b)) * don't update attribute mapping if nil ([#1665](https://github.com/supabase/auth/issues/1665)) ([7e67f3e](https://github.com/supabase/auth/commit/7e67f3edbf81766df297a66f52a8e472583438c6)) * refactor mfa models and add observability to loadFactor ([#1669](https://github.com/supabase/auth/issues/1669)) ([822fb93](https://github.com/supabase/auth/commit/822fb93faab325ba3d4bb628dff43381d68d0b5d)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7823f9002..80f34c19b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [2.155.5](https://github.com/supabase/auth/compare/v2.155.4...v2.155.5) (2024-07-19) + + +### Bug Fixes + +* check password max length in checkPasswordStrength ([#1659](https://github.com/supabase/auth/issues/1659)) ([1858c93](https://github.com/supabase/auth/commit/1858c93bba6f5bc41e4c65489f12c1a0786a1f2b)) +* don't update attribute mapping if nil ([#1665](https://github.com/supabase/auth/issues/1665)) ([7e67f3e](https://github.com/supabase/auth/commit/7e67f3edbf81766df297a66f52a8e472583438c6)) +* refactor mfa models and add observability to loadFactor ([#1669](https://github.com/supabase/auth/issues/1669)) ([822fb93](https://github.com/supabase/auth/commit/822fb93faab325ba3d4bb628dff43381d68d0b5d)) + ## [2.155.4](https://github.com/supabase/auth/compare/v2.155.3...v2.155.4) (2024-07-17) From 8efd57dab40346762a04bac61b314ce05d6fa69c Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Sun, 21 Jul 2024 22:42:09 -0700 Subject: [PATCH 062/118] fix: use deep equal (#1672) ## What kind of change does this PR introduce? * `Default` field in SAMLAttribute uses an `interface{}` type which requires using deep equality for comparisons ## What is the current behavior? Please link any relevant issues here. ## What is the new behavior? Feel free to include screenshots if it includes visual changes. ## Additional context Add any other context or screenshots. --- internal/models/sso.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/models/sso.go b/internal/models/sso.go index 1cf982604..28c2429ac 100644 --- a/internal/models/sso.go +++ b/internal/models/sso.go @@ -4,6 +4,7 @@ import ( "database/sql" "database/sql/driver" "encoding/json" + "reflect" "strings" "time" @@ -76,7 +77,7 @@ func (m *SAMLAttributeMapping) Equal(o *SAMLAttributeMapping) bool { } } - if mvalue.Default != value.Default { + if !reflect.DeepEqual(mvalue.Default, value.Default) { return false } From 25d1447715cb7b562d311147c4f53d67b1012e0d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 22 Jul 2024 07:54:14 +0200 Subject: [PATCH 063/118] chore(master): release 2.155.6 (#1673) :robot: I have created a release *beep* *boop* --- ## [2.155.6](https://github.com/supabase/auth/compare/v2.155.5...v2.155.6) (2024-07-22) ### Bug Fixes * use deep equal ([#1672](https://github.com/supabase/auth/issues/1672)) ([8efd57d](https://github.com/supabase/auth/commit/8efd57dab40346762a04bac61b314ce05d6fa69c)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80f34c19b..d3567d681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [2.155.6](https://github.com/supabase/auth/compare/v2.155.5...v2.155.6) (2024-07-22) + + +### Bug Fixes + +* use deep equal ([#1672](https://github.com/supabase/auth/issues/1672)) ([8efd57d](https://github.com/supabase/auth/commit/8efd57dab40346762a04bac61b314ce05d6fa69c)) + ## [2.155.5](https://github.com/supabase/auth/compare/v2.155.4...v2.155.5) (2024-07-19) From b57e2230102280ed873acf70be1aeb5a2f6f7a4f Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Thu, 25 Jul 2024 01:40:17 -0700 Subject: [PATCH 064/118] fix: restrict autoconfirm email change to anonymous users (#1679) --- internal/api/user.go | 4 +++- internal/api/user_test.go | 32 +++++++++++++++++++++++--------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/internal/api/user.go b/internal/api/user.go index 6fec273f9..b55259358 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -206,7 +206,9 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error { } if params.Email != "" && params.Email != user.GetEmail() { - if config.Mailer.Autoconfirm { + if user.IsAnonymous && config.Mailer.Autoconfirm { + // anonymous users can add an email with automatic confirmation, which is similar to signing up + // permanent users always need to verify their email address when changing it user.EmailChange = params.Email if _, terr := a.emailChangeVerify(r, tx, &VerifyParams{ Type: mailer.EmailChangeVerification, diff --git a/internal/api/user_test.go b/internal/api/user_test.go index cef28d607..ed6c585af 100644 --- a/internal/api/user_test.go +++ b/internal/api/user_test.go @@ -82,14 +82,14 @@ func (ts *UserTestSuite) TestUserGet() { func (ts *UserTestSuite) TestUserUpdateEmail() { cases := []struct { desc string - userData map[string]string + userData map[string]interface{} isSecureEmailChangeEnabled bool isMailerAutoconfirmEnabled bool expectedCode int }{ { desc: "User doesn't have an existing email", - userData: map[string]string{ + userData: map[string]interface{}{ "email": "", "phone": "", }, @@ -99,7 +99,7 @@ func (ts *UserTestSuite) TestUserUpdateEmail() { }, { desc: "User doesn't have an existing email and double email confirmation required", - userData: map[string]string{ + userData: map[string]interface{}{ "email": "", "phone": "234567890", }, @@ -109,7 +109,7 @@ func (ts *UserTestSuite) TestUserUpdateEmail() { }, { desc: "User has an existing email", - userData: map[string]string{ + userData: map[string]interface{}{ "email": "foo@example.com", "phone": "", }, @@ -119,7 +119,7 @@ func (ts *UserTestSuite) TestUserUpdateEmail() { }, { desc: "User has an existing email and double email confirmation required", - userData: map[string]string{ + userData: map[string]interface{}{ "email": "bar@example.com", "phone": "", }, @@ -129,7 +129,7 @@ func (ts *UserTestSuite) TestUserUpdateEmail() { }, { desc: "Update email with mailer autoconfirm enabled", - userData: map[string]string{ + userData: map[string]interface{}{ "email": "bar@example.com", "phone": "", }, @@ -137,14 +137,28 @@ func (ts *UserTestSuite) TestUserUpdateEmail() { isMailerAutoconfirmEnabled: true, expectedCode: http.StatusOK, }, + { + desc: "Update email with mailer autoconfirm enabled and anonymous user", + userData: map[string]interface{}{ + "email": "bar@example.com", + "phone": "", + "is_anonymous": true, + }, + isSecureEmailChangeEnabled: true, + isMailerAutoconfirmEnabled: true, + expectedCode: http.StatusOK, + }, } for _, c := range cases { ts.Run(c.desc, func() { u, err := models.NewUser("", "", "", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error creating test user model") - require.NoError(ts.T(), u.SetEmail(ts.API.db, c.userData["email"]), "Error setting user email") - require.NoError(ts.T(), u.SetPhone(ts.API.db, c.userData["phone"]), "Error setting user phone") + require.NoError(ts.T(), u.SetEmail(ts.API.db, c.userData["email"].(string)), "Error setting user email") + require.NoError(ts.T(), u.SetPhone(ts.API.db, c.userData["phone"].(string)), "Error setting user phone") + if isAnonymous, ok := c.userData["is_anonymous"]; ok { + u.IsAnonymous = isAnonymous.(bool) + } require.NoError(ts.T(), ts.API.db.Create(u), "Error saving test user") token := ts.generateAccessTokenAndSession(u) @@ -168,7 +182,7 @@ func (ts *UserTestSuite) TestUserUpdateEmail() { var data models.User require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data)) - if c.isMailerAutoconfirmEnabled { + if c.isMailerAutoconfirmEnabled && u.IsAnonymous { require.Empty(ts.T(), data.EmailChange) require.Equal(ts.T(), "new@example.com", data.GetEmail()) require.Len(ts.T(), data.Identities, 1) From f9df65c91e226084abfa2e868ab6bab892d16d2f Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Thu, 25 Jul 2024 10:44:38 +0200 Subject: [PATCH 065/118] feat: add is_anonymous claim to Auth hook jsonschema (#1667) ## What kind of change does this PR introduce? Add is_anonymous claim to hook spec to ensure that generated tokens always contain the is_anonymous claim. --- internal/hooks/auth_hooks.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/hooks/auth_hooks.go b/internal/hooks/auth_hooks.go index 315bff872..10f97167c 100644 --- a/internal/hooks/auth_hooks.go +++ b/internal/hooks/auth_hooks.go @@ -93,7 +93,7 @@ const MinimumViableTokenSchema = `{ "type": "string" } }, - "required": ["aud", "exp", "iat", "sub", "email", "phone", "role", "aal", "session_id"] + "required": ["aud", "exp", "iat", "sub", "email", "phone", "role", "aal", "session_id", "is_anonymous"] }` // AccessTokenClaims is a struct thats used for JWT claims From f0a40c5d9103b69896cef8723d403ec4dd997990 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 25 Jul 2024 16:22:30 -0700 Subject: [PATCH 066/118] chore(master): release 2.156.0 (#1680) :robot: I have created a release *beep* *boop* --- ## [2.156.0](https://github.com/supabase/auth/compare/v2.155.6...v2.156.0) (2024-07-25) ### Features * add is_anonymous claim to Auth hook jsonschema ([#1667](https://github.com/supabase/auth/issues/1667)) ([f9df65c](https://github.com/supabase/auth/commit/f9df65c91e226084abfa2e868ab6bab892d16d2f)) ### Bug Fixes * restrict autoconfirm email change to anonymous users ([#1679](https://github.com/supabase/auth/issues/1679)) ([b57e223](https://github.com/supabase/auth/commit/b57e2230102280ed873acf70be1aeb5a2f6f7a4f)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3567d681..5d63392d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [2.156.0](https://github.com/supabase/auth/compare/v2.155.6...v2.156.0) (2024-07-25) + + +### Features + +* add is_anonymous claim to Auth hook jsonschema ([#1667](https://github.com/supabase/auth/issues/1667)) ([f9df65c](https://github.com/supabase/auth/commit/f9df65c91e226084abfa2e868ab6bab892d16d2f)) + + +### Bug Fixes + +* restrict autoconfirm email change to anonymous users ([#1679](https://github.com/supabase/auth/issues/1679)) ([b57e223](https://github.com/supabase/auth/commit/b57e2230102280ed873acf70be1aeb5a2f6f7a4f)) + ## [2.155.6](https://github.com/supabase/auth/compare/v2.155.5...v2.155.6) (2024-07-22) From c7a2be347b301b666e99adc3d3fed78c5e287c82 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Fri, 26 Jul 2024 07:46:00 -0700 Subject: [PATCH 067/118] feat: add asymmetric jwt support (#1674) ## What kind of change does this PR introduce? * Adds asymmetric JWT support to auth, with zero downtime key rotation ## What is the current behavior? * Auth only supports symmetric JWTs which involves some downtime if the key needs to be rolled ## What is the new behavior? ### Config changes * Accepts a new env var `GOTRUE_JWT_KEYS` which takes in an array of JWK * The private key is encoded as a JWK that contains the kid, use and alg claims * Defaults to use `GOTRUE_JWT_SECRET` and `GOTRUE_JWT_KEY_ID` if `GOTRUE_JWT_KEYS` is missing, which is just the JWK representation of the symmetric secret * On config initialisation, `GOTRUE_JWT_KEYS` is transformed and stored as JWKs in-memory. * We use the `key_ops` claim in the JWK and to detect if they should be used to sign or verify a JWT (see [RFC](https://datatracker.ietf.org/doc/html/rfc7517#section-4.2)) * All JWKs represented as public keys will have the `key_ops` claim set to `["verify"]`, while the JWK represented as private keys will have the `key_ops` claim set to `["sign", "verify"]` if it is used for signing ### Endpoint * `GET /.well-known/jwks.json`: returns the JWKs for the auth service. Given the following config (generated from [this script](https://gist.github.com/kangmingtay/a1c83d9e1ea1f398d9388e2188deab2b)): ```bash GOTRUE_JWT_KEYS='[{"kty":"oct","k":"KtxwCvCPABNiOmUBij2_uzlO8FM477lO1zpe_E6nQhE","kid":"81763ee4-803e-4420-bed2-6849ef963262","key_ops":["verify"],"alg":"HS256"},{"kty":"RSA","n":"htA_Lzcc3qojwvcrF1JU6yPPRLvxvCp8x3tx_lCO6GyBFktE6HLsIHEpcWfvkiJfwxMZ4npn2CWI4rjjNbT2BHqax7CUOgGFATNZe13kTukx8SUQY3GHCIzPiN39oc55HcMBB_u4sLQBFD3RUCEcLqrlvwYRcTuCY317Xyn3j1YZogZ9gm6fY70v0Sj2hxLxtURr0UQurqhqRqUbXcujI6x3JqKKuk4-1o_K6J8j97hj4AcGMjRgmyi7G_7jM9hZG2SPJiFP7kbCpU1iT0rYYZptxVNUpWe6u5kg6onzXUE_s7Wu64YT7FE7xIFLg9MUrohBqWqrOjmF4IqTaU95Nw","e":"AQAB","d":"CM6rChEeLDfOTUrrgEMLNC9rN5DVupbF_xxD9rrZkzqfdk7lihAT-AycigGhx5jCS9LAIqkfhqHxHuq4QUZ4uhMucHRLQrzdrRXnNyWLqFIYxqnGt9BvY3IbjtP94WfFRtn6A8UArF6eIW3mckcveacFimTBl_Wsz4YfnLh3qV_817F9j8XQCRaaDfNVOF3_EFb61Ewvb4OxM_fa600wL4gJtnwwfQo_57E8bsvGJXYKvDLS1_3T-vrhARjZb3v-mHm7oXbl-0s_7L1orMRBJM1V5Ay5zFYcVr6ko7im4s0AZ-PA5Hkc_qk4rwzG8mrMWXBHi-NarCm-TG3dy6hBhQ","p":"udGq0glcgYqO6XRElohOJgOuQgdKbOodA0rojf1UkiSOHTcTKRqQYVeiYQAQUHfW3eyyNZt3XuZp_--SfjQMctYjD-SmPp2rpxt5Dz7ffFnNB3aKxBgzbiIMT3XHbONLEgRl0ohWzIzHBjZy6rOIqTUaAg2245DQRaZYnua8YdM","q":"ubr-DMx9AamMUcAIv4-aKwMcS_k15NLqulLwm6hjfDh5yezQw_-LWBpg5QxkFAf04lhJa__nAycLzdwpRqJ-u6P6EazGpeqtJtb4n5Zq8kE74ksouDCmymk9Nj5r2aZsfqZdDt5L3IId4AkzTM0yXhEV77dFupidVQZQy18lCI0","dp":"fd_dMnjq9EnTM6vyRnLBVZkKs2nS7eLNkoxs6rqgTnt61amYTjDTe01tDv6HDquPnzgXJJ9TBrNZPOmiN-G0SRpsF_kQ8LvIKuQ-Zqh1pfwDGrofmGS4ejOQWUd0t3tlQChAfZSkD96Rd9DsmbbSraTuIFP__zn7DCN6RvIQzMc","dq":"QuSqY4my7EpYk4kKnZPm_t7b7jEPzB57FCiTKDz5t9_PXX7BohYD5fN6OoS_9sb22B7cMt20IlqJ0dcdtqcH5iUlCACme1OOkZKTcUcHtcDxBIv1WoGLUROeTE8nIPjj0qmwko5V3FGw2OP3ag3tuhuFPxVPM-mLoPfpWZYnDHE","qi":"cyCVFBj5wM1-5syqt0xVuW5U8w6ZaPMM2F4GlkCu6lb-vNkymgmK3uGvr6p5VpJGU4UsEk1yrH3KrzlBHE4j8ssSMx4CLmq6Hpf4c9zkR8fO8-lgBdhyPmlHyIDvloeJqs4503qhFZO30UMTjVEKL6Wk_83CF00JkOhMo1uyv-A","kid":"38285a37-2843-48f9-a69c-6d72f8c4f016","key_ops":["verify"],"alg":"RS256"},{"kty":"EC","x":"8Whe4H2LCoTN4SODA7GIUrFYD-CpoYS7EvUsbOfcjn8","y":"Vs7VB8ozhyUkSy951Sq4clynrgg8URX91f6FjNDy18k","crv":"P-256","d":"UWPQ4T7opsJhdPbTohO0hf0noTkqGlEWOQrP0l_Tteo","kid":"406f824a-a71b-435d-8e0e-12ee8b07f88b","key_ops":["sign","verify"],"alg":"ES256"},{"crv":"Ed25519","d":"6MooMObDBKW1QRe1uHnrzKoWY9iJKcY55kD1rSY9jiI","x":"4yF8m6gflwZntZMc12j4hIUZFuZ5XJlAqbpnlEIgSxk","kty":"OKP","kid":"a678b12f-0f67-4802-ba7a-a3ad0ea5cc17","key_ops":["verify"],"alg":"EdDSA"}]' ``` The response returned is: ```json { "keys": [ { "alg": "ES256", "crv": "P-256", "kid": "fa6f71ab-03e2-435f-b2f3-9b6143a9c295", "kty": "EC", "use": "sig", "key_ops": ["verify"], "x": "3HcQyhPGXE9Tr_7VMIUvh-PJfQ_nXe_d2Ho7HWefJLA", "y": "CAdc8gjfA8eIwDjWzEdeRurZFUHs_OZ-SEMWcW_UUaE" }, { "alg": "RS256", "e": "AQAB", "kid": "479be47e-ca6e-44cd-a638-b17754611553", "kty": "RSA", "n": "16Tia3phliCATvt0VtISvrWazjIw_BN6a7b5p9VerjoaZfx98l6DJfRrxi2RW6aijPPuzT3DYTfHqNkb1SQUGPKQa_OXGDzPPFXT9RTma24I6vi2VjuLmDPQI6vpAiLqCuBHQ_5gMlyN8wa7F1r86Mf1F-14p_78tBBpea1zSPFcgaODjfOens8Om2CV_eKlK-_zPMCacM96X2Gtx00QVS1UEClQaiZWMvwfdprWfg9w8D2C4ze5wNWIVeMINeO-Ajug3nvhw8UJwZS-ZqiIVD34e3gCukc4bAht-F6xO7RGmOry7UpTe-56r9reaRfpN-W2G2si_sb5zikaJuQcEQ", "use": "sig", "key_ops": ["verify"] } ] } ``` ### JWT changes * Now supports encrypted and verifying using EC, RSA, Ed25519 and HMAC * Change the `use` claim in the JWK from `sig` to `enc` to specify the signing/encryption key - this conforms with the JWK spec. * `GOTRUE_JWT_KEYS` will default to use `GOTRUE_JWT_SECRET` if it's not set * A key can continue to be used for verification as long as it's present in `GOTRUE_JWT_KEYS` * A revoked key is one that is removed from `GOTRUE_JWT_KEYS` * `ValidMethods` are computed on config initialisation so we don't have to do that on every request --- go.mod | 19 +++- go.sum | 37 +++++-- internal/api/api.go | 1 + internal/api/auth.go | 16 ++- internal/api/auth_test.go | 110 ++++++++++++++++++--- internal/api/jwks.go | 30 ++++++ internal/api/jwks_test.go | 79 +++++++++++++++ internal/api/saml_test.go | 3 +- internal/api/token.go | 32 +++--- internal/conf/configuration.go | 69 +++++++++++-- internal/conf/jwk.go | 150 +++++++++++++++++++++++++++++ internal/conf/jwk_test.go | 81 ++++++++++++++++ internal/utilities/request_test.go | 5 +- 13 files changed, 583 insertions(+), 49 deletions(-) create mode 100644 internal/api/jwks.go create mode 100644 internal/api/jwks_test.go create mode 100644 internal/conf/jwk.go create mode 100644 internal/conf/jwk_test.go diff --git a/go.mod b/go.mod index cdea6cfac..3e4af443d 100644 --- a/go.mod +++ b/go.mod @@ -28,19 +28,27 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.6.1 github.com/stretchr/testify v1.9.0 - golang.org/x/crypto v0.21.0 + golang.org/x/crypto v0.24.0 golang.org/x/oauth2 v0.17.0 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df ) require ( github.com/bits-and-blooms/bitset v1.10.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect github.com/go-jose/go-jose/v3 v3.0.3 // indirect github.com/gobuffalo/nulls v0.4.2 // indirect + github.com/goccy/go-json v0.10.3 // indirect github.com/jackc/pgx/v4 v4.18.2 // indirect + github.com/lestrrat-go/blackmagic v1.0.2 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.5 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/segmentio/asm v1.2.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect - golang.org/x/mod v0.9.0 // indirect + golang.org/x/mod v0.17.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect ) @@ -68,6 +76,7 @@ require ( github.com/go-chi/chi/v5 v5.0.12 github.com/gobuffalo/pop/v6 v6.1.1 github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/lestrrat-go/jwx/v2 v2.1.0 github.com/standard-webhooks/standard-webhooks/libraries v0.0.0-20240303152453-e0e82adf1721 github.com/supabase/hibp v0.0.0-20231124125943-d225752ae869 github.com/supabase/mailme v0.2.0 @@ -134,9 +143,9 @@ require ( go.opentelemetry.io/proto/otlp v1.2.0 // indirect golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb golang.org/x/net v0.23.0 // indirect - golang.org/x/sync v0.6.0 // indirect - golang.org/x/sys v0.19.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect golang.org/x/time v0.0.0-20220411224347-583f2d630306 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/grpc v1.63.2 // indirect diff --git a/go.sum b/go.sum index 1cf79bda8..b30c1baaf 100644 --- a/go.sum +++ b/go.sum @@ -49,6 +49,8 @@ github.com/crewjam/saml v0.4.14/go.mod h1:UVSZCf18jJkk6GpWNVqcyQJMD5HsRugBPf4I1n github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/deepmap/oapi-codegen v1.12.4 h1:pPmn6qI9MuOtCz82WY2Xaw46EQjgvxednXXrP7g5Q2s= github.com/deepmap/oapi-codegen v1.12.4/go.mod h1:3lgHGMu6myQ2vqbbTXH2H1o4eXFTGnFiDaOaKKl5yas= github.com/didip/tollbooth/v5 v5.1.1 h1:QpKFg56jsbNuQ6FFj++Z1gn2fbBsvAc1ZPLUaDOYW5k= @@ -103,6 +105,8 @@ github.com/gobuffalo/validate/v3 v3.3.3 h1:o7wkIGSvZBYBd6ChQoLxkz2y1pfmhbI4jNJYh github.com/gobuffalo/validate/v3 v3.3.3/go.mod h1:YC7FsbJ/9hW/VjQdmXPvFqvRis4vrRYFxr69WiNZw6g= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI= @@ -208,6 +212,18 @@ github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= +github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.5 h1:bsTfiH8xaKOJPrg1R+E3iE/AWZr/x0Phj9PBTG/OLUk= +github.com/lestrrat-go/httprc v1.0.5/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.1.0 h1:0zs7Ya6+39qoit7gwAf+cYm1zzgS3fceIdo7RmQ5lkw= +github.com/lestrrat-go/jwx/v2 v2.1.0/go.mod h1:Xpw9QIaUGiIUD1Wx0NcY1sIHwFf8lDuZn/cmxtXYRys= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= @@ -279,6 +295,8 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/sebest/xff v0.0.0-20160910043805-6c115e0ffa35 h1:eajwn6K3weW5cd1ZXLu2sJ4pvwlBiCWY4uDejOr73gM= github.com/sebest/xff v0.0.0-20160910043805-6c115e0ffa35/go.mod h1:wozgYq9WEBQBaIJe4YZ0qTSFAMxmcwBhQH0fO0R34Z0= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sethvargo/go-password v0.2.0 h1:BTDl4CC/gjf/axHMaDQtw507ogrXLci6XRiLc7i/UHI= @@ -388,8 +406,8 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb h1:PaBZQdo+iSDyHT053FjUCgZQ/9uqVwPOcl7KSWhKn6w= golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -398,8 +416,8 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs= -golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20161007143504-f4b625ec9b21/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -422,8 +440,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -448,8 +466,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= -golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -466,8 +484,9 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/time v0.0.0-20160926182426-711ca1cb8763/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220411224347-583f2d630306 h1:+gHMid33q6pen7kv9xvT+JRinntgeXO2AeZVd0AWD3w= golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/internal/api/api.go b/internal/api/api.go index 6e49e8a19..2b139316a 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -116,6 +116,7 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne } r.Get("/health", api.HealthCheck) + r.Get("/.well-known/jwks.json", api.Jwks) r.Route("/callback", func(r *router) { r.Use(api.isValidExternalHost) diff --git a/internal/api/auth.go b/internal/api/auth.go index d6d079364..e881865dd 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -8,6 +8,7 @@ import ( "github.com/gofrs/uuid" jwt "github.com/golang-jwt/jwt/v5" + "github.com/supabase/auth/internal/conf" "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/storage" ) @@ -75,9 +76,20 @@ func (a *API) parseJWTClaims(bearer string, r *http.Request) (context.Context, e ctx := r.Context() config := a.config - p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) + p := jwt.NewParser(jwt.WithValidMethods(config.JWT.ValidMethods)) token, err := p.ParseWithClaims(bearer, &AccessTokenClaims{}, func(token *jwt.Token) (interface{}, error) { - return []byte(config.JWT.Secret), nil + if kid, ok := token.Header["kid"]; ok { + if kidStr, ok := kid.(string); ok { + return conf.FindPublicKeyByKid(kidStr, &config.JWT) + } + } + if alg, ok := token.Header["alg"]; ok { + if alg == jwt.SigningMethodHS256.Name { + // preserve backward compatibility for cases where the kid is not set + return []byte(config.JWT.Secret), nil + } + } + return nil, fmt.Errorf("missing kid") }) if err != nil { return nil, forbiddenError(ErrorCodeBadJWT, "invalid JWT: unable to parse or verify signature, %v", err).WithInternalError(err) diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 3150628d9..71afe6638 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -1,12 +1,14 @@ package api import ( + "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/gofrs/uuid" jwt "github.com/golang-jwt/jwt/v5" + jwk "github.com/lestrrat-go/jwx/v2/jwk" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "github.com/supabase/auth/internal/conf" @@ -55,20 +57,106 @@ func (ts *AuthTestSuite) TestExtractBearerToken() { } func (ts *AuthTestSuite) TestParseJWTClaims() { - userClaims := &AccessTokenClaims{ - Role: "authenticated", + cases := []struct { + desc string + key map[string]interface{} + }{ + { + desc: "HMAC key", + key: map[string]interface{}{ + "kty": "oct", + "k": "S1LgKUjeqXDEolv9WPtjUpADVMHU_KYu8uRDrM-pDGg", + "kid": "ac50c3cc-9cf7-4fd6-a11f-fe066fd39118", + "key_ops": []string{"sign", "verify"}, + "alg": "HS256", + }, + }, + { + desc: "RSA key", + key: map[string]interface{}{ + "kty": "RSA", + "n": "2g0B_hMIx5ZPuTUtLRpRr0k314XniYm3AUFgR5FmTZIjrn7vLwsWij-2egGZeHa-y9ypAgB9Q-lQ3AlT7RMPiCIyLQI6TTC8k10NEnj8c0QZwENx1Qr8aBbuZbOP9Cz30EMWZSbzMbz7r8-3rp5wBRBtIPnLlbfZh_p0iBaJfB77-r_mvhOIFM4xS7ef3nkE96dnvbEN5a-HfjzDJIAt-LniUvzMWW2gQcmHiM4oeijE3PHesapLMt2JpsMhSRo8L7tysags9VMoyZ1GnpCdjtRwb_KpY9QTjV6lL8G5nsKFH7bhABYcpjDOvqkfT5nPXj6C7oCo6MPRirPWUTbq2w", + "e": "AQAB", + "d": "OOTj_DNjOxCRRLYHT5lqbt4f3_BkdZKlWYKBaKsbkmnrPYCJUDEIdJIjPrpkHPZ-2hp9TrRp-upJ2t_kMhujFdY2WWAXbkSlL5475vICjODcBzqR3RC8wzwYgBjWGtQQ5RpcIZCELBovYbRFLR7SA8BBeTU0VaBe9gf3l_qpbOT9QIl268uFdWndTjpehGLQRmAtR1snhvTha0b9nsBZsM_K-EfnoF7Q_lPsjwWDvIGpFXao8Ifaa_sFtQkHjHVBMW2Qgx3ZSrEva_brk7w0MNSYI7Nsmr56xFOpFRwZy0v8ZtgQZ4hXmUInRHIoQ2APeds9YmemojvJKVflt9pLIQ", + "p": "-o2hdQ5Z35cIS5APTVULj_BMoPJpgkuX-PSYC1SeBeff9K04kG5zrFMWJy_-27-ys4q754lpNwJdX2CjN1nb6qyn-uKP8B2oLayKs9ebkiOqvm3S2Xblvi_F8x6sOLba3lTYHK8G7U9aMB9U0mhAzzMFdw15XXusVFDvk-zxL28", + "q": "3sp-7HzZE_elKRmebjivcDhkXO2GrcN3EIqYbbXssHZFXJwVE9oc2CErGWa7QetOCr9C--ZuTmX0X3L--CoYr-hMB0dN8lcAhapr3aau-4i7vE3DWSUdcFSyi0BBDg8pWQWbxNyTXBuWeh1cnRBsLjCxAOVTF0y3_BnVR7mbBVU", + "dp": "DuYHGMfOrk3zz1J0pnuNIXT_iX6AqZ_HHKWmuN3CO8Wq-oimWWhH9pJGOfRPqk9-19BDFiSEniHE3ZwIeI0eV5kGsBNyzatlybl90e3bMVhvmb08EXRRevqqQaesQ_8Tiq7u3t3Fgqz6RuxGBfDvEaMOCyNA-T8WYzkg1eH8AX8", + "dq": "opOCK3CvuDJvA57-TdBvtaRxGJ78OLD6oceBlA29useTthDwEJyJj-4kVVTyMRhUyuLnLoro06zytvRjuxR9D2CkmmseJkn2x5OlQwnvhv4wgSj99H9xDBfCcntg_bFyqtO859tObVh0ZogmnTbuuoYtpEm0aLxDRmRTjxOSXEE", + "qi": "8skVE7BDASHXytKSWYbkxD0B3WpXic2rtnLgiMgasdSxul8XwcB-vjVSZprVrxkcmm6ZhszoxOlq8yylBmMvAnG_gEzTls_xapeuEXGYiGaTcpkCt1r-tBKcQkka2SayaWwAljsX4xSw-zKP2koUkEET_tIcbBOW1R4OWfRGqOI", + "kid": "0d24b26c-b3ec-4c02-acfd-d5a54d50b3a4", + "key_ops": []string{"sign", "verify"}, + "alg": "RS256", + }, + }, + { + desc: "EC key", + key: map[string]interface{}{ + "kty": "EC", + "x": "5wsOh-DrNPpm9KkuydtgGs_cv3oNvtR9OdXywt12aS4", + "y": "0y01ZbuH_VQjMEd8fcYaLdiv25EVJ5GOrb79dJJsqrM", + "crv": "P-256", + "d": "EDP4ReMMpAUcf82EF3JYvkm8C5hVAh258Rj6f3HTx7c", + "kid": "10646a77-f470-44a8-8400-2f988d9c9c1a", + "key_ops": []string{"sign", "verify"}, + "alg": "ES256", + }, + }, + { + desc: "Ed25519 key", + key: map[string]interface{}{ + "crv": "Ed25519", + "d": "jVpCLvOxatVkKe1MW9nFRn6Q8VVZPq5yziKU_Z0Yu-c", + "x": "YDkGdufJBQEPO6ylvd9IKfZlzvm9tOG5VCDpkJSSkiA", + "kty": "OKP", + "kid": "ec5e7a96-ea66-456c-826c-d8d6cb928c0f", + "key_ops": []string{"sign", "verify"}, + "alg": "EdDSA", + }, + }, } - userJwt, err := jwt.NewWithClaims(jwt.SigningMethodHS256, userClaims).SignedString([]byte(ts.Config.JWT.Secret)) - require.NoError(ts.T(), err) - req := httptest.NewRequest(http.MethodGet, "http://localhost", nil) - req.Header.Set("Authorization", "Bearer "+userJwt) - ctx, err := ts.API.parseJWTClaims(userJwt, req) - require.NoError(ts.T(), err) + for _, c := range cases { + ts.Run(c.desc, func() { + bytes, err := json.Marshal(c.key) + require.NoError(ts.T(), err) + privKey, err := jwk.ParseKey(bytes) + require.NoError(ts.T(), err) + pubKey, err := privKey.PublicKey() + require.NoError(ts.T(), err) + ts.Config.JWT.Keys = conf.JwtKeysDecoder{privKey.KeyID(): conf.JwkInfo{ + PublicKey: pubKey, + PrivateKey: privKey, + }} + ts.Config.JWT.ValidMethods = nil + require.NoError(ts.T(), ts.Config.ApplyDefaults()) + + userClaims := &AccessTokenClaims{ + Role: "authenticated", + } + + // get signing key and method from config + jwk, err := conf.GetSigningJwk(&ts.Config.JWT) + require.NoError(ts.T(), err) + signingMethod := conf.GetSigningAlg(jwk) + signingKey, err := conf.GetSigningKey(jwk) + require.NoError(ts.T(), err) - // check if token is stored in context - token := getToken(ctx) - require.Equal(ts.T(), userJwt, token.Raw) + userJwtToken := jwt.NewWithClaims(signingMethod, userClaims) + require.NoError(ts.T(), err) + userJwtToken.Header["kid"] = jwk.KeyID() + userJwt, err := userJwtToken.SignedString(signingKey) + require.NoError(ts.T(), err) + + req := httptest.NewRequest(http.MethodGet, "http://localhost", nil) + req.Header.Set("Authorization", "Bearer "+userJwt) + ctx, err := ts.API.parseJWTClaims(userJwt, req) + require.NoError(ts.T(), err) + + // check if token is stored in context + token := getToken(ctx) + require.Equal(ts.T(), userJwt, token.Raw) + }) + } } func (ts *AuthTestSuite) TestMaybeLoadUserOrSession() { diff --git a/internal/api/jwks.go b/internal/api/jwks.go new file mode 100644 index 000000000..d03ae03fb --- /dev/null +++ b/internal/api/jwks.go @@ -0,0 +1,30 @@ +package api + +import ( + "net/http" + + "github.com/lestrrat-go/jwx/v2/jwa" + jwk "github.com/lestrrat-go/jwx/v2/jwk" +) + +type JwksResponse struct { + Keys []jwk.Key `json:"keys"` +} + +func (a *API) Jwks(w http.ResponseWriter, r *http.Request) error { + config := a.config + resp := JwksResponse{ + Keys: []jwk.Key{}, + } + + for _, key := range config.JWT.Keys { + // don't expose hmac jwk in endpoint + if key.PublicKey == nil || key.PublicKey.KeyType() == jwa.OctetSeq { + continue + } + resp.Keys = append(resp.Keys, key.PublicKey) + } + + w.Header().Set("Cache-Control", "public, max-age=600") + return sendJSON(w, http.StatusOK, resp) +} diff --git a/internal/api/jwks_test.go b/internal/api/jwks_test.go new file mode 100644 index 000000000..786d3438f --- /dev/null +++ b/internal/api/jwks_test.go @@ -0,0 +1,79 @@ +package api + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/conf" +) + +func TestJwks(t *testing.T) { + // generate RSA key pair for testing + rsaPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + rsaJwkPrivate, err := jwk.FromRaw(rsaPrivateKey) + require.NoError(t, err) + rsaJwkPublic, err := rsaJwkPrivate.PublicKey() + require.NoError(t, err) + kid := rsaJwkPublic.KeyID() + + cases := []struct { + desc string + config conf.JWTConfiguration + expectedLen int + }{ + { + desc: "hmac key should not be returned", + config: conf.JWTConfiguration{ + Aud: "authenticated", + Secret: "test-secret", + }, + expectedLen: 0, + }, + { + desc: "rsa public key returned", + config: conf.JWTConfiguration{ + Aud: "authenticated", + Secret: "test-secret", + Keys: conf.JwtKeysDecoder{ + kid: conf.JwkInfo{ + PublicKey: rsaJwkPublic, + PrivateKey: rsaJwkPrivate, + }, + }, + }, + expectedLen: 1, + }, + } + + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + mockAPI, _, err := setupAPIForTest() + require.NoError(t, err) + mockAPI.config.JWT = c.config + + req := httptest.NewRequest(http.MethodGet, "/.well-known/jwks.json", nil) + w := httptest.NewRecorder() + mockAPI.handler.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var data map[string]interface{} + require.NoError(t, json.NewDecoder(w.Body).Decode(&data)) + require.Len(t, data["keys"], c.expectedLen) + + for _, key := range data["keys"].([]interface{}) { + bytes, err := json.Marshal(key) + require.NoError(t, err) + actualKey, err := jwk.ParseKey(bytes) + require.NoError(t, err) + require.Equal(t, c.config.Keys[kid].PublicKey, actualKey) + } + }) + } +} diff --git a/internal/api/saml_test.go b/internal/api/saml_test.go index fa6580c9d..a290fb2e8 100644 --- a/internal/api/saml_test.go +++ b/internal/api/saml_test.go @@ -14,7 +14,8 @@ import ( ) func TestSAMLMetadataWithAPI(t *tst.T) { - config := &conf.GlobalConfiguration{} + config, err := conf.LoadGlobal(apiTestConfig) + require.NoError(t, err) config.API.ExternalURL = "https://projectref.supabase.co/auth/v1/" config.SAML.Enabled = true config.SAML.PrivateKey = "MIIEowIBAAKCAQEAszrVveMQcSsa0Y+zN1ZFb19cRS0jn4UgIHTprW2tVBmO2PABzjY3XFCfx6vPirMAPWBYpsKmXrvm1tr0A6DZYmA8YmJd937VUQ67fa6DMyppBYTjNgGEkEhmKuszvF3MARsIKCGtZqUrmS7UG4404wYxVppnr2EYm3RGtHlkYsXu20MBqSDXP47bQP+PkJqC3BuNGk3xt5UHl2FSFpTHelkI6lBynw16B+lUT1F96SERNDaMqi/TRsZdGe5mB/29ngC/QBMpEbRBLNRir5iUevKS7Pn4aph9Qjaxx/97siktK210FJT23KjHpgcUfjoQ6BgPBTLtEeQdRyDuc/CgfwIDAQABAoIBAGYDWOEpupQPSsZ4mjMnAYJwrp4ZISuMpEqVAORbhspVeb70bLKonT4IDcmiexCg7cQBcLQKGpPVM4CbQ0RFazXZPMVq470ZDeWDEyhoCfk3bGtdxc1Zc9CDxNMs6FeQs6r1beEZug6weG5J/yRn/qYxQife3qEuDMl+lzfl2EN3HYVOSnBmdt50dxRuX26iW3nqqbMRqYn9OHuJ1LvRRfYeyVKqgC5vgt/6Tf7DAJwGe0dD7q08byHV8DBZ0pnMVU0bYpf1GTgMibgjnLjK//EVWafFHtN+RXcjzGmyJrk3+7ZyPUpzpDjO21kpzUQLrpEkkBRnmg6bwHnSrBr8avECgYEA3pq1PTCAOuLQoIm1CWR9/dhkbJQiKTJevlWV8slXQLR50P0WvI2RdFuSxlWmA4xZej8s4e7iD3MYye6SBsQHygOVGc4efvvEZV8/XTlDdyj7iLVGhnEmu2r7AFKzy8cOvXx0QcLg+zNd7vxZv/8D3Qj9Jje2LjLHKM5n/dZ3RzUCgYEAzh5Lo2anc4WN8faLGt7rPkGQF+7/18ImQE11joHWa3LzAEy7FbeOGpE/vhOv5umq5M/KlWFIRahMEQv4RusieHWI19ZLIP+JwQFxWxS+cPp3xOiGcquSAZnlyVSxZ//dlVgaZq2o2MfrxECcovRlaknl2csyf+HjFFwKlNxHm2MCgYAr//R3BdEy0oZeVRndo2lr9YvUEmu2LOihQpWDCd0fQw0ZDA2kc28eysL2RROte95r1XTvq6IvX5a0w11FzRWlDpQ4J4/LlcQ6LVt+98SoFwew+/PWuyLmxLycUbyMOOpm9eSc4wJJZNvaUzMCSkvfMtmm5jgyZYMMQ9A2Ul/9SQKBgB9mfh9mhBwVPIqgBJETZMMXOdxrjI5SBYHGSyJqpT+5Q0vIZLfqPrvNZOiQFzwWXPJ+tV4Mc/YorW3rZOdo6tdvEGnRO6DLTTEaByrY/io3/gcBZXoSqSuVRmxleqFdWWRnB56c1hwwWLqNHU+1671FhL6pNghFYVK4suP6qu4BAoGBAMk+VipXcIlD67mfGrET/xDqiWWBZtgTzTMjTpODhDY1GZck1eb4CQMP5j5V3gFJ4cSgWDJvnWg8rcz0unz/q4aeMGl1rah5WNDWj1QKWMS6vJhMHM/rqN1WHWR0ZnV83svYgtg0zDnQKlLujqW4JmGXLMU7ur6a+e6lpa1fvLsP" diff --git a/internal/api/token.go b/internal/api/token.go index f8db6f330..9a94f3e14 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -350,6 +350,7 @@ func (a *API) generateAccessToken(r *http.Request, tx *storage.Connection, user } var token *jwt.Token + var gotrueClaims jwt.Claims = claims if config.Hook.CustomAccessToken.Enabled { input := hooks.CustomAccessTokenInput{ UserID: user.ID, @@ -363,25 +364,32 @@ func (a *API) generateAccessToken(r *http.Request, tx *storage.Connection, user if err != nil { return "", 0, err } - goTrueClaims := jwt.MapClaims(output.Claims) - - token = jwt.NewWithClaims(jwt.SigningMethodHS256, goTrueClaims) + gotrueClaims = jwt.MapClaims(output.Claims) + } - } else { - token = jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signingJwk, err := conf.GetSigningJwk(&config.JWT) + if err != nil { + return "", 0, err } - if config.JWT.KeyID != "" { - if token.Header == nil { - token.Header = make(map[string]interface{}) - } + signingMethod := conf.GetSigningAlg(signingJwk) + token = jwt.NewWithClaims(signingMethod, gotrueClaims) + if token.Header == nil { + token.Header = make(map[string]interface{}) + } - token.Header["kid"] = config.JWT.KeyID + if _, ok := token.Header["kid"]; !ok { + kid := signingJwk.KeyID() + token.Header["kid"] = kid } - // this serializes the aud claim was a string + // this serializes the aud claim to a string jwt.MarshalSingleStringAsArray = false - signed, err := token.SignedString([]byte(config.JWT.Secret)) + signingKey, err := conf.GetSigningKey(signingJwk) + if err != nil { + return "", 0, err + } + signed, err := token.SignedString(signingKey) if err != nil { return "", 0, err } diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 35024ea52..3e8d2ac26 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -13,8 +13,10 @@ import ( "time" "github.com/gobwas/glob" + "github.com/golang-jwt/jwt/v5" "github.com/joho/godotenv" "github.com/kelseyhightower/envconfig" + "github.com/lestrrat-go/jwx/v2/jwk" ) const defaultMinPasswordLength int = 6 @@ -92,14 +94,16 @@ func (c *DBConfiguration) Validate() error { // JWTConfiguration holds all the JWT related configuration. type JWTConfiguration struct { - Secret string `json:"secret" required:"true"` - Exp int `json:"exp"` - Aud string `json:"aud"` - AdminGroupName string `json:"admin_group_name" split_words:"true"` - AdminRoles []string `json:"admin_roles" split_words:"true"` - DefaultGroupName string `json:"default_group_name" split_words:"true"` - Issuer string `json:"issuer"` - KeyID string `json:"key_id" split_words:"true"` + Secret string `json:"secret" required:"true"` + Exp int `json:"exp"` + Aud string `json:"aud"` + AdminGroupName string `json:"admin_group_name" split_words:"true"` + AdminRoles []string `json:"admin_roles" split_words:"true"` + DefaultGroupName string `json:"default_group_name" split_words:"true"` + Issuer string `json:"issuer"` + KeyID string `json:"key_id" split_words:"true"` + Keys JwtKeysDecoder `json:"keys"` + ValidMethods []string `json:"-"` } // MFAConfiguration holds all the MFA related Configuration @@ -707,6 +711,54 @@ func (config *GlobalConfiguration) ApplyDefaults() error { config.JWT.Exp = 3600 } + if config.JWT.Keys == nil || len(config.JWT.Keys) == 0 { + // transform the secret into a JWK for consistency + bytes, err := base64.StdEncoding.DecodeString(config.JWT.Secret) + if err != nil { + bytes = []byte(config.JWT.Secret) + } + privKey, err := jwk.FromRaw(bytes) + if err != nil { + return err + } + if config.JWT.KeyID != "" { + if err := privKey.Set(jwk.KeyIDKey, config.JWT.KeyID); err != nil { + return err + } + } + if privKey.Algorithm().String() == "" { + if err := privKey.Set(jwk.AlgorithmKey, jwt.SigningMethodHS256.Name); err != nil { + return err + } + } + if err := privKey.Set(jwk.KeyUsageKey, "sig"); err != nil { + return err + } + if len(privKey.KeyOps()) == 0 { + if err := privKey.Set(jwk.KeyOpsKey, jwk.KeyOperationList{jwk.KeyOpSign, jwk.KeyOpVerify}); err != nil { + return err + } + } + pubKey, err := privKey.PublicKey() + if err != nil { + return err + } + config.JWT.Keys = make(JwtKeysDecoder) + config.JWT.Keys[config.JWT.KeyID] = JwkInfo{ + PublicKey: pubKey, + PrivateKey: privKey, + } + } + + if config.JWT.ValidMethods == nil { + config.JWT.ValidMethods = []string{} + for _, key := range config.JWT.Keys { + alg := GetSigningAlg(key.PublicKey) + config.JWT.ValidMethods = append(config.JWT.ValidMethods, alg.Alg()) + } + + } + if config.Mailer.Autoconfirm && config.Mailer.AllowUnverifiedEmailSignIns { return errors.New("cannot enable both GOTRUE_MAILER_AUTOCONFIRM and GOTRUE_MAILER_ALLOW_UNVERIFIED_EMAIL_SIGN_INS") } @@ -824,6 +876,7 @@ func (c *GlobalConfiguration) Validate() error { &c.Security, &c.Sessions, &c.Hook, + &c.JWT.Keys, } for _, validatable := range validatables { diff --git a/internal/conf/jwk.go b/internal/conf/jwk.go new file mode 100644 index 000000000..fffb0c2d6 --- /dev/null +++ b/internal/conf/jwk.go @@ -0,0 +1,150 @@ +package conf + +import ( + "encoding/json" + "fmt" + + "github.com/golang-jwt/jwt/v5" + "github.com/lestrrat-go/jwx/v2/jwk" +) + +type JwtKeysDecoder map[string]JwkInfo + +type JwkInfo struct { + PublicKey jwk.Key `json:"public_key"` + PrivateKey jwk.Key `json:"private_key"` +} + +// Decode implements the Decoder interface +func (j *JwtKeysDecoder) Decode(value string) error { + data := make([]json.RawMessage, 0) + if err := json.Unmarshal([]byte(value), &data); err != nil { + return err + } + + config := JwtKeysDecoder{} + for _, key := range data { + privJwk, err := jwk.ParseKey(key) + if err != nil { + return err + } + pubJwk, err := jwk.PublicKeyOf(privJwk) + if err != nil { + return err + } + + // all public keys should have the the use claim set to 'sig + if err := pubJwk.Set(jwk.KeyUsageKey, "sig"); err != nil { + return err + } + + // all public keys should only have 'verify' set as the key_ops + if err := pubJwk.Set(jwk.KeyOpsKey, jwk.KeyOperationList{jwk.KeyOpVerify}); err != nil { + return err + } + + config[pubJwk.KeyID()] = JwkInfo{ + PublicKey: pubJwk, + PrivateKey: privJwk, + } + } + *j = config + return nil +} + +func (j *JwtKeysDecoder) Validate() error { + // Validate performs _minimal_ checks if the data stored in the key are valid. + // By minimal, we mean that it does not check if the key is valid for use in + // cryptographic operations. For example, it does not check if an RSA key's + // `e` field is a valid exponent, or if the `n` field is a valid modulus. + // Instead, it checks for things such as the _presence_ of some required fields, + // or if certain keys' values are of particular length. + // + // Note that depending on the underlying key type, use of this method requires + // that multiple fields in the key are properly populated. For example, an EC + // key's "x", "y" fields cannot be validated unless the "crv" field is populated first. + signingKeys := []jwk.Key{} + for _, key := range *j { + if err := key.PrivateKey.Validate(); err != nil { + return err + } + // symmetric keys don't have public keys + if key.PublicKey != nil { + if err := key.PublicKey.Validate(); err != nil { + return err + } + } + + for _, op := range key.PrivateKey.KeyOps() { + if op == jwk.KeyOpSign { + signingKeys = append(signingKeys, key.PrivateKey) + break + } + } + } + + switch { + case len(signingKeys) == 0: + return fmt.Errorf("no signing key detected") + case len(signingKeys) > 1: + return fmt.Errorf("multiple signing keys detected, only 1 signing key is supported") + } + + return nil +} + +func GetSigningJwk(config *JWTConfiguration) (jwk.Key, error) { + for _, key := range config.Keys { + for _, op := range key.PrivateKey.KeyOps() { + // the private JWK with key_ops "sign" should be used as the signing key + if op == jwk.KeyOpSign { + return key.PrivateKey, nil + } + } + } + return nil, fmt.Errorf("no signing key found") +} + +func GetSigningKey(k jwk.Key) (any, error) { + var key any + if err := k.Raw(&key); err != nil { + return nil, err + } + return key, nil +} + +func GetSigningAlg(k jwk.Key) jwt.SigningMethod { + if k == nil { + return jwt.SigningMethodHS256 + } + + switch (k).Algorithm().String() { + case "RS256": + return jwt.SigningMethodRS256 + case "RS512": + return jwt.SigningMethodRS512 + case "ES256": + return jwt.SigningMethodES256 + case "ES512": + return jwt.SigningMethodES512 + case "EdDSA": + return jwt.SigningMethodEdDSA + } + + // return HS256 to preserve existing behaviour + return jwt.SigningMethodHS256 +} + +func FindPublicKeyByKid(kid string, config *JWTConfiguration) (any, error) { + if k, ok := config.Keys[kid]; ok { + key, err := GetSigningKey(k.PublicKey) + if err != nil { + return nil, err + } + return key, nil + } + if kid == config.KeyID { + return []byte(config.Secret), nil + } + return nil, fmt.Errorf("invalid kid: %s", kid) +} diff --git a/internal/conf/jwk_test.go b/internal/conf/jwk_test.go new file mode 100644 index 000000000..275c0eb2c --- /dev/null +++ b/internal/conf/jwk_test.go @@ -0,0 +1,81 @@ +package conf + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDecode(t *testing.T) { + // array of JWKs containing 4 keys + gotrueJwtKeys := `[{"kty":"oct","k":"9Sj51i2YvfY85NJZFD6rAl9fKDxSKjFgW6W6ZXOJLnU","kid":"f90202bc-413a-4db3-8e04-b70a02a65669","key_ops":["verify"],"alg":"HS256"},{"kty":"RSA","n":"4slQjr-XoU6I1KXFWOeeJi387RIUxjhyzXX3GUVNb75a0SPKoGShlJEbpvuXqkDLGDweLcIZy-01nqgjSzMY_tUO3L78MxVfIVn7MByJ4_zbrVf5rjKeAk9EEMl6pb8nKJGArph9sOwL68LLioNySt_WNo_hMfuxUuVkRagh5gLjYoQ4odkULQrgwlMcXxXNnvg0aYURUr2SDmncHNuZQ3adebRlI164mUZPPWui2fg72R7c9qhVaAEzbdG-JAuC3zn5iL4zZk-8pOwZkM7Qb_2lrcXwdTl_Qz6fMdAHz_3rggac5oeKkdvO2x7_XiUwGxIBYSghxg5BBxcyqd6WrQ","e":"AQAB","d":"FjJo7uH4aUoktO8kHhbHbY_KSdQpHDjKyc7yTS_0DWYgUfdozzubJfRDF42vI-KsXssF-NoB0wJf0uP0L8ip6G326XPuoMQRTMgcaF8j6swTwsapSOEagr7BzcECx1zpc2-ojhwbLHSvRutWDzPJkbrUccF8vRC6BsiAUG4Hapiumbot7JtJGwU8ZUhxico7_OEJ_MtkRrHByXgrOMnzNLrmViI9rzvtWOhVc8sNDzLogDDi01AP0j6WeBhbOpaZ_1BMLQ9IeeN5Iiy-7Qj-q4-8kBXIPXpYaKMFnDTmhB0GAVUFimF6ojhZNAJvV81VMHPjrEmmps0_qBfIlKAB","p":"9G7wBpiSJHAl-w47AWvW60v_hye50lte4Ep2P3KeRyinzgxtEMivzldoqirwdoyPCJWwU7nNsv7AjdXVoHFy3fJvJeV5mhArxb2zA36OS_Tr3CQXtB3OO-RFwVcG7AGO7XvA54PK28siXY2VvkG2Xn_ZrbVebJnHQprn7ddUIIE","q":"7YSaG2E_M9XpgUJ0izwKdfGew6Hz5utPUdwMWjqr81BjtLkUtQ3tGYWs2tdaRYUTK4mNFyR2MjLYnMK-F37rue4LSKitmEu2N6RD9TwzcqwiEL_vuQTC985iJ0hzUC58LcbhYtTLU3KqZXXUqaeBXEwQAWxK1NRf6rQRhOGk4C0","dp":"fOV-sfAdpI7FaW3RCp3euGYh0B6lXW4goXyKxUq8w2FrtOY2iH_zDP0u1tyP-BNENr-91Fo5V__BxfeAa7XsWqo4zuVdaDJhG24d3Wg6L2ebaOXsUrV0Hrg6SFs-hzMYpBI69FEsQ3idO65P2GJdXBX51T-6WsWMwmTCo44GR4E","dq":"O2DrJe0p38ualLYIbMaV1uaQyleyoggxzEU20VfZpPpz8rpScvEIVVkV3Z_48WhTYo8AtshmxCXyAT6uRzFzvQfFymRhAbHr2_01ABoMwp5F5eoWBCsskscFwsxaB7GXWdpefla0figscTED-WXm8SwS1Eg-bParBAIAXzgKAAE","qi":"Cezqw8ECfMmwnRXJuiG2A93lzhixHxXISvGC-qbWaRmCfetheSviZlM0_KxF6dsvrw_aNfIPa8rv1TbN-5F04v_RU1CD79QuluzXWLkZVhPXorkK5e8sUi_odzAJXOwHKQzal5ndInl4XYctDHQr8jXcFW5Un65FhPwdAC6-aek","kid":"74b1a36b-4b39-467f-976b-acc7ec600a6d","key_ops":["verify"],"alg":"RS256"},{"kty":"EC","x":"GwbnH57MUhgL14dJfayyzuI6o2_mB_Pm8xIuauHXtQs","y":"cYqN0VAcv0BC9wrg3vNgHlKhGP8ZEedUC2A8jXpaGwA","crv":"P-256","d":"4STEXq7W4UY0piCGPueMaQqAAZ5jVRjjA_b1Hq7YgmM","kid":"fa3ffc99-4635-4b19-b5c0-6d6a8d30c4eb","key_ops":["sign","verify"],"alg":"ES256"},{"crv":"Ed25519","d":"T179kXSOJHE8CNbqaI2HNdG8r3YbSoKYxNRSzTkpEcY","x":"iDYagELzmD4z6uaW7eAZLuQ9fiUlnLqtrh7AfNbiNiI","kty":"OKP","kid":"b1176272-46e4-4226-b0bd-12eef4fd7367","key_ops":["verify"],"alg":"EdDSA"}]` + var decoder JwtKeysDecoder + require.NoError(t, decoder.Decode(gotrueJwtKeys)) + require.Len(t, decoder, 4) + + for kid, key := range decoder { + require.NotEmpty(t, kid) + require.NotNil(t, key.PrivateKey) + require.NotNil(t, key.PublicKey) + require.NotEmpty(t, key.PublicKey.KeyOps(), "missing key_ops claim") + } +} + +func TestJWTConfiguration(t *testing.T) { + // array of JWKs containing 4 keys + gotrueJwtKeys := `[{"kty":"oct","k":"9Sj51i2YvfY85NJZFD6rAl9fKDxSKjFgW6W6ZXOJLnU","kid":"f90202bc-413a-4db3-8e04-b70a02a65669","key_ops":["verify"],"alg":"HS256"},{"kty":"RSA","n":"4slQjr-XoU6I1KXFWOeeJi387RIUxjhyzXX3GUVNb75a0SPKoGShlJEbpvuXqkDLGDweLcIZy-01nqgjSzMY_tUO3L78MxVfIVn7MByJ4_zbrVf5rjKeAk9EEMl6pb8nKJGArph9sOwL68LLioNySt_WNo_hMfuxUuVkRagh5gLjYoQ4odkULQrgwlMcXxXNnvg0aYURUr2SDmncHNuZQ3adebRlI164mUZPPWui2fg72R7c9qhVaAEzbdG-JAuC3zn5iL4zZk-8pOwZkM7Qb_2lrcXwdTl_Qz6fMdAHz_3rggac5oeKkdvO2x7_XiUwGxIBYSghxg5BBxcyqd6WrQ","e":"AQAB","d":"FjJo7uH4aUoktO8kHhbHbY_KSdQpHDjKyc7yTS_0DWYgUfdozzubJfRDF42vI-KsXssF-NoB0wJf0uP0L8ip6G326XPuoMQRTMgcaF8j6swTwsapSOEagr7BzcECx1zpc2-ojhwbLHSvRutWDzPJkbrUccF8vRC6BsiAUG4Hapiumbot7JtJGwU8ZUhxico7_OEJ_MtkRrHByXgrOMnzNLrmViI9rzvtWOhVc8sNDzLogDDi01AP0j6WeBhbOpaZ_1BMLQ9IeeN5Iiy-7Qj-q4-8kBXIPXpYaKMFnDTmhB0GAVUFimF6ojhZNAJvV81VMHPjrEmmps0_qBfIlKAB","p":"9G7wBpiSJHAl-w47AWvW60v_hye50lte4Ep2P3KeRyinzgxtEMivzldoqirwdoyPCJWwU7nNsv7AjdXVoHFy3fJvJeV5mhArxb2zA36OS_Tr3CQXtB3OO-RFwVcG7AGO7XvA54PK28siXY2VvkG2Xn_ZrbVebJnHQprn7ddUIIE","q":"7YSaG2E_M9XpgUJ0izwKdfGew6Hz5utPUdwMWjqr81BjtLkUtQ3tGYWs2tdaRYUTK4mNFyR2MjLYnMK-F37rue4LSKitmEu2N6RD9TwzcqwiEL_vuQTC985iJ0hzUC58LcbhYtTLU3KqZXXUqaeBXEwQAWxK1NRf6rQRhOGk4C0","dp":"fOV-sfAdpI7FaW3RCp3euGYh0B6lXW4goXyKxUq8w2FrtOY2iH_zDP0u1tyP-BNENr-91Fo5V__BxfeAa7XsWqo4zuVdaDJhG24d3Wg6L2ebaOXsUrV0Hrg6SFs-hzMYpBI69FEsQ3idO65P2GJdXBX51T-6WsWMwmTCo44GR4E","dq":"O2DrJe0p38ualLYIbMaV1uaQyleyoggxzEU20VfZpPpz8rpScvEIVVkV3Z_48WhTYo8AtshmxCXyAT6uRzFzvQfFymRhAbHr2_01ABoMwp5F5eoWBCsskscFwsxaB7GXWdpefla0figscTED-WXm8SwS1Eg-bParBAIAXzgKAAE","qi":"Cezqw8ECfMmwnRXJuiG2A93lzhixHxXISvGC-qbWaRmCfetheSviZlM0_KxF6dsvrw_aNfIPa8rv1TbN-5F04v_RU1CD79QuluzXWLkZVhPXorkK5e8sUi_odzAJXOwHKQzal5ndInl4XYctDHQr8jXcFW5Un65FhPwdAC6-aek","kid":"74b1a36b-4b39-467f-976b-acc7ec600a6d","key_ops":["verify"],"alg":"RS256"},{"kty":"EC","x":"GwbnH57MUhgL14dJfayyzuI6o2_mB_Pm8xIuauHXtQs","y":"cYqN0VAcv0BC9wrg3vNgHlKhGP8ZEedUC2A8jXpaGwA","crv":"P-256","d":"4STEXq7W4UY0piCGPueMaQqAAZ5jVRjjA_b1Hq7YgmM","kid":"fa3ffc99-4635-4b19-b5c0-6d6a8d30c4eb","key_ops":["sign","verify"],"alg":"ES256"},{"crv":"Ed25519","d":"T179kXSOJHE8CNbqaI2HNdG8r3YbSoKYxNRSzTkpEcY","x":"iDYagELzmD4z6uaW7eAZLuQ9fiUlnLqtrh7AfNbiNiI","kty":"OKP","kid":"b1176272-46e4-4226-b0bd-12eef4fd7367","key_ops":["verify"],"alg":"EdDSA"}]` + var decoder JwtKeysDecoder + require.NoError(t, decoder.Decode(gotrueJwtKeys)) + require.Len(t, decoder, 4) + + cases := []struct { + desc string + config JWTConfiguration + expectedLength int + }{ + { + desc: "GOTRUE_JWT_KEYS is nil", + config: JWTConfiguration{ + Secret: "testsecret", + KeyID: "testkeyid", + }, + expectedLength: 1, + }, + { + desc: "GOTRUE_JWT_KEYS is an empty map", + config: JWTConfiguration{ + Secret: "testsecret", + KeyID: "testkeyid", + Keys: JwtKeysDecoder{}, + }, + expectedLength: 1, + }, + { + desc: "Prefer GOTRUE_JWT_KEYS over GOTRUE_JWT_SECRET", + config: JWTConfiguration{ + Secret: "testsecret", + KeyID: "testkeyid", + Keys: decoder, + }, + expectedLength: 4, + }, + } + + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + globalConfig := GlobalConfiguration{ + JWT: c.config, + } + require.NoError(t, globalConfig.ApplyDefaults()) + require.NotEmpty(t, globalConfig.JWT.Keys) + require.Len(t, globalConfig.JWT.Keys, c.expectedLength) + for _, key := range globalConfig.JWT.Keys { + // public keys should contain these require claims + require.NotNil(t, key.PublicKey.Algorithm()) + require.NotNil(t, key.PublicKey.KeyID()) + require.NotNil(t, key.PublicKey.KeyOps()) + require.Equal(t, "sig", key.PublicKey.KeyUsage()) + } + }) + } +} diff --git a/internal/utilities/request_test.go b/internal/utilities/request_test.go index dab96110d..6704e3958 100644 --- a/internal/utilities/request_test.go +++ b/internal/utilities/request_test.go @@ -92,8 +92,11 @@ func TestGetReferrer(t *tst.T) { config := conf.GlobalConfiguration{ SiteURL: "https://example.com", URIAllowList: []string{"http://localhost:8000/*"}, + JWT: conf.JWTConfiguration{ + Secret: "testsecret", + }, } - config.ApplyDefaults() + require.NoError(t, config.ApplyDefaults()) cases := []struct { desc string redirectURL string From 7de0cb3c2f816504e452d0e6b02292f747030a88 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 26 Jul 2024 08:19:17 -0700 Subject: [PATCH 068/118] chore(master): release 2.157.0 (#1683) :robot: I have created a release *beep* *boop* --- ## [2.157.0](https://github.com/supabase/auth/compare/v2.156.0...v2.157.0) (2024-07-26) ### Features * add asymmetric jwt support ([#1674](https://github.com/supabase/auth/issues/1674)) ([c7a2be3](https://github.com/supabase/auth/commit/c7a2be347b301b666e99adc3d3fed78c5e287c82)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d63392d7..9591fe41e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [2.157.0](https://github.com/supabase/auth/compare/v2.156.0...v2.157.0) (2024-07-26) + + +### Features + +* add asymmetric jwt support ([#1674](https://github.com/supabase/auth/issues/1674)) ([c7a2be3](https://github.com/supabase/auth/commit/c7a2be347b301b666e99adc3d3fed78c5e287c82)) + ## [2.156.0](https://github.com/supabase/auth/compare/v2.155.6...v2.156.0) (2024-07-25) From 46491b867a4f5896494417391392a373a453fa5f Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Mon, 29 Jul 2024 16:44:27 +0200 Subject: [PATCH 069/118] feat: add hook log entry with `run_hook` action (#1684) Adds a log entry when hooks run. Also refactors the `invokeHook` API to not require redundant parameters like the URI. --- internal/api/hooks.go | 50 ++++++++++++++++++++++++++------------ internal/api/hooks_test.go | 4 +-- internal/api/mail.go | 2 +- internal/api/mfa.go | 2 +- internal/api/phone.go | 2 +- internal/api/token.go | 4 +-- 6 files changed, 41 insertions(+), 23 deletions(-) diff --git a/internal/api/hooks.go b/internal/api/hooks.go index c66a94b5f..197a62f15 100644 --- a/internal/api/hooks.go +++ b/internal/api/hooks.go @@ -10,7 +10,6 @@ import ( "mime" "net" "net/http" - "net/url" "strings" "time" @@ -188,13 +187,9 @@ func (a *API) runHTTPHook(r *http.Request, hookConfig conf.ExtensibilityPointCon // transaction is opened. If calling invokeHook within a transaction, always // pass the current transaction, as pool-exhaustion deadlocks are very easy to // trigger. -func (a *API) invokeHook(conn *storage.Connection, r *http.Request, input, output any, uri string) error { +func (a *API) invokeHook(conn *storage.Connection, r *http.Request, input, output any) error { var err error var response []byte - u, err := url.Parse(uri) - if err != nil { - return err - } switch input.(type) { case *hooks.SendSMSInput: @@ -202,7 +197,7 @@ func (a *API) invokeHook(conn *storage.Connection, r *http.Request, input, outpu if !ok { panic("output should be *hooks.SendSMSOutput") } - if response, err = a.runHook(r, conn, a.config.Hook.SendSMS, input, output, u.Scheme); err != nil { + if response, err = a.runHook(r, conn, a.config.Hook.SendSMS, input, output); err != nil { return err } if err := json.Unmarshal(response, hookOutput); err != nil { @@ -226,7 +221,7 @@ func (a *API) invokeHook(conn *storage.Connection, r *http.Request, input, outpu if !ok { panic("output should be *hooks.SendEmailOutput") } - if response, err = a.runHook(r, conn, a.config.Hook.SendEmail, input, output, u.Scheme); err != nil { + if response, err = a.runHook(r, conn, a.config.Hook.SendEmail, input, output); err != nil { return err } if err := json.Unmarshal(response, hookOutput); err != nil { @@ -252,7 +247,7 @@ func (a *API) invokeHook(conn *storage.Connection, r *http.Request, input, outpu if !ok { panic("output should be *hooks.MFAVerificationAttemptOutput") } - if response, err = a.runHook(r, conn, a.config.Hook.MFAVerificationAttempt, input, output, u.Scheme); err != nil { + if response, err = a.runHook(r, conn, a.config.Hook.MFAVerificationAttempt, input, output); err != nil { return err } if err := json.Unmarshal(response, hookOutput); err != nil { @@ -279,7 +274,7 @@ func (a *API) invokeHook(conn *storage.Connection, r *http.Request, input, outpu panic("output should be *hooks.PasswordVerificationAttemptOutput") } - if response, err = a.runHook(r, conn, a.config.Hook.PasswordVerificationAttempt, input, output, u.Scheme); err != nil { + if response, err = a.runHook(r, conn, a.config.Hook.PasswordVerificationAttempt, input, output); err != nil { return err } if err := json.Unmarshal(response, hookOutput); err != nil { @@ -306,7 +301,7 @@ func (a *API) invokeHook(conn *storage.Connection, r *http.Request, input, outpu if !ok { panic("output should be *hooks.CustomAccessTokenOutput") } - if response, err = a.runHook(r, conn, a.config.Hook.CustomAccessToken, input, output, u.Scheme); err != nil { + if response, err = a.runHook(r, conn, a.config.Hook.CustomAccessToken, input, output); err != nil { return err } if err := json.Unmarshal(response, hookOutput); err != nil { @@ -345,20 +340,43 @@ func (a *API) invokeHook(conn *storage.Connection, r *http.Request, input, outpu return nil } -func (a *API) runHook(r *http.Request, conn *storage.Connection, hookConfig conf.ExtensibilityPointConfiguration, input, output any, scheme string) ([]byte, error) { +func (a *API) runHook(r *http.Request, conn *storage.Connection, hookConfig conf.ExtensibilityPointConfiguration, input, output any) ([]byte, error) { ctx := r.Context() + + logEntry := observability.GetLogEntry(r) + hookStart := time.Now() + var response []byte var err error - switch strings.ToLower(scheme) { - case "http", "https": + + switch { + case strings.HasPrefix(hookConfig.URI, "http:") || strings.HasPrefix(hookConfig.URI, "https:"): response, err = a.runHTTPHook(r, hookConfig, input) - case "pg-functions": + case strings.HasPrefix(hookConfig.URI, "pg-functions:"): response, err = a.runPostgresHook(ctx, conn, hookConfig, input, output) default: - return nil, fmt.Errorf("unsupported protocol: %v only postgres hooks and HTTPS functions are supported at the moment", scheme) + return nil, fmt.Errorf("unsupported protocol: %q only postgres hooks and HTTPS functions are supported at the moment", hookConfig.URI) } + + duration := time.Since(hookStart) + if err != nil { + logEntry.Entry.WithFields(logrus.Fields{ + "action": "run_hook", + "hook": hookConfig.URI, + "success": false, + "duration": duration.Microseconds(), + }).WithError(err).Warn("Hook errored out") + return nil, internalServerError("Error running hook URI: %v", hookConfig.URI).WithInternalError(err) } + + logEntry.Entry.WithFields(logrus.Fields{ + "action": "run_hook", + "hook": hookConfig.URI, + "success": true, + "duration": duration.Microseconds(), + }).WithError(err).Info("Hook ran successfully") + return response, nil } diff --git a/internal/api/hooks_test.go b/internal/api/hooks_test.go index d645ef41b..8530b858a 100644 --- a/internal/api/hooks_test.go +++ b/internal/api/hooks_test.go @@ -262,7 +262,7 @@ func (ts *HooksTestSuite) TestInvokeHookIntegration() { input: &hooks.SendEmailInput{}, output: &hooks.SendEmailOutput{}, uri: "ftp://example.com/path", - expectedError: errors.New("unsupported protocol: ftp only postgres hooks and HTTPS functions are supported at the moment"), + expectedError: errors.New("unsupported protocol: \"ftp://example.com/path\" only postgres hooks and HTTPS functions are supported at the moment"), }, } @@ -274,7 +274,7 @@ func (ts *HooksTestSuite) TestInvokeHookIntegration() { require.NoError(ts.T(), ts.Config.Hook.SendEmail.PopulateExtensibilityPoint()) ts.Run(tc.description, func() { - err = ts.API.invokeHook(tc.conn, tc.request, tc.input, tc.output, tc.uri) + err = ts.API.invokeHook(tc.conn, tc.request, tc.input, tc.output) if tc.expectedError != nil { require.EqualError(ts.T(), err, tc.expectedError.Error()) } else { diff --git a/internal/api/mail.go b/internal/api/mail.go index 30f358ad2..35f529e25 100644 --- a/internal/api/mail.go +++ b/internal/api/mail.go @@ -589,7 +589,7 @@ func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User, EmailData: emailData, } output := hooks.SendEmailOutput{} - return a.invokeHook(tx, r, &input, &output, a.config.Hook.SendEmail.URI) + return a.invokeHook(tx, r, &input, &output) } switch emailActionType { diff --git a/internal/api/mfa.go b/internal/api/mfa.go index c15f4a3b4..df7b84d36 100644 --- a/internal/api/mfa.go +++ b/internal/api/mfa.go @@ -261,7 +261,7 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { } output := hooks.MFAVerificationAttemptOutput{} - err := a.invokeHook(nil, r, &input, &output, a.config.Hook.MFAVerificationAttempt.URI) + err := a.invokeHook(nil, r, &input, &output) if err != nil { return err } diff --git a/internal/api/phone.go b/internal/api/phone.go index 2147a959b..86d569b94 100644 --- a/internal/api/phone.go +++ b/internal/api/phone.go @@ -104,7 +104,7 @@ func (a *API) sendPhoneConfirmation(r *http.Request, tx *storage.Connection, use }, } output := hooks.SendSMSOutput{} - err := a.invokeHook(tx, r, &input, &output, a.config.Hook.SendSMS.URI) + err := a.invokeHook(tx, r, &input, &output) if err != nil { return "", err } diff --git a/internal/api/token.go b/internal/api/token.go index 9a94f3e14..712a44bd7 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -185,7 +185,7 @@ func (a *API) ResourceOwnerPasswordGrant(ctx context.Context, w http.ResponseWri Valid: isValidPassword, } output := hooks.PasswordVerificationAttemptOutput{} - err := a.invokeHook(nil, r, &input, &output, a.config.Hook.PasswordVerificationAttempt.URI) + err := a.invokeHook(nil, r, &input, &output) if err != nil { return err } @@ -360,7 +360,7 @@ func (a *API) generateAccessToken(r *http.Request, tx *storage.Connection, user output := hooks.CustomAccessTokenOutput{} - err := a.invokeHook(tx, r, &input, &output, a.config.Hook.CustomAccessToken.URI) + err := a.invokeHook(tx, r, &input, &output) if err != nil { return "", 0, err } From ae091aa942bdc5bc97481037508ec3bb4079d859 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Mon, 29 Jul 2024 22:11:15 +0200 Subject: [PATCH 070/118] feat: MFA (Phone) (#1668) ## What kind of change does this PR introduce? We introduce MFA (Phone) to allow developers to enroll a Phone-based MFA factor. We maintain the existing pattern of enroll, challenge, verify. The frontend bindings are [listed here](https://github.com/supabase/auth-js/pull/932/files) but as a summary `enroll` - `enroll({factorType: 'phone', phoneNumber:'', friendlyName:''})` `challenge` - `challenge({channel: ''})`. If no channel is specified it defaults to `sms`. ### How it works This is and additive change - there should be no impact on the existing flow unless one specifies `{'factor_type': 'phone'}` in the request body. ### Key Details - OTP Expiry is currently tied to challenge expiry. The OTP will last as long as the challenge. We can expose an option to decouple this in the future since it is and additive change. - It is independent of the phone provider. You can use MFA (Phone) even if Phone Provider is disabled. - There is however, links to the phone provider. MFA (Phone) will derive configuration from Phone Provider (e.g. if Phone provider is configured to use Twilio, MFA (Phone) will use Twilio. If you wish to use a separate phone provider please use the Send SMS Hook. - OTP's are stored encrypted in the database. #### Configuration - We have `ENROLL_ENABLED` and `VERIFY_ENABLED` toggles or both TOTP and Phone Factors. #### Integration with Hooks - When used with the MFA Verification Hook the input payload to the hook will contain a factor type indicator: ``` hooks.MFAVerificationAttemptInput { .... FactorType: 'sms' .... } ``` When used with the Send SMS Hook there's an indicator for the SMSType specifying that it is an MFA Hook. Use as needed. ``` hooks.SendSMSInput{ User: user, SMS: hooks.SMS{ ... SMSType: "mfa", }, } ``` #### Security Concerns - Vulnerability to Brute Force Attack / Distributed attack - Leakage of OTP Code anywhere --- internal/api/errorcodes.go | 4 + internal/api/helpers.go | 1 + internal/api/mfa.go | 346 +++++++++++++++++- internal/api/mfa_test.go | 209 +++++++++-- internal/api/token.go | 2 +- internal/conf/configuration.go | 54 ++- internal/hooks/auth_hooks.go | 10 +- internal/models/amr.go | 4 + internal/models/challenge.go | 44 ++- internal/models/factor.go | 79 +++- internal/models/sessions.go | 2 +- ...20240729123726_add_mfa_phone_config.up.sql | 14 + 12 files changed, 691 insertions(+), 78 deletions(-) create mode 100644 migrations/20240729123726_add_mfa_phone_config.up.sql diff --git a/internal/api/errorcodes.go b/internal/api/errorcodes.go index 036d747a5..9038dc25d 100644 --- a/internal/api/errorcodes.go +++ b/internal/api/errorcodes.go @@ -78,4 +78,8 @@ const ( ErrorCodeHookPayloadOverSizeLimit ErrorCode = "hook_payload_over_size_limit" ErrorCodeHookPayloadUnknownSize ErrorCode = "hook_payload_unknown_size" ErrorCodeRequestTimeout ErrorCode = "request_timeout" + ErrorCodeMFAPhoneEnrollDisabled ErrorCode = "mfa_phone_enroll_not_enabled" + ErrorCodeMFAPhoneVerifyDisabled ErrorCode = "mfa_phone_verify_not_enabled" + ErrorCodeMFATOTPEnrollDisabled ErrorCode = "mfa_totp_enroll_not_enabled" + ErrorCodeMFATOTPVerifyDisabled ErrorCode = "mfa_totp_verify_not_enabled" ) diff --git a/internal/api/helpers.go b/internal/api/helpers.go index 485a3870c..692139252 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -82,6 +82,7 @@ type RequestParams interface { VerifyFactorParams | VerifyParams | adminUserUpdateFactorParams | + ChallengeFactorParams | struct { Email string `json:"email"` Phone string `json:"phone"` diff --git a/internal/api/mfa.go b/internal/api/mfa.go index df7b84d36..126f33545 100644 --- a/internal/api/mfa.go +++ b/internal/api/mfa.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "crypto/subtle" "fmt" "net/http" "net/url" @@ -13,6 +14,7 @@ import ( "github.com/gofrs/uuid" "github.com/pquerna/otp" "github.com/pquerna/otp/totp" + "github.com/supabase/auth/internal/api/sms_provider" "github.com/supabase/auth/internal/crypto" "github.com/supabase/auth/internal/hooks" "github.com/supabase/auth/internal/metering" @@ -27,12 +29,13 @@ type EnrollFactorParams struct { FriendlyName string `json:"friendly_name"` FactorType string `json:"factor_type"` Issuer string `json:"issuer"` + Phone string `json:"phone"` } type TOTPObject struct { - QRCode string `json:"qr_code"` - Secret string `json:"secret"` - URI string `json:"uri"` + QRCode string `json:"qr_code,omitempty"` + Secret string `json:"secret,omitempty"` + URI string `json:"uri,omitempty"` } type EnrollFactorResponse struct { @@ -40,6 +43,11 @@ type EnrollFactorResponse struct { Type string `json:"type"` FriendlyName string `json:"friendly_name"` TOTP TOTPObject `json:"totp,omitempty"` + Phone string `json:"phone,omitempty"` +} + +type ChallengeFactorParams struct { + Channel string `json:"channel"` } type VerifyFactorParams struct { @@ -61,6 +69,74 @@ const ( QRCodeGenerationErrorMessage = "Error generating QR Code" ) +func (a *API) enrollPhoneFactor(w http.ResponseWriter, r *http.Request, params *EnrollFactorParams) error { + ctx := r.Context() + config := a.config + user := getUser(ctx) + session := getSession(ctx) + db := a.db.WithContext(ctx) + if params.Phone == "" { + return badRequestError(ErrorCodeValidationFailed, "Phone number required to enroll Phone factor") + } + + phone, err := validatePhone(params.Phone) + if err != nil { + return badRequestError(ErrorCodeValidationFailed, "Invalid phone number format (E.164 required)") + } + factors := user.Factors + + factorCount := len(factors) + numVerifiedFactors := 0 + if err := models.DeleteExpiredFactors(db, config.MFA.FactorExpiryDuration); err != nil { + return err + } + + for _, factor := range factors { + if factor.IsVerified() { + numVerifiedFactors += 1 + } + } + + if factorCount >= int(config.MFA.MaxEnrolledFactors) { + return unprocessableEntityError(ErrorCodeTooManyEnrolledMFAFactors, "Maximum number of verified factors reached, unenroll to continue") + } + + if numVerifiedFactors >= config.MFA.MaxVerifiedFactors { + return unprocessableEntityError(ErrorCodeTooManyEnrolledMFAFactors, "Maximum number of verified factors reached, unenroll to continue") + } + + if numVerifiedFactors > 0 && !session.IsAAL2() { + return forbiddenError(ErrorCodeInsufficientAAL, "AAL2 required to enroll a new factor") + } + factor := models.NewPhoneFactor(user, phone, params.FriendlyName, params.FactorType, models.FactorStateUnverified) + err = db.Transaction(func(tx *storage.Connection) error { + if terr := tx.Create(factor); terr != nil { + pgErr := utilities.NewPostgresError(terr) + if pgErr.IsUniqueConstraintViolated() { + return unprocessableEntityError(ErrorCodeMFAFactorNameConflict, fmt.Sprintf("A factor with the friendly name %q for this user likely already exists", factor.FriendlyName)) + } + return terr + + } + if terr := models.NewAuditLogEntry(r, tx, user, models.EnrollFactorAction, r.RemoteAddr, map[string]interface{}{ + "factor_id": factor.ID, + "factor_type": factor.FactorType, + }); terr != nil { + return terr + } + return nil + }) + if err != nil { + return err + } + return sendJSON(w, http.StatusOK, &EnrollFactorResponse{ + ID: factor.ID, + Type: models.Phone, + FriendlyName: factor.FriendlyName, + Phone: string(factor.Phone), + }) +} + func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() user := getUser(ctx) @@ -71,14 +147,23 @@ func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error { if session == nil || user == nil { return internalServerError("A valid session and a registered user are required to enroll a factor") } - params := &EnrollFactorParams{} if err := retrieveRequestParams(r, params); err != nil { return err } - if params.FactorType != models.TOTP { - return badRequestError(ErrorCodeValidationFailed, "factor_type needs to be totp") + switch params.FactorType { + case models.Phone: + if !config.MFA.Phone.EnrollEnabled { + return unprocessableEntityError(ErrorCodeMFAPhoneEnrollDisabled, "MFA enroll is disabled for Phone") + } + return a.enrollPhoneFactor(w, r, params) + case models.TOTP: + if !config.MFA.TOTP.EnrollEnabled { + return unprocessableEntityError(ErrorCodeMFATOTPEnrollDisabled, "MFA enroll is disabled for TOTP") + } + default: + return badRequestError(ErrorCodeValidationFailed, "factor_type needs to be TOTP or Phone") } issuer := "" @@ -117,7 +202,9 @@ func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error { if numVerifiedFactors > 0 && !session.IsAAL2() { return forbiddenError(ErrorCodeInsufficientAAL, "AAL2 required to enroll a new factor") } - + var factor *models.Factor + var buf bytes.Buffer + var key *otp.Key key, err := totp.Generate(totp.GenerateOpts{ Issuer: issuer, AccountName: user.GetEmail(), @@ -126,7 +213,6 @@ func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error { return internalServerError(QRCodeGenerationErrorMessage).WithInternalError(err) } - var buf bytes.Buffer svgData := svg.New(&buf) qrCode, _ := qr.Encode(key.String(), qr.H, qr.Auto) qs := goqrsvg.NewQrSVG(qrCode, DefaultQRSize) @@ -136,7 +222,7 @@ func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error { } svgData.End() - factor := models.NewFactor(user, params.FriendlyName, params.FactorType, models.FactorStateUnverified) + factor = models.NewFactor(user, params.FriendlyName, params.FactorType, models.FactorStateUnverified) if err := factor.SetSecret(key.Secret(), config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil { return err } @@ -160,7 +246,6 @@ func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error { if err != nil { return err } - return sendJSON(w, http.StatusOK, &EnrollFactorResponse{ ID: factor.ID, Type: models.TOTP, @@ -174,6 +259,92 @@ func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error { }) } +func (a *API) challengePhoneFactor(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + config := a.config + db := a.db.WithContext(ctx) + user := getUser(ctx) + factor := getFactor(ctx) + ipAddress := utilities.GetIPAddress(r) + params := &ChallengeFactorParams{} + if err := retrieveRequestParams(r, params); err != nil { + return err + } + channel := params.Channel + + if channel == "" { + channel = sms_provider.SMSProvider + } + smsProvider, err := sms_provider.GetSmsProvider(*config) + if err != nil { + return internalServerError("Failed to get SMS provider").WithInternalError(err) + } + if !sms_provider.IsValidMessageChannel(channel, config.Sms.Provider) { + return badRequestError(ErrorCodeValidationFailed, InvalidChannelError) + } + latestValidChallenge, err := factor.FindLatestUnexpiredChallenge(a.db, config.MFA.ChallengeExpiryDuration) + if err != nil { + if !models.IsNotFoundError(err) { + return internalServerError("error finding latest unexpired challenge") + } + } else if latestValidChallenge != nil && !latestValidChallenge.SentAt.Add(config.MFA.Phone.MaxFrequency).Before(time.Now()) { + return tooManyRequestsError(ErrorCodeOverSMSSendRateLimit, generateFrequencyLimitErrorMessage(latestValidChallenge.SentAt, config.MFA.Phone.MaxFrequency)) + } + + otp, err := crypto.GenerateOtp(config.MFA.Phone.OtpLength) + if err != nil { + panic(err) + } + challenge, err := factor.CreatePhoneChallenge(ipAddress, otp, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey) + if err != nil { + return internalServerError("error creating SMS Challenge") + } + + message, err := generateSMSFromTemplate(config.MFA.Phone.SMSTemplate, otp) + if err != nil { + return internalServerError("error generating sms template").WithInternalError(err) + } + if config.Hook.SendSMS.Enabled { + input := hooks.SendSMSInput{ + User: user, + SMS: hooks.SMS{ + OTP: otp, + SMSType: "mfa", + }, + } + output := hooks.SendSMSOutput{} + err := a.invokeHook(a.db, r, &input, &output) + if err != nil { + return internalServerError("error invoking hook") + } + } else { + + // We omit messageID for now, can consider reinstating if there are requests. + _, err := smsProvider.SendMessage(string(factor.Phone), message, channel, otp) + if err != nil { + return internalServerError("error sending message").WithInternalError(err) + } + } + if err := db.Transaction(func(tx *storage.Connection) error { + if terr := tx.Create(challenge); terr != nil { + return terr + } + if terr := models.NewAuditLogEntry(r, tx, user, models.CreateChallengeAction, r.RemoteAddr, map[string]interface{}{ + "factor_id": factor.ID, + "factor_status": factor.Status, + }); terr != nil { + return terr + } + return nil + }); err != nil { + return err + } + return sendJSON(w, http.StatusOK, &ChallengeFactorResponse{ + ID: challenge.ID, + ExpiresAt: challenge.GetExpiryTime(config.MFA.ChallengeExpiryDuration).Unix(), + }) +} + func (a *API) ChallengeFactor(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() config := a.config @@ -182,8 +353,10 @@ func (a *API) ChallengeFactor(w http.ResponseWriter, r *http.Request) error { user := getUser(ctx) factor := getFactor(ctx) ipAddress := utilities.GetIPAddress(r) + if factor.IsPhoneFactor() { + return a.challengePhoneFactor(w, r) + } challenge := factor.CreateChallenge(ipAddress) - if err := db.Transaction(func(tx *storage.Connection) error { if terr := tx.Create(challenge); terr != nil { return terr @@ -205,6 +378,129 @@ func (a *API) ChallengeFactor(w http.ResponseWriter, r *http.Request) error { }) } +func (a *API) verifyPhoneFactor(w http.ResponseWriter, r *http.Request, params *VerifyFactorParams) error { + ctx := r.Context() + config := a.config + user := getUser(ctx) + factor := getFactor(ctx) + db := a.db.WithContext(ctx) + currentIP := utilities.GetIPAddress(r) + + if !factor.IsOwnedBy(user) { + return notFoundError(ErrorCodeMFAFactorNotFound, "MFA factor not found") + + } + + challenge, err := factor.FindChallengeByID(db, params.ChallengeID) + if err != nil && models.IsNotFoundError(err) { + return notFoundError(ErrorCodeMFAFactorNotFound, "MFA factor with the provided challenge ID not found") + } else if err != nil { + return internalServerError("Database error finding Challenge").WithInternalError(err) + } + + if challenge.VerifiedAt != nil || challenge.IPAddress != currentIP { + return unprocessableEntityError(ErrorCodeMFAIPAddressMismatch, "Challenge and verify IP addresses mismatch") + } + + if challenge.HasExpired(config.MFA.ChallengeExpiryDuration) { + if err := db.Destroy(challenge); err != nil { + return internalServerError("Database error deleting challenge").WithInternalError(err) + } + return unprocessableEntityError(ErrorCodeMFAChallengeExpired, "MFA challenge %v has expired, verify against another challenge or create a new challenge.", challenge.ID) + } + otpCode, shouldReEncrypt, err := challenge.GetOtpCode(config.Security.DBEncryption.DecryptionKeys, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID) + if err != nil { + return internalServerError("Database error verifying MFA TOTP secret").WithInternalError(err) + } + valid := subtle.ConstantTimeCompare([]byte(otpCode), []byte(params.Code)) == 1 + if config.Hook.MFAVerificationAttempt.Enabled { + input := hooks.MFAVerificationAttemptInput{ + UserID: user.ID, + FactorID: factor.ID, + FactorType: factor.FactorType, + Valid: valid, + } + + output := hooks.MFAVerificationAttemptOutput{} + err := a.invokeHook(nil, r, &input, &output) + if err != nil { + return err + } + + if output.Decision == hooks.HookRejection { + if err := models.Logout(db, user.ID); err != nil { + return err + } + + if output.Message == "" { + output.Message = hooks.DefaultMFAHookRejectionMessage + } + + return forbiddenError(ErrorCodeMFAVerificationRejected, output.Message) + } + } + if !valid { + if shouldReEncrypt && config.Security.DBEncryption.Encrypt { + if err := challenge.SetOtpCode(otpCode, true, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil { + return err + } + + if err := db.UpdateOnly(challenge, "otp_code"); err != nil { + return err + } + } + return unprocessableEntityError(ErrorCodeMFAVerificationFailed, "Invalid MFA Phone code entered") + } + + var token *AccessTokenResponse + + err = db.Transaction(func(tx *storage.Connection) error { + var terr error + if terr = models.NewAuditLogEntry(r, tx, user, models.VerifyFactorAction, r.RemoteAddr, map[string]interface{}{ + "factor_id": factor.ID, + "challenge_id": challenge.ID, + "factor_type": factor.FactorType, + }); terr != nil { + return terr + } + if terr = challenge.Verify(tx); terr != nil { + return terr + } + if !factor.IsVerified() { + if terr = factor.UpdateStatus(tx, models.FactorStateVerified); terr != nil { + return terr + } + } + user, terr = models.FindUserByID(tx, user.ID) + if terr != nil { + return terr + } + + token, terr = a.updateMFASessionAndClaims(r, tx, user, models.MFAPhone, models.GrantParams{ + FactorID: &factor.ID, + }) + if terr != nil { + return terr + } + if terr = a.setCookieTokens(config, token, false, w); terr != nil { + return internalServerError("Failed to set JWT cookie. %s", terr) + } + if terr = models.InvalidateSessionsWithAALLessThan(tx, user.ID, models.AAL2.String()); terr != nil { + return internalServerError("Failed to update sessions. %s", terr) + } + if terr = models.DeleteUnverifiedFactors(tx, user); terr != nil { + return internalServerError("Error removing unverified factors. %s", terr) + } + return nil + }) + if err != nil { + return err + } + metering.RecordLogin(string(models.MFACodeLoginAction), user.ID) + + return sendJSON(w, http.StatusOK, token) +} + func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { var err error ctx := r.Context() @@ -217,13 +513,34 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { if err := retrieveRequestParams(r, params); err != nil { return err } + + switch factor.FactorType { + case models.Phone: + if !config.MFA.Phone.VerifyEnabled { + return unprocessableEntityError(ErrorCodeMFAPhoneEnrollDisabled, "MFA verification is disabled for Phone") + } + if params.Code == "" { + return badRequestError(ErrorCodeValidationFailed, "Code needs to be non-empty") + } + return a.verifyPhoneFactor(w, r, params) + case models.TOTP: + if !config.MFA.TOTP.VerifyEnabled { + return unprocessableEntityError(ErrorCodeMFATOTPEnrollDisabled, "MFA verification is disabled for TOTP") + } + if params.Code == "" { + return badRequestError(ErrorCodeValidationFailed, "Code needs to be non-empty") + } + default: + return badRequestError(ErrorCodeValidationFailed, "factor_type needs to be TOTP or Phone") + } + currentIP := utilities.GetIPAddress(r) if !factor.IsOwnedBy(user) { return internalServerError(InvalidFactorOwnerErrorMessage) } - challenge, err := models.FindChallengeByID(db, params.ChallengeID) + challenge, err := factor.FindChallengeByID(db, params.ChallengeID) if err != nil && models.IsNotFoundError(err) { return notFoundError(ErrorCodeMFAFactorNotFound, "MFA factor with the provided challenge ID not found") } else if err != nil { @@ -278,7 +595,6 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { return forbiddenError(ErrorCodeMFAVerificationRejected, output.Message) } } - if !valid { if shouldReEncrypt && config.Security.DBEncryption.Encrypt { if err := factor.SetSecret(secret, true, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil { @@ -293,6 +609,7 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { } var token *AccessTokenResponse + err = db.Transaction(func(tx *storage.Connection) error { var terr error if terr = models.NewAuditLogEntry(r, tx, user, models.VerifyFactorAction, r.RemoteAddr, map[string]interface{}{ @@ -309,7 +626,7 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { return terr } } - if shouldReEncrypt && config.Security.DBEncryption.Encrypt { + if shouldReEncrypt && config.Security.DBEncryption.Encrypt && factor.IsTOTPFactor() { es, terr := crypto.NewEncryptedString(factor.ID.String(), []byte(secret), config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey) if terr != nil { return terr @@ -324,6 +641,7 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { if terr != nil { return terr } + token, terr = a.updateMFASessionAndClaims(r, tx, user, models.TOTPSignIn, models.GrantParams{ FactorID: &factor.ID, }) diff --git a/internal/api/mfa_test.go b/internal/api/mfa_test.go index 101f6c660..4a8316c1e 100644 --- a/internal/api/mfa_test.go +++ b/internal/api/mfa_test.go @@ -16,6 +16,7 @@ import ( "github.com/pkg/errors" "github.com/pquerna/otp" + "github.com/supabase/auth/internal/api/sms_provider" "github.com/supabase/auth/internal/conf" "github.com/supabase/auth/internal/crypto" "github.com/supabase/auth/internal/models" @@ -85,6 +86,10 @@ func (ts *MFATestSuite) SetupTest() { testDomain := strings.Split(ts.TestEmail, "@")[1] ts.TestDomain = testDomain + // By default MFA Phone is disabled + ts.Config.MFA.Phone.EnrollEnabled = true + ts.Config.MFA.Phone.VerifyEnabled = true + key, err := totp.Generate(totp.GenerateOpts{ Issuer: ts.TestDomain, AccountName: ts.TestEmail, @@ -113,6 +118,7 @@ func (ts *MFATestSuite) TestEnrollFactor() { friendlyName string factorType string issuer string + phone string expectedCode int }{ { @@ -120,6 +126,7 @@ func (ts *MFATestSuite) TestEnrollFactor() { friendlyName: alternativeFriendlyName, factorType: models.TOTP, issuer: "", + phone: "", expectedCode: http.StatusOK, }, { @@ -127,6 +134,7 @@ func (ts *MFATestSuite) TestEnrollFactor() { friendlyName: testFriendlyName, factorType: "invalid_factor", issuer: ts.TestDomain, + phone: "", expectedCode: http.StatusBadRequest, }, { @@ -134,6 +142,7 @@ func (ts *MFATestSuite) TestEnrollFactor() { friendlyName: testFriendlyName, factorType: models.TOTP, issuer: ts.TestDomain, + phone: "", expectedCode: http.StatusOK, }, { @@ -141,12 +150,34 @@ func (ts *MFATestSuite) TestEnrollFactor() { friendlyName: "", factorType: models.TOTP, issuer: ts.TestDomain, + phone: "", expectedCode: http.StatusOK, }, + { + desc: "Phone: Enroll with friendly name", + friendlyName: "phone_factor", + factorType: models.Phone, + phone: "+12345677889", + expectedCode: http.StatusOK, + }, + { + desc: "Phone: Enroll with invalid phone number", + friendlyName: "phone_factor", + factorType: models.Phone, + phone: "+1", + expectedCode: http.StatusBadRequest, + }, + { + desc: "Phone: Enroll without phone number should return error", + friendlyName: "phone_factor_fail", + factorType: models.Phone, + phone: "", + expectedCode: http.StatusBadRequest, + }, } for _, c := range cases { ts.Run(c.desc, func() { - w := performEnrollFlow(ts, token, c.friendlyName, c.factorType, c.issuer, c.expectedCode) + w := performEnrollFlow(ts, token, c.friendlyName, c.factorType, c.issuer, c.phone, c.expectedCode) factors, err := FindFactorsByUser(ts.API.db, ts.TestUser) ts.Require().NoError(err) @@ -156,7 +187,7 @@ func (ts *MFATestSuite) TestEnrollFactor() { require.Equal(ts.T(), c.friendlyName, addedFactor.FriendlyName) } - if w.Code == http.StatusOK { + if w.Code == http.StatusOK && c.factorType == models.TOTP { enrollResp := EnrollFactorResponse{} require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp)) qrCode := enrollResp.TOTP.QRCode @@ -168,12 +199,12 @@ func (ts *MFATestSuite) TestEnrollFactor() { } } -func (ts *MFATestSuite) TestDuplicateEnrollsReturnExpectedMessage() { +func (ts *MFATestSuite) TestDuplicateTOTPEnrollsReturnExpectedMessage() { friendlyName := "mary" issuer := "https://issuer.com" token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID) - _ = performEnrollFlow(ts, token, friendlyName, models.TOTP, issuer, http.StatusOK) - response := performEnrollFlow(ts, token, friendlyName, models.TOTP, issuer, http.StatusUnprocessableEntity) + _ = performEnrollFlow(ts, token, friendlyName, models.TOTP, issuer, "", http.StatusOK) + response := performEnrollFlow(ts, token, friendlyName, models.TOTP, issuer, "", http.StatusUnprocessableEntity) var errorResponse HTTPError err := json.NewDecoder(response.Body).Decode(&errorResponse) @@ -195,7 +226,7 @@ func (ts *MFATestSuite) TestMultipleEnrollsCleanupExpiredFactors() { token := accessTokenResp.Token for i := 0; i < numFactors; i++ { - _ = performEnrollFlow(ts, token, "", models.TOTP, "https://issuer.com", http.StatusOK) + _ = performEnrollFlow(ts, token, "", models.TOTP, "https://issuer.com", "", http.StatusOK) } // All Factors except last factor should be expired @@ -206,7 +237,7 @@ func (ts *MFATestSuite) TestMultipleEnrollsCleanupExpiredFactors() { _ = performChallengeFlow(ts, factors[len(factors)-1].ID, token) // Enroll another Factor (Factor 3) - _ = performEnrollFlow(ts, token, "", models.TOTP, "https://issuer.com", http.StatusOK) + _ = performEnrollFlow(ts, token, "", models.TOTP, "https://issuer.com", "", http.StatusOK) factors, err = FindFactorsByUser(ts.API.db, ts.TestUser) require.NoError(ts.T(), err) require.Equal(ts.T(), 3, len(factors)) @@ -219,29 +250,110 @@ func (ts *MFATestSuite) TestChallengeFactor() { require.Equal(ts.T(), http.StatusOK, w.Code) } +func (ts *MFATestSuite) TestChallengeSMSFactor() { + // Challenge should still work with phone provider disabled + ts.Config.External.Phone.Enabled = false + ts.Config.Hook.SendSMS.Enabled = true + ts.Config.Hook.SendSMS.URI = "pg-functions://postgres/auth/send_sms_mfa_mock" + + ts.Config.MFA.Phone.MaxFrequency = 0 * time.Second + + require.NoError(ts.T(), ts.Config.Hook.SendSMS.PopulateExtensibilityPoint()) + require.NoError(ts.T(), ts.API.db.RawQuery(` + create or replace function send_sms_mfa_mock(input jsonb) + returns json as $$ + begin + return input; + end; $$ language plpgsql;`).Exec()) + // We still need a mock provider for hooks to work right now for backward compatibility + // The WhatsApp channel is only valid when twilio or twilio verify is set. + ts.Config.Sms.Provider = "twilio" + ts.Config.Sms.Twilio = conf.TwilioProviderConfiguration{ + AccountSid: "test_account_sid", + AuthToken: "test_auth_token", + MessageServiceSid: "test_message_service_id", + } + + phone := "+1234567" + friendlyName := "testchallengesmsfactor" + + f := models.NewPhoneFactor(ts.TestUser, phone, friendlyName, models.Phone, models.FactorStateUnverified) + require.NoError(ts.T(), ts.API.db.Create(f), "Error creating new SMS factor") + token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID) + + var cases = []struct { + desc string + channel string + expectedCode int + }{ + { + desc: "SMS Channel", + channel: sms_provider.SMSProvider, + expectedCode: http.StatusOK, + }, + { + desc: "WhatsApp Channel", + channel: sms_provider.WhatsappProvider, + expectedCode: http.StatusOK, + }, + } + + for _, tc := range cases { + ts.Run(tc.desc, func() { + w := performSMSChallengeFlow(ts, f.ID, token, tc.channel) + require.Equal(ts.T(), tc.expectedCode, w.Code, tc.desc) + }) + } +} + func (ts *MFATestSuite) TestMFAVerifyFactor() { cases := []struct { desc string validChallenge bool validCode bool + factorType string expectedHTTPCode int }{ { desc: "Invalid: Valid code and expired challenge", validChallenge: false, validCode: true, + factorType: models.TOTP, expectedHTTPCode: http.StatusUnprocessableEntity, }, { - desc: "Invalid: Invalid code and valid challenge ", + desc: "Invalid: Invalid code and valid challenge", validChallenge: true, validCode: false, + factorType: models.TOTP, expectedHTTPCode: http.StatusUnprocessableEntity, }, { desc: "Valid /verify request", validChallenge: true, validCode: true, + factorType: models.TOTP, + expectedHTTPCode: http.StatusOK, + }, + { + desc: "Invalid: Valid code and expired challenge (SMS)", + validChallenge: false, + validCode: true, + factorType: models.Phone, + expectedHTTPCode: http.StatusUnprocessableEntity, + }, + { + desc: "Invalid: Invalid code and valid challenge (SMS)", + validChallenge: true, + validCode: false, + factorType: models.Phone, + expectedHTTPCode: http.StatusUnprocessableEntity, + }, + { + desc: "Valid /verify request (SMS)", + validChallenge: true, + validCode: true, + factorType: models.Phone, expectedHTTPCode: http.StatusOK, }, } @@ -251,21 +363,52 @@ func (ts *MFATestSuite) TestMFAVerifyFactor() { var buffer bytes.Buffer r, err := models.GrantAuthenticatedUser(ts.API.db, ts.TestUser, models.GrantParams{}) require.NoError(ts.T(), err) - - sharedSecret := ts.TestOTPKey.Secret() - factors, err := FindFactorsByUser(ts.API.db, ts.TestUser) - f := factors[0] - f.Secret = sharedSecret - require.NoError(ts.T(), err) - require.NoError(ts.T(), ts.API.db.Update(f), "Error updating new test factor") - token := ts.generateAAL1Token(ts.TestUser, r.SessionId) + var f *models.Factor + var sharedSecret string + + if v.factorType == models.TOTP { + friendlyName := uuid.Must(uuid.NewV4()).String() + f = models.NewFactor(ts.TestUser, friendlyName, models.TOTP, models.FactorStateUnverified) + sharedSecret = ts.TestOTPKey.Secret() + f.Secret = sharedSecret + require.NoError(ts.T(), ts.API.db.Create(f), "Error updating new test factor") + } else if v.factorType == models.Phone { + friendlyName := uuid.Must(uuid.NewV4()).String() + numDigits := 10 + otp, err := crypto.GenerateOtp(numDigits) + require.NoError(ts.T(), err) + phone := fmt.Sprintf("+%s", otp) + f = models.NewPhoneFactor(ts.TestUser, phone, friendlyName, models.Phone, models.FactorStateUnverified) + require.NoError(ts.T(), ts.API.db.Create(f), "Error creating new SMS factor") + } + w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/factors/%s/verify", f.ID), &buffer) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - testIPAddress := utilities.GetIPAddress(req) - c := f.CreateChallenge(testIPAddress) + var c *models.Challenge + var code string + if v.factorType == models.TOTP { + c = f.CreateChallenge(utilities.GetIPAddress(req)) + // Verify TOTP code + code, err = totp.GenerateCode(sharedSecret, time.Now().UTC()) + require.NoError(ts.T(), err) + } else if v.factorType == models.Phone { + code = "123456" + c, err = f.CreatePhoneChallenge(utilities.GetIPAddress(req), code, ts.Config.Security.DBEncryption.Encrypt, ts.Config.Security.DBEncryption.EncryptionKeyID, ts.Config.Security.DBEncryption.EncryptionKey) + require.NoError(ts.T(), err) + } + + if !v.validCode && v.factorType == models.TOTP { + code, err = totp.GenerateCode(sharedSecret, time.Now().UTC().Add(-1*time.Minute*time.Duration(1))) + require.NoError(ts.T(), err) + + } else if !v.validCode && v.factorType == models.Phone { + invalidSuffix := "1" + code += invalidSuffix + } + require.NoError(ts.T(), ts.API.db.Create(c), "Error saving new test challenge") if !v.validChallenge { // Set challenge creation so that it has expired in present time. @@ -275,13 +418,6 @@ func (ts *MFATestSuite) TestMFAVerifyFactor() { require.NoError(ts.T(), err, "Error updating new test challenge") } - // Verify TOTP code - code, err := totp.GenerateCode(sharedSecret, time.Now().UTC()) - if !v.validCode { - // Use an inaccurate time, resulting in an invalid code(usually) - code, err = totp.GenerateCode(sharedSecret, time.Now().UTC().Add(-1*time.Minute*time.Duration(1))) - } - require.NoError(ts.T(), err) require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ "challenge_id": c.ID, "code": code, @@ -297,7 +433,7 @@ func (ts *MFATestSuite) TestMFAVerifyFactor() { } if !v.validChallenge { // Ensure invalid challenges are deleted - _, err := models.FindChallengeByID(ts.API.db, c.ID) + _, err := f.FindChallengeByID(ts.API.db, c.ID) require.EqualError(ts.T(), err, models.ChallengeNotFoundError{}.Error()) } }) @@ -461,9 +597,9 @@ func performTestSignupAndVerify(ts *MFATestSuite, email, password string, requir } -func performEnrollFlow(ts *MFATestSuite, token, friendlyName, factorType, issuer string, expectedCode int) *httptest.ResponseRecorder { +func performEnrollFlow(ts *MFATestSuite, token, friendlyName, factorType, issuer string, phone string, expectedCode int) *httptest.ResponseRecorder { var buffer bytes.Buffer - require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(EnrollFactorParams{FriendlyName: friendlyName, FactorType: factorType, Issuer: issuer})) + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(EnrollFactorParams{FriendlyName: friendlyName, FactorType: factorType, Issuer: issuer, Phone: phone})) w := ServeAuthenticatedRequest(ts, http.MethodPost, "http://localhost/factors/", token, buffer) require.Equal(ts.T(), expectedCode, w.Code) return w @@ -520,8 +656,23 @@ func performChallengeFlow(ts *MFATestSuite, factorID uuid.UUID, token string) *h } +func performSMSChallengeFlow(ts *MFATestSuite, factorID uuid.UUID, token, channel string) *httptest.ResponseRecorder { + params := ChallengeFactorParams{ + Channel: channel, + } + var buffer bytes.Buffer + if err := json.NewEncoder(&buffer).Encode(params); err != nil { + panic(err) // handle the error appropriately in real code + } + + w := ServeAuthenticatedRequest(ts, http.MethodPost, fmt.Sprintf("http://localhost/factors/%s/challenge", factorID), token, buffer) + require.Equal(ts.T(), http.StatusOK, w.Code) + return w + +} + func performEnrollAndVerify(ts *MFATestSuite, token string, requireStatusOK bool) *httptest.ResponseRecorder { - w := performEnrollFlow(ts, token, "", models.TOTP, ts.TestDomain, http.StatusOK) + w := performEnrollFlow(ts, token, "", models.TOTP, ts.TestDomain, "", http.StatusOK) enrollResp := EnrollFactorResponse{} require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp)) factorID := enrollResp.ID diff --git a/internal/api/token.go b/internal/api/token.go index 712a44bd7..0cee6c277 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -486,7 +486,7 @@ func (a *API) updateMFASessionAndClaims(r *http.Request, tx *storage.Connection, return err } - tokenString, expiresAt, terr = a.generateAccessToken(r, tx, user, &session.ID, models.TOTPSignIn) + tokenString, expiresAt, terr = a.generateAccessToken(r, tx, user, &session.ID, authenticationMethod) if terr != nil { httpErr, ok := terr.(*HTTPError) if ok { diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 3e8d2ac26..2b50606ac 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -106,14 +106,31 @@ type JWTConfiguration struct { ValidMethods []string `json:"-"` } +type MFAFactorTypeConfiguration struct { + EnrollEnabled bool `json:"enroll_enabled" split_words:"true" default:"true"` + VerifyEnabled bool `json:"verify_enabled" split_words:"true" default:"true"` +} + +type PhoneFactorTypeConfiguration struct { + // Default to false in order to ensure Phone MFA is opt-in + EnrollEnabled bool `json:"enroll_enabled" split_words:"true" default:"false"` + VerifyEnabled bool `json:"verify_enabled" split_words:"true" default:"false"` + OtpLength int `json:"otp_length" split_words:"true"` + SMSTemplate *template.Template `json:"-"` + MaxFrequency time.Duration `json:"max_frequency" split_words:"true"` + Template string `json:"template"` +} + // MFAConfiguration holds all the MFA related Configuration type MFAConfiguration struct { - Enabled bool `default:"false"` - ChallengeExpiryDuration float64 `json:"challenge_expiry_duration" default:"300" split_words:"true"` - FactorExpiryDuration time.Duration `json:"factor_expiry_duration" default:"300s" split_words:"true"` - RateLimitChallengeAndVerify float64 `split_words:"true" default:"15"` - MaxEnrolledFactors float64 `split_words:"true" default:"10"` - MaxVerifiedFactors int `split_words:"true" default:"10"` + Enabled bool `default:"false"` + ChallengeExpiryDuration float64 `json:"challenge_expiry_duration" default:"300" split_words:"true"` + FactorExpiryDuration time.Duration `json:"factor_expiry_duration" default:"300s" split_words:"true"` + RateLimitChallengeAndVerify float64 `split_words:"true" default:"15"` + MaxEnrolledFactors float64 `split_words:"true" default:"10"` + MaxVerifiedFactors int `split_words:"true" default:"10"` + Phone PhoneFactorTypeConfiguration `split_words:"true"` + TOTP MFAFactorTypeConfiguration `split_words:"true"` } type APIConfiguration struct { @@ -694,6 +711,19 @@ func LoadGlobal(filename string) (*GlobalConfiguration, error) { } config.Sms.SMSTemplate = template } + + if config.MFA.Phone.EnrollEnabled || config.MFA.Phone.VerifyEnabled { + smsTemplate := config.MFA.Phone.Template + if smsTemplate == "" { + smsTemplate = "Your code is {{ .Code }}" + } + template, err := template.New("").Parse(smsTemplate) + if err != nil { + return nil, err + } + config.MFA.Phone.SMSTemplate = template + } + return config, nil } @@ -845,12 +875,24 @@ func (config *GlobalConfiguration) ApplyDefaults() error { if config.Password.MinLength < defaultMinPasswordLength { config.Password.MinLength = defaultMinPasswordLength } + if config.MFA.ChallengeExpiryDuration < defaultChallengeExpiryDuration { config.MFA.ChallengeExpiryDuration = defaultChallengeExpiryDuration } + if config.MFA.FactorExpiryDuration < defaultFactorExpiryDuration { config.MFA.FactorExpiryDuration = defaultFactorExpiryDuration } + + if config.MFA.Phone.MaxFrequency == 0 { + config.MFA.Phone.MaxFrequency = 1 * time.Minute + } + + if config.MFA.Phone.OtpLength < 6 || config.MFA.Phone.OtpLength > 10 { + // 6-digit otp by default + config.MFA.Phone.OtpLength = 6 + } + if config.External.FlowStateExpiryDuration < defaultFlowStateExpiryDuration { config.External.FlowStateExpiryDuration = defaultFlowStateExpiryDuration } diff --git a/internal/hooks/auth_hooks.go b/internal/hooks/auth_hooks.go index 10f97167c..1a08d046e 100644 --- a/internal/hooks/auth_hooks.go +++ b/internal/hooks/auth_hooks.go @@ -34,7 +34,8 @@ type HookOutput interface { // TODO(joel): Move this to phone package type SMS struct { - OTP string `json:"otp,omitempty"` + OTP string `json:"otp,omitempty"` + SMSType string `json:"sms_type,omitempty"` } // #nosec @@ -111,9 +112,10 @@ type AccessTokenClaims struct { } type MFAVerificationAttemptInput struct { - UserID uuid.UUID `json:"user_id"` - FactorID uuid.UUID `json:"factor_id"` - Valid bool `json:"valid"` + UserID uuid.UUID `json:"user_id"` + FactorID uuid.UUID `json:"factor_id"` + FactorType string `json:"factor_type"` + Valid bool `json:"valid"` } type MFAVerificationAttemptOutput struct { diff --git a/internal/models/amr.go b/internal/models/amr.go index d341b89cf..c527ec86e 100644 --- a/internal/models/amr.go +++ b/internal/models/amr.go @@ -21,6 +21,10 @@ func (AMRClaim) TableName() string { return tableName } +func (cl *AMRClaim) IsAAL2Claim() bool { + return *cl.AuthenticationMethod == TOTPSignIn.String() || *cl.AuthenticationMethod == MFAPhone.String() +} + func AddClaimToSession(tx *storage.Connection, sessionId uuid.UUID, authenticationMethod AuthenticationMethod) error { id := uuid.Must(uuid.NewV4()) diff --git a/internal/models/challenge.go b/internal/models/challenge.go index c088f4b99..57a970061 100644 --- a/internal/models/challenge.go +++ b/internal/models/challenge.go @@ -1,9 +1,8 @@ package models import ( - "database/sql" "github.com/gofrs/uuid" - "github.com/pkg/errors" + "github.com/supabase/auth/internal/crypto" "github.com/supabase/auth/internal/storage" "time" ) @@ -15,6 +14,8 @@ type Challenge struct { VerifiedAt *time.Time `json:"verified_at,omitempty" db:"verified_at"` IPAddress string `json:"ip_address" db:"ip_address"` Factor *Factor `json:"factor,omitempty" belongs_to:"factor"` + OtpCode string `json:"otp_code,omitempty" db:"otp_code"` + SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"` } func (Challenge) TableName() string { @@ -22,17 +23,6 @@ func (Challenge) TableName() string { return tableName } -func FindChallengeByID(conn *storage.Connection, challengeID uuid.UUID) (*Challenge, error) { - var challenge Challenge - err := conn.Find(&challenge, challengeID) - if err != nil && errors.Cause(err) == sql.ErrNoRows { - return nil, ChallengeNotFoundError{} - } else if err != nil { - return nil, err - } - return &challenge, nil -} - // Update the verification timestamp func (c *Challenge) Verify(tx *storage.Connection) error { now := time.Now() @@ -47,3 +37,31 @@ func (c *Challenge) HasExpired(expiryDuration float64) bool { func (c *Challenge) GetExpiryTime(expiryDuration float64) time.Time { return c.CreatedAt.Add(time.Second * time.Duration(expiryDuration)) } + +func (c *Challenge) SetOtpCode(otpCode string, encrypt bool, encryptionKeyID, encryptionKey string) error { + c.OtpCode = otpCode + if encrypt { + es, err := crypto.NewEncryptedString(c.ID.String(), []byte(otpCode), encryptionKeyID, encryptionKey) + if err != nil { + return err + } + + c.OtpCode = es.String() + } + return nil + +} + +func (c *Challenge) GetOtpCode(decryptionKeys map[string]string, encrypt bool, encryptionKeyID string) (string, bool, error) { + if es := crypto.ParseEncryptedString(c.OtpCode); es != nil { + bytes, err := es.Decrypt(c.ID.String(), decryptionKeys) + if err != nil { + return "", false, err + } + + return string(bytes), encrypt && es.ShouldReEncrypt(encryptionKeyID), nil + } + + return c.OtpCode, encrypt, nil + +} diff --git a/internal/models/factor.go b/internal/models/factor.go index 6abd00080..1a618df8e 100644 --- a/internal/models/factor.go +++ b/internal/models/factor.go @@ -31,6 +31,7 @@ func (factorState FactorState) String() string { } const TOTP = "totp" +const Phone = "phone" type AuthenticationMethod int @@ -39,6 +40,7 @@ const ( PasswordGrant OTP TOTPSignIn + MFAPhone SSOSAML Recovery Invite @@ -75,6 +77,8 @@ func (authMethod AuthenticationMethod) String() string { return "token_refresh" case Anonymous: return "anonymous" + case MFAPhone: + return "mfa/phone" } return "" } @@ -106,21 +110,24 @@ func ParseAuthenticationMethod(authMethod string) (AuthenticationMethod, error) return EmailChange, nil case "token_refresh": return TokenRefresh, nil + case "mfa/sms": + return MFAPhone, nil } return 0, fmt.Errorf("unsupported authentication method %q", authMethod) } type Factor struct { - ID uuid.UUID `json:"id" db:"id"` - User User `json:"-" belongs_to:"user"` - UserID uuid.UUID `json:"-" db:"user_id"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` - Status string `json:"status" db:"status"` - FriendlyName string `json:"friendly_name,omitempty" db:"friendly_name"` - Secret string `json:"-" db:"secret"` - FactorType string `json:"factor_type" db:"factor_type"` - Challenge []Challenge `json:"-" has_many:"challenges"` + ID uuid.UUID `json:"id" db:"id"` + User User `json:"-" belongs_to:"user"` + UserID uuid.UUID `json:"-" db:"user_id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + Status string `json:"status" db:"status"` + FriendlyName string `json:"friendly_name,omitempty" db:"friendly_name"` + Secret string `json:"-" db:"secret"` + FactorType string `json:"factor_type" db:"factor_type"` + Challenge []Challenge `json:"-" has_many:"challenges"` + Phone storage.NullString `json:"phone" db:"phone"` } func (Factor) TableName() string { @@ -141,6 +148,12 @@ func NewFactor(user *User, friendlyName string, factorType string, state FactorS return factor } +func NewPhoneFactor(user *User, phone, friendlyName string, factorType string, state FactorState) *Factor { + factor := NewFactor(user, friendlyName, factorType, state) + factor.Phone = storage.NullString(phone) + return factor +} + func (f *Factor) SetSecret(secret string, encrypt bool, encryptionKeyID, encryptionKey string) error { f.Secret = secret if encrypt { @@ -197,6 +210,16 @@ func (f *Factor) CreateChallenge(ipAddress string) *Challenge { return challenge } +func (f *Factor) CreatePhoneChallenge(ipAddress string, otpCode string, encrypt bool, encryptionKeyID, encryptionKey string) (*Challenge, error) { + phoneChallenge := f.CreateChallenge(ipAddress) + if err := phoneChallenge.SetOtpCode(otpCode, encrypt, encryptionKeyID, encryptionKey); err != nil { + return nil, err + } + now := time.Now() + phoneChallenge.SentAt = &now + return phoneChallenge, nil +} + // UpdateFriendlyName changes the friendly name func (f *Factor) UpdateFriendlyName(tx *storage.Connection, friendlyName string) error { f.FriendlyName = friendlyName @@ -215,6 +238,14 @@ func (f *Factor) UpdateFactorType(tx *storage.Connection, factorType string) err return tx.UpdateOnly(f, "factor_type", "updated_at") } +func (f *Factor) IsTOTPFactor() bool { + return f.FactorType == TOTP +} + +func (f *Factor) IsPhoneFactor() bool { + return f.FactorType == Phone +} + func (f *Factor) DowngradeSessionsToAAL1(tx *storage.Connection) error { sessions, err := FindSessionsByFactorID(tx, f.ID) if err != nil { @@ -236,6 +267,17 @@ func (f *Factor) IsVerified() bool { return f.Status == FactorStateVerified.String() } +func (f *Factor) FindChallengeByID(conn *storage.Connection, challengeID uuid.UUID) (*Challenge, error) { + var challenge Challenge + err := conn.Q().Where("id = ? and factor_id = ?", challengeID, f.ID).First(&challenge) + if err != nil && errors.Cause(err) == sql.ErrNoRows { + return nil, ChallengeNotFoundError{} + } else if err != nil { + return nil, err + } + return &challenge, nil +} + func DeleteFactorsByUserId(tx *storage.Connection, userId uuid.UUID) error { if err := tx.RawQuery("DELETE FROM "+(&pop.Model{Value: Factor{}}).TableName()+" WHERE user_id = ?", userId).Exec(); err != nil { return err @@ -256,3 +298,20 @@ func DeleteExpiredFactors(tx *storage.Connection, validityDuration time.Duration } return nil } + +func (f *Factor) FindLatestUnexpiredChallenge(tx *storage.Connection, expiryDuration float64) (*Challenge, error) { + now := time.Now() + var challenge Challenge + expirationTime := now.Add(time.Duration(expiryDuration) * time.Second) + + err := tx.Where("sent_at > ?", expirationTime). + Order("sent_at desc"). + First(&challenge) + + if err != nil && errors.Cause(err) == sql.ErrNoRows { + return nil, ChallengeNotFoundError{} + } else if err != nil { + return nil, err + } + return &challenge, nil +} diff --git a/internal/models/sessions.go b/internal/models/sessions.go index ca4f135da..b909264b2 100644 --- a/internal/models/sessions.go +++ b/internal/models/sessions.go @@ -282,7 +282,7 @@ func (s *Session) UpdateAALAndAssociatedFactor(tx *storage.Connection, aal Authe func (s *Session) CalculateAALAndAMR(user *User) (aal AuthenticatorAssuranceLevel, amr []AMREntry, err error) { amr, aal = []AMREntry{}, AAL1 for _, claim := range s.AMRClaims { - if *claim.AuthenticationMethod == TOTPSignIn.String() { + if claim.IsAAL2Claim() { aal = AAL2 } amr = append(amr, AMREntry{Method: claim.GetAuthenticationMethod(), Timestamp: claim.UpdatedAt.Unix()}) diff --git a/migrations/20240729123726_add_mfa_phone_config.up.sql b/migrations/20240729123726_add_mfa_phone_config.up.sql new file mode 100644 index 000000000..47a47681f --- /dev/null +++ b/migrations/20240729123726_add_mfa_phone_config.up.sql @@ -0,0 +1,14 @@ +do $$ begin + alter type {{ index .Options "Namespace" }}.factor_type add value 'phone'; +exception + when duplicate_object then null; +end $$; + + +alter table {{ index .Options "Namespace" }}.mfa_factors add column if not exists phone text unique default null; +alter table {{ index .Options "Namespace" }}.mfa_challenges add column if not exists sent_at timestamptz null; +alter table {{ index .Options "Namespace" }}.mfa_challenges add column if not exists otp_code text null; + +create index if not exists idx_sent_at on {{ index .Options "Namespace" }}.mfa_challenges(sent_at); + +create unique index unique_verified_phone_factor on {{ index .Options "Namespace" }}.mfa_factors (user_id, phone) where status = 'verified'; From 0ad1402444348e47e1e42be186b3f052d31be824 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Mon, 29 Jul 2024 14:05:50 -0700 Subject: [PATCH 071/118] fix: maintain backward compatibility for asymmetric JWTs (#1690) ## What kind of change does this PR introduce? * Use the original value of `GOTRUE_JWT_SECRET` - no need to check for base64 decoding. * Don't include the kid claim if the kid is an empty string ## What is the current behavior? Please link any relevant issues here. ## What is the new behavior? Feel free to include screenshots if it includes visual changes. ## Additional context Add any other context or screenshots. --- internal/api/token.go | 5 +++-- internal/conf/configuration.go | 6 +----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/internal/api/token.go b/internal/api/token.go index 0cee6c277..f65ec9a67 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -379,8 +379,9 @@ func (a *API) generateAccessToken(r *http.Request, tx *storage.Connection, user } if _, ok := token.Header["kid"]; !ok { - kid := signingJwk.KeyID() - token.Header["kid"] = kid + if kid := signingJwk.KeyID(); kid != "" { + token.Header["kid"] = kid + } } // this serializes the aud claim to a string diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 2b50606ac..9ead9b37c 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -743,11 +743,7 @@ func (config *GlobalConfiguration) ApplyDefaults() error { if config.JWT.Keys == nil || len(config.JWT.Keys) == 0 { // transform the secret into a JWK for consistency - bytes, err := base64.StdEncoding.DecodeString(config.JWT.Secret) - if err != nil { - bytes = []byte(config.JWT.Secret) - } - privKey, err := jwk.FromRaw(bytes) + privKey, err := jwk.FromRaw([]byte(config.JWT.Secret)) if err != nil { return err } From 6aca52b56f8a6254de7709c767b9a5649f1da248 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Tue, 30 Jul 2024 08:45:33 +0200 Subject: [PATCH 072/118] fix: minor spelling errors (#1688) ## What kind of change does this PR introduce? fix #1682 --- openapi.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index 86c0e8568..253b0f3a0 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -181,7 +181,7 @@ paths: /verify: get: - summary: Authenticate by verifying the posession of a one-time token. Usually for use as clickable links. + summary: Authenticate by verifying the possession of a one-time token. Usually for use as clickable links. tags: - auth parameters: @@ -214,7 +214,7 @@ paths: 302: $ref: "#/components/responses/AccessRefreshTokenRedirectResponse" post: - summary: Authenticate by verifying the posession of a one-time token. + summary: Authenticate by verifying the possession of a one-time token. tags: - auth security: From 3d448fa73cb77eb8511dbc47bfafecce4a4a2150 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Wed, 31 Jul 2024 15:07:32 +0200 Subject: [PATCH 073/118] fix: MFA NewFactor to default to creating unverfied factors (#1692) ## What kind of change does this PR introduce? - Split `NewFactor` into `NewPhoneFactor()` and `NewTOTPFactor()`. - All `NewFactor` methods will now create unverified factors. - Additionally, also guards the `Challenge` endpoint when verification is disabled for a factor. The hope is to reduce cognitive load and the chance of creating a factor in an undesired state Should one wish to obtain a Verified Factor (say for tests) they can call `UpdateStatus`. It is unlikely for this to be a common use case though. Someone might have brought this up prior but only getting to it now --- internal/api/admin_test.go | 7 ++++--- internal/api/mfa.go | 21 +++++++++++++++++---- internal/api/mfa_test.go | 8 ++++---- internal/models/factor.go | 16 ++++++---------- internal/models/factor_test.go | 2 +- 5 files changed, 32 insertions(+), 22 deletions(-) diff --git a/internal/api/admin_test.go b/internal/api/admin_test.go index e1b2c0328..e2904d8bf 100644 --- a/internal/api/admin_test.go +++ b/internal/api/admin_test.go @@ -769,7 +769,8 @@ func (ts *AdminTestSuite) TestAdminUserDeleteFactor() { require.NoError(ts.T(), err, "Error making new user") require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user") - f := models.NewFactor(u, "testSimpleName", models.TOTP, models.FactorStateVerified) + f := models.NewTOTPFactor(u, "testSimpleName") + require.NoError(ts.T(), f.UpdateStatus(ts.API.db, models.FactorStateVerified)) require.NoError(ts.T(), f.SetSecret("secretkey", ts.Config.Security.DBEncryption.Encrypt, ts.Config.Security.DBEncryption.EncryptionKeyID, ts.Config.Security.DBEncryption.EncryptionKey)) require.NoError(ts.T(), ts.API.db.Create(f), "Error saving new test factor") @@ -793,7 +794,7 @@ func (ts *AdminTestSuite) TestAdminUserGetFactors() { require.NoError(ts.T(), err, "Error making new user") require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user") - f := models.NewFactor(u, "testSimpleName", models.TOTP, models.FactorStateUnverified) + f := models.NewTOTPFactor(u, "testSimpleName") require.NoError(ts.T(), f.SetSecret("secretkey", ts.Config.Security.DBEncryption.Encrypt, ts.Config.Security.DBEncryption.EncryptionKeyID, ts.Config.Security.DBEncryption.EncryptionKey)) require.NoError(ts.T(), ts.API.db.Create(f), "Error saving new test factor") @@ -815,7 +816,7 @@ func (ts *AdminTestSuite) TestAdminUserUpdateFactor() { require.NoError(ts.T(), err, "Error making new user") require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user") - f := models.NewFactor(u, "testSimpleName", models.TOTP, models.FactorStateUnverified) + f := models.NewTOTPFactor(u, "testSimpleName") require.NoError(ts.T(), f.SetSecret("secretkey", ts.Config.Security.DBEncryption.Encrypt, ts.Config.Security.DBEncryption.EncryptionKeyID, ts.Config.Security.DBEncryption.EncryptionKey)) require.NoError(ts.T(), ts.API.db.Create(f), "Error saving new test factor") diff --git a/internal/api/mfa.go b/internal/api/mfa.go index 126f33545..a3d4d9f55 100644 --- a/internal/api/mfa.go +++ b/internal/api/mfa.go @@ -108,7 +108,7 @@ func (a *API) enrollPhoneFactor(w http.ResponseWriter, r *http.Request, params * if numVerifiedFactors > 0 && !session.IsAAL2() { return forbiddenError(ErrorCodeInsufficientAAL, "AAL2 required to enroll a new factor") } - factor := models.NewPhoneFactor(user, phone, params.FriendlyName, params.FactorType, models.FactorStateUnverified) + factor := models.NewPhoneFactor(user, phone, params.FriendlyName) err = db.Transaction(func(tx *storage.Connection) error { if terr := tx.Create(factor); terr != nil { pgErr := utilities.NewPostgresError(terr) @@ -222,7 +222,7 @@ func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error { } svgData.End() - factor = models.NewFactor(user, params.FriendlyName, params.FactorType, models.FactorStateUnverified) + factor = models.NewTOTPFactor(user, params.FriendlyName) if err := factor.SetSecret(key.Secret(), config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil { return err } @@ -352,10 +352,23 @@ func (a *API) ChallengeFactor(w http.ResponseWriter, r *http.Request) error { user := getUser(ctx) factor := getFactor(ctx) + ipAddress := utilities.GetIPAddress(r) - if factor.IsPhoneFactor() { + switch factor.FactorType { + case models.Phone: + if !config.MFA.Phone.VerifyEnabled { + return unprocessableEntityError(ErrorCodeMFAPhoneEnrollDisabled, "MFA verification is disabled for Phone") + } return a.challengePhoneFactor(w, r) + + case models.TOTP: + if !config.MFA.TOTP.VerifyEnabled { + return unprocessableEntityError(ErrorCodeMFATOTPEnrollDisabled, "MFA verification is disabled for TOTP") + } + default: + return badRequestError(ErrorCodeValidationFailed, "factor_type needs to be TOTP or Phone") } + challenge := factor.CreateChallenge(ipAddress) if err := db.Transaction(func(tx *storage.Connection) error { if terr := tx.Create(challenge); terr != nil { @@ -626,7 +639,7 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { return terr } } - if shouldReEncrypt && config.Security.DBEncryption.Encrypt && factor.IsTOTPFactor() { + if shouldReEncrypt && config.Security.DBEncryption.Encrypt { es, terr := crypto.NewEncryptedString(factor.ID.String(), []byte(secret), config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey) if terr != nil { return terr diff --git a/internal/api/mfa_test.go b/internal/api/mfa_test.go index 4a8316c1e..e08f20514 100644 --- a/internal/api/mfa_test.go +++ b/internal/api/mfa_test.go @@ -62,7 +62,7 @@ func (ts *MFATestSuite) SetupTest() { require.NoError(ts.T(), err, "Error creating test user model") require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new test user") // Create Factor - f := models.NewFactor(u, "test_factor", models.TOTP, models.FactorStateUnverified) + f := models.NewTOTPFactor(u, "test_factor") require.NoError(ts.T(), f.SetSecret("secretkey", ts.Config.Security.DBEncryption.Encrypt, ts.Config.Security.DBEncryption.EncryptionKeyID, ts.Config.Security.DBEncryption.EncryptionKey)) require.NoError(ts.T(), ts.API.db.Create(f), "Error saving new test factor") // Create corresponding session @@ -277,7 +277,7 @@ func (ts *MFATestSuite) TestChallengeSMSFactor() { phone := "+1234567" friendlyName := "testchallengesmsfactor" - f := models.NewPhoneFactor(ts.TestUser, phone, friendlyName, models.Phone, models.FactorStateUnverified) + f := models.NewPhoneFactor(ts.TestUser, phone, friendlyName) require.NoError(ts.T(), ts.API.db.Create(f), "Error creating new SMS factor") token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID) @@ -369,7 +369,7 @@ func (ts *MFATestSuite) TestMFAVerifyFactor() { if v.factorType == models.TOTP { friendlyName := uuid.Must(uuid.NewV4()).String() - f = models.NewFactor(ts.TestUser, friendlyName, models.TOTP, models.FactorStateUnverified) + f = models.NewTOTPFactor(ts.TestUser, friendlyName) sharedSecret = ts.TestOTPKey.Secret() f.Secret = sharedSecret require.NoError(ts.T(), ts.API.db.Create(f), "Error updating new test factor") @@ -379,7 +379,7 @@ func (ts *MFATestSuite) TestMFAVerifyFactor() { otp, err := crypto.GenerateOtp(numDigits) require.NoError(ts.T(), err) phone := fmt.Sprintf("+%s", otp) - f = models.NewPhoneFactor(ts.TestUser, phone, friendlyName, models.Phone, models.FactorStateUnverified) + f = models.NewPhoneFactor(ts.TestUser, phone, friendlyName) require.NoError(ts.T(), ts.API.db.Create(f), "Error creating new SMS factor") } diff --git a/internal/models/factor.go b/internal/models/factor.go index 1a618df8e..4776ab972 100644 --- a/internal/models/factor.go +++ b/internal/models/factor.go @@ -148,8 +148,12 @@ func NewFactor(user *User, friendlyName string, factorType string, state FactorS return factor } -func NewPhoneFactor(user *User, phone, friendlyName string, factorType string, state FactorState) *Factor { - factor := NewFactor(user, friendlyName, factorType, state) +func NewTOTPFactor(user *User, friendlyName string) *Factor { + return NewFactor(user, friendlyName, TOTP, FactorStateUnverified) +} + +func NewPhoneFactor(user *User, phone, friendlyName string) *Factor { + factor := NewFactor(user, friendlyName, Phone, FactorStateUnverified) factor.Phone = storage.NullString(phone) return factor } @@ -238,14 +242,6 @@ func (f *Factor) UpdateFactorType(tx *storage.Connection, factorType string) err return tx.UpdateOnly(f, "factor_type", "updated_at") } -func (f *Factor) IsTOTPFactor() bool { - return f.FactorType == TOTP -} - -func (f *Factor) IsPhoneFactor() bool { - return f.FactorType == Phone -} - func (f *Factor) DowngradeSessionsToAAL1(tx *storage.Connection) error { sessions, err := FindSessionsByFactorID(tx, f.ID) if err != nil { diff --git a/internal/models/factor_test.go b/internal/models/factor_test.go index 1ca782ce6..614cff239 100644 --- a/internal/models/factor_test.go +++ b/internal/models/factor_test.go @@ -37,7 +37,7 @@ func (ts *FactorTestSuite) SetupTest() { require.NoError(ts.T(), err) require.NoError(ts.T(), ts.db.Create(user)) - factor := NewFactor(user, "asimplename", TOTP, FactorStateUnverified) + factor := NewTOTPFactor(user, "asimplename") require.NoError(ts.T(), factor.SetSecret("topsecret", false, "", "")) require.NoError(ts.T(), ts.db.Create(factor)) ts.TestFactor = factor From fdff1e703bccf93217636266f1862bd0a9205edb Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Wed, 31 Jul 2024 15:37:27 +0200 Subject: [PATCH 074/118] fix: update mfa phone migration to be idempotent (#1687) ## What kind of change does this PR introduce? - Add `if not exists` so the migration is idempotent - Also drops the partial unique constraint on phone factors to avoid potential database bloat --- migrations/20240729123726_add_mfa_phone_config.up.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/20240729123726_add_mfa_phone_config.up.sql b/migrations/20240729123726_add_mfa_phone_config.up.sql index 47a47681f..cd52973ed 100644 --- a/migrations/20240729123726_add_mfa_phone_config.up.sql +++ b/migrations/20240729123726_add_mfa_phone_config.up.sql @@ -11,4 +11,4 @@ alter table {{ index .Options "Namespace" }}.mfa_challenges add column if not ex create index if not exists idx_sent_at on {{ index .Options "Namespace" }}.mfa_challenges(sent_at); -create unique index unique_verified_phone_factor on {{ index .Options "Namespace" }}.mfa_factors (user_id, phone) where status = 'verified'; +create unique index if not exists unique_verified_phone_factor on {{ index .Options "Namespace" }}.mfa_factors (user_id, phone); From 8015251400bd52cbdad3ea28afb83b1cdfe816dd Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Wed, 31 Jul 2024 17:36:11 +0200 Subject: [PATCH 075/118] fix: treat `GOTRUE_MFA_ENABLED` as meaning TOTP enabled on enroll and verify (#1694) `GOTRUE_MFA_ENABLED` used to control whether TOTP enroll and verify were on, but with #1668 this config option was disregarded, meaning that TOTP will stop working for already configured projects. --- internal/api/mfa.go | 12 ++++++++++-- internal/conf/configuration.go | 4 +++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/internal/api/mfa.go b/internal/api/mfa.go index a3d4d9f55..affc09466 100644 --- a/internal/api/mfa.go +++ b/internal/api/mfa.go @@ -159,7 +159,11 @@ func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error { } return a.enrollPhoneFactor(w, r, params) case models.TOTP: - if !config.MFA.TOTP.EnrollEnabled { + // Prior to the introduction of MFA.TOTP.EnrollEnabled, + // MFA.Enabled was used to configure whether TOTP was on. So + // both have to be set to false to regard the feature as + // disabled. + if !config.MFA.Enabled && !config.MFA.TOTP.EnrollEnabled { return unprocessableEntityError(ErrorCodeMFATOTPEnrollDisabled, "MFA enroll is disabled for TOTP") } default: @@ -362,7 +366,11 @@ func (a *API) ChallengeFactor(w http.ResponseWriter, r *http.Request) error { return a.challengePhoneFactor(w, r) case models.TOTP: - if !config.MFA.TOTP.VerifyEnabled { + // Prior to the introduction of MFA.TOTP.VerifyEnabled, + // MFA.Enabled was used to configure whether TOTP was on. So + // both have to be set to false to regard the feature as + // disabled. + if !config.MFA.Enabled && !config.MFA.TOTP.VerifyEnabled { return unprocessableEntityError(ErrorCodeMFATOTPEnrollDisabled, "MFA verification is disabled for TOTP") } default: diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 9ead9b37c..81d058c29 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -123,7 +123,9 @@ type PhoneFactorTypeConfiguration struct { // MFAConfiguration holds all the MFA related Configuration type MFAConfiguration struct { - Enabled bool `default:"false"` + // Enabled is deprecated, but still used to signal TOTP.EnrollEnabled and TOTP.VerifyEnabled. + Enabled bool `default:"false"` + ChallengeExpiryDuration float64 `json:"challenge_expiry_duration" default:"300" split_words:"true"` FactorExpiryDuration time.Duration `json:"factor_expiry_duration" default:"300s" split_words:"true"` RateLimitChallengeAndVerify float64 `split_words:"true" default:"15"` From 4aef63fc117029ec7bdd8c5dd74f00da24c4863a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 1 Aug 2024 01:04:35 +0200 Subject: [PATCH 076/118] chore(master): release 2.158.0 (#1686) :robot: I have created a release *beep* *boop* --- ## [2.158.0](https://github.com/supabase/auth/compare/v2.157.0...v2.158.0) (2024-07-31) ### Features * add hook log entry with `run_hook` action ([#1684](https://github.com/supabase/auth/issues/1684)) ([46491b8](https://github.com/supabase/auth/commit/46491b867a4f5896494417391392a373a453fa5f)) * MFA (Phone) ([#1668](https://github.com/supabase/auth/issues/1668)) ([ae091aa](https://github.com/supabase/auth/commit/ae091aa942bdc5bc97481037508ec3bb4079d859)) ### Bug Fixes * maintain backward compatibility for asymmetric JWTs ([#1690](https://github.com/supabase/auth/issues/1690)) ([0ad1402](https://github.com/supabase/auth/commit/0ad1402444348e47e1e42be186b3f052d31be824)) * MFA NewFactor to default to creating unverfied factors ([#1692](https://github.com/supabase/auth/issues/1692)) ([3d448fa](https://github.com/supabase/auth/commit/3d448fa73cb77eb8511dbc47bfafecce4a4a2150)) * minor spelling errors ([#1688](https://github.com/supabase/auth/issues/1688)) ([6aca52b](https://github.com/supabase/auth/commit/6aca52b56f8a6254de7709c767b9a5649f1da248)), closes [#1682](https://github.com/supabase/auth/issues/1682) * treat `GOTRUE_MFA_ENABLED` as meaning TOTP enabled on enroll and verify ([#1694](https://github.com/supabase/auth/issues/1694)) ([8015251](https://github.com/supabase/auth/commit/8015251400bd52cbdad3ea28afb83b1cdfe816dd)) * update mfa phone migration to be idempotent ([#1687](https://github.com/supabase/auth/issues/1687)) ([fdff1e7](https://github.com/supabase/auth/commit/fdff1e703bccf93217636266f1862bd0a9205edb)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9591fe41e..b769e7d84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [2.158.0](https://github.com/supabase/auth/compare/v2.157.0...v2.158.0) (2024-07-31) + + +### Features + +* add hook log entry with `run_hook` action ([#1684](https://github.com/supabase/auth/issues/1684)) ([46491b8](https://github.com/supabase/auth/commit/46491b867a4f5896494417391392a373a453fa5f)) +* MFA (Phone) ([#1668](https://github.com/supabase/auth/issues/1668)) ([ae091aa](https://github.com/supabase/auth/commit/ae091aa942bdc5bc97481037508ec3bb4079d859)) + + +### Bug Fixes + +* maintain backward compatibility for asymmetric JWTs ([#1690](https://github.com/supabase/auth/issues/1690)) ([0ad1402](https://github.com/supabase/auth/commit/0ad1402444348e47e1e42be186b3f052d31be824)) +* MFA NewFactor to default to creating unverfied factors ([#1692](https://github.com/supabase/auth/issues/1692)) ([3d448fa](https://github.com/supabase/auth/commit/3d448fa73cb77eb8511dbc47bfafecce4a4a2150)) +* minor spelling errors ([#1688](https://github.com/supabase/auth/issues/1688)) ([6aca52b](https://github.com/supabase/auth/commit/6aca52b56f8a6254de7709c767b9a5649f1da248)), closes [#1682](https://github.com/supabase/auth/issues/1682) +* treat `GOTRUE_MFA_ENABLED` as meaning TOTP enabled on enroll and verify ([#1694](https://github.com/supabase/auth/issues/1694)) ([8015251](https://github.com/supabase/auth/commit/8015251400bd52cbdad3ea28afb83b1cdfe816dd)) +* update mfa phone migration to be idempotent ([#1687](https://github.com/supabase/auth/issues/1687)) ([fdff1e7](https://github.com/supabase/auth/commit/fdff1e703bccf93217636266f1862bd0a9205edb)) + ## [2.157.0](https://github.com/supabase/auth/compare/v2.156.0...v2.157.0) (2024-07-26) From a3da4b89820c37f03ea128889616aca598d99f68 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Thu, 1 Aug 2024 07:32:36 +0200 Subject: [PATCH 077/118] fix: update openapi spec for MFA (Phone) (#1689) ## What kind of change does this PR introduce? Complement to #1668 Add OpenAPI specification for MFA (Phone). In particular, updates the parameters and `/enroll` `/challenge` and `/verify` --- openapi.yaml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/openapi.yaml b/openapi.yaml index 253b0f3a0..f4d1e2e1d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -693,11 +693,15 @@ paths: type: string enum: - totp + - phone friendly_name: type: string issuer: type: string format: uri + phone: + type: string + format: phone responses: 200: description: > @@ -713,6 +717,7 @@ paths: type: string enum: - totp + - phone totp: type: object properties: @@ -722,6 +727,9 @@ paths: type: string uri: type: string + phone: + type: string + format: phone 400: $ref: "#/components/responses/BadRequestResponse" @@ -741,6 +749,18 @@ paths: schema: type: string format: uuid + requestBody: + content: + application/json: + schema: + type: object + properties: + channel: + type: string + enum: + - sms + - whatsapp + responses: 200: description: > @@ -1959,6 +1979,11 @@ components: description: |- Usually one of: - totp + - phone + phone: + type: string + format: phone + IdentitySchema: type: object From 6ccd814309dca70a9e3585543887194b05d725d3 Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Thu, 1 Aug 2024 08:56:04 +0200 Subject: [PATCH 078/118] fix: expose `X-Supabase-Api-Version` header in CORS (#1612) Fixes #1589. --- internal/api/api.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index 2b139316a..85292775f 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -328,8 +328,8 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne corsHandler := cors.New(cors.Options{ AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}, - AllowedHeaders: globalConfig.CORS.AllAllowedHeaders([]string{"Accept", "Authorization", "Content-Type", "X-Client-IP", "X-Client-Info", audHeaderName, useCookieHeader}), - ExposedHeaders: []string{"X-Total-Count", "Link"}, + AllowedHeaders: globalConfig.CORS.AllAllowedHeaders([]string{"Accept", "Authorization", "Content-Type", "X-Client-IP", "X-Client-Info", audHeaderName, useCookieHeader, APIVersionHeaderName}), + ExposedHeaders: []string{"X-Total-Count", "Link", APIVersionHeaderName}, AllowCredentials: true, }) From 250d92f9a18d38089d1bf262ef9088022a446965 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Fri, 2 Aug 2024 18:39:43 +0200 Subject: [PATCH 079/118] fix: refactor TOTP MFA into separate methods (#1698) ## What kind of change does this PR introduce? Refactors TOTP, Challenge, Enroll, and Verify into separate branches for consistency with other methods and also readability. Adds an additional check to ensure a user must own a factor in order to challenge it. --------- Co-authored-by: Kang Ming --- internal/api/mfa.go | 272 ++++++++++++++++++++++++-------------------- 1 file changed, 150 insertions(+), 122 deletions(-) diff --git a/internal/api/mfa.go b/internal/api/mfa.go index affc09466..ae4c24b74 100644 --- a/internal/api/mfa.go +++ b/internal/api/mfa.go @@ -137,39 +137,12 @@ func (a *API) enrollPhoneFactor(w http.ResponseWriter, r *http.Request, params * }) } -func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error { +func (a *API) enrollTOTPFactor(w http.ResponseWriter, r *http.Request, params *EnrollFactorParams) error { ctx := r.Context() user := getUser(ctx) - session := getSession(ctx) - config := a.config db := a.db.WithContext(ctx) - - if session == nil || user == nil { - return internalServerError("A valid session and a registered user are required to enroll a factor") - } - params := &EnrollFactorParams{} - if err := retrieveRequestParams(r, params); err != nil { - return err - } - - switch params.FactorType { - case models.Phone: - if !config.MFA.Phone.EnrollEnabled { - return unprocessableEntityError(ErrorCodeMFAPhoneEnrollDisabled, "MFA enroll is disabled for Phone") - } - return a.enrollPhoneFactor(w, r, params) - case models.TOTP: - // Prior to the introduction of MFA.TOTP.EnrollEnabled, - // MFA.Enabled was used to configure whether TOTP was on. So - // both have to be set to false to regard the feature as - // disabled. - if !config.MFA.Enabled && !config.MFA.TOTP.EnrollEnabled { - return unprocessableEntityError(ErrorCodeMFATOTPEnrollDisabled, "MFA enroll is disabled for TOTP") - } - default: - return badRequestError(ErrorCodeValidationFailed, "factor_type needs to be TOTP or Phone") - } - + config := a.config + session := getSession(ctx) issuer := "" if params.Issuer == "" { u, err := url.ParseRequestURI(config.SiteURL) @@ -263,6 +236,41 @@ func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error { }) } +func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + user := getUser(ctx) + session := getSession(ctx) + config := a.config + + if session == nil || user == nil { + return internalServerError("A valid session and a registered user are required to enroll a factor") + } + params := &EnrollFactorParams{} + if err := retrieveRequestParams(r, params); err != nil { + return err + } + + switch params.FactorType { + case models.Phone: + if !config.MFA.Phone.EnrollEnabled { + return unprocessableEntityError(ErrorCodeMFAPhoneEnrollDisabled, "MFA enroll is disabled for Phone") + } + return a.enrollPhoneFactor(w, r, params) + case models.TOTP: + // Prior to the introduction of MFA.TOTP.EnrollEnabled, + // MFA.Enabled was used to configure whether TOTP was on. So + // both have to be set to false to regard the feature as + // disabled. + if !config.MFA.Enabled && !config.MFA.TOTP.EnrollEnabled { + return unprocessableEntityError(ErrorCodeMFATOTPEnrollDisabled, "MFA enroll is disabled for TOTP") + } + return a.enrollTOTPFactor(w, r, params) + default: + return badRequestError(ErrorCodeValidationFailed, "factor_type needs to be totp or phone") + } + +} + func (a *API) challengePhoneFactor(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() config := a.config @@ -349,33 +357,14 @@ func (a *API) challengePhoneFactor(w http.ResponseWriter, r *http.Request) error }) } -func (a *API) ChallengeFactor(w http.ResponseWriter, r *http.Request) error { +func (a *API) challengeTOTPFactor(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() config := a.config db := a.db.WithContext(ctx) user := getUser(ctx) factor := getFactor(ctx) - ipAddress := utilities.GetIPAddress(r) - switch factor.FactorType { - case models.Phone: - if !config.MFA.Phone.VerifyEnabled { - return unprocessableEntityError(ErrorCodeMFAPhoneEnrollDisabled, "MFA verification is disabled for Phone") - } - return a.challengePhoneFactor(w, r) - - case models.TOTP: - // Prior to the introduction of MFA.TOTP.VerifyEnabled, - // MFA.Enabled was used to configure whether TOTP was on. So - // both have to be set to false to regard the feature as - // disabled. - if !config.MFA.Enabled && !config.MFA.TOTP.VerifyEnabled { - return unprocessableEntityError(ErrorCodeMFATOTPEnrollDisabled, "MFA verification is disabled for TOTP") - } - default: - return badRequestError(ErrorCodeValidationFailed, "factor_type needs to be TOTP or Phone") - } challenge := factor.CreateChallenge(ipAddress) if err := db.Transaction(func(tx *storage.Connection) error { @@ -399,17 +388,52 @@ func (a *API) ChallengeFactor(w http.ResponseWriter, r *http.Request) error { }) } -func (a *API) verifyPhoneFactor(w http.ResponseWriter, r *http.Request, params *VerifyFactorParams) error { +func (a *API) ChallengeFactor(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() config := a.config + factor := getFactor(ctx) + user := getUser(ctx) + + switch factor.FactorType { + case models.Phone: + if !config.MFA.Phone.VerifyEnabled { + return unprocessableEntityError(ErrorCodeMFAPhoneEnrollDisabled, "MFA verification is disabled for Phone") + } + if !factor.IsOwnedBy(user) { + return notFoundError(ErrorCodeMFAFactorNotFound, "MFA factor not found") + } + return a.challengePhoneFactor(w, r) + + case models.TOTP: + // Prior to the introduction of MFA.TOTP.VerifyEnabled, + // MFA.Enabled was used to configure whether TOTP was on. So + // both have to be set to false to regard the feature as + // disabled. + if !config.MFA.Enabled && !config.MFA.TOTP.VerifyEnabled { + return unprocessableEntityError(ErrorCodeMFATOTPEnrollDisabled, "MFA verification is disabled for TOTP") + } + if !factor.IsOwnedBy(user) { + return notFoundError(ErrorCodeMFAFactorNotFound, "MFA factor not found") + } + return a.challengeTOTPFactor(w, r) + default: + return badRequestError(ErrorCodeValidationFailed, "factor_type needs to be TOTP or Phone") + } + +} + +func (a *API) verifyTOTPFactor(w http.ResponseWriter, r *http.Request, params *VerifyFactorParams) error { + var err error + ctx := r.Context() user := getUser(ctx) factor := getFactor(ctx) + config := a.config db := a.db.WithContext(ctx) currentIP := utilities.GetIPAddress(r) if !factor.IsOwnedBy(user) { - return notFoundError(ErrorCodeMFAFactorNotFound, "MFA factor not found") - + // TODO: Should be changed to notFoundError. Retained as internalServerError to preserve backward compatibility. + return internalServerError(InvalidFactorOwnerErrorMessage) } challenge, err := factor.FindChallengeByID(db, params.ChallengeID) @@ -429,17 +453,24 @@ func (a *API) verifyPhoneFactor(w http.ResponseWriter, r *http.Request, params * } return unprocessableEntityError(ErrorCodeMFAChallengeExpired, "MFA challenge %v has expired, verify against another challenge or create a new challenge.", challenge.ID) } - otpCode, shouldReEncrypt, err := challenge.GetOtpCode(config.Security.DBEncryption.DecryptionKeys, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID) + + secret, shouldReEncrypt, err := factor.GetSecret(config.Security.DBEncryption.DecryptionKeys, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID) if err != nil { return internalServerError("Database error verifying MFA TOTP secret").WithInternalError(err) } - valid := subtle.ConstantTimeCompare([]byte(otpCode), []byte(params.Code)) == 1 + + valid, verr := totp.ValidateCustom(params.Code, secret, time.Now().UTC(), totp.ValidateOpts{ + Period: 30, + Skew: 1, + Digits: otp.DigitsSix, + Algorithm: otp.AlgorithmSHA1, + }) + if config.Hook.MFAVerificationAttempt.Enabled { input := hooks.MFAVerificationAttemptInput{ - UserID: user.ID, - FactorID: factor.ID, - FactorType: factor.FactorType, - Valid: valid, + UserID: user.ID, + FactorID: factor.ID, + Valid: valid, } output := hooks.MFAVerificationAttemptOutput{} @@ -462,15 +493,15 @@ func (a *API) verifyPhoneFactor(w http.ResponseWriter, r *http.Request, params * } if !valid { if shouldReEncrypt && config.Security.DBEncryption.Encrypt { - if err := challenge.SetOtpCode(otpCode, true, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil { + if err := factor.SetSecret(secret, true, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil { return err } - if err := db.UpdateOnly(challenge, "otp_code"); err != nil { + if err := db.UpdateOnly(factor, "secret"); err != nil { return err } } - return unprocessableEntityError(ErrorCodeMFAVerificationFailed, "Invalid MFA Phone code entered") + return unprocessableEntityError(ErrorCodeMFAVerificationFailed, "Invalid TOTP code entered").WithInternalError(verr) } var token *AccessTokenResponse @@ -480,7 +511,6 @@ func (a *API) verifyPhoneFactor(w http.ResponseWriter, r *http.Request, params * if terr = models.NewAuditLogEntry(r, tx, user, models.VerifyFactorAction, r.RemoteAddr, map[string]interface{}{ "factor_id": factor.ID, "challenge_id": challenge.ID, - "factor_type": factor.FactorType, }); terr != nil { return terr } @@ -492,12 +522,23 @@ func (a *API) verifyPhoneFactor(w http.ResponseWriter, r *http.Request, params * return terr } } + if shouldReEncrypt && config.Security.DBEncryption.Encrypt { + es, terr := crypto.NewEncryptedString(factor.ID.String(), []byte(secret), config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey) + if terr != nil { + return terr + } + + factor.Secret = es.String() + if terr := tx.UpdateOnly(factor, "secret"); terr != nil { + return terr + } + } user, terr = models.FindUserByID(tx, user.ID) if terr != nil { return terr } - token, terr = a.updateMFASessionAndClaims(r, tx, user, models.MFAPhone, models.GrantParams{ + token, terr = a.updateMFASessionAndClaims(r, tx, user, models.TOTPSignIn, models.GrantParams{ FactorID: &factor.ID, }) if terr != nil { @@ -520,45 +561,19 @@ func (a *API) verifyPhoneFactor(w http.ResponseWriter, r *http.Request, params * metering.RecordLogin(string(models.MFACodeLoginAction), user.ID) return sendJSON(w, http.StatusOK, token) + } -func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { - var err error +func (a *API) verifyPhoneFactor(w http.ResponseWriter, r *http.Request, params *VerifyFactorParams) error { ctx := r.Context() + config := a.config user := getUser(ctx) factor := getFactor(ctx) - config := a.config db := a.db.WithContext(ctx) - - params := &VerifyFactorParams{} - if err := retrieveRequestParams(r, params); err != nil { - return err - } - - switch factor.FactorType { - case models.Phone: - if !config.MFA.Phone.VerifyEnabled { - return unprocessableEntityError(ErrorCodeMFAPhoneEnrollDisabled, "MFA verification is disabled for Phone") - } - if params.Code == "" { - return badRequestError(ErrorCodeValidationFailed, "Code needs to be non-empty") - } - return a.verifyPhoneFactor(w, r, params) - case models.TOTP: - if !config.MFA.TOTP.VerifyEnabled { - return unprocessableEntityError(ErrorCodeMFATOTPEnrollDisabled, "MFA verification is disabled for TOTP") - } - if params.Code == "" { - return badRequestError(ErrorCodeValidationFailed, "Code needs to be non-empty") - } - default: - return badRequestError(ErrorCodeValidationFailed, "factor_type needs to be TOTP or Phone") - } - currentIP := utilities.GetIPAddress(r) if !factor.IsOwnedBy(user) { - return internalServerError(InvalidFactorOwnerErrorMessage) + return notFoundError(ErrorCodeMFAFactorNotFound, "MFA factor not found") } challenge, err := factor.FindChallengeByID(db, params.ChallengeID) @@ -578,24 +593,17 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { } return unprocessableEntityError(ErrorCodeMFAChallengeExpired, "MFA challenge %v has expired, verify against another challenge or create a new challenge.", challenge.ID) } - - secret, shouldReEncrypt, err := factor.GetSecret(config.Security.DBEncryption.DecryptionKeys, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID) + otpCode, shouldReEncrypt, err := challenge.GetOtpCode(config.Security.DBEncryption.DecryptionKeys, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID) if err != nil { return internalServerError("Database error verifying MFA TOTP secret").WithInternalError(err) } - - valid, verr := totp.ValidateCustom(params.Code, secret, time.Now().UTC(), totp.ValidateOpts{ - Period: 30, - Skew: 1, - Digits: otp.DigitsSix, - Algorithm: otp.AlgorithmSHA1, - }) - + valid := subtle.ConstantTimeCompare([]byte(otpCode), []byte(params.Code)) == 1 if config.Hook.MFAVerificationAttempt.Enabled { input := hooks.MFAVerificationAttemptInput{ - UserID: user.ID, - FactorID: factor.ID, - Valid: valid, + UserID: user.ID, + FactorID: factor.ID, + FactorType: factor.FactorType, + Valid: valid, } output := hooks.MFAVerificationAttemptOutput{} @@ -618,15 +626,15 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { } if !valid { if shouldReEncrypt && config.Security.DBEncryption.Encrypt { - if err := factor.SetSecret(secret, true, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil { + if err := challenge.SetOtpCode(otpCode, true, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil { return err } - if err := db.UpdateOnly(factor, "secret"); err != nil { + if err := db.UpdateOnly(challenge, "otp_code"); err != nil { return err } } - return unprocessableEntityError(ErrorCodeMFAVerificationFailed, "Invalid TOTP code entered").WithInternalError(verr) + return unprocessableEntityError(ErrorCodeMFAVerificationFailed, "Invalid MFA Phone code entered") } var token *AccessTokenResponse @@ -636,6 +644,7 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { if terr = models.NewAuditLogEntry(r, tx, user, models.VerifyFactorAction, r.RemoteAddr, map[string]interface{}{ "factor_id": factor.ID, "challenge_id": challenge.ID, + "factor_type": factor.FactorType, }); terr != nil { return terr } @@ -647,23 +656,12 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { return terr } } - if shouldReEncrypt && config.Security.DBEncryption.Encrypt { - es, terr := crypto.NewEncryptedString(factor.ID.String(), []byte(secret), config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey) - if terr != nil { - return terr - } - - factor.Secret = es.String() - if terr := tx.UpdateOnly(factor, "secret"); terr != nil { - return terr - } - } user, terr = models.FindUserByID(tx, user.ID) if terr != nil { return terr } - token, terr = a.updateMFASessionAndClaims(r, tx, user, models.TOTPSignIn, models.GrantParams{ + token, terr = a.updateMFASessionAndClaims(r, tx, user, models.MFAPhone, models.GrantParams{ FactorID: &factor.ID, }) if terr != nil { @@ -686,6 +684,36 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { metering.RecordLogin(string(models.MFACodeLoginAction), user.ID) return sendJSON(w, http.StatusOK, token) +} + +func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + factor := getFactor(ctx) + config := a.config + + params := &VerifyFactorParams{} + if err := retrieveRequestParams(r, params); err != nil { + return err + } + if params.Code == "" { + return badRequestError(ErrorCodeValidationFailed, "Code needs to be non-empty") + } + + switch factor.FactorType { + case models.Phone: + if !config.MFA.Phone.VerifyEnabled { + return unprocessableEntityError(ErrorCodeMFAPhoneEnrollDisabled, "MFA verification is disabled for Phone") + } + + return a.verifyPhoneFactor(w, r, params) + case models.TOTP: + if !config.MFA.TOTP.VerifyEnabled { + return unprocessableEntityError(ErrorCodeMFATOTPEnrollDisabled, "MFA verification is disabled for TOTP") + } + return a.verifyTOTPFactor(w, r, params) + default: + return badRequestError(ErrorCodeValidationFailed, "factor_type needs to be TOTP or Phone") + } } From 81b332d2f48622008469d2c5a9b130465a65f2a3 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Fri, 2 Aug 2024 10:20:48 -0700 Subject: [PATCH 080/118] fix: remove check for content-length (#1700) ## What kind of change does this PR introduce? * Not all responses are gonna contain the `Content-Length` header. According to the HTTP/2 spec, it is not required to return the content-length header in the response. If the "Transfer Encoding" is chunked, the content-length header will also not be present as the response is sent in chunks and the content-length is unknown initially. ## What is the current behavior? Please link any relevant issues here. ## What is the new behavior? Feel free to include screenshots if it includes visual changes. ## Additional context Add any other context or screenshots. --- internal/api/errorcodes.go | 1 - internal/api/hooks.go | 15 +++++++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/internal/api/errorcodes.go b/internal/api/errorcodes.go index 9038dc25d..ce36bb65c 100644 --- a/internal/api/errorcodes.go +++ b/internal/api/errorcodes.go @@ -76,7 +76,6 @@ const ( ErrorCodeHookTimeout ErrorCode = "hook_timeout" ErrorCodeHookTimeoutAfterRetry ErrorCode = "hook_timeout_after_retry" ErrorCodeHookPayloadOverSizeLimit ErrorCode = "hook_payload_over_size_limit" - ErrorCodeHookPayloadUnknownSize ErrorCode = "hook_payload_unknown_size" ErrorCodeRequestTimeout ErrorCode = "request_timeout" ErrorCodeMFAPhoneEnrollDisabled ErrorCode = "mfa_phone_enroll_not_enabled" ErrorCodeMFAPhoneVerifyDisabled ErrorCode = "mfa_phone_verify_not_enabled" diff --git a/internal/api/hooks.go b/internal/api/hooks.go index 197a62f15..7b89dc561 100644 --- a/internal/api/hooks.go +++ b/internal/api/hooks.go @@ -152,18 +152,17 @@ func (a *API) runHTTPHook(r *http.Request, hookConfig conf.ExtensibilityPointCon if rsp.Body == nil { return nil, nil } - contentLength := rsp.ContentLength - if contentLength == -1 { - return nil, unprocessableEntityError(ErrorCodeHookPayloadUnknownSize, "Payload size not known") - } - if contentLength >= PayloadLimit { - return nil, unprocessableEntityError(ErrorCodeHookPayloadOverSizeLimit, fmt.Sprintf("Payload size is: %d bytes exceeded size limit of %d bytes", contentLength, PayloadLimit)) - } - limitedReader := io.LimitedReader{R: rsp.Body, N: contentLength} + limitedReader := io.LimitedReader{R: rsp.Body, N: PayloadLimit} body, err := io.ReadAll(&limitedReader) if err != nil { return nil, err } + if limitedReader.N <= 0 { + // check if the response body still has excess bytes to be read + if n, _ := rsp.Body.Read(make([]byte, 1)); n > 0 { + return nil, unprocessableEntityError(ErrorCodeHookPayloadOverSizeLimit, fmt.Sprintf("Payload size exceeded size limit of %d bytes", PayloadLimit)) + } + } return body, nil case http.StatusTooManyRequests, http.StatusServiceUnavailable: retryAfterHeader := rsp.Header.Get("retry-after") From ac14e82b33545466184da99e99b9d3fe5f3876d9 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Fri, 2 Aug 2024 11:26:23 -0700 Subject: [PATCH 081/118] fix: include factor_id in query (#1702) ## What kind of change does this PR introduce? * `FixLatestUnexpiredChallenge` should be scoped to the `factor_id` and not all challenges in the table ## What is the current behavior? Please link any relevant issues here. ## What is the new behavior? Feel free to include screenshots if it includes visual changes. ## Additional context Add any other context or screenshots. --- internal/models/factor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/models/factor.go b/internal/models/factor.go index 4776ab972..06a672583 100644 --- a/internal/models/factor.go +++ b/internal/models/factor.go @@ -300,7 +300,7 @@ func (f *Factor) FindLatestUnexpiredChallenge(tx *storage.Connection, expiryDura var challenge Challenge expirationTime := now.Add(time.Duration(expiryDuration) * time.Second) - err := tx.Where("sent_at > ?", expirationTime). + err := tx.Where("sent_at > ? and factor_id = ?", expirationTime, f.ID). Order("sent_at desc"). First(&challenge) From 701a779cf092e777dd4ad4954dc650164b09ab32 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Sat, 3 Aug 2024 12:13:02 +0200 Subject: [PATCH 082/118] fix: move is owned by check to load factor (#1703) ## What kind of change does this PR introduce? In `loadFactor` ensure that all factors which are loaded are owned by the user --- internal/api/admin.go | 6 ++++-- internal/api/mfa.go | 22 +--------------------- internal/api/mfa_test.go | 24 ++++++++++++++++++++++++ internal/models/user.go | 12 ++++++++++++ 4 files changed, 41 insertions(+), 23 deletions(-) diff --git a/internal/api/admin.go b/internal/api/admin.go index 7df41fb65..0e5ae0cd9 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -69,9 +69,11 @@ func (a *API) loadUser(w http.ResponseWriter, r *http.Request) (context.Context, return withUser(ctx, u), nil } +// Use only after requireAuthentication, so that there is a valid user func (a *API) loadFactor(w http.ResponseWriter, r *http.Request) (context.Context, error) { ctx := r.Context() db := a.db.WithContext(ctx) + user := getUser(ctx) factorID, err := uuid.FromString(chi.URLParam(r, "factor_id")) if err != nil { return nil, notFoundError(ErrorCodeValidationFailed, "factor_id must be an UUID") @@ -79,14 +81,14 @@ func (a *API) loadFactor(w http.ResponseWriter, r *http.Request) (context.Contex observability.LogEntrySetField(r, "factor_id", factorID) - f, err := models.FindFactorByFactorID(db, factorID) + factor, err := user.FindOwnedFactorByID(db, factorID) if err != nil { if models.IsNotFoundError(err) { return nil, notFoundError(ErrorCodeMFAFactorNotFound, "Factor not found") } return nil, internalServerError("Database error loading factor").WithInternalError(err) } - return withFactor(ctx, f), nil + return withFactor(ctx, factor), nil } func (a *API) getAdminParams(r *http.Request) (*AdminUserParams, error) { diff --git a/internal/api/mfa.go b/internal/api/mfa.go index ae4c24b74..da4eec4ee 100644 --- a/internal/api/mfa.go +++ b/internal/api/mfa.go @@ -65,8 +65,7 @@ type UnenrollFactorResponse struct { } const ( - InvalidFactorOwnerErrorMessage = "Factor does not belong to user" - QRCodeGenerationErrorMessage = "Error generating QR Code" + QRCodeGenerationErrorMessage = "Error generating QR Code" ) func (a *API) enrollPhoneFactor(w http.ResponseWriter, r *http.Request, params *EnrollFactorParams) error { @@ -392,16 +391,12 @@ func (a *API) ChallengeFactor(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() config := a.config factor := getFactor(ctx) - user := getUser(ctx) switch factor.FactorType { case models.Phone: if !config.MFA.Phone.VerifyEnabled { return unprocessableEntityError(ErrorCodeMFAPhoneEnrollDisabled, "MFA verification is disabled for Phone") } - if !factor.IsOwnedBy(user) { - return notFoundError(ErrorCodeMFAFactorNotFound, "MFA factor not found") - } return a.challengePhoneFactor(w, r) case models.TOTP: @@ -412,9 +407,6 @@ func (a *API) ChallengeFactor(w http.ResponseWriter, r *http.Request) error { if !config.MFA.Enabled && !config.MFA.TOTP.VerifyEnabled { return unprocessableEntityError(ErrorCodeMFATOTPEnrollDisabled, "MFA verification is disabled for TOTP") } - if !factor.IsOwnedBy(user) { - return notFoundError(ErrorCodeMFAFactorNotFound, "MFA factor not found") - } return a.challengeTOTPFactor(w, r) default: return badRequestError(ErrorCodeValidationFailed, "factor_type needs to be TOTP or Phone") @@ -431,11 +423,6 @@ func (a *API) verifyTOTPFactor(w http.ResponseWriter, r *http.Request, params *V db := a.db.WithContext(ctx) currentIP := utilities.GetIPAddress(r) - if !factor.IsOwnedBy(user) { - // TODO: Should be changed to notFoundError. Retained as internalServerError to preserve backward compatibility. - return internalServerError(InvalidFactorOwnerErrorMessage) - } - challenge, err := factor.FindChallengeByID(db, params.ChallengeID) if err != nil && models.IsNotFoundError(err) { return notFoundError(ErrorCodeMFAFactorNotFound, "MFA factor with the provided challenge ID not found") @@ -572,10 +559,6 @@ func (a *API) verifyPhoneFactor(w http.ResponseWriter, r *http.Request, params * db := a.db.WithContext(ctx) currentIP := utilities.GetIPAddress(r) - if !factor.IsOwnedBy(user) { - return notFoundError(ErrorCodeMFAFactorNotFound, "MFA factor not found") - } - challenge, err := factor.FindChallengeByID(db, params.ChallengeID) if err != nil && models.IsNotFoundError(err) { return notFoundError(ErrorCodeMFAFactorNotFound, "MFA factor with the provided challenge ID not found") @@ -732,9 +715,6 @@ func (a *API) UnenrollFactor(w http.ResponseWriter, r *http.Request) error { if factor.IsVerified() && !session.IsAAL2() { return unprocessableEntityError(ErrorCodeInsufficientAAL, "AAL2 required to unenroll verified factor") } - if !factor.IsOwnedBy(user) { - return internalServerError(InvalidFactorOwnerErrorMessage) - } err = db.Transaction(func(tx *storage.Connection) error { var terr error diff --git a/internal/api/mfa_test.go b/internal/api/mfa_test.go index e08f20514..ed9a13e1b 100644 --- a/internal/api/mfa_test.go +++ b/internal/api/mfa_test.go @@ -569,6 +569,30 @@ func (ts *MFATestSuite) TestMFAFollowedByPasswordSignIn() { require.True(ts.T(), session.IsAAL2()) } +func (ts *MFATestSuite) TestChallengeFactorNotOwnedByUser() { + var buffer bytes.Buffer + email := "nomfaenabled@test.com" + password := "testpassword" + signUpResp := signUp(ts, email, password) + + friendlyName := "testfactor" + phoneNumber := "+1234567" + + otherUsersPhoneFactor := models.NewPhoneFactor(ts.TestUser, phoneNumber, friendlyName) + require.NoError(ts.T(), ts.API.db.Create(otherUsersPhoneFactor), "Error creating factor") + + w := ServeAuthenticatedRequest(ts, http.MethodPost, fmt.Sprintf("http://localhost/factors/%s/challenge", otherUsersPhoneFactor.ID), signUpResp.Token, buffer) + + expectedError := notFoundError(ErrorCodeMFAFactorNotFound, "Factor not found") + + var data HTTPError + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data)) + + require.Equal(ts.T(), expectedError.ErrorCode, data.ErrorCode) + require.Equal(ts.T(), http.StatusNotFound, w.Code) + +} + func signUp(ts *MFATestSuite, email, password string) (signUpResp AccessTokenResponse) { ts.API.config.Mailer.Autoconfirm = true var buffer bytes.Buffer diff --git a/internal/models/user.go b/internal/models/user.go index 12ac52816..e50b9647d 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -906,6 +906,18 @@ func (u *User) SoftDeleteUserIdentities(tx *storage.Connection) error { return nil } +func (u *User) FindOwnedFactorByID(tx *storage.Connection, factorID uuid.UUID) (*Factor, error) { + var factor Factor + err := tx.Where("user_id = ? AND id = ?", u.ID, factorID).First(&factor) + if err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil, &FactorNotFoundError{} + } + return nil, err + } + return &factor, nil +} + func obfuscateValue(id uuid.UUID, value string) string { hash := sha256.Sum256([]byte(id.String() + value)) return base64.RawURLEncoding.EncodeToString(hash[:]) From 575e88ac345adaeb76ab6aae077307fdab9cda3c Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Sun, 4 Aug 2024 10:56:38 -0700 Subject: [PATCH 083/118] fix: allow enabling sms hook without setting up sms provider (#1704) ## What kind of change does this PR introduce? * Resolves issue where the custom SMS hook cannot be used unless a SMS provider is configured by moving `GetSmsProvider` into `sendPhoneConfirmation` and only calls it if the hook is not enabled. * Allows `channel` to be set for MFA (phone) if hook is enabled ## What is the current behavior? * It's not possible to set up a hook without adding config for a SMS provider ## TODO - [x] Fix broken tests --- internal/api/hooks_test.go | 2 +- internal/api/mfa.go | 25 ++++----- internal/api/mfa_test.go | 9 ---- internal/api/otp.go | 21 +++----- internal/api/phone.go | 36 +++++++------ internal/api/phone_test.go | 64 ++++++++++------------- internal/api/reauthenticate.go | 7 +-- internal/api/resend.go | 12 +---- internal/api/signup.go | 10 ++-- internal/api/sms_provider/sms_provider.go | 15 +++++- internal/api/user.go | 13 ++--- 11 files changed, 90 insertions(+), 124 deletions(-) diff --git a/internal/api/hooks_test.go b/internal/api/hooks_test.go index 8530b858a..034dbeb87 100644 --- a/internal/api/hooks_test.go +++ b/internal/api/hooks_test.go @@ -5,9 +5,9 @@ import ( "net/http" "testing" - "errors" "net/http/httptest" + "github.com/pkg/errors" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" diff --git a/internal/api/mfa.go b/internal/api/mfa.go index da4eec4ee..420f3f552 100644 --- a/internal/api/mfa.go +++ b/internal/api/mfa.go @@ -282,15 +282,10 @@ func (a *API) challengePhoneFactor(w http.ResponseWriter, r *http.Request) error return err } channel := params.Channel - if channel == "" { channel = sms_provider.SMSProvider } - smsProvider, err := sms_provider.GetSmsProvider(*config) - if err != nil { - return internalServerError("Failed to get SMS provider").WithInternalError(err) - } - if !sms_provider.IsValidMessageChannel(channel, config.Sms.Provider) { + if !sms_provider.IsValidMessageChannel(channel, config) { return badRequestError(ErrorCodeValidationFailed, InvalidChannelError) } latestValidChallenge, err := factor.FindLatestUnexpiredChallenge(a.db, config.MFA.ChallengeExpiryDuration) @@ -301,20 +296,18 @@ func (a *API) challengePhoneFactor(w http.ResponseWriter, r *http.Request) error } else if latestValidChallenge != nil && !latestValidChallenge.SentAt.Add(config.MFA.Phone.MaxFrequency).Before(time.Now()) { return tooManyRequestsError(ErrorCodeOverSMSSendRateLimit, generateFrequencyLimitErrorMessage(latestValidChallenge.SentAt, config.MFA.Phone.MaxFrequency)) } - otp, err := crypto.GenerateOtp(config.MFA.Phone.OtpLength) if err != nil { panic(err) } - challenge, err := factor.CreatePhoneChallenge(ipAddress, otp, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey) - if err != nil { - return internalServerError("error creating SMS Challenge") - } - message, err := generateSMSFromTemplate(config.MFA.Phone.SMSTemplate, otp) if err != nil { return internalServerError("error generating sms template").WithInternalError(err) } + challenge, err := factor.CreatePhoneChallenge(ipAddress, otp, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey) + if err != nil { + return internalServerError("error creating SMS Challenge") + } if config.Hook.SendSMS.Enabled { input := hooks.SendSMSInput{ User: user, @@ -329,10 +322,12 @@ func (a *API) challengePhoneFactor(w http.ResponseWriter, r *http.Request) error return internalServerError("error invoking hook") } } else { - - // We omit messageID for now, can consider reinstating if there are requests. - _, err := smsProvider.SendMessage(string(factor.Phone), message, channel, otp) + smsProvider, err := sms_provider.GetSmsProvider(*config) if err != nil { + return internalServerError("Failed to get SMS provider").WithInternalError(err) + } + // We omit messageID for now, can consider reinstating if there are requests. + if _, err = smsProvider.SendMessage(string(factor.Phone), message, channel, otp); err != nil { return internalServerError("error sending message").WithInternalError(err) } } diff --git a/internal/api/mfa_test.go b/internal/api/mfa_test.go index ed9a13e1b..ce9d27508 100644 --- a/internal/api/mfa_test.go +++ b/internal/api/mfa_test.go @@ -265,14 +265,6 @@ func (ts *MFATestSuite) TestChallengeSMSFactor() { begin return input; end; $$ language plpgsql;`).Exec()) - // We still need a mock provider for hooks to work right now for backward compatibility - // The WhatsApp channel is only valid when twilio or twilio verify is set. - ts.Config.Sms.Provider = "twilio" - ts.Config.Sms.Twilio = conf.TwilioProviderConfiguration{ - AccountSid: "test_account_sid", - AuthToken: "test_auth_token", - MessageServiceSid: "test_message_service_id", - } phone := "+1234567" friendlyName := "testchallengesmsfactor" @@ -491,7 +483,6 @@ func (ts *MFATestSuite) TestUnenrollVerifiedFactor() { func (ts *MFATestSuite) TestUnenrollUnverifiedFactor() { var buffer bytes.Buffer f := ts.TestUser.Factors[0] - f.Secret = ts.TestOTPKey.Secret() token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID) require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ diff --git a/internal/api/otp.go b/internal/api/otp.go index d0e3d6f18..e690bdfe2 100644 --- a/internal/api/otp.go +++ b/internal/api/otp.go @@ -8,6 +8,7 @@ import ( "github.com/sethvargo/go-password/password" "github.com/supabase/auth/internal/api/sms_provider" + "github.com/supabase/auth/internal/conf" "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/storage" ) @@ -45,17 +46,15 @@ func (p *OtpParams) Validate() error { return nil } -func (p *SmsParams) Validate(smsProvider string) error { - if p.Phone != "" && !sms_provider.IsValidMessageChannel(p.Channel, smsProvider) { - return badRequestError(ErrorCodeValidationFailed, InvalidChannelError) - } - +func (p *SmsParams) Validate(config *conf.GlobalConfiguration) error { var err error p.Phone, err = validatePhone(p.Phone) if err != nil { return err } - + if !sms_provider.IsValidMessageChannel(p.Channel, config) { + return badRequestError(ErrorCodeValidationFailed, InvalidChannelError) + } return nil } @@ -119,7 +118,7 @@ func (a *API) SmsOtp(w http.ResponseWriter, r *http.Request) error { params.Channel = sms_provider.SMSProvider } - if err := params.Validate(config.Sms.Provider); err != nil { + if err := params.Validate(config); err != nil { return err } @@ -191,13 +190,9 @@ func (a *API) SmsOtp(w http.ResponseWriter, r *http.Request) error { }); err != nil { return err } - smsProvider, terr := sms_provider.GetSmsProvider(*config) - if terr != nil { - return internalServerError("Unable to get SMS provider").WithInternalError(err) - } - mID, serr := a.sendPhoneConfirmation(r, tx, user, params.Phone, phoneConfirmationOtp, smsProvider, params.Channel) + mID, serr := a.sendPhoneConfirmation(r, tx, user, params.Phone, phoneConfirmationOtp, params.Channel) if serr != nil { - return badRequestError(ErrorCodeSMSSendFailed, "Error sending sms OTP: %v", serr).WithInternalError(serr) + return serr } messageID = mID return nil diff --git a/internal/api/phone.go b/internal/api/phone.go index 86d569b94..8e7d39e63 100644 --- a/internal/api/phone.go +++ b/internal/api/phone.go @@ -43,7 +43,7 @@ func formatPhoneNumber(phone string) string { } // sendPhoneConfirmation sends an otp to the user's phone number -func (a *API) sendPhoneConfirmation(r *http.Request, tx *storage.Connection, user *models.User, phone, otpType string, smsProvider sms_provider.SmsProvider, channel string) (string, error) { +func (a *API) sendPhoneConfirmation(r *http.Request, tx *storage.Connection, user *models.User, phone, otpType string, channel string) (string, error) { config := a.config var token *string @@ -71,7 +71,7 @@ func (a *API) sendPhoneConfirmation(r *http.Request, tx *storage.Connection, use // intentionally keeping this before the test OTP, so that the behavior // of regular and test OTPs is similar if sentAt != nil && !sentAt.Add(config.Sms.MaxFrequency).Before(time.Now()) { - return "", MaxFrequencyLimitError + return "", tooManyRequestsError(ErrorCodeOverSMSSendRateLimit, generateFrequencyLimitErrorMessage(sentAt, config.Sms.MaxFrequency)) } now := time.Now() @@ -89,14 +89,7 @@ func (a *API) sendPhoneConfirmation(r *http.Request, tx *storage.Connection, use if err != nil { return "", internalServerError("error generating otp").WithInternalError(err) } - - message, err := generateSMSFromTemplate(config.Sms.SMSTemplate, otp) - if err != nil { - return "", err - } - - // Hook should only be called if SMS autoconfirm is disabled - if !config.Sms.Autoconfirm && config.Hook.SendSMS.Enabled { + if config.Hook.SendSMS.Enabled { input := hooks.SendSMSInput{ User: user, SMS: hooks.SMS{ @@ -109,9 +102,17 @@ func (a *API) sendPhoneConfirmation(r *http.Request, tx *storage.Connection, use return "", err } } else { - messageID, err = smsProvider.SendMessage(phone, message, channel, otp) + smsProvider, err := sms_provider.GetSmsProvider(*config) + if err != nil { + return "", internalServerError("Unable to get SMS provider").WithInternalError(err) + } + message, err := generateSMSFromTemplate(config.Sms.SMSTemplate, otp) + if err != nil { + return "", internalServerError("error generating sms template").WithInternalError(err) + } + messageID, err := smsProvider.SendMessage(phone, message, channel, otp) if err != nil { - return messageID, err + return messageID, unprocessableEntityError(ErrorCodeSMSSendFailed, "Error sending %s OTP to provider: %v", otpType, err) } } } @@ -131,21 +132,24 @@ func (a *API) sendPhoneConfirmation(r *http.Request, tx *storage.Connection, use return messageID, errors.Wrap(err, "Database error updating user for phone") } + var ottErr error switch otpType { case phoneConfirmationOtp: if err := models.CreateOneTimeToken(tx, user.ID, user.GetPhone(), user.ConfirmationToken, models.ConfirmationToken); err != nil { - return messageID, errors.Wrap(err, "Database error creating confirmation token for phone") + ottErr = errors.Wrap(err, "Database error creating confirmation token for phone") } case phoneChangeVerification: if err := models.CreateOneTimeToken(tx, user.ID, user.PhoneChange, user.PhoneChangeToken, models.PhoneChangeToken); err != nil { - return messageID, errors.Wrap(err, "Database error creating phone change token") + ottErr = errors.Wrap(err, "Database error creating phone change token") } case phoneReauthenticationOtp: if err := models.CreateOneTimeToken(tx, user.ID, user.GetPhone(), user.ReauthenticationToken, models.ReauthenticationToken); err != nil { - return messageID, errors.Wrap(err, "Database error creating reauthentication token for phone") + ottErr = errors.Wrap(err, "Database error creating reauthentication token for phone") } } - + if ottErr != nil { + return messageID, internalServerError("error creating one time token").WithInternalError(ottErr) + } return messageID, nil } diff --git a/internal/api/phone_test.go b/internal/api/phone_test.go index 38daa4941..468c39955 100644 --- a/internal/api/phone_test.go +++ b/internal/api/phone_test.go @@ -111,8 +111,9 @@ func doTestSendPhoneConfirmation(ts *PhoneTestSuite, useTestOTP bool) { for _, c := range cases { ts.Run(c.desc, func() { provider := &TestSmsProvider{} + sms_provider.MockProvider = provider - _, err = ts.API.sendPhoneConfirmation(req, ts.API.db, u, "123456789", c.otpType, provider, sms_provider.SMSProvider) + _, err = ts.API.sendPhoneConfirmation(req, ts.API.db, u, "123456789", c.otpType, sms_provider.SMSProvider) require.Equal(ts.T(), c.expected, err) u, err = models.FindUserByPhoneAndAudience(ts.API.db, "123456789", ts.Config.JWT.Aud) require.NoError(ts.T(), err) @@ -306,13 +307,13 @@ func (ts *PhoneTestSuite) TestSendSMSHook() { method: http.MethodPost, uri: "pg-functions://postgres/auth/send_sms_signup", hookFunctionSQL: ` - create or replace function send_sms_signup(input jsonb) - returns json as $$ - begin - insert into job_queue(job_type, payload) - values ('sms_signup', input); - return input; - end; $$ language plpgsql;`, + create or replace function send_sms_signup(input jsonb) + returns json as $$ + begin + insert into job_queue(job_type, payload) + values ('sms_signup', input); + return input; + end; $$ language plpgsql;`, header: "", body: map[string]string{ "phone": "1234567890", @@ -327,13 +328,13 @@ func (ts *PhoneTestSuite) TestSendSMSHook() { method: http.MethodPost, uri: "pg-functions://postgres/auth/send_sms_otp", hookFunctionSQL: ` - create or replace function send_sms_otp(input jsonb) - returns json as $$ - begin - insert into job_queue(job_type, payload) - values ('sms_signup', input); - return input; - end; $$ language plpgsql;`, + create or replace function send_sms_otp(input jsonb) + returns json as $$ + begin + insert into job_queue(job_type, payload) + values ('sms_signup', input); + return input; + end; $$ language plpgsql;`, header: "", body: map[string]string{ "phone": "123456789", @@ -348,13 +349,13 @@ func (ts *PhoneTestSuite) TestSendSMSHook() { method: http.MethodPut, uri: "pg-functions://postgres/auth/send_sms_phone_change", hookFunctionSQL: ` - create or replace function send_sms_phone_change(input jsonb) - returns json as $$ - begin - insert into job_queue(job_type, payload) - values ('phone_change', input); - return input; - end; $$ language plpgsql;`, + create or replace function send_sms_phone_change(input jsonb) + returns json as $$ + begin + insert into job_queue(job_type, payload) + values ('phone_change', input); + return input; + end; $$ language plpgsql;`, header: token, body: map[string]string{ "phone": "111111111", @@ -369,11 +370,11 @@ func (ts *PhoneTestSuite) TestSendSMSHook() { method: http.MethodGet, uri: "pg-functions://postgres/auth/reauthenticate", hookFunctionSQL: ` - create or replace function reauthenticate(input jsonb) - returns json as $$ - begin - return input; - end; $$ language plpgsql;`, + create or replace function reauthenticate(input jsonb) + returns json as $$ + begin + return input; + end; $$ language plpgsql;`, header: "", body: nil, expectToken: true, @@ -396,7 +397,7 @@ func (ts *PhoneTestSuite) TestSendSMSHook() { "phone": "123456789", }, expectToken: false, - expectedCode: http.StatusBadRequest, + expectedCode: http.StatusInternalServerError, hookFunctionIdentifier: "send_sms_otp_failure(input jsonb)", }, } @@ -409,13 +410,6 @@ func (ts *PhoneTestSuite) TestSendSMSHook() { ts.Config.Hook.SendSMS.URI = c.uri // Disable FrequencyLimit to allow back to back sending ts.Config.Sms.MaxFrequency = 0 * time.Second - // We still need a mock provider for hooks to work right now for backward compatibility - ts.Config.Sms.Provider = "twilio" - ts.Config.Sms.Twilio = conf.TwilioProviderConfiguration{ - AccountSid: "test_account_sid", - AuthToken: "test_auth_token", - MessageServiceSid: "test_message_service_id", - } require.NoError(ts.T(), ts.Config.Hook.SendSMS.PopulateExtensibilityPoint()) require.NoError(t, ts.API.db.RawQuery(c.hookFunctionSQL).Exec()) diff --git a/internal/api/reauthenticate.go b/internal/api/reauthenticate.go index cf86d102f..df46bad03 100644 --- a/internal/api/reauthenticate.go +++ b/internal/api/reauthenticate.go @@ -17,7 +17,6 @@ const InvalidNonceMessage = "Nonce has expired or is invalid" func (a *API) Reauthenticate(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) - config := a.config user := getUser(ctx) email, phone := user.GetEmail(), user.GetPhone() @@ -44,11 +43,7 @@ func (a *API) Reauthenticate(w http.ResponseWriter, r *http.Request) error { if email != "" { return a.sendReauthenticationOtp(r, tx, user) } else if phone != "" { - smsProvider, terr := sms_provider.GetSmsProvider(*config) - if terr != nil { - return internalServerError("Failed to get SMS provider").WithInternalError(terr) - } - mID, err := a.sendPhoneConfirmation(r, tx, user, phone, phoneReauthenticationOtp, smsProvider, sms_provider.SMSProvider) + mID, err := a.sendPhoneConfirmation(r, tx, user, phone, phoneReauthenticationOtp, sms_provider.SMSProvider) if err != nil { return err } diff --git a/internal/api/resend.go b/internal/api/resend.go index b9e16df51..1dfc47762 100644 --- a/internal/api/resend.go +++ b/internal/api/resend.go @@ -127,11 +127,7 @@ func (a *API) Resend(w http.ResponseWriter, r *http.Request) error { if terr := models.NewAuditLogEntry(r, tx, user, models.UserRecoveryRequestedAction, "", nil); terr != nil { return terr } - smsProvider, terr := sms_provider.GetSmsProvider(*config) - if terr != nil { - return terr - } - mID, terr := a.sendPhoneConfirmation(r, tx, user, params.Phone, phoneConfirmationOtp, smsProvider, sms_provider.SMSProvider) + mID, terr := a.sendPhoneConfirmation(r, tx, user, params.Phone, phoneConfirmationOtp, sms_provider.SMSProvider) if terr != nil { return terr } @@ -139,11 +135,7 @@ func (a *API) Resend(w http.ResponseWriter, r *http.Request) error { case mail.EmailChangeVerification: return a.sendEmailChange(r, tx, user, user.EmailChange, models.ImplicitFlow) case phoneChangeVerification: - smsProvider, terr := sms_provider.GetSmsProvider(*config) - if terr != nil { - return terr - } - mID, terr := a.sendPhoneConfirmation(r, tx, user, user.PhoneChange, phoneChangeVerification, smsProvider, sms_provider.SMSProvider) + mID, terr := a.sendPhoneConfirmation(r, tx, user, user.PhoneChange, phoneChangeVerification, sms_provider.SMSProvider) if terr != nil { return terr } diff --git a/internal/api/signup.go b/internal/api/signup.go index b396178db..cc5e189e8 100644 --- a/internal/api/signup.go +++ b/internal/api/signup.go @@ -41,7 +41,7 @@ func (a *API) validateSignupParams(ctx context.Context, p *SignupParams) error { if p.Email != "" && p.Phone != "" { return badRequestError(ErrorCodeValidationFailed, "Only an email address or phone number should be provided on signup.") } - if p.Provider == "phone" && !sms_provider.IsValidMessageChannel(p.Channel, config.Sms.Provider) { + if p.Provider == "phone" && !sms_provider.IsValidMessageChannel(p.Channel, config) { return badRequestError(ErrorCodeValidationFailed, InvalidChannelError) } // PKCE not needed as phone signups already return access token in body @@ -267,12 +267,8 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { }); terr != nil { return terr } - smsProvider, terr := sms_provider.GetSmsProvider(*config) - if terr != nil { - return internalServerError("Unable to get SMS provider").WithInternalError(terr) - } - if _, terr := a.sendPhoneConfirmation(r, tx, user, params.Phone, phoneConfirmationOtp, smsProvider, params.Channel); terr != nil { - return unprocessableEntityError(ErrorCodeSMSSendFailed, "Error sending confirmation sms: %v", terr).WithInternalError(terr) + if _, terr := a.sendPhoneConfirmation(r, tx, user, params.Phone, phoneConfirmationOtp, params.Channel); terr != nil { + return terr } } } diff --git a/internal/api/sms_provider/sms_provider.go b/internal/api/sms_provider/sms_provider.go index 21836acdf..1643991f5 100644 --- a/internal/api/sms_provider/sms_provider.go +++ b/internal/api/sms_provider/sms_provider.go @@ -9,6 +9,9 @@ import ( "github.com/supabase/auth/internal/conf" ) +// overrides the SmsProvider set to always return the mock provider +var MockProvider SmsProvider = nil + var defaultTimeout time.Duration = time.Second * 10 const SMSProvider = "sms" @@ -30,6 +33,10 @@ type SmsProvider interface { } func GetSmsProvider(config conf.GlobalConfiguration) (SmsProvider, error) { + if MockProvider != nil { + return MockProvider, nil + } + switch name := config.Sms.Provider; name { case "twilio": return NewTwilioProvider(config.Sms.Twilio) @@ -46,12 +53,16 @@ func GetSmsProvider(config conf.GlobalConfiguration) (SmsProvider, error) { } } -func IsValidMessageChannel(channel string, smsProvider string) bool { +func IsValidMessageChannel(channel string, config *conf.GlobalConfiguration) bool { + if config.Hook.SendSMS.Enabled { + // channel doesn't matter if SMS hook is enabled + return true + } switch channel { case SMSProvider: return true case WhatsappProvider: - return smsProvider == "twilio" || smsProvider == "twilio_verify" + return config.Sms.Provider == "twilio" || config.Sms.Provider == "twilio_verify" default: return false } diff --git a/internal/api/user.go b/internal/api/user.go index b55259358..f82d2bf1c 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -44,7 +44,7 @@ func (a *API) validateUserUpdateParams(ctx context.Context, p *UserUpdateParams) if p.Channel == "" { p.Channel = sms_provider.SMSProvider } - if !sms_provider.IsValidMessageChannel(p.Channel, config.Sms.Provider) { + if !sms_provider.IsValidMessageChannel(p.Channel, config) { return badRequestError(ErrorCodeValidationFailed, InvalidChannelError) } } @@ -245,15 +245,8 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error { return terr } } else { - smsProvider, terr := sms_provider.GetSmsProvider(*config) - if terr != nil { - return internalServerError("Error finding SMS provider").WithInternalError(terr) - } - if _, terr := a.sendPhoneConfirmation(r, tx, user, params.Phone, phoneChangeVerification, smsProvider, params.Channel); terr != nil { - if errors.Is(terr, MaxFrequencyLimitError) { - return tooManyRequestsError(ErrorCodeOverSMSSendRateLimit, generateFrequencyLimitErrorMessage(user.PhoneChangeSentAt, config.Sms.MaxFrequency)) - } - return internalServerError("Error sending phone change otp").WithInternalError(terr) + if _, terr := a.sendPhoneConfirmation(r, tx, user, params.Phone, phoneChangeVerification, params.Channel); terr != nil { + return terr } } } From 078c3a8adcd51e57b68ab1b582549f5813cccd14 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Sun, 4 Aug 2024 20:02:18 +0200 Subject: [PATCH 084/118] fix: drop the MFA_ENABLED config (#1701) ## What kind of change does this PR introduce? The `MFA_ENABLED` config is deprecated and not in active use. --- internal/api/mfa.go | 12 ++---------- internal/api/settings.go | 2 -- internal/conf/configuration.go | 3 --- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/internal/api/mfa.go b/internal/api/mfa.go index 420f3f552..21a2fbc66 100644 --- a/internal/api/mfa.go +++ b/internal/api/mfa.go @@ -256,11 +256,7 @@ func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error { } return a.enrollPhoneFactor(w, r, params) case models.TOTP: - // Prior to the introduction of MFA.TOTP.EnrollEnabled, - // MFA.Enabled was used to configure whether TOTP was on. So - // both have to be set to false to regard the feature as - // disabled. - if !config.MFA.Enabled && !config.MFA.TOTP.EnrollEnabled { + if !config.MFA.TOTP.EnrollEnabled { return unprocessableEntityError(ErrorCodeMFATOTPEnrollDisabled, "MFA enroll is disabled for TOTP") } return a.enrollTOTPFactor(w, r, params) @@ -395,11 +391,7 @@ func (a *API) ChallengeFactor(w http.ResponseWriter, r *http.Request) error { return a.challengePhoneFactor(w, r) case models.TOTP: - // Prior to the introduction of MFA.TOTP.VerifyEnabled, - // MFA.Enabled was used to configure whether TOTP was on. So - // both have to be set to false to regard the feature as - // disabled. - if !config.MFA.Enabled && !config.MFA.TOTP.VerifyEnabled { + if !config.MFA.TOTP.VerifyEnabled { return unprocessableEntityError(ErrorCodeMFATOTPEnrollDisabled, "MFA verification is disabled for TOTP") } return a.challengeTOTPFactor(w, r) diff --git a/internal/api/settings.go b/internal/api/settings.go index 16817db10..bc2f38692 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -36,7 +36,6 @@ type Settings struct { MailerAutoconfirm bool `json:"mailer_autoconfirm"` PhoneAutoconfirm bool `json:"phone_autoconfirm"` SmsProvider string `json:"sms_provider"` - MFAEnabled bool `json:"mfa_enabled"` SAMLEnabled bool `json:"saml_enabled"` } @@ -75,7 +74,6 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error { MailerAutoconfirm: config.Mailer.Autoconfirm, PhoneAutoconfirm: config.Sms.Autoconfirm, SmsProvider: config.Sms.Provider, - MFAEnabled: config.MFA.Enabled, SAMLEnabled: config.SAML.Enabled, }) } diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 81d058c29..1f81f6b7a 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -123,9 +123,6 @@ type PhoneFactorTypeConfiguration struct { // MFAConfiguration holds all the MFA related Configuration type MFAConfiguration struct { - // Enabled is deprecated, but still used to signal TOTP.EnrollEnabled and TOTP.VerifyEnabled. - Enabled bool `default:"false"` - ChallengeExpiryDuration float64 `json:"challenge_expiry_duration" default:"300" split_words:"true"` FactorExpiryDuration time.Duration `json:"factor_expiry_duration" default:"300s" split_words:"true"` RateLimitChallengeAndVerify float64 `split_words:"true" default:"15"` From af8e2dda15a1234a05e7d2d34d316eaa029e0912 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Sun, 4 Aug 2024 20:02:57 +0200 Subject: [PATCH 085/118] fix: remove FindFactorsByUser (#1707) ## What kind of change does this PR introduce? Refactors the tests and removes FindFactorsByUser. There shouldn't be a need to call it as it's possible to directly load the Factors. We note that there is significant opportunity for refactoring [in this test](https://github.com/supabase/auth/pull/1707/files#diff-776a4afc31ddc19c68d15827910389a0f2598c3351ec1df5495344d3e286c36cL309) this will be done later in the week in the interest of time --- internal/api/mfa_test.go | 68 +++++++++++++++------------------------- 1 file changed, 25 insertions(+), 43 deletions(-) diff --git a/internal/api/mfa_test.go b/internal/api/mfa_test.go index ce9d27508..d88eebb91 100644 --- a/internal/api/mfa_test.go +++ b/internal/api/mfa_test.go @@ -12,15 +12,11 @@ import ( "github.com/gofrs/uuid" - "database/sql" - - "github.com/pkg/errors" "github.com/pquerna/otp" "github.com/supabase/auth/internal/api/sms_provider" "github.com/supabase/auth/internal/conf" "github.com/supabase/auth/internal/crypto" "github.com/supabase/auth/internal/models" - "github.com/supabase/auth/internal/storage" "github.com/supabase/auth/internal/utilities" "github.com/pquerna/otp/totp" @@ -178,23 +174,26 @@ func (ts *MFATestSuite) TestEnrollFactor() { for _, c := range cases { ts.Run(c.desc, func() { w := performEnrollFlow(ts, token, c.friendlyName, c.factorType, c.issuer, c.phone, c.expectedCode) + enrollResp := EnrollFactorResponse{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp)) - factors, err := FindFactorsByUser(ts.API.db, ts.TestUser) - ts.Require().NoError(err) - addedFactor := factors[len(factors)-1] - require.False(ts.T(), addedFactor.IsVerified()) - if c.friendlyName != "" && c.expectedCode == http.StatusOK { - require.Equal(ts.T(), c.friendlyName, addedFactor.FriendlyName) + if c.expectedCode == http.StatusOK { + addedFactor, err := models.FindFactorByFactorID(ts.API.db, enrollResp.ID) + require.NoError(ts.T(), err) + require.False(ts.T(), addedFactor.IsVerified()) + + if c.friendlyName != "" { + require.Equal(ts.T(), c.friendlyName, addedFactor.FriendlyName) + } + + if c.factorType == models.TOTP { + qrCode := enrollResp.TOTP.QRCode + hasSVGStartAndEnd := strings.Contains(qrCode, "") + require.True(ts.T(), hasSVGStartAndEnd) + require.Equal(ts.T(), c.friendlyName, enrollResp.FriendlyName) + } } - if w.Code == http.StatusOK && c.factorType == models.TOTP { - enrollResp := EnrollFactorResponse{} - require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp)) - qrCode := enrollResp.TOTP.QRCode - hasSVGStartAndEnd := strings.Contains(qrCode, "") - require.True(ts.T(), hasSVGStartAndEnd) - require.Equal(ts.T(), c.friendlyName, enrollResp.FriendlyName) - } }) } } @@ -224,23 +223,22 @@ func (ts *MFATestSuite) TestMultipleEnrollsCleanupExpiredFactors() { accessTokenResp := &AccessTokenResponse{} require.NoError(ts.T(), json.NewDecoder(resp.Body).Decode(&accessTokenResp)) + var w *httptest.ResponseRecorder token := accessTokenResp.Token for i := 0; i < numFactors; i++ { - _ = performEnrollFlow(ts, token, "", models.TOTP, "https://issuer.com", "", http.StatusOK) + w = performEnrollFlow(ts, token, "", models.TOTP, "https://issuer.com", "", http.StatusOK) } - // All Factors except last factor should be expired - factors, err := FindFactorsByUser(ts.API.db, ts.TestUser) - require.NoError(ts.T(), err) + enrollResp := EnrollFactorResponse{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp)) // Make a challenge so last, unverified factor isn't deleted on next enroll (Factor 2) - _ = performChallengeFlow(ts, factors[len(factors)-1].ID, token) + _ = performChallengeFlow(ts, enrollResp.ID, token) // Enroll another Factor (Factor 3) _ = performEnrollFlow(ts, token, "", models.TOTP, "https://issuer.com", "", http.StatusOK) - factors, err = FindFactorsByUser(ts.API.db, ts.TestUser) - require.NoError(ts.T(), err) - require.Equal(ts.T(), 3, len(factors)) + require.NoError(ts.T(), ts.API.db.Eager("Factors").Find(ts.TestUser, ts.TestUser.ID)) + require.Equal(ts.T(), 3, len(ts.TestUser.Factors)) } func (ts *MFATestSuite) TestChallengeFactor() { @@ -454,12 +452,8 @@ func (ts *MFATestSuite) TestUnenrollVerifiedFactor() { var buffer bytes.Buffer // Create Session to test behaviour which downgrades other sessions - factors, err := FindFactorsByUser(ts.API.db, ts.TestUser) - require.NoError(ts.T(), err, "error finding factors") - f := factors[0] - f.Secret = ts.TestOTPKey.Secret() + f := ts.TestUser.Factors[0] require.NoError(ts.T(), f.UpdateStatus(ts.API.db, models.FactorStateVerified)) - require.NoError(ts.T(), ts.API.db.Update(f), "Error updating new test factor") if v.isAAL2 { ts.TestSession.UpdateAALAndAssociatedFactor(ts.API.db, models.AAL2, &f.ID) } @@ -835,15 +829,3 @@ func cleanupHook(ts *MFATestSuite, hookName string) { err := ts.API.db.RawQuery(cleanupHookSQL).Exec() require.NoError(ts.T(), err) } - -// FindFactorsByUser returns all factors belonging to a user ordered by timestamp. Don't use this outside of tests. -func FindFactorsByUser(tx *storage.Connection, user *models.User) ([]*models.Factor, error) { - factors := []*models.Factor{} - if err := tx.Q().Where("user_id = ?", user.ID).Order("created_at asc").All(&factors); err != nil { - if errors.Cause(err) == sql.ErrNoRows { - return factors, nil - } - return nil, errors.Wrap(err, "Database error when finding MFA factors associated to user") - } - return factors, nil -} From 70446cc11d70b0493d742fe03f272330bb5b633e Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Mon, 5 Aug 2024 12:27:26 +0200 Subject: [PATCH 086/118] fix: enforce uniqueness on verified phone numbers (#1693) ## What kind of change does this PR introduce? With this change: - Multiple verified phone mfa factors can exist so long as they have distinct phone numbers (see discussion below) - Enrolling a factor with a number that is the same as the existing verified factor will result in a 422 status code - Enrolling a factor with a number that is the same as another existing unverified factor will result in the deletion of the older factor. Also includes: - A refactor to check for duplicate constraints at application level then at the Postgres layer. - A narrowing of deletion so that only unverified factors of the same type are deleted upon first successful verification Follow up to #1687 to support the unique constraint on phone factors. --- internal/api/errorcodes.go | 1 + internal/api/mfa.go | 45 +++++++++++++++------ internal/api/mfa_test.go | 81 ++++++++++++++++++++++++++++++++++++++ internal/models/factor.go | 15 +++++-- 4 files changed, 127 insertions(+), 15 deletions(-) diff --git a/internal/api/errorcodes.go b/internal/api/errorcodes.go index ce36bb65c..b400f41f0 100644 --- a/internal/api/errorcodes.go +++ b/internal/api/errorcodes.go @@ -81,4 +81,5 @@ const ( ErrorCodeMFAPhoneVerifyDisabled ErrorCode = "mfa_phone_verify_not_enabled" ErrorCodeMFATOTPEnrollDisabled ErrorCode = "mfa_totp_enroll_not_enabled" ErrorCodeMFATOTPVerifyDisabled ErrorCode = "mfa_totp_verify_not_enabled" + ErrorCodeVerifiedFactorExists ErrorCode = "mfa_verified_factor_exists" ) diff --git a/internal/api/mfa.go b/internal/api/mfa.go index 21a2fbc66..f62a418d4 100644 --- a/internal/api/mfa.go +++ b/internal/api/mfa.go @@ -89,13 +89,37 @@ func (a *API) enrollPhoneFactor(w http.ResponseWriter, r *http.Request, params * if err := models.DeleteExpiredFactors(db, config.MFA.FactorExpiryDuration); err != nil { return err } + var factorsToDelete []models.Factor + for _, factor := range user.Factors { + switch { + case factor.FriendlyName == params.FriendlyName: + return unprocessableEntityError( + ErrorCodeMFAFactorNameConflict, + fmt.Sprintf("A factor with the friendly name %q for this user already exists", factor.FriendlyName), + ) + + case factor.IsPhoneFactor(): + if factor.Phone.String() == phone { + if factor.IsVerified() { + return unprocessableEntityError( + ErrorCodeVerifiedFactorExists, + "A verified phone factor already exists, unenroll the existing factor to continue", + ) + } else if factor.IsUnverified() { + factorsToDelete = append(factorsToDelete, factor) + } - for _, factor := range factors { - if factor.IsVerified() { - numVerifiedFactors += 1 + } + + case factor.IsVerified(): + numVerifiedFactors++ } } + if err := db.Destroy(&factorsToDelete); err != nil { + return internalServerError("Database error deleting unverified phone factors").WithInternalError(err) + } + if factorCount >= int(config.MFA.MaxEnrolledFactors) { return unprocessableEntityError(ErrorCodeTooManyEnrolledMFAFactors, "Maximum number of verified factors reached, unenroll to continue") } @@ -110,12 +134,7 @@ func (a *API) enrollPhoneFactor(w http.ResponseWriter, r *http.Request, params * factor := models.NewPhoneFactor(user, phone, params.FriendlyName) err = db.Transaction(func(tx *storage.Connection) error { if terr := tx.Create(factor); terr != nil { - pgErr := utilities.NewPostgresError(terr) - if pgErr.IsUniqueConstraintViolated() { - return unprocessableEntityError(ErrorCodeMFAFactorNameConflict, fmt.Sprintf("A factor with the friendly name %q for this user likely already exists", factor.FriendlyName)) - } return terr - } if terr := models.NewAuditLogEntry(r, tx, user, models.EnrollFactorAction, r.RemoteAddr, map[string]interface{}{ "factor_id": factor.ID, @@ -132,7 +151,7 @@ func (a *API) enrollPhoneFactor(w http.ResponseWriter, r *http.Request, params * ID: factor.ID, Type: models.Phone, FriendlyName: factor.FriendlyName, - Phone: string(factor.Phone), + Phone: params.Phone, }) } @@ -323,7 +342,7 @@ func (a *API) challengePhoneFactor(w http.ResponseWriter, r *http.Request) error return internalServerError("Failed to get SMS provider").WithInternalError(err) } // We omit messageID for now, can consider reinstating if there are requests. - if _, err = smsProvider.SendMessage(string(factor.Phone), message, channel, otp); err != nil { + if _, err = smsProvider.SendMessage(factor.Phone.String(), message, channel, otp); err != nil { return internalServerError("error sending message").WithInternalError(err) } } @@ -417,6 +436,7 @@ func (a *API) verifyTOTPFactor(w http.ResponseWriter, r *http.Request, params *V return internalServerError("Database error finding Challenge").WithInternalError(err) } + // Ambiguous so as not to leak whether there is a verified challenge if challenge.VerifiedAt != nil || challenge.IPAddress != currentIP { return unprocessableEntityError(ErrorCodeMFAIPAddressMismatch, "Challenge and verify IP addresses mismatch") } @@ -485,6 +505,7 @@ func (a *API) verifyTOTPFactor(w http.ResponseWriter, r *http.Request, params *V if terr = models.NewAuditLogEntry(r, tx, user, models.VerifyFactorAction, r.RemoteAddr, map[string]interface{}{ "factor_id": factor.ID, "challenge_id": challenge.ID, + "factor_type": factor.FactorType, }); terr != nil { return terr } @@ -524,7 +545,7 @@ func (a *API) verifyTOTPFactor(w http.ResponseWriter, r *http.Request, params *V if terr = models.InvalidateSessionsWithAALLessThan(tx, user.ID, models.AAL2.String()); terr != nil { return internalServerError("Failed to update sessions. %s", terr) } - if terr = models.DeleteUnverifiedFactors(tx, user); terr != nil { + if terr = models.DeleteUnverifiedFactors(tx, user, factor.FactorType); terr != nil { return internalServerError("Error removing unverified factors. %s", terr) } return nil @@ -643,7 +664,7 @@ func (a *API) verifyPhoneFactor(w http.ResponseWriter, r *http.Request, params * if terr = models.InvalidateSessionsWithAALLessThan(tx, user.ID, models.AAL2.String()); terr != nil { return internalServerError("Failed to update sessions. %s", terr) } - if terr = models.DeleteUnverifiedFactors(tx, user); terr != nil { + if terr = models.DeleteUnverifiedFactors(tx, user, factor.FactorType); terr != nil { return internalServerError("Error removing unverified factors. %s", terr) } return nil diff --git a/internal/api/mfa_test.go b/internal/api/mfa_test.go index d88eebb91..71691d847 100644 --- a/internal/api/mfa_test.go +++ b/internal/api/mfa_test.go @@ -198,6 +198,87 @@ func (ts *MFATestSuite) TestEnrollFactor() { } } +func (ts *MFATestSuite) TestDuplicateEnrollPhoneFactor() { + testPhoneNumber := "+12345677889" + altPhoneNumber := "+987412444444" + friendlyName := "phone_factor" + altFriendlyName := "alt_phone_factor" + token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID) + + var cases = []struct { + desc string + earlierFactorName string + laterFactorName string + phone string + secondPhone string + expectedCode int + expectedNumberOfFactors int + }{ + { + desc: "Phone: Only the latest factor should persist when enrolling two unverified phone factors with the same number", + earlierFactorName: friendlyName, + laterFactorName: altFriendlyName, + phone: testPhoneNumber, + secondPhone: testPhoneNumber, + expectedNumberOfFactors: 1, + }, + + { + desc: "Phone: Both factors should persist when enrolling two different unverified numbers", + earlierFactorName: friendlyName, + laterFactorName: altFriendlyName, + phone: testPhoneNumber, + secondPhone: altPhoneNumber, + expectedNumberOfFactors: 2, + }, + } + + for _, c := range cases { + ts.Run(c.desc, func() { + // Delete all test factors to start from clean slate + require.NoError(ts.T(), ts.API.db.Destroy(ts.TestUser.Factors)) + _ = performEnrollFlow(ts, token, c.earlierFactorName, models.Phone, ts.TestDomain, c.phone, http.StatusOK) + + w := performEnrollFlow(ts, token, c.laterFactorName, models.Phone, ts.TestDomain, c.secondPhone, http.StatusOK) + enrollResp := EnrollFactorResponse{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp)) + + laterFactor, err := models.FindFactorByFactorID(ts.API.db, enrollResp.ID) + require.NoError(ts.T(), err) + require.False(ts.T(), laterFactor.IsVerified()) + + require.NoError(ts.T(), ts.API.db.Eager("Factors").Find(ts.TestUser, ts.TestUser.ID)) + require.Equal(ts.T(), len(ts.TestUser.Factors), c.expectedNumberOfFactors) + + }) + } +} + +func (ts *MFATestSuite) TestDuplicateEnrollPhoneFactorWithVerified() { + testPhoneNumber := "+12345677889" + friendlyName := "phone_factor" + altFriendlyName := "alt_phone_factor" + token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID) + + ts.Run("Phone: Enrolling a factor with the same number as an existing verified phone factor should result in an error", func() { + require.NoError(ts.T(), ts.API.db.Destroy(ts.TestUser.Factors)) + + // Setup verified factor + w := performEnrollFlow(ts, token, friendlyName, models.Phone, ts.TestDomain, testPhoneNumber, http.StatusOK) + enrollResp := EnrollFactorResponse{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp)) + firstFactor, err := models.FindFactorByFactorID(ts.API.db, enrollResp.ID) + require.NoError(ts.T(), err) + require.NoError(ts.T(), firstFactor.UpdateStatus(ts.API.db, models.FactorStateVerified)) + + expectedStatusCode := http.StatusUnprocessableEntity + _ = performEnrollFlow(ts, token, altFriendlyName, models.Phone, ts.TestDomain, testPhoneNumber, expectedStatusCode) + + require.NoError(ts.T(), ts.API.db.Eager("Factors").Find(ts.TestUser, ts.TestUser.ID)) + require.Equal(ts.T(), len(ts.TestUser.Factors), 1) + }) +} + func (ts *MFATestSuite) TestDuplicateTOTPEnrollsReturnExpectedMessage() { friendlyName := "mary" issuer := "https://issuer.com" diff --git a/internal/models/factor.go b/internal/models/factor.go index 06a672583..14e483b53 100644 --- a/internal/models/factor.go +++ b/internal/models/factor.go @@ -117,7 +117,8 @@ func ParseAuthenticationMethod(authMethod string) (AuthenticationMethod, error) } type Factor struct { - ID uuid.UUID `json:"id" db:"id"` + ID uuid.UUID `json:"id" db:"id"` + // TODO: Consider removing this nested user field. We don't use it. User User `json:"-" belongs_to:"user"` UserID uuid.UUID `json:"-" db:"user_id"` CreatedAt time.Time `json:"created_at" db:"created_at"` @@ -196,8 +197,8 @@ func FindFactorByFactorID(conn *storage.Connection, factorID uuid.UUID) (*Factor return &factor, nil } -func DeleteUnverifiedFactors(tx *storage.Connection, user *User) error { - if err := tx.RawQuery("DELETE FROM "+(&pop.Model{Value: Factor{}}).TableName()+" WHERE user_id = ? and status = ?", user.ID, FactorStateUnverified.String()).Exec(); err != nil { +func DeleteUnverifiedFactors(tx *storage.Connection, user *User, factorType string) error { + if err := tx.RawQuery("DELETE FROM "+(&pop.Model{Value: Factor{}}).TableName()+" WHERE user_id = ? and status = ? and factor_type = ?", user.ID, FactorStateUnverified.String(), factorType).Exec(); err != nil { return err } @@ -263,6 +264,14 @@ func (f *Factor) IsVerified() bool { return f.Status == FactorStateVerified.String() } +func (f *Factor) IsUnverified() bool { + return f.Status == FactorStateUnverified.String() +} + +func (f *Factor) IsPhoneFactor() bool { + return f.FactorType == Phone +} + func (f *Factor) FindChallengeByID(conn *storage.Connection, challengeID uuid.UUID) (*Challenge, error) { var challenge Challenge err := conn.Q().Where("id = ? and factor_id = ?", challengeID, f.ID).First(&challenge) From 29cbeb799ff35ce528bfbd01b7103a24903d8061 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Mon, 5 Aug 2024 12:51:44 +0200 Subject: [PATCH 087/118] fix: add last_challenged_at field to mfa factors (#1705) ## What kind of change does this PR introduce? Deprecates `sent_at` on Challenge in favour of the `last_challenged_at` field on factors. We use this to calculate whether it's appropriate to allow for more SMS-es to be sent. Base is pointed to #1693 as it depends on the PR and diffs are smaller when pointed against #1693 --- internal/api/mfa.go | 26 +++++++------ internal/models/challenge.go | 1 - internal/models/factor.go | 38 +++++++++++++------ ...20240729123726_add_mfa_phone_config.up.sql | 2 - ...a_factors_column_last_challenged_at.up.sql | 1 + 5 files changed, 41 insertions(+), 27 deletions(-) create mode 100644 migrations/20240802193726_add_mfa_factors_column_last_challenged_at.up.sql diff --git a/internal/api/mfa.go b/internal/api/mfa.go index f62a418d4..8ad0207ec 100644 --- a/internal/api/mfa.go +++ b/internal/api/mfa.go @@ -303,26 +303,26 @@ func (a *API) challengePhoneFactor(w http.ResponseWriter, r *http.Request) error if !sms_provider.IsValidMessageChannel(channel, config) { return badRequestError(ErrorCodeValidationFailed, InvalidChannelError) } - latestValidChallenge, err := factor.FindLatestUnexpiredChallenge(a.db, config.MFA.ChallengeExpiryDuration) - if err != nil { - if !models.IsNotFoundError(err) { - return internalServerError("error finding latest unexpired challenge") + + if factor.IsPhoneFactor() && factor.LastChallengedAt != nil { + if !factor.LastChallengedAt.Add(config.MFA.Phone.MaxFrequency).Before(time.Now()) { + return tooManyRequestsError(ErrorCodeOverSMSSendRateLimit, generateFrequencyLimitErrorMessage(factor.LastChallengedAt, config.MFA.Phone.MaxFrequency)) } - } else if latestValidChallenge != nil && !latestValidChallenge.SentAt.Add(config.MFA.Phone.MaxFrequency).Before(time.Now()) { - return tooManyRequestsError(ErrorCodeOverSMSSendRateLimit, generateFrequencyLimitErrorMessage(latestValidChallenge.SentAt, config.MFA.Phone.MaxFrequency)) } otp, err := crypto.GenerateOtp(config.MFA.Phone.OtpLength) if err != nil { panic(err) } - message, err := generateSMSFromTemplate(config.MFA.Phone.SMSTemplate, otp) - if err != nil { - return internalServerError("error generating sms template").WithInternalError(err) - } challenge, err := factor.CreatePhoneChallenge(ipAddress, otp, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey) if err != nil { return internalServerError("error creating SMS Challenge") } + + message, err := generateSMSFromTemplate(config.MFA.Phone.SMSTemplate, otp) + if err != nil { + return internalServerError("error generating sms template").WithInternalError(err) + } + if config.Hook.SendSMS.Enabled { input := hooks.SendSMSInput{ User: user, @@ -347,9 +347,10 @@ func (a *API) challengePhoneFactor(w http.ResponseWriter, r *http.Request) error } } if err := db.Transaction(func(tx *storage.Connection) error { - if terr := tx.Create(challenge); terr != nil { + if terr := factor.WriteChallengeToDatabase(tx, challenge); terr != nil { return terr } + if terr := models.NewAuditLogEntry(r, tx, user, models.CreateChallengeAction, r.RemoteAddr, map[string]interface{}{ "factor_id": factor.ID, "factor_status": factor.Status, @@ -376,8 +377,9 @@ func (a *API) challengeTOTPFactor(w http.ResponseWriter, r *http.Request) error ipAddress := utilities.GetIPAddress(r) challenge := factor.CreateChallenge(ipAddress) + if err := db.Transaction(func(tx *storage.Connection) error { - if terr := tx.Create(challenge); terr != nil { + if terr := factor.WriteChallengeToDatabase(tx, challenge); terr != nil { return terr } if terr := models.NewAuditLogEntry(r, tx, user, models.CreateChallengeAction, r.RemoteAddr, map[string]interface{}{ diff --git a/internal/models/challenge.go b/internal/models/challenge.go index 57a970061..24d334586 100644 --- a/internal/models/challenge.go +++ b/internal/models/challenge.go @@ -15,7 +15,6 @@ type Challenge struct { IPAddress string `json:"ip_address" db:"ip_address"` Factor *Factor `json:"factor,omitempty" belongs_to:"factor"` OtpCode string `json:"otp_code,omitempty" db:"otp_code"` - SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"` } func (Challenge) TableName() string { diff --git a/internal/models/factor.go b/internal/models/factor.go index 14e483b53..7c6f6dd30 100644 --- a/internal/models/factor.go +++ b/internal/models/factor.go @@ -119,16 +119,17 @@ func ParseAuthenticationMethod(authMethod string) (AuthenticationMethod, error) type Factor struct { ID uuid.UUID `json:"id" db:"id"` // TODO: Consider removing this nested user field. We don't use it. - User User `json:"-" belongs_to:"user"` - UserID uuid.UUID `json:"-" db:"user_id"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` - Status string `json:"status" db:"status"` - FriendlyName string `json:"friendly_name,omitempty" db:"friendly_name"` - Secret string `json:"-" db:"secret"` - FactorType string `json:"factor_type" db:"factor_type"` - Challenge []Challenge `json:"-" has_many:"challenges"` - Phone storage.NullString `json:"phone" db:"phone"` + User User `json:"-" belongs_to:"user"` + UserID uuid.UUID `json:"-" db:"user_id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + Status string `json:"status" db:"status"` + FriendlyName string `json:"friendly_name,omitempty" db:"friendly_name"` + Secret string `json:"-" db:"secret"` + FactorType string `json:"factor_type" db:"factor_type"` + Challenge []Challenge `json:"-" has_many:"challenges"` + Phone storage.NullString `json:"phone" db:"phone"` + LastChallengedAt *time.Time `json:"last_challenged_at" db:"last_challenged_at"` } func (Factor) TableName() string { @@ -212,16 +213,29 @@ func (f *Factor) CreateChallenge(ipAddress string) *Challenge { FactorID: f.ID, IPAddress: ipAddress, } + return challenge } +func (f *Factor) WriteChallengeToDatabase(tx *storage.Connection, challenge *Challenge) error { + if challenge.FactorID != f.ID { + return errors.New("Can only write challenges that you own") + } + now := time.Now() + f.LastChallengedAt = &now + if terr := tx.Create(challenge); terr != nil { + return terr + } + if err := tx.UpdateOnly(f, "last_challenged_at"); err != nil { + return err + } + return nil +} func (f *Factor) CreatePhoneChallenge(ipAddress string, otpCode string, encrypt bool, encryptionKeyID, encryptionKey string) (*Challenge, error) { phoneChallenge := f.CreateChallenge(ipAddress) if err := phoneChallenge.SetOtpCode(otpCode, encrypt, encryptionKeyID, encryptionKey); err != nil { return nil, err } - now := time.Now() - phoneChallenge.SentAt = &now return phoneChallenge, nil } diff --git a/migrations/20240729123726_add_mfa_phone_config.up.sql b/migrations/20240729123726_add_mfa_phone_config.up.sql index cd52973ed..ec94d7bcb 100644 --- a/migrations/20240729123726_add_mfa_phone_config.up.sql +++ b/migrations/20240729123726_add_mfa_phone_config.up.sql @@ -6,9 +6,7 @@ end $$; alter table {{ index .Options "Namespace" }}.mfa_factors add column if not exists phone text unique default null; -alter table {{ index .Options "Namespace" }}.mfa_challenges add column if not exists sent_at timestamptz null; alter table {{ index .Options "Namespace" }}.mfa_challenges add column if not exists otp_code text null; -create index if not exists idx_sent_at on {{ index .Options "Namespace" }}.mfa_challenges(sent_at); create unique index if not exists unique_verified_phone_factor on {{ index .Options "Namespace" }}.mfa_factors (user_id, phone); diff --git a/migrations/20240802193726_add_mfa_factors_column_last_challenged_at.up.sql b/migrations/20240802193726_add_mfa_factors_column_last_challenged_at.up.sql new file mode 100644 index 000000000..bc3eea989 --- /dev/null +++ b/migrations/20240802193726_add_mfa_factors_column_last_challenged_at.up.sql @@ -0,0 +1 @@ +alter table {{ index .Options "Namespace" }}.mfa_factors add column if not exists last_challenged_at timestamptz unique default null; From e1a21a34779ca4b2254caf8b7578db4a50172751 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Mon, 5 Aug 2024 13:32:18 +0200 Subject: [PATCH 088/118] fix: expose factor type on challenge (#1709) ## What kind of change does this PR introduce? Expose factor type on challenge so that developers can know whether it is a phone or TOTP factor they have challenged and redirect to appropriate page or perform relevant action Also helps us distinguish between the two so we can prevent unintended behaviour (e.g. the use of `challengeAndVerify` with a phone factor) As typescript follow structural typing I think addition of a new field should be fine. We will test this before proceeding. However, putting it out there first for early review or consideration Update - tested and should be fine --- internal/api/mfa.go | 3 +++ internal/api/mfa_test.go | 11 ++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/internal/api/mfa.go b/internal/api/mfa.go index 8ad0207ec..35bdd193e 100644 --- a/internal/api/mfa.go +++ b/internal/api/mfa.go @@ -57,6 +57,7 @@ type VerifyFactorParams struct { type ChallengeFactorResponse struct { ID uuid.UUID `json:"id"` + Type string `json:"type"` ExpiresAt int64 `json:"expires_at"` } @@ -363,6 +364,7 @@ func (a *API) challengePhoneFactor(w http.ResponseWriter, r *http.Request) error } return sendJSON(w, http.StatusOK, &ChallengeFactorResponse{ ID: challenge.ID, + Type: factor.FactorType, ExpiresAt: challenge.GetExpiryTime(config.MFA.ChallengeExpiryDuration).Unix(), }) } @@ -395,6 +397,7 @@ func (a *API) challengeTOTPFactor(w http.ResponseWriter, r *http.Request) error return sendJSON(w, http.StatusOK, &ChallengeFactorResponse{ ID: challenge.ID, + Type: factor.FactorType, ExpiresAt: challenge.GetExpiryTime(config.MFA.ChallengeExpiryDuration).Unix(), }) } diff --git a/internal/api/mfa_test.go b/internal/api/mfa_test.go index 71691d847..25a5d47b8 100644 --- a/internal/api/mfa_test.go +++ b/internal/api/mfa_test.go @@ -322,11 +322,17 @@ func (ts *MFATestSuite) TestMultipleEnrollsCleanupExpiredFactors() { require.Equal(ts.T(), 3, len(ts.TestUser.Factors)) } -func (ts *MFATestSuite) TestChallengeFactor() { +func (ts *MFATestSuite) TestChallengeTOTPFactor() { + // Test Factor is a TOTP Factor f := ts.TestUser.Factors[0] token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID) w := performChallengeFlow(ts, f.ID, token) + challengeResp := ChallengeFactorResponse{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&challengeResp)) + require.Equal(ts.T(), http.StatusOK, w.Code) + require.Equal(ts.T(), challengeResp.Type, models.TOTP) + } func (ts *MFATestSuite) TestChallengeSMSFactor() { @@ -372,6 +378,9 @@ func (ts *MFATestSuite) TestChallengeSMSFactor() { for _, tc := range cases { ts.Run(tc.desc, func() { w := performSMSChallengeFlow(ts, f.ID, token, tc.channel) + challengeResp := ChallengeFactorResponse{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&challengeResp)) + require.Equal(ts.T(), challengeResp.Type, models.Phone) require.Equal(ts.T(), tc.expectedCode, w.Code, tc.desc) }) } From 499f6fca024260c4457ec1d9cae32726804e92d3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 5 Aug 2024 13:32:39 +0200 Subject: [PATCH 089/118] chore(master): release 2.158.1 (#1699) :robot: I have created a release *beep* *boop* --- ## [2.158.1](https://github.com/supabase/auth/compare/v2.158.0...v2.158.1) (2024-08-05) ### Bug Fixes * add last_challenged_at field to mfa factors ([#1705](https://github.com/supabase/auth/issues/1705)) ([29cbeb7](https://github.com/supabase/auth/commit/29cbeb799ff35ce528bfbd01b7103a24903d8061)) * allow enabling sms hook without setting up sms provider ([#1704](https://github.com/supabase/auth/issues/1704)) ([575e88a](https://github.com/supabase/auth/commit/575e88ac345adaeb76ab6aae077307fdab9cda3c)) * drop the MFA_ENABLED config ([#1701](https://github.com/supabase/auth/issues/1701)) ([078c3a8](https://github.com/supabase/auth/commit/078c3a8adcd51e57b68ab1b582549f5813cccd14)) * enforce uniqueness on verified phone numbers ([#1693](https://github.com/supabase/auth/issues/1693)) ([70446cc](https://github.com/supabase/auth/commit/70446cc11d70b0493d742fe03f272330bb5b633e)) * expose `X-Supabase-Api-Version` header in CORS ([#1612](https://github.com/supabase/auth/issues/1612)) ([6ccd814](https://github.com/supabase/auth/commit/6ccd814309dca70a9e3585543887194b05d725d3)) * include factor_id in query ([#1702](https://github.com/supabase/auth/issues/1702)) ([ac14e82](https://github.com/supabase/auth/commit/ac14e82b33545466184da99e99b9d3fe5f3876d9)) * move is owned by check to load factor ([#1703](https://github.com/supabase/auth/issues/1703)) ([701a779](https://github.com/supabase/auth/commit/701a779cf092e777dd4ad4954dc650164b09ab32)) * refactor TOTP MFA into separate methods ([#1698](https://github.com/supabase/auth/issues/1698)) ([250d92f](https://github.com/supabase/auth/commit/250d92f9a18d38089d1bf262ef9088022a446965)) * remove check for content-length ([#1700](https://github.com/supabase/auth/issues/1700)) ([81b332d](https://github.com/supabase/auth/commit/81b332d2f48622008469d2c5a9b130465a65f2a3)) * remove FindFactorsByUser ([#1707](https://github.com/supabase/auth/issues/1707)) ([af8e2dd](https://github.com/supabase/auth/commit/af8e2dda15a1234a05e7d2d34d316eaa029e0912)) * update openapi spec for MFA (Phone) ([#1689](https://github.com/supabase/auth/issues/1689)) ([a3da4b8](https://github.com/supabase/auth/commit/a3da4b89820c37f03ea128889616aca598d99f68)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b769e7d84..e0f867150 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [2.158.1](https://github.com/supabase/auth/compare/v2.158.0...v2.158.1) (2024-08-05) + + +### Bug Fixes + +* add last_challenged_at field to mfa factors ([#1705](https://github.com/supabase/auth/issues/1705)) ([29cbeb7](https://github.com/supabase/auth/commit/29cbeb799ff35ce528bfbd01b7103a24903d8061)) +* allow enabling sms hook without setting up sms provider ([#1704](https://github.com/supabase/auth/issues/1704)) ([575e88a](https://github.com/supabase/auth/commit/575e88ac345adaeb76ab6aae077307fdab9cda3c)) +* drop the MFA_ENABLED config ([#1701](https://github.com/supabase/auth/issues/1701)) ([078c3a8](https://github.com/supabase/auth/commit/078c3a8adcd51e57b68ab1b582549f5813cccd14)) +* enforce uniqueness on verified phone numbers ([#1693](https://github.com/supabase/auth/issues/1693)) ([70446cc](https://github.com/supabase/auth/commit/70446cc11d70b0493d742fe03f272330bb5b633e)) +* expose `X-Supabase-Api-Version` header in CORS ([#1612](https://github.com/supabase/auth/issues/1612)) ([6ccd814](https://github.com/supabase/auth/commit/6ccd814309dca70a9e3585543887194b05d725d3)) +* include factor_id in query ([#1702](https://github.com/supabase/auth/issues/1702)) ([ac14e82](https://github.com/supabase/auth/commit/ac14e82b33545466184da99e99b9d3fe5f3876d9)) +* move is owned by check to load factor ([#1703](https://github.com/supabase/auth/issues/1703)) ([701a779](https://github.com/supabase/auth/commit/701a779cf092e777dd4ad4954dc650164b09ab32)) +* refactor TOTP MFA into separate methods ([#1698](https://github.com/supabase/auth/issues/1698)) ([250d92f](https://github.com/supabase/auth/commit/250d92f9a18d38089d1bf262ef9088022a446965)) +* remove check for content-length ([#1700](https://github.com/supabase/auth/issues/1700)) ([81b332d](https://github.com/supabase/auth/commit/81b332d2f48622008469d2c5a9b130465a65f2a3)) +* remove FindFactorsByUser ([#1707](https://github.com/supabase/auth/issues/1707)) ([af8e2dd](https://github.com/supabase/auth/commit/af8e2dda15a1234a05e7d2d34d316eaa029e0912)) +* update openapi spec for MFA (Phone) ([#1689](https://github.com/supabase/auth/issues/1689)) ([a3da4b8](https://github.com/supabase/auth/commit/a3da4b89820c37f03ea128889616aca598d99f68)) + ## [2.158.0](https://github.com/supabase/auth/compare/v2.157.0...v2.158.0) (2024-07-31) From 92409eaf940de07ac822685661d49b34c6b5ec66 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Tue, 6 Aug 2024 08:37:33 -0700 Subject: [PATCH 090/118] chore: remove unused hook outputs (#1712) ## What kind of change does this PR introduce? * Remove unused "Success" field in the hook outputs for "Send SMS" and "Send Email" ## What is the current behavior? Please link any relevant issues here. ## What is the new behavior? Feel free to include screenshots if it includes visual changes. ## Additional context Add any other context or screenshots. --- internal/api/hooks_test.go | 61 ++++++++++++++++++------------------ internal/hooks/auth_hooks.go | 2 -- 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/internal/api/hooks_test.go b/internal/api/hooks_test.go index 034dbeb87..c78ce5f2f 100644 --- a/internal/api/hooks_test.go +++ b/internal/api/hooks_test.go @@ -58,6 +58,7 @@ func (ts *HooksTestSuite) SetupTest() { } func (ts *HooksTestSuite) TestRunHTTPHook() { + // setup mock requests for hooks defer gock.OffAll() input := hooks.SendSMSInput{ @@ -66,62 +67,62 @@ func (ts *HooksTestSuite) TestRunHTTPHook() { OTP: "123456", }, } - successOutput := hooks.SendSMSOutput{Success: true} testURL := "http://localhost:54321/functions/v1/custom-sms-sender" ts.Config.Hook.SendSMS.URI = testURL + unsuccessfulResponse := hooks.AuthHookError{ + HTTPCode: http.StatusUnprocessableEntity, + Message: "test error", + } + testCases := []struct { description string - mockResponse interface{} - status int expectError bool + mockResponse hooks.AuthHookError }{ { - description: "Successful Post request with delay", - mockResponse: successOutput, - status: http.StatusOK, + description: "Hook returns success", expectError: false, + mockResponse: hooks.AuthHookError{}, }, { - description: "Too many requests without retry header should not retry", - status: http.StatusUnprocessableEntity, - expectError: true, + description: "Hook returns error", + expectError: true, + mockResponse: unsuccessfulResponse, }, } - for _, tc := range testCases { - ts.Run(tc.description, func() { - if tc.status == http.StatusOK { - gock.New(ts.Config.Hook.SendSMS.URI). - Post("/"). - MatchType("json"). - Reply(tc.status). - JSON(tc.mockResponse).SetHeader("content-length", "21") - } else { - gock.New(ts.Config.Hook.SendSMS.URI). - Post("/"). - MatchType("json"). - Reply(tc.status). - JSON(tc.mockResponse) + gock.New(ts.Config.Hook.SendSMS.URI). + Post("/"). + MatchType("json"). + Reply(http.StatusOK). + JSON(hooks.SendSMSOutput{}) - } + gock.New(ts.Config.Hook.SendSMS.URI). + Post("/"). + MatchType("json"). + Reply(http.StatusUnprocessableEntity). + JSON(hooks.SendSMSOutput{HookError: unsuccessfulResponse}) + for _, tc := range testCases { + ts.Run(tc.description, func() { req, _ := http.NewRequest("POST", ts.Config.Hook.SendSMS.URI, nil) body, err := ts.API.runHTTPHook(req, ts.Config.Hook.SendSMS, &input) if !tc.expectError { require.NoError(ts.T(), err) + } else { + require.Error(ts.T(), err) if body != nil { var output hooks.SendSMSOutput require.NoError(ts.T(), json.Unmarshal(body, &output)) - require.True(ts.T(), output.Success) + require.Equal(ts.T(), unsuccessfulResponse.HTTPCode, output.HookError.HTTPCode) + require.Equal(ts.T(), unsuccessfulResponse.Message, output.HookError.Message) } - } else { - require.Error(ts.T(), err) } - require.True(ts.T(), gock.IsDone()) }) } + require.True(ts.T(), gock.IsDone()) } func (ts *HooksTestSuite) TestShouldRetryWithRetryAfterHeader() { @@ -133,7 +134,6 @@ func (ts *HooksTestSuite) TestShouldRetryWithRetryAfterHeader() { OTP: "123456", }, } - successOutput := hooks.SendSMSOutput{Success: true} testURL := "http://localhost:54321/functions/v1/custom-sms-sender" ts.Config.Hook.SendSMS.URI = testURL @@ -148,7 +148,7 @@ func (ts *HooksTestSuite) TestShouldRetryWithRetryAfterHeader() { Post("/"). MatchType("json"). Reply(http.StatusOK). - JSON(successOutput).SetHeader("content-type", "application/json") + JSON(hooks.SendSMSOutput{}).SetHeader("content-type", "application/json") // Simulate the original HTTP request which triggered the hook req, err := http.NewRequest("POST", "http://localhost:9998/otp", nil) @@ -160,7 +160,6 @@ func (ts *HooksTestSuite) TestShouldRetryWithRetryAfterHeader() { var output hooks.SendSMSOutput err = json.Unmarshal(body, &output) require.NoError(ts.T(), err, "Unmarshal should not fail") - require.True(ts.T(), output.Success, "Expected success on retry") // Ensure that all expected HTTP interactions (mocks) have been called require.True(ts.T(), gock.IsDone(), "Expected all mocks to have been called including retry") diff --git a/internal/hooks/auth_hooks.go b/internal/hooks/auth_hooks.go index 1a08d046e..1b881d36f 100644 --- a/internal/hooks/auth_hooks.go +++ b/internal/hooks/auth_hooks.go @@ -153,7 +153,6 @@ type SendSMSInput struct { } type SendSMSOutput struct { - Success bool `json:"success"` HookError AuthHookError `json:"error,omitempty"` } @@ -163,7 +162,6 @@ type SendEmailInput struct { } type SendEmailOutput struct { - Success bool `json:"success"` HookError AuthHookError `json:"error,omitempty"` } From b9bc769b93b6e700925fcbc1ebf8bf9678034205 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Wed, 7 Aug 2024 08:06:38 +0400 Subject: [PATCH 091/118] fix: change phone constraint to per user (#1713) ## What kind of change does this PR introduce? Currently, the phone constraint only allows for one phone number across all users. Changes to make phone numbers unique per user. Also renames the constraint on phone factors to reflect that it applies to all factors rather than verified factors. --- ...073726_drop_uniqueness_constraint_on_phone.up.sql | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 migrations/20240806073726_drop_uniqueness_constraint_on_phone.up.sql diff --git a/migrations/20240806073726_drop_uniqueness_constraint_on_phone.up.sql b/migrations/20240806073726_drop_uniqueness_constraint_on_phone.up.sql new file mode 100644 index 000000000..644e58fdc --- /dev/null +++ b/migrations/20240806073726_drop_uniqueness_constraint_on_phone.up.sql @@ -0,0 +1,12 @@ +alter table {{ index .Options "Namespace" }}.mfa_factors drop constraint if exists mfa_factors_phone_key; +do $$ +begin + if exists ( + select 1 + from pg_indexes + where indexname = 'unique_verified_phone_factor' + and schemaname = '{{ index .Options "Namespace" }}' + ) then + execute 'alter index {{ index .Options "Namespace" }}.unique_verified_phone_factor rename to unique_phone_factor_per_user'; + end if; +end $$; From 4b043275dd2d94600a8138d4ebf4638754ed926b Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Fri, 9 Aug 2024 16:22:05 +0800 Subject: [PATCH 092/118] fix: remove TOTP field for phone enroll response (#1717) ## What kind of change does this PR introduce? Make TOTPObject a pointer so that it is not returned in the case of an `EnrollResponse` for phone --- internal/api/mfa.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/api/mfa.go b/internal/api/mfa.go index 35bdd193e..ea3894d68 100644 --- a/internal/api/mfa.go +++ b/internal/api/mfa.go @@ -39,11 +39,11 @@ type TOTPObject struct { } type EnrollFactorResponse struct { - ID uuid.UUID `json:"id"` - Type string `json:"type"` - FriendlyName string `json:"friendly_name"` - TOTP TOTPObject `json:"totp,omitempty"` - Phone string `json:"phone,omitempty"` + ID uuid.UUID `json:"id"` + Type string `json:"type"` + FriendlyName string `json:"friendly_name"` + TOTP *TOTPObject `json:"totp,omitempty"` + Phone string `json:"phone,omitempty"` } type ChallengeFactorParams struct { @@ -246,7 +246,7 @@ func (a *API) enrollTOTPFactor(w http.ResponseWriter, r *http.Request, params *E ID: factor.ID, Type: models.TOTP, FriendlyName: factor.FriendlyName, - TOTP: TOTPObject{ + TOTP: &TOTPObject{ // See: https://css-tricks.com/probably-dont-base64-svg/ QRCode: buf.String(), Secret: key.Secret(), From 435122627a0784f1c5cb76d7e08caa1f6259423b Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Thu, 15 Aug 2024 17:26:42 -0400 Subject: [PATCH 093/118] fix: add error codes to password login flow (#1721) ## What kind of change does this PR introduce? * Add error codes to the password login flow, which fixes #1631 ## What is the current behavior? * Most errors in the password login flow are returned as `oauthError`, which doesn't have support for an error code as the struct conforms to the [oauth error response](https://datatracker.ietf.org/doc/html/rfc6749#section-5.2) specified in the RFC ## What is the new behavior? * Errors which were previously returned as an `oauthError` struct now return `badRequestError` instead with the following error code `invalid_login_credentials` * In certain cases, we can return existing error codes like `ErrorCodeUserBanned`, `ErrorCodeEmailNotConfirmed`, `ErrorCodePhoneNotConfirmed` or `ErrorCodeValidationFailed` * Some error messages are updated to provide more clarity on the underlying error Feel free to include screenshots if it includes visual changes. ## Additional context Add any other context or screenshots. --- internal/api/errorcodes.go | 4 +++- internal/api/mfa.go | 2 +- internal/api/token.go | 24 ++++++++++++------------ internal/conf/configuration.go | 4 ++-- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/internal/api/errorcodes.go b/internal/api/errorcodes.go index b400f41f0..a37a4513a 100644 --- a/internal/api/errorcodes.go +++ b/internal/api/errorcodes.go @@ -81,5 +81,7 @@ const ( ErrorCodeMFAPhoneVerifyDisabled ErrorCode = "mfa_phone_verify_not_enabled" ErrorCodeMFATOTPEnrollDisabled ErrorCode = "mfa_totp_enroll_not_enabled" ErrorCodeMFATOTPVerifyDisabled ErrorCode = "mfa_totp_verify_not_enabled" - ErrorCodeVerifiedFactorExists ErrorCode = "mfa_verified_factor_exists" + ErrorCodeMFAVerifiedFactorExists ErrorCode = "mfa_verified_factor_exists" + //#nosec G101 -- Not a secret value. + ErrorCodeInvalidCredentials ErrorCode = "invalid_credentials" ) diff --git a/internal/api/mfa.go b/internal/api/mfa.go index ea3894d68..70c263fba 100644 --- a/internal/api/mfa.go +++ b/internal/api/mfa.go @@ -103,7 +103,7 @@ func (a *API) enrollPhoneFactor(w http.ResponseWriter, r *http.Request, params * if factor.Phone.String() == phone { if factor.IsVerified() { return unprocessableEntityError( - ErrorCodeVerifiedFactorExists, + ErrorCodeMFAVerifiedFactorExists, "A verified phone factor already exists, unenroll the existing factor to continue", ) } else if factor.IsUnverified() { diff --git a/internal/api/token.go b/internal/api/token.go index f65ec9a67..f9dc89829 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -91,7 +91,7 @@ func (a *API) Token(w http.ResponseWriter, r *http.Request) error { case "pkce": return a.PKCE(ctx, w, r) default: - return oauthError("unsupported_grant_type", "") + return badRequestError(ErrorCodeInvalidCredentials, "unsupported_grant_type") } } @@ -131,22 +131,22 @@ func (a *API) ResourceOwnerPasswordGrant(ctx context.Context, w http.ResponseWri params.Phone = formatPhoneNumber(params.Phone) user, err = models.FindUserByPhoneAndAudience(db, params.Phone, aud) } else { - return oauthError("invalid_grant", InvalidLoginMessage) + return badRequestError(ErrorCodeValidationFailed, "missing email or phone") } if err != nil { if models.IsNotFoundError(err) { - return oauthError("invalid_grant", InvalidLoginMessage) + return badRequestError(ErrorCodeInvalidCredentials, InvalidLoginMessage) } return internalServerError("Database error querying schema").WithInternalError(err) } if !user.HasPassword() { - return oauthError("invalid_grant", InvalidLoginMessage) + return badRequestError(ErrorCodeInvalidCredentials, InvalidLoginMessage) } if user.IsBanned() { - return oauthError("invalid_grant", InvalidLoginMessage) + return badRequestError(ErrorCodeUserBanned, "User is banned") } isValidPassword, shouldReEncrypt, err := user.Authenticate(ctx, db, params.Password, config.Security.DBEncryption.DecryptionKeys, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID) @@ -185,8 +185,7 @@ func (a *API) ResourceOwnerPasswordGrant(ctx context.Context, w http.ResponseWri Valid: isValidPassword, } output := hooks.PasswordVerificationAttemptOutput{} - err := a.invokeHook(nil, r, &input, &output) - if err != nil { + if err := a.invokeHook(nil, r, &input, &output); err != nil { return err } @@ -199,17 +198,17 @@ func (a *API) ResourceOwnerPasswordGrant(ctx context.Context, w http.ResponseWri return err } } - return oauthError("invalid_grant", InvalidLoginMessage) + return badRequestError(ErrorCodeInvalidCredentials, output.Message) } } if !isValidPassword { - return oauthError("invalid_grant", InvalidLoginMessage) + return badRequestError(ErrorCodeInvalidCredentials, InvalidLoginMessage) } if params.Email != "" && !user.IsConfirmed() { - return oauthError("invalid_grant", "Email not confirmed") + return badRequestError(ErrorCodeEmailNotConfirmed, "Email not confirmed") } else if params.Phone != "" && !user.IsPhoneConfirmed() { - return oauthError("invalid_grant", "Phone not confirmed") + return badRequestError(ErrorCodePhoneNotConfirmed, "Phone not confirmed") } var token *AccessTokenResponse @@ -292,7 +291,8 @@ func (a *API) PKCE(ctx context.Context, w http.ResponseWriter, r *http.Request) } token, terr = a.issueRefreshToken(r, tx, user, authMethod, grantParams) if terr != nil { - return oauthError("server_error", terr.Error()) + // error type is already handled in issueRefreshToken + return terr } token.ProviderAccessToken = flowState.ProviderAccessToken // Because not all providers give out a refresh token diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 1f81f6b7a..21216fedb 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -732,7 +732,7 @@ func (config *GlobalConfiguration) ApplyDefaults() error { config.JWT.AdminGroupName = "admin" } - if config.JWT.AdminRoles == nil || len(config.JWT.AdminRoles) == 0 { + if len(config.JWT.AdminRoles) == 0 { config.JWT.AdminRoles = []string{"service_role", "supabase_admin"} } @@ -740,7 +740,7 @@ func (config *GlobalConfiguration) ApplyDefaults() error { config.JWT.Exp = 3600 } - if config.JWT.Keys == nil || len(config.JWT.Keys) == 0 { + if len(config.JWT.Keys) == 0 { // transform the secret into a JWK for consistency privKey, err := jwk.FromRaw([]byte(config.JWT.Secret)) if err != nil { From b2b11239dc9f9bd3c85d76f6c23ee94beb3330bb Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Fri, 16 Aug 2024 09:16:10 -0400 Subject: [PATCH 094/118] fix: redirect invalid state errors to site url (#1722) --- internal/api/api.go | 2 +- internal/api/external.go | 20 +++++++++++++++----- internal/api/external_oauth.go | 28 ++++++++++++++++------------ internal/api/external_test.go | 2 +- internal/api/samlacs.go | 19 ++++++++++++++++--- 5 files changed, 49 insertions(+), 22 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index 85292775f..49b810696 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -274,7 +274,7 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne tollbooth.NewLimiter(api.config.SAML.RateLimitAssertion/(60*5), &limiter.ExpirableOptions{ DefaultExpirationTTL: time.Hour, }).SetBurst(30), - )).Post("/acs", api.SAMLACS) + )).Post("/acs", api.SamlAcs) }) }) diff --git a/internal/api/external.go b/internal/api/external.go index ef6032d9a..4df4c6502 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -138,7 +138,7 @@ func (a *API) ExternalProviderCallback(w http.ResponseWriter, r *http.Request) e if err != nil { return err } - a.redirectErrors(a.internalExternalProviderCallback, w, r, u) + redirectErrors(a.internalExternalProviderCallback, w, r, u) return nil } @@ -478,7 +478,17 @@ func (a *API) processInvite(r *http.Request, tx *storage.Connection, userData *p return user, nil } -func (a *API) loadExternalState(ctx context.Context, state string) (context.Context, error) { +func (a *API) loadExternalState(ctx context.Context, r *http.Request) (context.Context, error) { + var state string + switch r.Method { + case http.MethodPost: + state = r.FormValue("state") + default: + state = r.URL.Query().Get("state") + } + if state == "" { + return ctx, badRequestError(ErrorCodeBadOAuthCallback, "OAuth state parameter missing") + } config := a.config claims := ExternalProviderClaims{} p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) @@ -486,10 +496,10 @@ func (a *API) loadExternalState(ctx context.Context, state string) (context.Cont return []byte(config.JWT.Secret), nil }) if err != nil { - return nil, badRequestError(ErrorCodeBadOAuthState, "OAuth callback with invalid state").WithInternalError(err) + return ctx, badRequestError(ErrorCodeBadOAuthState, "OAuth callback with invalid state").WithInternalError(err) } if claims.Provider == "" { - return nil, badRequestError(ErrorCodeBadOAuthState, "OAuth callback with invalid state (missing provider)") + return ctx, badRequestError(ErrorCodeBadOAuthState, "OAuth callback with invalid state (missing provider)") } if claims.InviteToken != "" { ctx = withInviteToken(ctx, claims.InviteToken) @@ -573,7 +583,7 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide } } -func (a *API) redirectErrors(handler apiHandler, w http.ResponseWriter, r *http.Request, u *url.URL) { +func redirectErrors(handler apiHandler, w http.ResponseWriter, r *http.Request, u *url.URL) { ctx := r.Context() log := observability.GetLogEntry(r).Entry errorID := utilities.GetRequestID(ctx) diff --git a/internal/api/external_oauth.go b/internal/api/external_oauth.go index af3dd51f4..cb098e373 100644 --- a/internal/api/external_oauth.go +++ b/internal/api/external_oauth.go @@ -10,6 +10,7 @@ import ( "github.com/sirupsen/logrus" "github.com/supabase/auth/internal/api/provider" "github.com/supabase/auth/internal/observability" + "github.com/supabase/auth/internal/utilities" ) // OAuthProviderData contains the userData and token returned by the oauth provider @@ -23,17 +24,6 @@ type OAuthProviderData struct { // loadFlowState parses the `state` query parameter as a JWS payload, // extracting the provider requested func (a *API) loadFlowState(w http.ResponseWriter, r *http.Request) (context.Context, error) { - var state string - if r.Method == http.MethodPost { - state = r.FormValue("state") - } else { - state = r.URL.Query().Get("state") - } - - if state == "" { - return nil, badRequestError(ErrorCodeBadOAuthCallback, "OAuth state parameter missing") - } - ctx := r.Context() oauthToken := r.URL.Query().Get("oauth_token") if oauthToken != "" { @@ -43,7 +33,21 @@ func (a *API) loadFlowState(w http.ResponseWriter, r *http.Request) (context.Con if oauthVerifier != "" { ctx = withOAuthVerifier(ctx, oauthVerifier) } - return a.loadExternalState(ctx, state) + + var err error + ctx, err = a.loadExternalState(ctx, r) + if err != nil { + u, uerr := url.ParseRequestURI(a.config.SiteURL) + if uerr != nil { + return ctx, internalServerError("site url is improperly formatted").WithInternalError(uerr) + } + + q := getErrorQueryString(err, utilities.GetRequestID(ctx), observability.GetLogEntry(r).Entry, u.Query()) + u.RawQuery = q.Encode() + + http.Redirect(w, r, u.String(), http.StatusSeeOther) + } + return ctx, err } func (a *API) oAuthCallback(ctx context.Context, r *http.Request, providerType string) (*OAuthProviderData, error) { diff --git a/internal/api/external_test.go b/internal/api/external_test.go index 09fdcc433..bef89d736 100644 --- a/internal/api/external_test.go +++ b/internal/api/external_test.go @@ -235,7 +235,7 @@ func (ts *ExternalTestSuite) TestRedirectErrorsShouldPreserveParams() { parsedURL, err := url.Parse(c.RedirectURL) require.Equal(ts.T(), err, nil) - ts.API.redirectErrors(ts.API.internalExternalProviderCallback, w, req, parsedURL) + redirectErrors(ts.API.internalExternalProviderCallback, w, req, parsedURL) parsedParams, err := url.ParseQuery(parsedURL.RawQuery) require.Equal(ts.T(), err, nil) diff --git a/internal/api/samlacs.go b/internal/api/samlacs.go index 0916a7235..907efcd4c 100644 --- a/internal/api/samlacs.go +++ b/internal/api/samlacs.go @@ -43,8 +43,22 @@ func IsSAMLMetadataStale(idpMetadata *saml.EntityDescriptor, samlProvider models return hasValidityExpired || hasCacheDurationExceeded || needsForceUpdate } -// SAMLACS implements the main Assertion Consumer Service endpoint behavior. -func (a *API) SAMLACS(w http.ResponseWriter, r *http.Request) error { +func (a *API) SamlAcs(w http.ResponseWriter, r *http.Request) error { + if err := a.handleSamlAcs(w, r); err != nil { + u, uerr := url.Parse(a.config.SiteURL) + if uerr != nil { + return internalServerError("site url is improperly formattted").WithInternalError(err) + } + + q := getErrorQueryString(err, utilities.GetRequestID(r.Context()), observability.GetLogEntry(r).Entry, u.Query()) + u.RawQuery = q.Encode() + http.Redirect(w, r, u.String(), http.StatusSeeOther) + } + return nil +} + +// handleSamlAcs implements the main Assertion Consumer Service endpoint behavior. +func (a *API) handleSamlAcs(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) @@ -61,7 +75,6 @@ func (a *API) SAMLACS(w http.ResponseWriter, r *http.Request) error { var requestIds []string var flowState *models.FlowState - flowState = nil if relayStateUUID != uuid.Nil { // relay state is a valid UUID, therefore this is likely a SP initiated flow From 0658bbeebc5b57cbb20787cc16acb4bd16aae013 Mon Sep 17 00:00:00 2001 From: hiteshbedre <32206192+hiteshbedre@users.noreply.github.com> Date: Mon, 19 Aug 2024 20:13:01 +0530 Subject: [PATCH 095/118] chore: updated regex for provider name (#1723) ## What kind of change does this PR introduce? Bug fix ## What is the current behavior? A valid provider name gets invalidated. ## What is the new behavior? A possible valid provider name gets verified by regex correctly. ## Additional context `[^a]+` -> This regex will match any char except character `a` `^a$` -> This regex will match string have single char `a` `^[a-zA-Z0-9]+$` -> This regex will match any alphanumeric string. `^` denotes start of string and `$` denotes end of string. This pull request will address https://github.com/supabase/auth/issues/1719 --- openapi.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openapi.yaml b/openapi.yaml index f4d1e2e1d..ef0738012 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -282,7 +282,7 @@ paths: required: true schema: type: string - pattern: "[^a-zA-Z0-9]+" + pattern: "^[a-zA-Z0-9]+$" - name: scopes in: query required: true From 53c11d173a79ae5c004871b1b5840c6f9425a080 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Tue, 20 Aug 2024 07:46:18 -0400 Subject: [PATCH 096/118] fix: ignore errors if transaction has closed already (#1726) --- internal/storage/dial.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/storage/dial.go b/internal/storage/dial.go index 274a06a2c..16cd75c98 100644 --- a/internal/storage/dial.go +++ b/internal/storage/dial.go @@ -145,7 +145,13 @@ func (c *Connection) Transaction(fn func(*Connection) error) error { return err } }); terr != nil { - return terr + // there exists a race condition when the context deadline is exceeded + // and whether the transaction has been committed or not + // e.g. if the context deadline has exceeded but the transaction has already been committed, + // it won't be possible to perform a rollback on the transaction since the transaction has been closed + if !errors.Is(terr, sql.ErrTxDone) { + return terr + } } return returnErr } From a9ff3612196af4a228b53a8bfb9c11785bcfba8d Mon Sep 17 00:00:00 2001 From: Jonathan Summers-Muir Date: Wed, 21 Aug 2024 18:29:35 +0800 Subject: [PATCH 097/118] feat: Vercel marketplace OIDC (#1731) --- internal/api/external.go | 2 + internal/api/provider/oidc.go | 36 ++++++++++ internal/api/provider/vercel_marketplace.go | 78 +++++++++++++++++++++ internal/api/token_oidc.go | 6 ++ internal/conf/configuration.go | 1 + 5 files changed, 123 insertions(+) create mode 100644 internal/api/provider/vercel_marketplace.go diff --git a/internal/api/external.go b/internal/api/external.go index 4df4c6502..f941f55e5 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -574,6 +574,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide return provider.NewTwitchProvider(config.External.Twitch, scopes) case "twitter": return provider.NewTwitterProvider(config.External.Twitter, scopes) + case "vercel_marketplace": + return provider.NewVercelMarketplaceProvider(config.External.VercelMarketplace, scopes) case "workos": return provider.NewWorkOSProvider(config.External.WorkOS) case "zoom": diff --git a/internal/api/provider/oidc.go b/internal/api/provider/oidc.go index 51deaeff3..51c88e639 100644 --- a/internal/api/provider/oidc.go +++ b/internal/api/provider/oidc.go @@ -61,6 +61,8 @@ func ParseIDToken(ctx context.Context, provider *oidc.Provider, config *oidc.Con token, data, err = parseLinkedinIDToken(token) case IssuerKakao: token, data, err = parseKakaoIDToken(token) + case IssuerVercelMarketplace: + token, data, err = parseVercelMarketplaceIDToken(token) default: if IsAzureIssuer(token.Issuer) { token, data, err = parseAzureIDToken(token) @@ -351,6 +353,40 @@ func parseKakaoIDToken(token *oidc.IDToken) (*oidc.IDToken, *UserProvidedData, e return token, &data, nil } +type VercelMarketplaceIDTokenClaims struct { + jwt.RegisteredClaims + + UserEmail string `json:"user_email"` + UserName string `json:"user_name"` + UserAvatarUrl string `json:"user_avatar_url"` +} + +func parseVercelMarketplaceIDToken(token *oidc.IDToken) (*oidc.IDToken, *UserProvidedData, error) { + var claims VercelMarketplaceIDTokenClaims + + if err := token.Claims(&claims); err != nil { + return nil, nil, err + } + + var data UserProvidedData + + data.Emails = append(data.Emails, Email{ + Email: claims.UserEmail, + Verified: true, + Primary: true, + }) + + data.Metadata = &Claims{ + Issuer: token.Issuer, + Subject: token.Subject, + ProviderId: token.Subject, + Name: claims.UserName, + Picture: claims.UserAvatarUrl, + } + + return token, &data, nil +} + func parseGenericIDToken(token *oidc.IDToken) (*oidc.IDToken, *UserProvidedData, error) { var data UserProvidedData diff --git a/internal/api/provider/vercel_marketplace.go b/internal/api/provider/vercel_marketplace.go new file mode 100644 index 000000000..ba76a7412 --- /dev/null +++ b/internal/api/provider/vercel_marketplace.go @@ -0,0 +1,78 @@ +package provider + +import ( + "context" + "errors" + "strings" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/supabase/auth/internal/conf" + "golang.org/x/oauth2" +) + +const ( + defaultVercelMarketplaceAPIBase = "api.vercel.com" + IssuerVercelMarketplace = "https://marketplace.vercel.com" +) + +type vercelMarketplaceProvider struct { + *oauth2.Config + oidc *oidc.Provider + APIPath string +} + +// NewVercelMarketplaceProvider creates a VercelMarketplace account provider via OIDC. +func NewVercelMarketplaceProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) { + if err := ext.ValidateOAuth(); err != nil { + return nil, err + } + + apiPath := chooseHost(ext.URL, defaultVercelMarketplaceAPIBase) + + oauthScopes := []string{} + + if scopes != "" { + oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...) + } + + oidcProvider, err := oidc.NewProvider(context.Background(), IssuerVercelMarketplace) + if err != nil { + return nil, err + } + + return &vercelMarketplaceProvider{ + oidc: oidcProvider, + Config: &oauth2.Config{ + ClientID: ext.ClientID[0], + ClientSecret: ext.Secret, + Endpoint: oauth2.Endpoint{ + AuthURL: apiPath + "/oauth/v2/authorization", + TokenURL: apiPath + "/oauth/v2/accessToken", + }, + Scopes: oauthScopes, + RedirectURL: ext.RedirectURI, + }, + APIPath: apiPath, + }, nil +} + +func (g vercelMarketplaceProvider) GetOAuthToken(code string) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code) +} + +func (g vercelMarketplaceProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { + idToken := tok.Extra("id_token") + if tok.AccessToken == "" || idToken == nil { + return nil, errors.New("vercel_marketplace: no OIDC ID token present in response") + } + + _, data, err := ParseIDToken(ctx, g.oidc, &oidc.Config{ + ClientID: g.ClientID, + }, idToken.(string), ParseIDTokenOptions{ + AccessToken: tok.AccessToken, + }) + if err != nil { + return nil, err + } + return data, nil +} diff --git a/internal/api/token_oidc.go b/internal/api/token_oidc.go index 7b0a8155b..f94a67788 100644 --- a/internal/api/token_oidc.go +++ b/internal/api/token_oidc.go @@ -80,6 +80,12 @@ func (p *IdTokenGrantParams) getProvider(ctx context.Context, config *conf.Globa issuer = provider.IssuerKakao acceptableClientIDs = append(acceptableClientIDs, config.External.Kakao.ClientID...) + case p.Provider == "vercel_marketplace" || p.Issuer == provider.IssuerVercelMarketplace: + cfg = &config.External.VercelMarketplace + providerType = "vercel_marketplace" + issuer = provider.IssuerVercelMarketplace + acceptableClientIDs = append(acceptableClientIDs, config.External.VercelMarketplace.ClientID...) + default: log.WithField("issuer", p.Issuer).WithField("client_id", p.ClientID).Warn("Use of POST /token with arbitrary issuer and client_id is deprecated for security reasons. Please switch to using the API with provider only!") diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 21216fedb..8426616c2 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -322,6 +322,7 @@ type ProviderConfiguration struct { SlackOIDC OAuthProviderConfiguration `json:"slack_oidc" envconfig:"SLACK_OIDC"` Twitter OAuthProviderConfiguration `json:"twitter"` Twitch OAuthProviderConfiguration `json:"twitch"` + VercelMarketplace OAuthProviderConfiguration `json:"vercel_marketplace" split_words:"true"` WorkOS OAuthProviderConfiguration `json:"workos"` Email EmailProviderConfiguration `json:"email"` Phone PhoneProviderConfiguration `json:"phone"` From dc2391d15f2c0725710aa388cd32a18797e6769c Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Wed, 21 Aug 2024 15:52:05 +0200 Subject: [PATCH 098/118] fix: custom SMS does not work with Twilio Verify (#1733) Custom SMS verification did not work if Twilio Verify was enabled. Furthermore, test OTP flow was misplaced. --- internal/api/verify.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/api/verify.go b/internal/api/verify.go index 97a13b93b..74a25bd85 100644 --- a/internal/api/verify.go +++ b/internal/api/verify.go @@ -688,6 +688,12 @@ func (a *API) verifyUserAndToken(conn *storage.Connection, params *VerifyParams, isValid = isOtpValid(tokenHash, user.EmailChangeTokenCurrent, user.EmailChangeSentAt, config.Mailer.OtpExp) || isOtpValid(tokenHash, user.EmailChangeTokenNew, user.EmailChangeSentAt, config.Mailer.OtpExp) case phoneChangeVerification, smsVerification: + if testOTP, ok := config.Sms.GetTestOTP(params.Phone, time.Now()); ok { + if params.Token == testOTP { + return user, nil + } + } + phone := params.Phone sentAt := user.ConfirmationSentAt expectedToken := user.ConfirmationToken @@ -696,12 +702,8 @@ func (a *API) verifyUserAndToken(conn *storage.Connection, params *VerifyParams, sentAt = user.PhoneChangeSentAt expectedToken = user.PhoneChangeToken } - if config.Sms.IsTwilioVerifyProvider() { - if testOTP, ok := config.Sms.GetTestOTP(params.Phone, time.Now()); ok { - if params.Token == testOTP { - return user, nil - } - } + + if !config.Hook.SendSMS.Enabled && config.Sms.IsTwilioVerifyProvider() { if err := smsProvider.(*sms_provider.TwilioVerifyProvider).VerifyOTP(phone, params.Token); err != nil { return nil, forbiddenError(ErrorCodeOTPExpired, "Token has expired or is invalid").WithInternalError(err) } From 66fd0c8434388bbff1e1bf02f40517aca0e9d339 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Wed, 21 Aug 2024 12:46:27 -0400 Subject: [PATCH 099/118] fix: use signing jwk to sign oauth state (#1728) ## What kind of change does this PR introduce? * OAuth state is now signed with the same JWK that is used to sign the access tokens ## What is the current behavior? * currently, it's weird for the `GOTRUE_JWT_SECRET` to be set (other than it being a fallback option) just for the sake of signing the oauth state ## What is the new behavior? Feel free to include screenshots if it includes visual changes. ## Additional context Add any other context or screenshots. --- internal/api/external.go | 19 +++++++++++++++---- internal/api/jwks.go | 31 +++++++++++++++++++++++++++++++ internal/api/token.go | 26 +------------------------- 3 files changed, 47 insertions(+), 29 deletions(-) diff --git a/internal/api/external.go b/internal/api/external.go index f941f55e5..89f75a4fc 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -15,6 +15,7 @@ import ( jwt "github.com/golang-jwt/jwt/v5" "github.com/sirupsen/logrus" "github.com/supabase/auth/internal/api/provider" + "github.com/supabase/auth/internal/conf" "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/observability" "github.com/supabase/auth/internal/storage" @@ -106,8 +107,7 @@ func (a *API) GetExternalProviderRedirectURL(w http.ResponseWriter, r *http.Requ claims.LinkingTargetID = linkingTargetUser.ID.String() } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - tokenString, err := token.SignedString([]byte(config.JWT.Secret)) + tokenString, err := signJwt(&config.JWT, claims) if err != nil { return "", internalServerError("Error creating state").WithInternalError(err) } @@ -491,9 +491,20 @@ func (a *API) loadExternalState(ctx context.Context, r *http.Request) (context.C } config := a.config claims := ExternalProviderClaims{} - p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) + p := jwt.NewParser(jwt.WithValidMethods(config.JWT.ValidMethods)) _, err := p.ParseWithClaims(state, &claims, func(token *jwt.Token) (interface{}, error) { - return []byte(config.JWT.Secret), nil + if kid, ok := token.Header["kid"]; ok { + if kidStr, ok := kid.(string); ok { + return conf.FindPublicKeyByKid(kidStr, &config.JWT) + } + } + if alg, ok := token.Header["alg"]; ok { + if alg == jwt.SigningMethodHS256.Name { + // preserve backward compatibility for cases where the kid is not set + return []byte(config.JWT.Secret), nil + } + } + return nil, fmt.Errorf("missing kid") }) if err != nil { return ctx, badRequestError(ErrorCodeBadOAuthState, "OAuth callback with invalid state").WithInternalError(err) diff --git a/internal/api/jwks.go b/internal/api/jwks.go index d03ae03fb..b8304d2dd 100644 --- a/internal/api/jwks.go +++ b/internal/api/jwks.go @@ -3,8 +3,10 @@ package api import ( "net/http" + jwt "github.com/golang-jwt/jwt/v5" "github.com/lestrrat-go/jwx/v2/jwa" jwk "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/supabase/auth/internal/conf" ) type JwksResponse struct { @@ -28,3 +30,32 @@ func (a *API) Jwks(w http.ResponseWriter, r *http.Request) error { w.Header().Set("Cache-Control", "public, max-age=600") return sendJSON(w, http.StatusOK, resp) } + +func signJwt(config *conf.JWTConfiguration, claims jwt.Claims) (string, error) { + signingJwk, err := conf.GetSigningJwk(config) + if err != nil { + return "", err + } + signingMethod := conf.GetSigningAlg(signingJwk) + token := jwt.NewWithClaims(signingMethod, claims) + if token.Header == nil { + token.Header = make(map[string]interface{}) + } + + if _, ok := token.Header["kid"]; !ok { + if kid := signingJwk.KeyID(); kid != "" { + token.Header["kid"] = kid + } + } + // this serializes the aud claim to a string + jwt.MarshalSingleStringAsArray = false + signingKey, err := conf.GetSigningKey(signingJwk) + if err != nil { + return "", err + } + signed, err := token.SignedString(signingKey) + if err != nil { + return "", err + } + return signed, nil +} diff --git a/internal/api/token.go b/internal/api/token.go index f9dc89829..f9a13ac00 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -349,7 +349,6 @@ func (a *API) generateAccessToken(r *http.Request, tx *storage.Connection, user IsAnonymous: user.IsAnonymous, } - var token *jwt.Token var gotrueClaims jwt.Claims = claims if config.Hook.CustomAccessToken.Enabled { input := hooks.CustomAccessTokenInput{ @@ -367,30 +366,7 @@ func (a *API) generateAccessToken(r *http.Request, tx *storage.Connection, user gotrueClaims = jwt.MapClaims(output.Claims) } - signingJwk, err := conf.GetSigningJwk(&config.JWT) - if err != nil { - return "", 0, err - } - - signingMethod := conf.GetSigningAlg(signingJwk) - token = jwt.NewWithClaims(signingMethod, gotrueClaims) - if token.Header == nil { - token.Header = make(map[string]interface{}) - } - - if _, ok := token.Header["kid"]; !ok { - if kid := signingJwk.KeyID(); kid != "" { - token.Header["kid"] = kid - } - } - - // this serializes the aud claim to a string - jwt.MarshalSingleStringAsArray = false - signingKey, err := conf.GetSigningKey(signingJwk) - if err != nil { - return "", 0, err - } - signed, err := token.SignedString(signingKey) + signed, err := signJwt(&config.JWT, gotrueClaims) if err != nil { return "", 0, err } From a6de8c94fc9a8cbf592fb38216419a0d638a172d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 Aug 2024 13:03:59 -0400 Subject: [PATCH 100/118] chore(master): release 2.159.0 (#1716) :robot: I have created a release *beep* *boop* --- ## [2.159.0](https://github.com/supabase/auth/compare/v2.158.1...v2.159.0) (2024-08-21) ### Features * Vercel marketplace OIDC ([#1731](https://github.com/supabase/auth/issues/1731)) ([a9ff361](https://github.com/supabase/auth/commit/a9ff3612196af4a228b53a8bfb9c11785bcfba8d)) ### Bug Fixes * add error codes to password login flow ([#1721](https://github.com/supabase/auth/issues/1721)) ([4351226](https://github.com/supabase/auth/commit/435122627a0784f1c5cb76d7e08caa1f6259423b)) * change phone constraint to per user ([#1713](https://github.com/supabase/auth/issues/1713)) ([b9bc769](https://github.com/supabase/auth/commit/b9bc769b93b6e700925fcbc1ebf8bf9678034205)) * custom SMS does not work with Twilio Verify ([#1733](https://github.com/supabase/auth/issues/1733)) ([dc2391d](https://github.com/supabase/auth/commit/dc2391d15f2c0725710aa388cd32a18797e6769c)) * ignore errors if transaction has closed already ([#1726](https://github.com/supabase/auth/issues/1726)) ([53c11d1](https://github.com/supabase/auth/commit/53c11d173a79ae5c004871b1b5840c6f9425a080)) * redirect invalid state errors to site url ([#1722](https://github.com/supabase/auth/issues/1722)) ([b2b1123](https://github.com/supabase/auth/commit/b2b11239dc9f9bd3c85d76f6c23ee94beb3330bb)) * remove TOTP field for phone enroll response ([#1717](https://github.com/supabase/auth/issues/1717)) ([4b04327](https://github.com/supabase/auth/commit/4b043275dd2d94600a8138d4ebf4638754ed926b)) * use signing jwk to sign oauth state ([#1728](https://github.com/supabase/auth/issues/1728)) ([66fd0c8](https://github.com/supabase/auth/commit/66fd0c8434388bbff1e1bf02f40517aca0e9d339)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0f867150..2583d28b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [2.159.0](https://github.com/supabase/auth/compare/v2.158.1...v2.159.0) (2024-08-21) + + +### Features + +* Vercel marketplace OIDC ([#1731](https://github.com/supabase/auth/issues/1731)) ([a9ff361](https://github.com/supabase/auth/commit/a9ff3612196af4a228b53a8bfb9c11785bcfba8d)) + + +### Bug Fixes + +* add error codes to password login flow ([#1721](https://github.com/supabase/auth/issues/1721)) ([4351226](https://github.com/supabase/auth/commit/435122627a0784f1c5cb76d7e08caa1f6259423b)) +* change phone constraint to per user ([#1713](https://github.com/supabase/auth/issues/1713)) ([b9bc769](https://github.com/supabase/auth/commit/b9bc769b93b6e700925fcbc1ebf8bf9678034205)) +* custom SMS does not work with Twilio Verify ([#1733](https://github.com/supabase/auth/issues/1733)) ([dc2391d](https://github.com/supabase/auth/commit/dc2391d15f2c0725710aa388cd32a18797e6769c)) +* ignore errors if transaction has closed already ([#1726](https://github.com/supabase/auth/issues/1726)) ([53c11d1](https://github.com/supabase/auth/commit/53c11d173a79ae5c004871b1b5840c6f9425a080)) +* redirect invalid state errors to site url ([#1722](https://github.com/supabase/auth/issues/1722)) ([b2b1123](https://github.com/supabase/auth/commit/b2b11239dc9f9bd3c85d76f6c23ee94beb3330bb)) +* remove TOTP field for phone enroll response ([#1717](https://github.com/supabase/auth/issues/1717)) ([4b04327](https://github.com/supabase/auth/commit/4b043275dd2d94600a8138d4ebf4638754ed926b)) +* use signing jwk to sign oauth state ([#1728](https://github.com/supabase/auth/issues/1728)) ([66fd0c8](https://github.com/supabase/auth/commit/66fd0c8434388bbff1e1bf02f40517aca0e9d339)) + ## [2.158.1](https://github.com/supabase/auth/compare/v2.158.0...v2.158.1) (2024-08-05) From 60cfb6063afa574dfe4993df6b0e087d4df71309 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Fri, 23 Aug 2024 02:33:24 -0400 Subject: [PATCH 101/118] fix: return oauth identity when user is created (#1736) ## What kind of change does this PR introduce? * When the oauth user is first created, the identity needs to be returned in the user object as well. ## What is the current behavior? Please link any relevant issues here. ## What is the new behavior? Feel free to include screenshots if it includes visual changes. ## Additional context Add any other context or screenshots. --- internal/api/external.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/external.go b/internal/api/external.go index 89f75a4fc..c609e53c0 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -338,7 +338,7 @@ func (a *API) createAccountFromExternalIdentity(tx *storage.Connection, r *http. if identity, terr = a.createNewIdentity(tx, user, providerType, identityData); terr != nil { return nil, terr } - + user.Identities = append(user.Identities, *identity) case models.AccountExists: user = decision.User identity = decision.Identities[0] From 10fa347e4355366cbb52d9429d3ba451582af702 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 23 Aug 2024 16:28:17 +0800 Subject: [PATCH 102/118] chore(master): release 2.159.1 (#1737) :robot: I have created a release *beep* *boop* --- ## [2.159.1](https://github.com/supabase/auth/compare/v2.159.0...v2.159.1) (2024-08-23) ### Bug Fixes * return oauth identity when user is created ([#1736](https://github.com/supabase/auth/issues/1736)) ([60cfb60](https://github.com/supabase/auth/commit/60cfb6063afa574dfe4993df6b0e087d4df71309)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2583d28b1..c55037bb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [2.159.1](https://github.com/supabase/auth/compare/v2.159.0...v2.159.1) (2024-08-23) + + +### Bug Fixes + +* return oauth identity when user is created ([#1736](https://github.com/supabase/auth/issues/1736)) ([60cfb60](https://github.com/supabase/auth/commit/60cfb6063afa574dfe4993df6b0e087d4df71309)) + ## [2.159.0](https://github.com/supabase/auth/compare/v2.158.1...v2.159.0) (2024-08-21) From 2d519569d7b8540886d0a64bf3e561ef5f91eb63 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Mon, 26 Aug 2024 00:08:04 -0400 Subject: [PATCH 103/118] fix: allow anonymous user to update password (#1739) --- internal/api/anonymous_test.go | 29 +++++++++++++++++++++++++---- internal/api/user.go | 9 ++++----- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/internal/api/anonymous_test.go b/internal/api/anonymous_test.go index 6ab2bae04..81d900de8 100644 --- a/internal/api/anonymous_test.go +++ b/internal/api/anonymous_test.go @@ -80,7 +80,7 @@ func (ts *AnonymousTestSuite) TestAnonymousLogins() { func (ts *AnonymousTestSuite) TestConvertAnonymousUserToPermanent() { ts.Config.External.AnonymousUsers.Enabled = true - ts.Config.Sms.TestOTP = map[string]string{"1234567890": "000000"} + ts.Config.Sms.TestOTP = map[string]string{"1234567890": "000000", "1234560000": "000000"} // test OTPs still require setting up an sms provider ts.Config.Sms.Provider = "twilio" ts.Config.Sms.Twilio.AccountSid = "fake-sid" @@ -106,6 +106,22 @@ func (ts *AnonymousTestSuite) TestConvertAnonymousUserToPermanent() { }, verificationType: "phone_change", }, + { + desc: "convert anonymous user to permanent user with email & password", + body: map[string]interface{}{ + "email": "test2@example.com", + "password": "test-password", + }, + verificationType: "email_change", + }, + { + desc: "convert anonymous user to permanent user with phone & password", + body: map[string]interface{}{ + "phone": "1234560000", + "password": "test-password", + }, + verificationType: "phone_change", + }, } for _, c := range cases { @@ -142,6 +158,11 @@ func (ts *AnonymousTestSuite) TestConvertAnonymousUserToPermanent() { require.NotEmpty(ts.T(), user) require.True(ts.T(), user.IsAnonymous) + // Check if user has a password set + if c.body["password"] != nil { + require.True(ts.T(), user.HasPassword()) + } + switch c.verificationType { case mail.EmailChangeVerification: require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ @@ -150,7 +171,7 @@ func (ts *AnonymousTestSuite) TestConvertAnonymousUserToPermanent() { })) case phoneChangeVerification: require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ - "phone": "1234567890", + "phone": user.PhoneChange, "token": "000000", "type": c.verificationType, })) @@ -176,11 +197,11 @@ func (ts *AnonymousTestSuite) TestConvertAnonymousUserToPermanent() { switch c.verificationType { case mail.EmailChangeVerification: - assert.Equal(ts.T(), "test@example.com", data.User.GetEmail()) + assert.Equal(ts.T(), c.body["email"], data.User.GetEmail()) assert.Equal(ts.T(), models.JSONMap(models.JSONMap{"provider": "email", "providers": []interface{}{"email"}}), data.User.AppMetaData) assert.NotEmpty(ts.T(), data.User.EmailConfirmedAt) case phoneChangeVerification: - assert.Equal(ts.T(), "1234567890", data.User.GetPhone()) + assert.Equal(ts.T(), c.body["phone"], data.User.GetPhone()) assert.Equal(ts.T(), models.JSONMap(models.JSONMap{"provider": "phone", "providers": []interface{}{"phone"}}), data.User.AppMetaData) assert.NotEmpty(ts.T(), data.User.PhoneConfirmedAt) } diff --git a/internal/api/user.go b/internal/api/user.go index f82d2bf1c..c89c5226d 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -102,11 +102,10 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error { } if user.IsAnonymous { - updatingForbiddenFields := false - updatingForbiddenFields = updatingForbiddenFields || (params.Password != nil && *params.Password != "") - if updatingForbiddenFields { - // CHECK - return unprocessableEntityError(ErrorCodeUnknown, "Updating password of an anonymous user is not possible") + if params.Password != nil && *params.Password != "" { + if params.Email == "" && params.Phone == "" { + return unprocessableEntityError(ErrorCodeValidationFailed, "Updating password of an anonymous user without an email or phone is not allowed") + } } } From c6efec4cbc950e01e1fd06d45ed821bd27c2ad08 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Wed, 28 Aug 2024 09:11:18 +0800 Subject: [PATCH 104/118] fix: remove server side cookie token methods (#1742) ## What kind of change does this PR introduce? Remove set cookie tokens and clear cookie token methods as it looks like they aren't done server side. Task: https://www.notion.so/supabase/team-auth-113cb19144c1419ea5fb1d600281d959?p=ff083dad758e44ef9b4e230804e0fee7&pm=s --- example.env | 3 -- internal/api/anonymous.go | 3 -- internal/api/auth.go | 4 --- internal/api/external.go | 4 --- internal/api/logout.go | 3 -- internal/api/logout_test.go | 8 ----- internal/api/mfa.go | 6 ---- internal/api/middleware.go | 1 - internal/api/samlacs.go | 4 --- internal/api/signup.go | 4 --- internal/api/token.go | 58 ---------------------------------- internal/api/token_refresh.go | 5 --- internal/api/verify.go | 9 ------ internal/conf/configuration.go | 21 ++---------- 14 files changed, 2 insertions(+), 131 deletions(-) diff --git a/example.env b/example.env index e408dcb40..01371183e 100644 --- a/example.env +++ b/example.env @@ -216,9 +216,6 @@ GOTRUE_OPERATOR_TOKEN="unused-operator-token" GOTRUE_RATE_LIMIT_HEADER="X-Forwarded-For" GOTRUE_RATE_LIMIT_EMAIL_SENT="100" -# Cookie config -GOTRUE_COOKIE_KEY="sb" -GOTRUE_COOKIE_DOMAIN="localhost" GOTRUE_MAX_VERIFIED_FACTORS=10 # Auth Hook Configuration diff --git a/internal/api/anonymous.go b/internal/api/anonymous.go index fada3cb65..294f860cb 100644 --- a/internal/api/anonymous.go +++ b/internal/api/anonymous.go @@ -44,9 +44,6 @@ func (a *API) SignupAnonymously(w http.ResponseWriter, r *http.Request) error { if terr != nil { return terr } - if terr := a.setCookieTokens(config, token, false, w); terr != nil { - return terr - } return nil }) if err != nil { diff --git a/internal/api/auth.go b/internal/api/auth.go index e881865dd..b03767f02 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -16,21 +16,17 @@ import ( // requireAuthentication checks incoming requests for tokens presented using the Authorization header func (a *API) requireAuthentication(w http.ResponseWriter, r *http.Request) (context.Context, error) { token, err := a.extractBearerToken(r) - config := a.config if err != nil { - a.clearCookieTokens(config, w) return nil, err } ctx, err := a.parseJWTClaims(token, r) if err != nil { - a.clearCookieTokens(config, w) return ctx, err } ctx, err = a.maybeLoadUserOrSession(ctx) if err != nil { - a.clearCookieTokens(config, w) return ctx, err } return ctx, err diff --git a/internal/api/external.go b/internal/api/external.go index c609e53c0..d0cec1536 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -164,7 +164,6 @@ func (a *API) handleOAuthCallback(r *http.Request) (*OAuthProviderData, error) { func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) - config := a.config var grantParams models.GrantParams grantParams.FillGrantParams(r) @@ -264,9 +263,6 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re rurl = token.AsRedirectURL(rurl, q) - if err := a.setCookieTokens(config, token, false, w); err != nil { - return internalServerError("Failed to set JWT cookie. %s", err) - } } http.Redirect(w, r, rurl, http.StatusFound) diff --git a/internal/api/logout.go b/internal/api/logout.go index ea5b871f7..a2c31a312 100644 --- a/internal/api/logout.go +++ b/internal/api/logout.go @@ -20,8 +20,6 @@ const ( func (a *API) Logout(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) - config := a.config - scope := LogoutGlobal if r.URL.Query() != nil { @@ -64,7 +62,6 @@ func (a *API) Logout(w http.ResponseWriter, r *http.Request) error { return internalServerError("Error logging out user").WithInternalError(err) } - a.clearCookieTokens(config, w) w.WriteHeader(http.StatusNoContent) return nil diff --git a/internal/api/logout_test.go b/internal/api/logout_test.go index 3a4094109..b1a0fdbb6 100644 --- a/internal/api/logout_test.go +++ b/internal/api/logout_test.go @@ -71,13 +71,5 @@ func (ts *LogoutTestSuite) TestLogoutSuccess() { ts.API.handler.ServeHTTP(w, req) require.Equal(ts.T(), http.StatusNoContent, w.Code) - - accessTokenKey := fmt.Sprintf("%v-access-token", ts.Config.Cookie.Key) - refreshTokenKey := fmt.Sprintf("%v-refresh-token", ts.Config.Cookie.Key) - for _, c := range w.Result().Cookies() { - if c.Name == accessTokenKey || c.Name == refreshTokenKey { - require.Equal(ts.T(), "", c.Value) - } - } } } diff --git a/internal/api/mfa.go b/internal/api/mfa.go index 70c263fba..357eea34c 100644 --- a/internal/api/mfa.go +++ b/internal/api/mfa.go @@ -544,9 +544,6 @@ func (a *API) verifyTOTPFactor(w http.ResponseWriter, r *http.Request, params *V if terr != nil { return terr } - if terr = a.setCookieTokens(config, token, false, w); terr != nil { - return internalServerError("Failed to set JWT cookie. %s", terr) - } if terr = models.InvalidateSessionsWithAALLessThan(tx, user.ID, models.AAL2.String()); terr != nil { return internalServerError("Failed to update sessions. %s", terr) } @@ -663,9 +660,6 @@ func (a *API) verifyPhoneFactor(w http.ResponseWriter, r *http.Request, params * if terr != nil { return terr } - if terr = a.setCookieTokens(config, token, false, w); terr != nil { - return internalServerError("Failed to set JWT cookie. %s", terr) - } if terr = models.InvalidateSessionsWithAALLessThan(tx, user.ID, models.AAL2.String()); terr != nil { return internalServerError("Failed to update sessions. %s", terr) } diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 8caa5d885..972ac5ab3 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -143,7 +143,6 @@ func (a *API) requireAdminCredentials(w http.ResponseWriter, req *http.Request) ctx, err := a.parseJWTClaims(t, req) if err != nil { - a.clearCookieTokens(a.config, w) return nil, err } diff --git a/internal/api/samlacs.go b/internal/api/samlacs.go index 907efcd4c..cc99a5274 100644 --- a/internal/api/samlacs.go +++ b/internal/api/samlacs.go @@ -307,10 +307,6 @@ func (a *API) handleSamlAcs(w http.ResponseWriter, r *http.Request) error { return err } - if err := a.setCookieTokens(config, token, false, w); err != nil { - return internalServerError("Failed to set JWT cookie").WithInternalError(err) - } - if !utilities.IsRedirectURLValid(config, redirectTo) { redirectTo = config.SiteURL } diff --git a/internal/api/signup.go b/internal/api/signup.go index cc5e189e8..d7d946c8c 100644 --- a/internal/api/signup.go +++ b/internal/api/signup.go @@ -323,10 +323,6 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { if terr != nil { return terr } - - if terr = a.setCookieTokens(config, token, false, w); terr != nil { - return internalServerError("Failed to set JWT cookie. %s", terr) - } return nil }) if err != nil { diff --git a/internal/api/token.go b/internal/api/token.go index f9a13ac00..d8790e679 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -2,7 +2,6 @@ package api import ( "context" - "errors" "net/http" "net/url" "strconv" @@ -14,7 +13,6 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/xeipuuv/gojsonschema" - "github.com/supabase/auth/internal/conf" "github.com/supabase/auth/internal/hooks" "github.com/supabase/auth/internal/metering" "github.com/supabase/auth/internal/models" @@ -224,9 +222,6 @@ func (a *API) ResourceOwnerPasswordGrant(ctx context.Context, w http.ResponseWri return terr } - if terr = a.setCookieTokens(config, token, false, w); terr != nil { - return internalServerError("Failed to set JWT cookie. %s", terr) - } return nil }) if err != nil { @@ -486,59 +481,6 @@ func (a *API) updateMFASessionAndClaims(r *http.Request, tx *storage.Connection, }, nil } -// setCookieTokens sets the access_token & refresh_token in the cookies -func (a *API) setCookieTokens(config *conf.GlobalConfiguration, token *AccessTokenResponse, session bool, w http.ResponseWriter) error { - // don't need to catch error here since we always set the cookie name - _ = a.setCookieToken(config, "access-token", token.Token, session, w) - _ = a.setCookieToken(config, "refresh-token", token.RefreshToken, session, w) - return nil -} - -func (a *API) setCookieToken(config *conf.GlobalConfiguration, name string, tokenString string, session bool, w http.ResponseWriter) error { - if name == "" { - return errors.New("failed to set cookie, invalid name") - } - cookieName := config.Cookie.Key + "-" + name - exp := time.Second * time.Duration(config.Cookie.Duration) - cookie := &http.Cookie{ - Name: cookieName, - Value: tokenString, - Secure: true, - HttpOnly: true, - Path: "/", - Domain: config.Cookie.Domain, - } - if !session { - cookie.Expires = time.Now().Add(exp) - cookie.MaxAge = config.Cookie.Duration - } - - http.SetCookie(w, cookie) - return nil -} - -func (a *API) clearCookieTokens(config *conf.GlobalConfiguration, w http.ResponseWriter) { - a.clearCookieToken(config, "access-token", w) - a.clearCookieToken(config, "refresh-token", w) -} - -func (a *API) clearCookieToken(config *conf.GlobalConfiguration, name string, w http.ResponseWriter) { - cookieName := config.Cookie.Key - if name != "" { - cookieName += "-" + name - } - http.SetCookie(w, &http.Cookie{ - Name: cookieName, - Value: "", - Expires: time.Now().Add(-1 * time.Hour * 10), - MaxAge: -1, - Secure: true, - HttpOnly: true, - Path: "/", - Domain: config.Cookie.Domain, - }) -} - func validateTokenClaims(outputClaims map[string]interface{}) error { schemaLoader := gojsonschema.NewStringLoader(hooks.MinimumViableTokenSchema) diff --git a/internal/api/token_refresh.go b/internal/api/token_refresh.go index 5c9d1984d..8f1ddd320 100644 --- a/internal/api/token_refresh.go +++ b/internal/api/token_refresh.go @@ -182,9 +182,7 @@ func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *h time.Second * time.Duration(config.Security.RefreshTokenReuseInterval)) if a.Now().After(reuseUntil) { - a.clearCookieTokens(config, w) // not OK to reuse this token - if config.Security.RefreshTokenRotationEnabled { // Revoke all tokens in token family if err := models.RevokeTokenFamily(tx, token); err != nil { @@ -248,9 +246,6 @@ func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *h RefreshToken: issuedToken.Token, User: user, } - if terr = a.setCookieTokens(config, newTokenResponse, false, w); terr != nil { - return internalServerError("Failed to set JWT cookie. %s", terr) - } return nil }) diff --git a/internal/api/verify.go b/internal/api/verify.go index 74a25bd85..5adc80744 100644 --- a/internal/api/verify.go +++ b/internal/api/verify.go @@ -117,7 +117,6 @@ func (a *API) Verify(w http.ResponseWriter, r *http.Request) error { func (a *API) verifyGet(w http.ResponseWriter, r *http.Request, params *VerifyParams) error { ctx := r.Context() db := a.db.WithContext(ctx) - config := a.config var ( user *models.User @@ -185,9 +184,6 @@ func (a *API) verifyGet(w http.ResponseWriter, r *http.Request, params *VerifyPa return terr } - if terr = a.setCookieTokens(config, token, false, w); terr != nil { - return internalServerError("Failed to set JWT cookie. %s", terr) - } } else if isPKCEFlow(flowType) { if authCode, terr = issueAuthCode(tx, user, authenticationMethod); terr != nil { return badRequestError(ErrorCodeFlowStateNotFound, "No associated flow state found. %s", terr) @@ -227,7 +223,6 @@ func (a *API) verifyGet(w http.ResponseWriter, r *http.Request, params *VerifyPa func (a *API) verifyPost(w http.ResponseWriter, r *http.Request, params *VerifyParams) error { ctx := r.Context() db := a.db.WithContext(ctx) - config := a.config var ( user *models.User @@ -285,10 +280,6 @@ func (a *API) verifyPost(w http.ResponseWriter, r *http.Request, params *VerifyP if terr != nil { return terr } - - if terr = a.setCookieTokens(config, token, false, w); terr != nil { - return internalServerError("Failed to set JWT cookie. %s", terr) - } return nil }) if err != nil { diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 8426616c2..7a0cffab4 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -257,13 +257,8 @@ type GlobalConfiguration struct { Security SecurityConfiguration `json:"security"` Sessions SessionsConfiguration `json:"sessions"` MFA MFAConfiguration `json:"MFA"` - Cookie struct { - Key string `json:"key"` - Domain string `json:"domain"` - Duration int `json:"duration"` - } `json:"cookies"` - SAML SAMLConfiguration `json:"saml"` - CORS CORSConfiguration `json:"cors"` + SAML SAMLConfiguration `json:"saml"` + CORS CORSConfiguration `json:"cors"` } type CORSConfiguration struct { @@ -844,18 +839,6 @@ func (config *GlobalConfiguration) ApplyDefaults() error { config.Sms.Template = "" } - if config.Cookie.Key == "" { - config.Cookie.Key = "sb" - } - - if config.Cookie.Domain == "" { - config.Cookie.Domain = "" - } - - if config.Cookie.Duration == 0 { - config.Cookie.Duration = 86400 - } - if config.URIAllowList == nil { config.URIAllowList = []string{} } From 7e38f4cf37768fe2adf92bbd0723d1d521b3d74c Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Thu, 29 Aug 2024 01:26:09 +0800 Subject: [PATCH 105/118] fix: hide hook name (#1743) ## What kind of change does this PR introduce? The HookName is generated from the Postgres URI and used internally to invoke the hook. Context: https://github.com/supabase/auth/issues/1734#issuecomment-2308342232 Since it's for internal use, we don't expose it similar to what we do with [encryption key](https://github.com/supabase/auth/compare/j0/hide_hook_name?expand=1#diff-4c28cb40881781a1067b3b3681c43f805dab629f31f3c7614b0f781ffa096505L457) and other similar fields. This is fine since we don't marshal the extensibility point. ## Testing setup We don't marshal the struct so it should be fine but for additional sanity check ran this locally: ``` GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED="true" GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI="pg-functions://postgres/public/custom_access_token_hook" ``` ``` create or replace function public.custom_access_token_hook(event jsonb) returns jsonb language plpgsql as $$ declare claims jsonb; begin -- Proceed only if the user is an admin claims := event->'claims'; -- Check if 'user_metadata' exists in claims if jsonb_typeof(claims->'user_metadata') is null then -- If 'user_metadata' does not exist, create an empty object claims := jsonb_set(claims, '{user_metadata}', '{}'); end if; -- Set a claim of 'admin' claims := jsonb_set(claims, '{user_metadata, admin}', 'true'); -- Update the 'claims' object in the original event event := jsonb_set(event, '{claims}', claims); -- Return the modified or original event return event; end; $$; grant execute on function public.custom_access_token_hook to supabase_auth_admin; revoke execute on function public.custom_access_token_hook from authenticated, anon, public; grant usage on schema public to supabase_auth_admin; ``` --- internal/conf/configuration.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 7a0cffab4..55d7a8fab 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -547,9 +547,10 @@ func (h *HTTPHookSecrets) Decode(value string) error { } type ExtensibilityPointConfiguration struct { - URI string `json:"uri"` - Enabled bool `json:"enabled"` - HookName string `json:"hook_name"` + URI string `json:"uri"` + Enabled bool `json:"enabled"` + // For internal use together with Postgres Hook. Not publicly exposed. + HookName string `json:"-"` // We use | as a separator for keys and : as a separator for keys within a keypair. For instance: v1,whsec_test|v1a,whpk_myother:v1a,whsk_testkey|v1,whsec_secret3 HTTPHookSecrets HTTPHookSecrets `json:"secrets" envconfig:"secrets"` } From d03a54efd05f55c7ada2de600a2d4b554bcce31f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 28 Aug 2024 16:09:32 -0700 Subject: [PATCH 106/118] chore(master): release 2.159.2 (#1741) :robot: I have created a release *beep* *boop* --- ## [2.159.2](https://github.com/supabase/auth/compare/v2.159.1...v2.159.2) (2024-08-28) ### Bug Fixes * allow anonymous user to update password ([#1739](https://github.com/supabase/auth/issues/1739)) ([2d51956](https://github.com/supabase/auth/commit/2d519569d7b8540886d0a64bf3e561ef5f91eb63)) * hide hook name ([#1743](https://github.com/supabase/auth/issues/1743)) ([7e38f4c](https://github.com/supabase/auth/commit/7e38f4cf37768fe2adf92bbd0723d1d521b3d74c)) * remove server side cookie token methods ([#1742](https://github.com/supabase/auth/issues/1742)) ([c6efec4](https://github.com/supabase/auth/commit/c6efec4cbc950e01e1fd06d45ed821bd27c2ad08)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c55037bb9..ba26d6f22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [2.159.2](https://github.com/supabase/auth/compare/v2.159.1...v2.159.2) (2024-08-28) + + +### Bug Fixes + +* allow anonymous user to update password ([#1739](https://github.com/supabase/auth/issues/1739)) ([2d51956](https://github.com/supabase/auth/commit/2d519569d7b8540886d0a64bf3e561ef5f91eb63)) +* hide hook name ([#1743](https://github.com/supabase/auth/issues/1743)) ([7e38f4c](https://github.com/supabase/auth/commit/7e38f4cf37768fe2adf92bbd0723d1d521b3d74c)) +* remove server side cookie token methods ([#1742](https://github.com/supabase/auth/issues/1742)) ([c6efec4](https://github.com/supabase/auth/commit/c6efec4cbc950e01e1fd06d45ed821bd27c2ad08)) + ## [2.159.1](https://github.com/supabase/auth/compare/v2.159.0...v2.159.1) (2024-08-23) From bf276ab49753642793471815727559172fea4efc Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Wed, 28 Aug 2024 16:33:51 -0700 Subject: [PATCH 107/118] fix: apply shared limiters before email / sms is sent (#1748) ## What kind of change does this PR introduce? * Fixes https://github.com/supabase/auth/issues/1236 * Reduces the number of false positives arising from validation errors when counting rate limits for emails / sms sent * This change applies the shared rate limiter for email and phone functions before the actual email / sms is being sent out rather than at the start of the request * The `limitEmailOrPhoneSentHandler()` now initialises the rate limiters and sets it in the request context so we can subsequently retrieve it right before the email / sms is sent ## What is the current behavior? Please link any relevant issues here. ## What is the new behavior? Feel free to include screenshots if it includes visual changes. ## Additional context Add any other context or screenshots. --- internal/api/context.go | 19 +++++ internal/api/external.go | 6 +- internal/api/identity.go | 5 +- internal/api/invite.go | 2 +- internal/api/magic_link.go | 6 +- internal/api/mail.go | 147 ++++++++++++++++++-------------- internal/api/middleware.go | 36 ++------ internal/api/middleware_test.go | 41 +++++++-- internal/api/phone.go | 12 ++- internal/api/reauthenticate.go | 9 -- internal/api/recover.go | 6 +- internal/api/resend.go | 13 +-- internal/api/signup.go | 14 +-- internal/api/user.go | 6 +- internal/mailer/mailer.go | 1 - 15 files changed, 158 insertions(+), 165 deletions(-) diff --git a/internal/api/context.go b/internal/api/context.go index 3047f3dd6..ff01e7120 100644 --- a/internal/api/context.go +++ b/internal/api/context.go @@ -4,6 +4,7 @@ import ( "context" "net/url" + "github.com/didip/tollbooth/v5/limiter" jwt "github.com/golang-jwt/jwt/v5" "github.com/supabase/auth/internal/models" ) @@ -31,6 +32,7 @@ const ( ssoProviderKey = contextKey("sso_provider") externalHostKey = contextKey("external_host") flowStateKey = contextKey("flow_state_id") + sharedLimiterKey = contextKey("shared_limiter") ) // withToken adds the JWT token to the context. @@ -241,3 +243,20 @@ func getExternalHost(ctx context.Context) *url.URL { } return obj.(*url.URL) } + +type SharedLimiter struct { + EmailLimiter *limiter.Limiter + PhoneLimiter *limiter.Limiter +} + +func withLimiter(ctx context.Context, limiter *SharedLimiter) context.Context { + return context.WithValue(ctx, sharedLimiterKey, limiter) +} + +func getLimiter(ctx context.Context) *SharedLimiter { + obj := ctx.Value(sharedLimiterKey) + if obj == nil { + return nil + } + return obj.(*SharedLimiter) +} diff --git a/internal/api/external.go b/internal/api/external.go index d0cec1536..2eff891ef 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -2,7 +2,6 @@ package api import ( "context" - "errors" "fmt" "net/http" "net/url" @@ -383,10 +382,7 @@ func (a *API) createAccountFromExternalIdentity(tx *storage.Connection, r *http. emailConfirmationSent := false if decision.CandidateEmail.Email != "" { if terr = a.sendConfirmation(r, tx, user, models.ImplicitFlow); terr != nil { - if errors.Is(terr, MaxFrequencyLimitError) { - return nil, tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, "For security purposes, you can only request this once every minute") - } - return nil, internalServerError("Error sending confirmation mail").WithInternalError(terr) + return nil, terr } emailConfirmationSent = true } diff --git a/internal/api/identity.go b/internal/api/identity.go index 69d3f854f..53cef86f9 100644 --- a/internal/api/identity.go +++ b/internal/api/identity.go @@ -7,7 +7,6 @@ import ( "github.com/fatih/structs" "github.com/go-chi/chi/v5" "github.com/gofrs/uuid" - "github.com/pkg/errors" "github.com/supabase/auth/internal/api/provider" "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/storage" @@ -133,9 +132,7 @@ func (a *API) linkIdentityToUser(r *http.Request, ctx context.Context, tx *stora } if !userData.Metadata.EmailVerified { if terr := a.sendConfirmation(r, tx, targetUser, models.ImplicitFlow); terr != nil { - if errors.Is(terr, MaxFrequencyLimitError) { - return nil, tooManyRequestsError(ErrorCodeOverSMSSendRateLimit, "For security purposes, you can only request this once every minute") - } + return nil, terr } return nil, storage.NewCommitWithError(unprocessableEntityError(ErrorCodeEmailNotConfirmed, "Unverified email with %v. A confirmation email has been sent to your %v email", providerType, providerType)) } diff --git a/internal/api/invite.go b/internal/api/invite.go index 2e07e7135..76852f711 100644 --- a/internal/api/invite.go +++ b/internal/api/invite.go @@ -80,7 +80,7 @@ func (a *API) Invite(w http.ResponseWriter, r *http.Request) error { } if err := a.sendInvite(r, tx, user); err != nil { - return internalServerError("Error inviting user").WithInternalError(err) + return err } return nil }) diff --git a/internal/api/magic_link.go b/internal/api/magic_link.go index eeabafd39..d7e941b79 100644 --- a/internal/api/magic_link.go +++ b/internal/api/magic_link.go @@ -3,7 +3,6 @@ package api import ( "bytes" "encoding/json" - "errors" "fmt" "io" "net/http" @@ -141,10 +140,7 @@ func (a *API) MagicLink(w http.ResponseWriter, r *http.Request) error { return a.sendMagicLink(r, tx, user, flowType) }) if err != nil { - if errors.Is(err, MaxFrequencyLimitError) { - return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, generateFrequencyLimitErrorMessage(user.RecoverySentAt, config.SMTP.MaxFrequency)) - } - return internalServerError("Error sending magic link").WithInternalError(err) + return err } return sendJSON(w, http.StatusOK, make(map[string]string)) diff --git a/internal/api/mail.go b/internal/api/mail.go index 35f529e25..00bb58e7e 100644 --- a/internal/api/mail.go +++ b/internal/api/mail.go @@ -5,8 +5,11 @@ import ( "strings" "time" + "github.com/didip/tollbooth/v5" "github.com/supabase/auth/internal/hooks" mail "github.com/supabase/auth/internal/mailer" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" "github.com/badoux/checkmail" "github.com/fatih/structs" @@ -20,7 +23,7 @@ import ( ) var ( - MaxFrequencyLimitError error = errors.New("frequency limit reached") + EmailRateLimitExceeded error = errors.New("email rate limit exceeded") ) type GenerateLinkParams struct { @@ -301,7 +304,6 @@ func (a *API) sendConfirmation(r *http.Request, tx *storage.Connection, u *model maxFrequency := config.SMTP.MaxFrequency otpLength := config.Mailer.OtpLength - var err error if err := validateSentWithinFrequencyLimit(u.ConfirmationSentAt, maxFrequency); err != nil { return err } @@ -314,20 +316,20 @@ func (a *API) sendConfirmation(r *http.Request, tx *storage.Connection, u *model token := crypto.GenerateTokenHash(u.GetEmail(), otp) u.ConfirmationToken = addFlowPrefixToToken(token, flowType) now := time.Now() - err = a.sendEmail(r, tx, u, mail.SignupVerification, otp, "", u.ConfirmationToken) - if err != nil { + if err = a.sendEmail(r, tx, u, mail.SignupVerification, otp, "", u.ConfirmationToken); err != nil { u.ConfirmationToken = oldToken - return errors.Wrap(err, "Error sending confirmation email") + if errors.Is(err, EmailRateLimitExceeded) { + return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error()) + } + return internalServerError("Error sending confirmation email").WithInternalError(err) } u.ConfirmationSentAt = &now - err = tx.UpdateOnly(u, "confirmation_token", "confirmation_sent_at") - if err != nil { - return errors.Wrap(err, "Database error updating user for confirmation") + if err := tx.UpdateOnly(u, "confirmation_token", "confirmation_sent_at"); err != nil { + return internalServerError("Error sending confirmation email").WithInternalError(errors.Wrap(err, "Database error updating user for confirmation")) } - err = models.CreateOneTimeToken(tx, u.ID, u.GetEmail(), u.ConfirmationToken, models.ConfirmationToken) - if err != nil { - return errors.Wrap(err, "Database error creating confirmation token") + if err := models.CreateOneTimeToken(tx, u.ID, u.GetEmail(), u.ConfirmationToken, models.ConfirmationToken); err != nil { + return internalServerError("Error sending confirmation email").WithInternalError(errors.Wrap(err, "Database error creating confirmation token")) } return nil @@ -345,21 +347,23 @@ func (a *API) sendInvite(r *http.Request, tx *storage.Connection, u *models.User } u.ConfirmationToken = crypto.GenerateTokenHash(u.GetEmail(), otp) now := time.Now() - err = a.sendEmail(r, tx, u, mail.InviteVerification, otp, "", u.ConfirmationToken) - if err != nil { + if err = a.sendEmail(r, tx, u, mail.InviteVerification, otp, "", u.ConfirmationToken); err != nil { u.ConfirmationToken = oldToken - return errors.Wrap(err, "Error sending invite email") + if errors.Is(err, EmailRateLimitExceeded) { + return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error()) + } + return internalServerError("Error sending invite email").WithInternalError(err) } u.InvitedAt = &now u.ConfirmationSentAt = &now err = tx.UpdateOnly(u, "confirmation_token", "confirmation_sent_at", "invited_at") if err != nil { - return errors.Wrap(err, "Database error updating user for invite") + return internalServerError("Error inviting user").WithInternalError(errors.Wrap(err, "Database error updating user for invite")) } err = models.CreateOneTimeToken(tx, u.ID, u.GetEmail(), u.ConfirmationToken, models.ConfirmationToken) if err != nil { - return errors.Wrap(err, "Database error creating confirmation token for invite") + return internalServerError("Error inviting user").WithInternalError(errors.Wrap(err, "Database error creating confirmation token for invite")) } return nil @@ -367,10 +371,9 @@ func (a *API) sendInvite(r *http.Request, tx *storage.Connection, u *models.User func (a *API) sendPasswordRecovery(r *http.Request, tx *storage.Connection, u *models.User, flowType models.FlowType) error { config := a.config - maxFrequency := config.SMTP.MaxFrequency otpLength := config.Mailer.OtpLength - var err error - if err := validateSentWithinFrequencyLimit(u.RecoverySentAt, maxFrequency); err != nil { + + if err := validateSentWithinFrequencyLimit(u.RecoverySentAt, config.SMTP.MaxFrequency); err != nil { return err } @@ -383,20 +386,21 @@ func (a *API) sendPasswordRecovery(r *http.Request, tx *storage.Connection, u *m token := crypto.GenerateTokenHash(u.GetEmail(), otp) u.RecoveryToken = addFlowPrefixToToken(token, flowType) now := time.Now() - err = a.sendEmail(r, tx, u, mail.RecoveryVerification, otp, "", u.RecoveryToken) - if err != nil { + if err = a.sendEmail(r, tx, u, mail.RecoveryVerification, otp, "", u.RecoveryToken); err != nil { u.RecoveryToken = oldToken - return errors.Wrap(err, "Error sending recovery email") + if errors.Is(err, EmailRateLimitExceeded) { + return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error()) + } + return internalServerError("Error sending recovery email").WithInternalError(err) } u.RecoverySentAt = &now - err = tx.UpdateOnly(u, "recovery_token", "recovery_sent_at") - if err != nil { - return errors.Wrap(err, "Database error updating user for recovery") + + if err := tx.UpdateOnly(u, "recovery_token", "recovery_sent_at"); err != nil { + return internalServerError("Error sending recovery email").WithInternalError(errors.Wrap(err, "Database error updating user for recovery")) } - err = models.CreateOneTimeToken(tx, u.ID, u.GetEmail(), u.RecoveryToken, models.RecoveryToken) - if err != nil { - return errors.Wrap(err, "Database error creating recovery token") + if err := models.CreateOneTimeToken(tx, u.ID, u.GetEmail(), u.RecoveryToken, models.RecoveryToken); err != nil { + return internalServerError("Error sending recovery email").WithInternalError(errors.Wrap(err, "Database error creating recovery token")) } return nil @@ -406,7 +410,6 @@ func (a *API) sendReauthenticationOtp(r *http.Request, tx *storage.Connection, u config := a.config maxFrequency := config.SMTP.MaxFrequency otpLength := config.Mailer.OtpLength - var err error if err := validateSentWithinFrequencyLimit(u.ReauthenticationSentAt, maxFrequency); err != nil { return err @@ -420,20 +423,21 @@ func (a *API) sendReauthenticationOtp(r *http.Request, tx *storage.Connection, u } u.ReauthenticationToken = crypto.GenerateTokenHash(u.GetEmail(), otp) now := time.Now() - err = a.sendEmail(r, tx, u, mail.ReauthenticationVerification, otp, "", u.ReauthenticationToken) - if err != nil { + + if err := a.sendEmail(r, tx, u, mail.ReauthenticationVerification, otp, "", u.ReauthenticationToken); err != nil { u.ReauthenticationToken = oldToken - return errors.Wrap(err, "Error sending reauthentication email") + if errors.Is(err, EmailRateLimitExceeded) { + return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error()) + } + return internalServerError("Error sending reauthentication email").WithInternalError(err) } u.ReauthenticationSentAt = &now - err = tx.UpdateOnly(u, "reauthentication_token", "reauthentication_sent_at") - if err != nil { - return errors.Wrap(err, "Database error updating user for reauthentication") + if err := tx.UpdateOnly(u, "reauthentication_token", "reauthentication_sent_at"); err != nil { + return internalServerError("Error sending reauthentication email").WithInternalError(errors.Wrap(err, "Database error updating user for reauthentication")) } - err = models.CreateOneTimeToken(tx, u.ID, u.GetEmail(), u.ReauthenticationToken, models.ReauthenticationToken) - if err != nil { - return errors.Wrap(err, "Database error creating reauthentication token") + if err := models.CreateOneTimeToken(tx, u.ID, u.GetEmail(), u.ReauthenticationToken, models.ReauthenticationToken); err != nil { + return internalServerError("Error sending reauthentication email").WithInternalError(errors.Wrap(err, "Database error creating reauthentication token")) } return nil @@ -442,11 +446,10 @@ func (a *API) sendReauthenticationOtp(r *http.Request, tx *storage.Connection, u func (a *API) sendMagicLink(r *http.Request, tx *storage.Connection, u *models.User, flowType models.FlowType) error { config := a.config otpLength := config.Mailer.OtpLength - maxFrequency := config.SMTP.MaxFrequency - var err error + // since Magic Link is just a recovery with a different template and behaviour // around new users we will reuse the recovery db timer to prevent potential abuse - if err := validateSentWithinFrequencyLimit(u.RecoverySentAt, maxFrequency); err != nil { + if err := validateSentWithinFrequencyLimit(u.RecoverySentAt, config.SMTP.MaxFrequency); err != nil { return err } @@ -460,20 +463,20 @@ func (a *API) sendMagicLink(r *http.Request, tx *storage.Connection, u *models.U u.RecoveryToken = addFlowPrefixToToken(token, flowType) now := time.Now() - err = a.sendEmail(r, tx, u, mail.MagicLinkVerification, otp, "", u.RecoveryToken) - if err != nil { + if err = a.sendEmail(r, tx, u, mail.MagicLinkVerification, otp, "", u.RecoveryToken); err != nil { u.RecoveryToken = oldToken - return errors.Wrap(err, "Error sending magic link email") + if errors.Is(err, EmailRateLimitExceeded) { + return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error()) + } + return internalServerError("Error sending magic link email").WithInternalError(err) } u.RecoverySentAt = &now - err = tx.UpdateOnly(u, "recovery_token", "recovery_sent_at") - if err != nil { - return errors.Wrap(err, "Database error updating user for recovery") + if err := tx.UpdateOnly(u, "recovery_token", "recovery_sent_at"); err != nil { + return internalServerError("Error sending magic link email").WithInternalError(errors.Wrap(err, "Database error updating user for recovery")) } - err = models.CreateOneTimeToken(tx, u.ID, u.GetEmail(), u.RecoveryToken, models.RecoveryToken) - if err != nil { - return errors.Wrap(err, "Database error creating recovery token") + if err := models.CreateOneTimeToken(tx, u.ID, u.GetEmail(), u.RecoveryToken, models.RecoveryToken); err != nil { + return internalServerError("Error sending magic link email").WithInternalError(errors.Wrap(err, "Database error creating recovery token")) } return nil @@ -483,7 +486,7 @@ func (a *API) sendMagicLink(r *http.Request, tx *storage.Connection, u *models.U func (a *API) sendEmailChange(r *http.Request, tx *storage.Connection, u *models.User, email string, flowType models.FlowType) error { config := a.config otpLength := config.Mailer.OtpLength - var err error + if err := validateSentWithinFrequencyLimit(u.EmailChangeSentAt, config.SMTP.MaxFrequency); err != nil { return err } @@ -510,36 +513,35 @@ func (a *API) sendEmailChange(r *http.Request, tx *storage.Connection, u *models u.EmailChangeConfirmStatus = zeroConfirmation now := time.Now() - err = a.sendEmail(r, tx, u, mail.EmailChangeVerification, otpCurrent, otpNew, u.EmailChangeTokenNew) - if err != nil { - return err + + if err := a.sendEmail(r, tx, u, mail.EmailChangeVerification, otpCurrent, otpNew, u.EmailChangeTokenNew); err != nil { + if errors.Is(err, EmailRateLimitExceeded) { + return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error()) + } + return internalServerError("Error sending email change email").WithInternalError(err) } u.EmailChangeSentAt = &now - err = tx.UpdateOnly( + if err := tx.UpdateOnly( u, "email_change_token_current", "email_change_token_new", "email_change", "email_change_sent_at", "email_change_confirm_status", - ) - - if err != nil { - return errors.Wrap(err, "Database error updating user for email change") + ); err != nil { + return internalServerError("Error sending email change email").WithInternalError(errors.Wrap(err, "Database error updating user for email change")) } if u.EmailChangeTokenCurrent != "" { - err = models.CreateOneTimeToken(tx, u.ID, u.GetEmail(), u.EmailChangeTokenCurrent, models.EmailChangeTokenCurrent) - if err != nil { - return errors.Wrap(err, "Database error creating email change token current") + if err := models.CreateOneTimeToken(tx, u.ID, u.GetEmail(), u.EmailChangeTokenCurrent, models.EmailChangeTokenCurrent); err != nil { + return internalServerError("Error sending email change email").WithInternalError(errors.Wrap(err, "Database error creating email change token current")) } } if u.EmailChangeTokenNew != "" { - err = models.CreateOneTimeToken(tx, u.ID, u.EmailChange, u.EmailChangeTokenNew, models.EmailChangeTokenNew) - if err != nil { - return errors.Wrap(err, "Database error creating email change token new") + if err := models.CreateOneTimeToken(tx, u.ID, u.EmailChange, u.EmailChangeTokenNew, models.EmailChangeTokenNew); err != nil { + return internalServerError("Error sending email change email").WithInternalError(errors.Wrap(err, "Database error creating email change token new")) } } @@ -561,7 +563,7 @@ func validateEmail(email string) (string, error) { func validateSentWithinFrequencyLimit(sentAt *time.Time, frequency time.Duration) error { if sentAt != nil && sentAt.Add(frequency).After(time.Now()) { - return MaxFrequencyLimitError + return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, generateFrequencyLimitErrorMessage(sentAt, frequency)) } return nil } @@ -572,6 +574,19 @@ func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User, config := a.config referrerURL := utilities.GetReferrer(r, config) externalURL := getExternalHost(ctx) + + // apply rate limiting before the email is sent out + if limiter := getLimiter(ctx); limiter != nil { + if err := tollbooth.LimitByKeys(limiter.EmailLimiter, []string{"email_functions"}); err != nil { + emailRateLimitCounter.Add( + ctx, + 1, + metric.WithAttributeSet(attribute.NewSet(attribute.String("path", r.URL.Path))), + ) + return EmailRateLimitExceeded + } + } + if config.Hook.SendEmail.Enabled { emailData := mail.EmailData{ Token: otp, diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 972ac5ab3..aa2c3e9ff 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -15,8 +15,6 @@ import ( "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/observability" "github.com/supabase/auth/internal/security" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/metric" "github.com/didip/tollbooth/v5" "github.com/didip/tollbooth/v5/limiter" @@ -99,35 +97,11 @@ func (a *API) limitEmailOrPhoneSentHandler() middlewareHandler { if shouldRateLimitEmail || shouldRateLimitPhone { if req.Method == "PUT" || req.Method == "POST" { - var requestBody struct { - Email string `json:"email"` - Phone string `json:"phone"` - } - - if err := retrieveRequestParams(req, &requestBody); err != nil { - return c, err - } - - if shouldRateLimitEmail { - if requestBody.Email != "" { - if err := tollbooth.LimitByKeys(emailLimiter, []string{"email_functions"}); err != nil { - emailRateLimitCounter.Add( - req.Context(), - 1, - metric.WithAttributeSet(attribute.NewSet(attribute.String("path", req.URL.Path))), - ) - return c, tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, "Email rate limit exceeded") - } - } - } - - if shouldRateLimitPhone { - if requestBody.Phone != "" { - if err := tollbooth.LimitByKeys(phoneLimiter, []string{"phone_functions"}); err != nil { - return c, tooManyRequestsError(ErrorCodeOverSMSSendRateLimit, "SMS rate limit exceeded") - } - } - } + // store rate limiter in request context + c = withLimiter(c, &SharedLimiter{ + EmailLimiter: emailLimiter, + PhoneLimiter: phoneLimiter, + }) } } diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go index eb8c5da3b..2d7a32493 100644 --- a/internal/api/middleware_test.go +++ b/internal/api/middleware_test.go @@ -221,15 +221,12 @@ func (ts *MiddlewareTestSuite) TestLimitEmailOrPhoneSentHandler() { req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() - for i := 0; i < 5; i++ { - _, err := limiter(w, req) - require.NoError(ts.T(), err) - } + ctx, err := limiter(w, req) + require.NoError(ts.T(), err) - // should exceed rate limit on 5th try - _, err := limiter(w, req) - require.Error(ts.T(), err) - require.Equal(ts.T(), c.expectedErrorMsg, err.Error()) + // check that shared limiter is set in the request context + sharedLimiter := getLimiter(ctx) + require.NotNil(ts.T(), sharedLimiter) }) } } @@ -406,6 +403,34 @@ func (ts *MiddlewareTestSuite) TestLimitHandlerWithSharedLimiter() { } okHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + limiter := getLimiter(r.Context()) + if limiter != nil { + var requestBody struct { + Email string `json:"email"` + Phone string `json:"phone"` + } + err := retrieveRequestParams(r, &requestBody) + require.NoError(ts.T(), err) + + if requestBody.Email != "" { + if err := tollbooth.LimitByKeys(limiter.EmailLimiter, []string{"email_functions"}); err != nil { + sendJSON(w, http.StatusTooManyRequests, HTTPError{ + HTTPStatus: http.StatusTooManyRequests, + ErrorCode: ErrorCodeOverEmailSendRateLimit, + Message: "Email rate limit exceeded", + }) + } + } + if requestBody.Phone != "" { + if err := tollbooth.LimitByKeys(limiter.EmailLimiter, []string{"phone_functions"}); err != nil { + sendJSON(w, http.StatusTooManyRequests, HTTPError{ + HTTPStatus: http.StatusTooManyRequests, + ErrorCode: ErrorCodeOverSMSSendRateLimit, + Message: "SMS rate limit exceeded", + }) + } + } + } w.WriteHeader(http.StatusOK) }) diff --git a/internal/api/phone.go b/internal/api/phone.go index 8e7d39e63..ce11c5a3f 100644 --- a/internal/api/phone.go +++ b/internal/api/phone.go @@ -8,6 +8,7 @@ import ( "text/template" "time" + "github.com/didip/tollbooth/v5" "github.com/supabase/auth/internal/hooks" "github.com/pkg/errors" @@ -44,6 +45,7 @@ func formatPhoneNumber(phone string) string { // sendPhoneConfirmation sends an otp to the user's phone number func (a *API) sendPhoneConfirmation(r *http.Request, tx *storage.Connection, user *models.User, phone, otpType string, channel string) (string, error) { + ctx := r.Context() config := a.config var token *string @@ -84,7 +86,15 @@ func (a *API) sendPhoneConfirmation(r *http.Request, tx *storage.Connection, use messageID = "test-otp" } - if otp == "" { // not using test OTPs + // not using test OTPs + if otp == "" { + // apply rate limiting before the sms is sent out + limiter := getLimiter(ctx) + if limiter != nil { + if err := tollbooth.LimitByKeys(limiter.PhoneLimiter, []string{"phone_functions"}); err != nil { + return "", tooManyRequestsError(ErrorCodeOverSMSSendRateLimit, "SMS rate limit exceeded") + } + } otp, err = crypto.GenerateOtp(config.Sms.OtpLength) if err != nil { return "", internalServerError("error generating otp").WithInternalError(err) diff --git a/internal/api/reauthenticate.go b/internal/api/reauthenticate.go index df46bad03..5146ae409 100644 --- a/internal/api/reauthenticate.go +++ b/internal/api/reauthenticate.go @@ -1,7 +1,6 @@ package api import ( - "errors" "net/http" "github.com/supabase/auth/internal/api/sms_provider" @@ -53,14 +52,6 @@ func (a *API) Reauthenticate(w http.ResponseWriter, r *http.Request) error { return nil }) if err != nil { - if errors.Is(err, MaxFrequencyLimitError) { - reason := ErrorCodeOverEmailSendRateLimit - if phone != "" { - reason = ErrorCodeOverSMSSendRateLimit - } - - return tooManyRequestsError(reason, "For security purposes, you can only request this once every 60 seconds") - } return err } diff --git a/internal/api/recover.go b/internal/api/recover.go index 0fa9760ae..cbcff81d8 100644 --- a/internal/api/recover.go +++ b/internal/api/recover.go @@ -1,7 +1,6 @@ package api import ( - "errors" "net/http" "github.com/supabase/auth/internal/models" @@ -67,10 +66,7 @@ func (a *API) Recover(w http.ResponseWriter, r *http.Request) error { return a.sendPasswordRecovery(r, tx, user, flowType) }) if err != nil { - if errors.Is(err, MaxFrequencyLimitError) { - return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, "For security purposes, you can only request this once every 60 seconds") - } - return internalServerError("Unable to process request").WithInternalError(err) + return err } return sendJSON(w, http.StatusOK, map[string]string{}) diff --git a/internal/api/resend.go b/internal/api/resend.go index 1dfc47762..a1f4246c8 100644 --- a/internal/api/resend.go +++ b/internal/api/resend.go @@ -1,9 +1,7 @@ package api import ( - "errors" "net/http" - "time" "github.com/supabase/auth/internal/api/sms_provider" "github.com/supabase/auth/internal/conf" @@ -144,16 +142,7 @@ func (a *API) Resend(w http.ResponseWriter, r *http.Request) error { return nil }) if err != nil { - if errors.Is(err, MaxFrequencyLimitError) { - reason := ErrorCodeOverEmailSendRateLimit - if params.Type == smsVerification || params.Type == phoneChangeVerification { - reason = ErrorCodeOverSMSSendRateLimit - } - - until := time.Until(user.ConfirmationSentAt.Add(config.SMTP.MaxFrequency)) / time.Second - return tooManyRequestsError(reason, "For security purposes, you can only request this once every %d seconds.", until) - } - return internalServerError("Unable to process request").WithInternalError(err) + return err } ret := map[string]any{} diff --git a/internal/api/signup.go b/internal/api/signup.go index d7d946c8c..0a1b8c6c4 100644 --- a/internal/api/signup.go +++ b/internal/api/signup.go @@ -244,10 +244,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { } } if terr = a.sendConfirmation(r, tx, user, flowType); terr != nil { - if errors.Is(terr, MaxFrequencyLimitError) { - return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, generateFrequencyLimitErrorMessage(user.ConfirmationSentAt, config.SMTP.MaxFrequency)) - } - return internalServerError("Error sending confirmation mail").WithInternalError(terr) + return terr } } } else if params.Provider == "phone" && !user.IsPhoneConfirmed() { @@ -277,14 +274,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { }) if err != nil { - reason := ErrorCodeOverEmailSendRateLimit - if params.Provider == "phone" { - reason = ErrorCodeOverSMSSendRateLimit - } - - if errors.Is(err, MaxFrequencyLimitError) { - return tooManyRequestsError(reason, "For security purposes, you can only request this once every minute") - } else if errors.Is(err, UserExistsError) { + if errors.Is(err, UserExistsError) { err = db.Transaction(func(tx *storage.Connection) error { if terr := models.NewAuditLogEntry(r, tx, user, models.UserRepeatedSignUpAction, "", map[string]interface{}{ "provider": params.Provider, diff --git a/internal/api/user.go b/internal/api/user.go index c89c5226d..616ad23c7 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -2,7 +2,6 @@ package api import ( "context" - "errors" "net/http" "time" @@ -226,10 +225,7 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error { } if terr = a.sendEmailChange(r, tx, user, params.Email, flowType); terr != nil { - if errors.Is(terr, MaxFrequencyLimitError) { - return tooManyRequestsError(ErrorCodeOverEmailSendRateLimit, generateFrequencyLimitErrorMessage(user.EmailChangeSentAt, config.SMTP.MaxFrequency)) - } - return internalServerError("Error sending change email").WithInternalError(terr) + return terr } } } diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go index ff19239d8..a05e6e27a 100644 --- a/internal/mailer/mailer.go +++ b/internal/mailer/mailer.go @@ -15,7 +15,6 @@ import ( // Mailer defines the interface a mailer must implement. type Mailer interface { - Send(user *models.User, subject, body string, data map[string]interface{}) error InviteMail(r *http.Request, user *models.User, otp, referrerURL string, externalURL *url.URL) error ConfirmationMail(r *http.Request, user *models.User, otp, referrerURL string, externalURL *url.URL) error RecoveryMail(r *http.Request, user *models.User, otp, referrerURL string, externalURL *url.URL) error From c5480ef83248ec2e7e3d3d87f92f43f17161ed25 Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Mon, 2 Sep 2024 10:26:10 +0200 Subject: [PATCH 108/118] feat: add support for saml encrypted assertions (#1752) By setting the `GOTRUE_SAML_ALLOW_ENCRYPTED_ASSERTIONS` to `true` the SAML private key will be advertised as usable with encryption too. Encrypted assertions are fairly rare these days because: - They make it very hard to debug what's going on. - HTTPS is the default protocol on the web for over 10 years, including in intranets. **Why not use a separate key?** The underlying library [does not support it](https://pkg.go.dev/github.com/crewjam/saml@v0.4.14/samlsp#Options) and there are no significant cryptological issues using the same RSA key for signatures and encryption, especially in a limited setting like this. --- internal/api/saml.go | 6 ++---- internal/conf/saml.go | 5 +++++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/internal/api/saml.go b/internal/api/saml.go index def4e3912..f32d4436d 100644 --- a/internal/api/saml.go +++ b/internal/api/saml.go @@ -80,16 +80,14 @@ func (a *API) SAMLMetadata(w http.ResponseWriter, r *http.Request) error { } } - // don't advertize the encryption keys as it makes it much difficult to debug - // requests / responses, and does not increase security since assertions are - // not "private" and not necessary to be hidden from the browser for i := range metadata.SPSSODescriptors { spd := &metadata.SPSSODescriptors[i] var keyDescriptors []saml.KeyDescriptor for _, kd := range spd.KeyDescriptors { - if kd.Use == "signing" { + // only advertize key as usable for encryption if allowed + if kd.Use == "signing" || (a.config.SAML.AllowEncryptedAssertions && kd.Use == "encryption") { keyDescriptors = append(keyDescriptors, kd) } } diff --git a/internal/conf/saml.go b/internal/conf/saml.go index 246868ed6..66a820caf 100644 --- a/internal/conf/saml.go +++ b/internal/conf/saml.go @@ -17,6 +17,7 @@ import ( type SAMLConfiguration struct { Enabled bool `json:"enabled"` PrivateKey string `json:"-" split_words:"true"` + AllowEncryptedAssertions bool `json:"allow_encrypted_assertions" split_words:"true"` RelayStateValidityPeriod time.Duration `json:"relay_state_validity_period" split_words:"true"` RSAPrivateKey *rsa.PrivateKey `json:"-"` @@ -111,6 +112,10 @@ func (c *SAMLConfiguration) PopulateFields(externalURL string) error { }, } + if c.AllowEncryptedAssertions { + certTemplate.KeyUsage = certTemplate.KeyUsage | x509.KeyUsageDataEncipherment + } + certDer, err := x509.CreateCertificate(nil, certTemplate, certTemplate, c.RSAPublicKey, c.RSAPrivateKey) if err != nil { return err From 2ad07373aa9239eba94abdabbb01c9abfa8c48de Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Mon, 2 Sep 2024 12:00:59 +0200 Subject: [PATCH 109/118] feat: add option to disable magic links (#1756) Adds an option to disable magic links, as they are more prone to email sending abuse than other email authentication methods. --- internal/api/magic_link.go | 4 ++++ internal/conf/configuration.go | 2 ++ 2 files changed, 6 insertions(+) diff --git a/internal/api/magic_link.go b/internal/api/magic_link.go index d7e941b79..e3fc0315b 100644 --- a/internal/api/magic_link.go +++ b/internal/api/magic_link.go @@ -46,6 +46,10 @@ func (a *API) MagicLink(w http.ResponseWriter, r *http.Request) error { return unprocessableEntityError(ErrorCodeEmailProviderDisabled, "Email logins are disabled") } + if !config.External.Email.MagicLinkEnabled { + return unprocessableEntityError(ErrorCodeEmailProviderDisabled, "Login with magic link is disabled") + } + params := &MagicLinkParams{} jsonDecoder := json.NewDecoder(r.Body) err := jsonDecoder.Decode(params) diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 55d7a8fab..18b5c3ff9 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -71,6 +71,8 @@ type AnonymousProviderConfiguration struct { type EmailProviderConfiguration struct { Enabled bool `json:"enabled" default:"true"` + + MagicLinkEnabled bool `json:"magic_link_enabled" default:"true" split_words:"true"` } // DBConfiguration holds all the database related configuration. From f3a28d182d193cf528cc72a985dfeaf7ecb67056 Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Mon, 2 Sep 2024 13:07:41 +0200 Subject: [PATCH 110/118] feat: add authorized email address support (#1757) Adds support for authorized email addresses, useful when needing to restrict email sending to a handful of email addresses. In the Supabase platform use case, this can be used to allow sending of emails on new projects only to the project's owners or developers, reducing email abuse. To enable it, specify `GOTRUE_EXTERNAL_EMAIL_AUTHORIZED_ADDRESSES` as a comma-delimited string of email addresses. The addresses should be lowercased and without labels. Labels are supported so emails will be sent to `someone+test1@gmail.com` and `someone+test2@gmail.com` if and only if the `someone@gmail.com` address is added in the authorized list. Not a substitute for blocklists! --- internal/api/admin.go | 4 ++-- internal/api/errorcodes.go | 3 ++- internal/api/invite.go | 2 +- internal/api/magic_link.go | 6 +++--- internal/api/mail.go | 28 +++++++++++++++++++++++---- internal/api/mail_test.go | 35 ++++++++++++++++++++++++++++++++++ internal/api/otp.go | 2 +- internal/api/recover.go | 6 +++--- internal/api/resend.go | 10 +++++----- internal/api/signup.go | 2 +- internal/api/user.go | 2 +- internal/api/verify.go | 8 ++++---- internal/api/verify_test.go | 2 +- internal/conf/configuration.go | 2 ++ 14 files changed, 85 insertions(+), 27 deletions(-) diff --git a/internal/api/admin.go b/internal/api/admin.go index 0e5ae0cd9..45b08f041 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -150,7 +150,7 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error { } if params.Email != "" { - params.Email, err = validateEmail(params.Email) + params.Email, err = a.validateEmail(params.Email) if err != nil { return err } @@ -343,7 +343,7 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error { var providers []string if params.Email != "" { - params.Email, err = validateEmail(params.Email) + params.Email, err = a.validateEmail(params.Email) if err != nil { return err } diff --git a/internal/api/errorcodes.go b/internal/api/errorcodes.go index a37a4513a..f6a890566 100644 --- a/internal/api/errorcodes.go +++ b/internal/api/errorcodes.go @@ -83,5 +83,6 @@ const ( ErrorCodeMFATOTPVerifyDisabled ErrorCode = "mfa_totp_verify_not_enabled" ErrorCodeMFAVerifiedFactorExists ErrorCode = "mfa_verified_factor_exists" //#nosec G101 -- Not a secret value. - ErrorCodeInvalidCredentials ErrorCode = "invalid_credentials" + ErrorCodeInvalidCredentials ErrorCode = "invalid_credentials" + ErrorCodeEmailAddressNotAuthorized ErrorCode = "email_address_not_authorized" ) diff --git a/internal/api/invite.go b/internal/api/invite.go index 76852f711..f0260dd97 100644 --- a/internal/api/invite.go +++ b/internal/api/invite.go @@ -26,7 +26,7 @@ func (a *API) Invite(w http.ResponseWriter, r *http.Request) error { } var err error - params.Email, err = validateEmail(params.Email) + params.Email, err = a.validateEmail(params.Email) if err != nil { return err } diff --git a/internal/api/magic_link.go b/internal/api/magic_link.go index e3fc0315b..44f9fc88f 100644 --- a/internal/api/magic_link.go +++ b/internal/api/magic_link.go @@ -21,12 +21,12 @@ type MagicLinkParams struct { CodeChallenge string `json:"code_challenge"` } -func (p *MagicLinkParams) Validate() error { +func (p *MagicLinkParams) Validate(a *API) error { if p.Email == "" { return unprocessableEntityError(ErrorCodeValidationFailed, "Password recovery requires an email") } var err error - p.Email, err = validateEmail(p.Email) + p.Email, err = a.validateEmail(p.Email) if err != nil { return err } @@ -57,7 +57,7 @@ func (a *API) MagicLink(w http.ResponseWriter, r *http.Request) error { return badRequestError(ErrorCodeBadJSON, "Could not read verification params: %v", err).WithInternalError(err) } - if err := params.Validate(); err != nil { + if err := params.Validate(a); err != nil { return err } diff --git a/internal/api/mail.go b/internal/api/mail.go index 00bb58e7e..c82214918 100644 --- a/internal/api/mail.go +++ b/internal/api/mail.go @@ -2,6 +2,7 @@ package api import ( "net/http" + "regexp" "strings" "time" @@ -24,6 +25,7 @@ import ( var ( EmailRateLimitExceeded error = errors.New("email rate limit exceeded") + AddressNotAuthorized error = errors.New("Destination email address not authorized") ) type GenerateLinkParams struct { @@ -56,7 +58,7 @@ func (a *API) adminGenerateLink(w http.ResponseWriter, r *http.Request) error { } var err error - params.Email, err = validateEmail(params.Email) + params.Email, err = a.validateEmail(params.Email) if err != nil { return err } @@ -230,7 +232,7 @@ func (a *API) adminGenerateLink(w http.ResponseWriter, r *http.Request) error { if !config.Mailer.SecureEmailChangeEnabled && params.Type == "email_change_current" { return badRequestError(ErrorCodeValidationFailed, "Enable secure email change to generate link for current email") } - params.NewEmail, terr = validateEmail(params.NewEmail) + params.NewEmail, terr = a.validateEmail(params.NewEmail) if terr != nil { return terr } @@ -548,7 +550,9 @@ func (a *API) sendEmailChange(r *http.Request, tx *storage.Connection, u *models return nil } -func validateEmail(email string) (string, error) { +var emailLabelPattern = regexp.MustCompile("[+][^@]+@") + +func (a *API) validateEmail(email string) (string, error) { if email == "" { return "", badRequestError(ErrorCodeValidationFailed, "An email address is required") } @@ -558,7 +562,23 @@ func validateEmail(email string) (string, error) { if err := checkmail.ValidateFormat(email); err != nil { return "", badRequestError(ErrorCodeValidationFailed, "Unable to validate email address: "+err.Error()) } - return strings.ToLower(email), nil + + email = strings.ToLower(email) + + if len(a.config.External.Email.AuthorizedAddresses) > 0 { + // allow labelled emails when authorization rules are in place + normalized := emailLabelPattern.ReplaceAllString(email, "@") + + for _, authorizedAddress := range a.config.External.Email.AuthorizedAddresses { + if normalized == authorizedAddress { + return email, nil + } + } + + return "", badRequestError(ErrorCodeEmailAddressNotAuthorized, "Email address %q cannot be used as it is not authorized", email) + } + + return email, nil } func validateSentWithinFrequencyLimit(sentAt *time.Time, frequency time.Duration) error { diff --git a/internal/api/mail_test.go b/internal/api/mail_test.go index 90608a13a..fd3de7c80 100644 --- a/internal/api/mail_test.go +++ b/internal/api/mail_test.go @@ -48,6 +48,41 @@ func (ts *MailTestSuite) SetupTest() { require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new user") } +func (ts *MailTestSuite) TestValidateEmailAuthorizedAddresses() { + ts.Config.External.Email.AuthorizedAddresses = []string{"someone-a@example.com", "someone-b@example.com"} + defer func() { + ts.Config.External.Email.AuthorizedAddresses = nil + }() + + positiveExamples := []string{ + "someone-a@example.com", + "someone-b@example.com", + "someone-a+test-1@example.com", + "someone-b+test-2@example.com", + "someone-A@example.com", + "someone-B@example.com", + "someone-a@Example.com", + "someone-b@Example.com", + } + + negativeExamples := []string{ + "someone@example.com", + "s.omeone@example.com", + "someone-a+@example.com", + "someone+a@example.com", + } + + for _, example := range positiveExamples { + _, err := ts.API.validateEmail(example) + require.NoError(ts.T(), err) + } + + for _, example := range negativeExamples { + _, err := ts.API.validateEmail(example) + require.Error(ts.T(), err) + } +} + func (ts *MailTestSuite) TestGenerateLink() { // create admin jwt claims := &AccessTokenClaims{ diff --git a/internal/api/otp.go b/internal/api/otp.go index e690bdfe2..1821da3ee 100644 --- a/internal/api/otp.go +++ b/internal/api/otp.go @@ -216,7 +216,7 @@ func (a *API) shouldCreateUser(r *http.Request, params *OtpParams) (bool, error) aud := a.requestAud(ctx, r) var err error if params.Email != "" { - params.Email, err = validateEmail(params.Email) + params.Email, err = a.validateEmail(params.Email) if err != nil { return false, err } diff --git a/internal/api/recover.go b/internal/api/recover.go index cbcff81d8..7c03c3246 100644 --- a/internal/api/recover.go +++ b/internal/api/recover.go @@ -14,12 +14,12 @@ type RecoverParams struct { CodeChallengeMethod string `json:"code_challenge_method"` } -func (p *RecoverParams) Validate() error { +func (p *RecoverParams) Validate(a *API) error { if p.Email == "" { return badRequestError(ErrorCodeValidationFailed, "Password recovery requires an email") } var err error - if p.Email, err = validateEmail(p.Email); err != nil { + if p.Email, err = a.validateEmail(p.Email); err != nil { return err } if err := validatePKCEParams(p.CodeChallengeMethod, p.CodeChallenge); err != nil { @@ -38,7 +38,7 @@ func (a *API) Recover(w http.ResponseWriter, r *http.Request) error { } flowType := getFlowFromChallenge(params.CodeChallenge) - if err := params.Validate(); err != nil { + if err := params.Validate(a); err != nil { return err } diff --git a/internal/api/resend.go b/internal/api/resend.go index a1f4246c8..2c305360b 100644 --- a/internal/api/resend.go +++ b/internal/api/resend.go @@ -4,7 +4,6 @@ import ( "net/http" "github.com/supabase/auth/internal/api/sms_provider" - "github.com/supabase/auth/internal/conf" mail "github.com/supabase/auth/internal/mailer" "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/storage" @@ -17,7 +16,9 @@ type ResendConfirmationParams struct { Phone string `json:"phone"` } -func (p *ResendConfirmationParams) Validate(config *conf.GlobalConfiguration) error { +func (p *ResendConfirmationParams) Validate(a *API) error { + config := a.config + switch p.Type { case mail.SignupVerification, mail.EmailChangeVerification, smsVerification, phoneChangeVerification: break @@ -40,7 +41,7 @@ func (p *ResendConfirmationParams) Validate(config *conf.GlobalConfiguration) er if !config.External.Email.Enabled { return badRequestError(ErrorCodeEmailProviderDisabled, "Email logins are disabled") } - p.Email, err = validateEmail(p.Email) + p.Email, err = a.validateEmail(p.Email) if err != nil { return err } @@ -63,13 +64,12 @@ func (p *ResendConfirmationParams) Validate(config *conf.GlobalConfiguration) er func (a *API) Resend(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) - config := a.config params := &ResendConfirmationParams{} if err := retrieveRequestParams(r, params); err != nil { return err } - if err := params.Validate(config); err != nil { + if err := params.Validate(a); err != nil { return err } diff --git a/internal/api/signup.go b/internal/api/signup.go index 0a1b8c6c4..22ac7dc02 100644 --- a/internal/api/signup.go +++ b/internal/api/signup.go @@ -141,7 +141,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { if !config.External.Email.Enabled { return badRequestError(ErrorCodeEmailProviderDisabled, "Email signups are disabled") } - params.Email, err = validateEmail(params.Email) + params.Email, err = a.validateEmail(params.Email) if err != nil { return err } diff --git a/internal/api/user.go b/internal/api/user.go index 616ad23c7..47ec8e9fd 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -30,7 +30,7 @@ func (a *API) validateUserUpdateParams(ctx context.Context, p *UserUpdateParams) var err error if p.Email != "" { - p.Email, err = validateEmail(p.Email) + p.Email, err = a.validateEmail(p.Email) if err != nil { return err } diff --git a/internal/api/verify.go b/internal/api/verify.go index 5adc80744..dbd4d76c6 100644 --- a/internal/api/verify.go +++ b/internal/api/verify.go @@ -45,7 +45,7 @@ type VerifyParams struct { RedirectTo string `json:"redirect_to"` } -func (p *VerifyParams) Validate(r *http.Request) error { +func (p *VerifyParams) Validate(r *http.Request, a *API) error { var err error if p.Type == "" { return badRequestError(ErrorCodeValidationFailed, "Verify requires a verification type") @@ -69,7 +69,7 @@ func (p *VerifyParams) Validate(r *http.Request) error { } p.TokenHash = crypto.GenerateTokenHash(p.Phone, p.Token) } else if isEmailOtpVerification(p) { - p.Email, err = validateEmail(p.Email) + p.Email, err = a.validateEmail(p.Email) if err != nil { return unprocessableEntityError(ErrorCodeValidationFailed, "Invalid email format").WithInternalError(err) } @@ -96,7 +96,7 @@ func (a *API) Verify(w http.ResponseWriter, r *http.Request) error { params.Token = r.FormValue("token") params.Type = r.FormValue("type") params.RedirectTo = utilities.GetReferrer(r, a.config) - if err := params.Validate(r); err != nil { + if err := params.Validate(r, a); err != nil { return err } return a.verifyGet(w, r, params) @@ -104,7 +104,7 @@ func (a *API) Verify(w http.ResponseWriter, r *http.Request) error { if err := retrieveRequestParams(r, params); err != nil { return err } - if err := params.Validate(r); err != nil { + if err := params.Validate(r, a); err != nil { return err } return a.verifyPost(w, r, params) diff --git a/internal/api/verify_test.go b/internal/api/verify_test.go index 73ef6f768..a0232efcf 100644 --- a/internal/api/verify_test.go +++ b/internal/api/verify_test.go @@ -1248,7 +1248,7 @@ func (ts *VerifyTestSuite) TestVerifyValidateParams() { for _, c := range cases { ts.Run(c.desc, func() { req := httptest.NewRequest(c.method, "http://localhost", nil) - err := c.params.Validate(req) + err := c.params.Validate(req, ts.API) require.Equal(ts.T(), c.expected, err) }) } diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 18b5c3ff9..a4315866c 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -72,6 +72,8 @@ type AnonymousProviderConfiguration struct { type EmailProviderConfiguration struct { Enabled bool `json:"enabled" default:"true"` + AuthorizedAddresses []string `json:"authorized_addresses" split_words:"true"` + MagicLinkEnabled bool `json:"magic_link_enabled" default:"true" split_words:"true"` } From 0084625ad0790dd7c14b412d932425f4b84bb4c8 Mon Sep 17 00:00:00 2001 From: Lasha <72510037+LashaJini@users.noreply.github.com> Date: Mon, 2 Sep 2024 16:43:44 +0400 Subject: [PATCH 111/118] fix: simplify WaitForCleanup (#1747) Deduplicate the `WaitForCleanup` function into one under `utilities`. --- internal/api/cleanup.go | 18 +++--------------- internal/observability/cleanup.go | 18 +++--------------- internal/utilities/context.go | 25 ++++++++++++++++++++++++- 3 files changed, 30 insertions(+), 31 deletions(-) diff --git a/internal/api/cleanup.go b/internal/api/cleanup.go index da0bcb1c0..aebe0ddd4 100644 --- a/internal/api/cleanup.go +++ b/internal/api/cleanup.go @@ -3,6 +3,8 @@ package api import ( "context" "sync" + + "github.com/supabase/auth/internal/utilities" ) var ( @@ -12,19 +14,5 @@ var ( // WaitForCleanup waits until all API servers are shut down cleanly or until // the provided context signals done, whichever comes first. func WaitForCleanup(ctx context.Context) { - cleanupDone := make(chan struct{}) - - go func() { - defer close(cleanupDone) - - cleanupWaitGroup.Wait() - }() - - select { - case <-ctx.Done(): - return - - case <-cleanupDone: - return - } + utilities.WaitForCleanup(ctx, &cleanupWaitGroup) } diff --git a/internal/observability/cleanup.go b/internal/observability/cleanup.go index b6e409a75..2e88c3590 100644 --- a/internal/observability/cleanup.go +++ b/internal/observability/cleanup.go @@ -3,6 +3,8 @@ package observability import ( "context" "sync" + + "github.com/supabase/auth/internal/utilities" ) var ( @@ -12,19 +14,5 @@ var ( // WaitForCleanup waits until all observability long-running goroutines shut // down cleanly or until the provided context signals done. func WaitForCleanup(ctx context.Context) { - cleanupDone := make(chan struct{}) - - go func() { - defer close(cleanupDone) - - cleanupWaitGroup.Wait() - }() - - select { - case <-ctx.Done(): - return - - case <-cleanupDone: - return - } + utilities.WaitForCleanup(ctx, &cleanupWaitGroup) } diff --git a/internal/utilities/context.go b/internal/utilities/context.go index 2818fdd6c..06aa74a39 100644 --- a/internal/utilities/context.go +++ b/internal/utilities/context.go @@ -1,6 +1,9 @@ package utilities -import "context" +import ( + "context" + "sync" +) type contextKey string @@ -26,3 +29,23 @@ func GetRequestID(ctx context.Context) string { return obj.(string) } + +// WaitForCleanup waits until all long-running goroutines shut +// down cleanly or until the provided context signals done. +func WaitForCleanup(ctx context.Context, wg *sync.WaitGroup) { + cleanupDone := make(chan struct{}) + + go func() { + defer close(cleanupDone) + + wg.Wait() + }() + + select { + case <-ctx.Done(): + return + + case <-cleanupDone: + return + } +} From 700920264d4b6d0b4afe8b6848127aef45c5315f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 2 Sep 2024 14:52:50 +0200 Subject: [PATCH 112/118] chore(master): release 2.160.0 (#1749) :robot: I have created a release *beep* *boop* --- ## [2.160.0](https://github.com/supabase/auth/compare/v2.159.2...v2.160.0) (2024-09-02) ### Features * add authorized email address support ([#1757](https://github.com/supabase/auth/issues/1757)) ([f3a28d1](https://github.com/supabase/auth/commit/f3a28d182d193cf528cc72a985dfeaf7ecb67056)) * add option to disable magic links ([#1756](https://github.com/supabase/auth/issues/1756)) ([2ad0737](https://github.com/supabase/auth/commit/2ad07373aa9239eba94abdabbb01c9abfa8c48de)) * add support for saml encrypted assertions ([#1752](https://github.com/supabase/auth/issues/1752)) ([c5480ef](https://github.com/supabase/auth/commit/c5480ef83248ec2e7e3d3d87f92f43f17161ed25)) ### Bug Fixes * apply shared limiters before email / sms is sent ([#1748](https://github.com/supabase/auth/issues/1748)) ([bf276ab](https://github.com/supabase/auth/commit/bf276ab49753642793471815727559172fea4efc)) * simplify WaitForCleanup ([#1747](https://github.com/supabase/auth/issues/1747)) ([0084625](https://github.com/supabase/auth/commit/0084625ad0790dd7c14b412d932425f4b84bb4c8)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba26d6f22..d6b2a05ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [2.160.0](https://github.com/supabase/auth/compare/v2.159.2...v2.160.0) (2024-09-02) + + +### Features + +* add authorized email address support ([#1757](https://github.com/supabase/auth/issues/1757)) ([f3a28d1](https://github.com/supabase/auth/commit/f3a28d182d193cf528cc72a985dfeaf7ecb67056)) +* add option to disable magic links ([#1756](https://github.com/supabase/auth/issues/1756)) ([2ad0737](https://github.com/supabase/auth/commit/2ad07373aa9239eba94abdabbb01c9abfa8c48de)) +* add support for saml encrypted assertions ([#1752](https://github.com/supabase/auth/issues/1752)) ([c5480ef](https://github.com/supabase/auth/commit/c5480ef83248ec2e7e3d3d87f92f43f17161ed25)) + + +### Bug Fixes + +* apply shared limiters before email / sms is sent ([#1748](https://github.com/supabase/auth/issues/1748)) ([bf276ab](https://github.com/supabase/auth/commit/bf276ab49753642793471815727559172fea4efc)) +* simplify WaitForCleanup ([#1747](https://github.com/supabase/auth/issues/1747)) ([0084625](https://github.com/supabase/auth/commit/0084625ad0790dd7c14b412d932425f4b84bb4c8)) + ## [2.159.2](https://github.com/supabase/auth/compare/v2.159.1...v2.159.2) (2024-08-28) From 9d419b400f0637b10e5c235b8fd5bac0d69352bd Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Tue, 3 Sep 2024 16:03:47 +0200 Subject: [PATCH 113/118] fix: user sanitization should clean up email change info too (#1759) The `sanitizeUser` function did not cleanup the **EmailChange** and **EmailChangeSentAt** properties on a User. If a User had a pending email address change, the new address could be leaked via a crafted `signUp` request. --- internal/api/signup.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/api/signup.go b/internal/api/signup.go index 22ac7dc02..1c74da6b6 100644 --- a/internal/api/signup.go +++ b/internal/api/signup.go @@ -336,9 +336,9 @@ func sanitizeUser(u *models.User, params *SignupParams) (*models.User, error) { u.ID = uuid.Must(uuid.NewV4()) - u.Role = "" + u.Role, u.EmailChange = "", "" u.CreatedAt, u.UpdatedAt, u.ConfirmationSentAt = now, now, &now - u.LastSignInAt, u.ConfirmedAt, u.EmailConfirmedAt, u.PhoneConfirmedAt = nil, nil, nil, nil + u.LastSignInAt, u.ConfirmedAt, u.EmailChangeSentAt, u.EmailConfirmedAt, u.PhoneConfirmedAt = nil, nil, nil, nil, nil u.Identities = make([]models.Identity, 0) u.UserMetaData = params.Data u.Aud = params.Aud From a6c18243b92b74798b6317e1c35c8a73bc3fd6e1 Mon Sep 17 00:00:00 2001 From: Chris Stockton <180184+cstockton@users.noreply.github.com> Date: Mon, 9 Sep 2024 12:03:14 -0700 Subject: [PATCH 114/118] chore: fix gosec warnings via ignore annotations in comments (#1770) ## What kind of change does this PR introduce? Fix to gosec warnings so builds can complete. ## What is the current behavior? The gosec checks are halting builds. ## What is the new behavior? The gosec checks are passing. ## Additional context I didn't see any warnings that led to real vulnerabilities / security issues. That said long term it may be worth adding some defensive bounds checks for a couple of the integer overflow warnings, just to future proof us age the code ages. Given that we allow supabase users to write to the database, not sure we can guarantee a user doesn't provide a bring-your-own-hash singup flow or something like that. Unbound allocations are a prime target for DoS attacks. For the nonce issues, neither is was real issue. Open is not "fixable, see gosec issue [#1211](https://github.com/securego/gosec/issues/1211). For Seal I tried: ``` nonce := make([]byte, cipher.NonceSize()) if _, err := rand.Read(nonce); err != nil { panic(err) } es := EncryptedString{ KeyID: keyID, Algorithm: "aes-gcm-hkdf", Nonce: nonce, } es.Data = cipher.Seal(nil, es.Nonce, data, nil) ``` But it then considers es.Nonce to be stored / hardcoded. The only fix I could get to work was: ```Go nonce := make([]byte, cipher.NonceSize()) if _, err := rand.Read(nonce); err != nil { panic(err) } es := EncryptedString{ KeyID: keyID, Algorithm: "aes-gcm-hkdf", Nonce: nonce, Data: cipher.Seal(nil, nonce, data, nil), } ``` It seems the gosec tool requires using `rand.Read`. I changed the `cipher.NonceSize()` back to `12` (just in case it a numerical constant for a reason) and it started failing again. I think it also checks that cipher.NonceSize() is used as well, just doesn't report that. I ultimately decided to ignore this so there was no changes to crypto functions given the existing code is correct. Co-authored-by: Chris Stockton --- internal/api/verify.go | 2 +- internal/crypto/crypto.go | 4 ++-- internal/crypto/password.go | 6 +++--- internal/models/audit_log_entry.go | 4 ++-- internal/models/cleanup.go | 5 +++-- internal/models/user.go | 4 ++-- 6 files changed, 13 insertions(+), 12 deletions(-) diff --git a/internal/api/verify.go b/internal/api/verify.go index dbd4d76c6..ad5a7d096 100644 --- a/internal/api/verify.go +++ b/internal/api/verify.go @@ -718,7 +718,7 @@ func isOtpValid(actual, expected string, sentAt *time.Time, otpExp uint) bool { } func isOtpExpired(sentAt *time.Time, otpExp uint) bool { - return time.Now().After(sentAt.Add(time.Second * time.Duration(otpExp))) + return time.Now().After(sentAt.Add(time.Second * time.Duration(otpExp))) // #nosec G115 } // isPhoneOtpVerification checks if the verification came from a phone otp diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index be6a2b5df..236df4b3f 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -112,7 +112,7 @@ func (es *EncryptedString) Decrypt(id string, decryptionKeys map[string]string) return nil, err } - decrypted, err := cipher.Open(nil, es.Nonce, es.Data, nil) + decrypted, err := cipher.Open(nil, es.Nonce, es.Data, nil) // #nosec G407 if err != nil { return nil, err } @@ -203,7 +203,7 @@ func NewEncryptedString(id string, data []byte, keyID string, keyBase64URL strin panic(err) } - es.Data = cipher.Seal(nil, es.Nonce, data, nil) + es.Data = cipher.Seal(nil, es.Nonce, data, nil) // #nosec G407 return &es, nil } diff --git a/internal/crypto/password.go b/internal/crypto/password.go index bce145a45..49db8b6a3 100644 --- a/internal/crypto/password.go +++ b/internal/crypto/password.go @@ -141,7 +141,7 @@ func compareHashAndPasswordArgon2(ctx context.Context, hash, password string) er attribute.Int64("t", int64(input.time)), attribute.Int("p", int(input.threads)), attribute.Int("len", len(input.rawHash)), - } + } // #nosec G115 var match bool var derivedKey []byte @@ -157,10 +157,10 @@ func compareHashAndPasswordArgon2(ctx context.Context, hash, password string) er switch input.alg { case "argon2i": - derivedKey = argon2.Key([]byte(password), input.salt, uint32(input.time), uint32(input.memory)*1024, uint8(input.threads), uint32(len(input.rawHash))) + derivedKey = argon2.Key([]byte(password), input.salt, uint32(input.time), uint32(input.memory)*1024, uint8(input.threads), uint32(len(input.rawHash))) // #nosec G115 case "argon2id": - derivedKey = argon2.IDKey([]byte(password), input.salt, uint32(input.time), uint32(input.memory)*1024, uint8(input.threads), uint32(len(input.rawHash))) + derivedKey = argon2.IDKey([]byte(password), input.salt, uint32(input.time), uint32(input.memory)*1024, uint8(input.threads), uint32(len(input.rawHash))) // #nosec G115 } match = subtle.ConstantTimeCompare(derivedKey, input.rawHash) == 0 diff --git a/internal/models/audit_log_entry.go b/internal/models/audit_log_entry.go index 52d9f6d4a..5bbc9b0b6 100644 --- a/internal/models/audit_log_entry.go +++ b/internal/models/audit_log_entry.go @@ -156,8 +156,8 @@ func FindAuditLogEntries(tx *storage.Connection, filterColumns []string, filterV logs := []*AuditLogEntry{} var err error if pageParams != nil { - err = q.Paginate(int(pageParams.Page), int(pageParams.PerPage)).All(&logs) - pageParams.Count = uint64(q.Paginator.TotalEntriesSize) + err = q.Paginate(int(pageParams.Page), int(pageParams.PerPage)).All(&logs) // #nosec G115 + pageParams.Count = uint64(q.Paginator.TotalEntriesSize) // #nosec G115 } else { err = q.All(&logs) } diff --git a/internal/models/cleanup.go b/internal/models/cleanup.go index 46d4c2bdd..69cf7c7a3 100644 --- a/internal/models/cleanup.go +++ b/internal/models/cleanup.go @@ -3,10 +3,11 @@ package models import ( "context" "fmt" + "sync/atomic" + "github.com/sirupsen/logrus" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/metric" - "sync/atomic" "go.opentelemetry.io/otel/attribute" @@ -111,7 +112,7 @@ func (c *Cleanup) Clean(db *storage.Connection) (int, error) { defer span.SetAttributes(attribute.Int64("gotrue.cleanup.affected_rows", int64(affectedRows))) if err := db.WithContext(ctx).Transaction(func(tx *storage.Connection) error { - nextIndex := atomic.AddUint32(&c.cleanupNext, 1) % uint32(len(c.cleanupStatements)) + nextIndex := atomic.AddUint32(&c.cleanupNext, 1) % uint32(len(c.cleanupStatements)) // #nosec G115 statement := c.cleanupStatements[nextIndex] count, terr := tx.RawQuery(statement).ExecWithCount() diff --git a/internal/models/user.go b/internal/models/user.go index e50b9647d..2f40e26f9 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -683,8 +683,8 @@ func FindUsersInAudience(tx *storage.Connection, aud string, pageParams *Paginat var err error if pageParams != nil { - err = q.Paginate(int(pageParams.Page), int(pageParams.PerPage)).All(&users) - pageParams.Count = uint64(q.Paginator.TotalEntriesSize) + err = q.Paginate(int(pageParams.Page), int(pageParams.PerPage)).All(&users) // #nosec G115 + pageParams.Count = uint64(q.Paginator.TotalEntriesSize) // #nosec G115 } else { err = q.All(&users) } From 7e472ad72042e86882dab3fddce9fafa66a8236c Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Wed, 11 Sep 2024 17:31:20 +0300 Subject: [PATCH 115/118] fix: add token to hook payload for non-secure email change (#1763) ## What kind of change does this PR introduce? Fix #1744 by introducing the token to the Auth Hook payload for Send Email. The tokenHash seems to be already present. Currently, it's passed into the function as `otpNew`. Though it is indeed the OTP needed to validate the new email address we place it in the `token` field to maintain the convention that `token_hash_new` is only populated when secure email change is enabled New output structure: image --- internal/api/mail.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/api/mail.go b/internal/api/mail.go index c82214918..8bf69ba94 100644 --- a/internal/api/mail.go +++ b/internal/api/mail.go @@ -608,6 +608,11 @@ func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User, } if config.Hook.SendEmail.Enabled { + // When secure email change is disabled, we place the token for the new email on emailData.Token + if emailActionType == mail.EmailChangeVerification && !config.Mailer.SecureEmailChangeEnabled && u.GetEmail() != "" { + otp = otpNew + } + emailData := mail.EmailData{ Token: otp, EmailActionType: emailActionType, From 567ea7ebd18eacc5e6daea8adc72e59e94459991 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Thu, 12 Sep 2024 18:45:01 +0300 Subject: [PATCH 116/118] fix: update mfa admin methods (#1774) ## What kind of change does this PR introduce? Update admin MFA methods to allow an admin to update a phone factor's phone number. Also disallows and removes factor type as an updatable field. Having the factor type field is redundant as it previously allowed for update of only one factor type (TOTP). --- internal/api/admin.go | 12 +++++++----- internal/api/admin_test.go | 14 +++----------- internal/models/factor.go | 11 +++++------ 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/internal/api/admin.go b/internal/api/admin.go index 45b08f041..d6ff069ff 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -39,7 +39,7 @@ type adminUserDeleteParams struct { type adminUserUpdateFactorParams struct { FriendlyName string `json:"friendly_name"` - FactorType string `json:"factor_type"` + Phone string `json:"phone"` } type AdminListUsersResponse struct { @@ -618,11 +618,13 @@ func (a *API) adminUserUpdateFactor(w http.ResponseWriter, r *http.Request) erro return terr } } - if params.FactorType != "" { - if params.FactorType != models.TOTP { - return badRequestError(ErrorCodeValidationFailed, "Factor Type not valid") + + if params.Phone != "" && factor.IsPhoneFactor() { + phone, err := validatePhone(params.Phone) + if err != nil { + return badRequestError(ErrorCodeValidationFailed, "Invalid phone number format (E.164 required)") } - if terr := factor.UpdateFactorType(tx, params.FactorType); terr != nil { + if terr := factor.UpdatePhone(tx, phone); terr != nil { return terr } } diff --git a/internal/api/admin_test.go b/internal/api/admin_test.go index e2904d8bf..a2070d7e2 100644 --- a/internal/api/admin_test.go +++ b/internal/api/admin_test.go @@ -816,7 +816,7 @@ func (ts *AdminTestSuite) TestAdminUserUpdateFactor() { require.NoError(ts.T(), err, "Error making new user") require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user") - f := models.NewTOTPFactor(u, "testSimpleName") + f := models.NewPhoneFactor(u, "123456789", "testSimpleName") require.NoError(ts.T(), f.SetSecret("secretkey", ts.Config.Security.DBEncryption.Encrypt, ts.Config.Security.DBEncryption.EncryptionKeyID, ts.Config.Security.DBEncryption.EncryptionKey)) require.NoError(ts.T(), ts.API.db.Create(f), "Error saving new test factor") @@ -833,20 +833,12 @@ func (ts *AdminTestSuite) TestAdminUserUpdateFactor() { ExpectedCode: http.StatusOK, }, { - Desc: "Update factor: valid factor type", + Desc: "Update Factor phone number", FactorData: map[string]interface{}{ - "friendly_name": "john", - "factor_type": models.TOTP, + "phone": "+1976154321", }, ExpectedCode: http.StatusOK, }, - { - Desc: "Update factor: invalid factor", - FactorData: map[string]interface{}{ - "factor_type": "invalid_factor", - }, - ExpectedCode: http.StatusBadRequest, - }, } // Initialize factor data diff --git a/internal/models/factor.go b/internal/models/factor.go index 7c6f6dd30..24cda188f 100644 --- a/internal/models/factor.go +++ b/internal/models/factor.go @@ -245,18 +245,17 @@ func (f *Factor) UpdateFriendlyName(tx *storage.Connection, friendlyName string) return tx.UpdateOnly(f, "friendly_name", "updated_at") } +func (f *Factor) UpdatePhone(tx *storage.Connection, phone string) error { + f.Phone = storage.NullString(phone) + return tx.UpdateOnly(f, "phone", "updated_at") +} + // UpdateStatus modifies the factor status func (f *Factor) UpdateStatus(tx *storage.Connection, state FactorState) error { f.Status = state.String() return tx.UpdateOnly(f, "status", "updated_at") } -// UpdateFactorType modifies the factor type -func (f *Factor) UpdateFactorType(tx *storage.Connection, factorType string) error { - f.FactorType = factorType - return tx.UpdateOnly(f, "factor_type", "updated_at") -} - func (f *Factor) DowngradeSessionsToAAL1(tx *storage.Connection) error { sessions, err := FindSessionsByFactorID(tx, f.ID) if err != nil { From 77d58976ae624dbb7f8abee041dd4557aab81109 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Fri, 13 Sep 2024 12:34:58 +0300 Subject: [PATCH 117/118] feat: add webauthn configuration variables (#1773) ## What kind of change does this PR introduce? Add `MFA_WEB_AUTHN_ENROLL_ENABLED` and `MFA_WEB_AUTHN_VERIFY_ENABLED` in support of the MFA WebAuthn implementation. --- example.env | 3 +++ internal/conf/configuration.go | 19 ++++++++++++------- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/example.env b/example.env index 01371183e..e645c96e9 100644 --- a/example.env +++ b/example.env @@ -233,3 +233,6 @@ GOTRUE_HOOK_CUSTOM_SMS_PROVIDER_SECRET="" # Test OTP Config GOTRUE_SMS_TEST_OTP=":, :..." GOTRUE_SMS_TEST_OTP_VALID_UNTIL="" # (e.g. 2023-09-29T08:14:06Z) + +GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED="false" +GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED="false" diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index a4315866c..792061a1a 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -111,18 +111,22 @@ type JWTConfiguration struct { } type MFAFactorTypeConfiguration struct { + EnrollEnabled bool `json:"enroll_enabled" split_words:"true" default:"false"` + VerifyEnabled bool `json:"verify_enabled" split_words:"true" default:"false"` +} + +type TOTPFactorTypeConfiguration struct { EnrollEnabled bool `json:"enroll_enabled" split_words:"true" default:"true"` VerifyEnabled bool `json:"verify_enabled" split_words:"true" default:"true"` } type PhoneFactorTypeConfiguration struct { // Default to false in order to ensure Phone MFA is opt-in - EnrollEnabled bool `json:"enroll_enabled" split_words:"true" default:"false"` - VerifyEnabled bool `json:"verify_enabled" split_words:"true" default:"false"` - OtpLength int `json:"otp_length" split_words:"true"` - SMSTemplate *template.Template `json:"-"` - MaxFrequency time.Duration `json:"max_frequency" split_words:"true"` - Template string `json:"template"` + MFAFactorTypeConfiguration + OtpLength int `json:"otp_length" split_words:"true"` + SMSTemplate *template.Template `json:"-"` + MaxFrequency time.Duration `json:"max_frequency" split_words:"true"` + Template string `json:"template"` } // MFAConfiguration holds all the MFA related Configuration @@ -133,7 +137,8 @@ type MFAConfiguration struct { MaxEnrolledFactors float64 `split_words:"true" default:"10"` MaxVerifiedFactors int `split_words:"true" default:"10"` Phone PhoneFactorTypeConfiguration `split_words:"true"` - TOTP MFAFactorTypeConfiguration `split_words:"true"` + TOTP TOTPFactorTypeConfiguration `split_words:"true"` + WebAuthn MFAFactorTypeConfiguration `split_words:"true"` } type APIConfiguration struct { From 25d98743f6cc2cca2b490a087f468c8556ec5e44 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Tue, 17 Sep 2024 22:31:32 +0200 Subject: [PATCH 118/118] fix: update aal requirements to update user (#1766) ## What kind of change does this PR introduce? If a user has verified factors (mfa enabled) we should require an AAL2 session in order to proceed with any operation We restrict phone, email, and password from updates as we consider those as sensitive fields Context: https://supabase.slack.com/archives/C02AK9166FR/p1725466764804889 --------- Co-authored-by: Stojan Dimitrovski --- internal/api/mfa_test.go | 62 ++++++++++++++++++++++++++++++++++++++++ internal/api/user.go | 6 ++++ internal/models/user.go | 10 +++++++ 3 files changed, 78 insertions(+) diff --git a/internal/api/mfa_test.go b/internal/api/mfa_test.go index 25a5d47b8..557b0ab13 100644 --- a/internal/api/mfa_test.go +++ b/internal/api/mfa_test.go @@ -295,6 +295,68 @@ func (ts *MFATestSuite) TestDuplicateTOTPEnrollsReturnExpectedMessage() { require.Contains(ts.T(), errorResponse.Message, expectedErrorMessage) } +func (ts *MFATestSuite) AAL2RequiredToUpdatePasswordAfterEnrollment() { + resp := performTestSignupAndVerify(ts, ts.TestEmail, ts.TestPassword, true /* <- requireStatusOK */) + accessTokenResp := &AccessTokenResponse{} + require.NoError(ts.T(), json.NewDecoder(resp.Body).Decode(&accessTokenResp)) + + var w *httptest.ResponseRecorder + var buffer bytes.Buffer + token := accessTokenResp.Token + // Update Password to new password + newPassword := "newpass" + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "password": newPassword, + })) + + req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer) + req.Header.Set("Content-Type", "application/json") + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + w = httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + // Logout + reqURL := "http://localhost/logout" + req = httptest.NewRequest(http.MethodPost, reqURL, nil) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + w = httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusNoContent, w.Code) + + // Get AAL1 token + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "email": ts.TestEmail, + "password": newPassword, + })) + + req = httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=password", &buffer) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + session1 := AccessTokenResponse{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&session1)) + + // Update Password again, this should fail + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "password": ts.TestPassword, + })) + + req = httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer) + req.Header.Set("Content-Type", "application/json") + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", session1.Token)) + + w = httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusUnauthorized, w.Code) + +} + func (ts *MFATestSuite) TestMultipleEnrollsCleanupExpiredFactors() { // All factors are deleted when a subsequent enroll is made ts.API.config.MFA.FactorExpiryDuration = 0 * time.Second diff --git a/internal/api/user.go b/internal/api/user.go index 47ec8e9fd..8588ce319 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -100,6 +100,12 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error { } } + if user.HasMFAEnabled() && !session.IsAAL2() { + if (params.Password != nil && *params.Password != "") || (params.Email != "" && user.GetEmail() != params.Email) || (params.Phone != "" && user.GetPhone() != params.Phone) { + return httpError(http.StatusUnauthorized, ErrorCodeInsufficientAAL, "AAL2 session is required to update email or password when MFA is enabled.") + } + } + if user.IsAnonymous { if params.Password != nil && *params.Password != "" { if params.Email == "" && params.Phone == "" { diff --git a/internal/models/user.go b/internal/models/user.go index 2f40e26f9..ffbd241c5 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -772,6 +772,16 @@ func (u *User) IsBanned() bool { return time.Now().Before(*u.BannedUntil) } +func (u *User) HasMFAEnabled() bool { + for _, factor := range u.Factors { + if factor.IsVerified() { + return true + } + } + + return false +} + func (u *User) UpdateBannedUntil(tx *storage.Connection) error { return tx.UpdateOnly(u, "banned_until") }