From 02d42520392ce473e8616a22eae9019c953913e3 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Sat, 30 May 2026 12:30:37 +0530 Subject: [PATCH] refactor(service): extract transport-agnostic service layer; migrate SignUp (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces internal/service as the home for transport-agnostic public-API operations. Each method takes a RequestMetadata (host, IP, UA, raw request) and returns a typed response plus a ResponseSideEffects bag that the transport applies (today: cookies; later: redirects, trailing headers). SignUp is the first migrated operation — chosen as the seam-proof because it exercises every gin coupling the legacy resolvers had: GetHost, GetIP/GetUserAgent, token issuance, session + mfa cookie writes, verification email/SMS dispatch. The GraphQL resolver in internal/graphql/signup.go becomes a thin adapter: build RequestMetadata from gin.Context, call the service, apply side-effects. This is the seam Phase 2's gRPC and gRPC-gateway REST handlers will reuse. Supporting changes (all transport-agnostic mirrors of existing helpers, with the gin wrappers delegating to them so behaviour is identical): - parsers.GetHostFromRequest / GetAppURLFromRequest (raw *http.Request) - cookie.BuildSessionCookies / BuildMfaSessionCookies (return []*http.Cookie) Wiring: - service.Provider constructed in cmd/root.go and integration_tests/test_helper.go - graphql.Dependencies + http_handlers.Dependencies grow a ServiceProvider field - http_handlers.GraphqlHandler passes it through to graphql.New Known follow-up: TokenProvider.CreateAuthToken still takes *gin.Context even though it doesn't use it. The service synthesizes a minimal shim (`&gin.Context{Request: meta.Request}`); refactoring CreateAuthToken to take *http.Request directly is a Phase 2 cleanup tracked by a TODO at the call site. Tests: - All 11 TestSignup sub-cases pass - Full SQLite integration suite (71s) still green — no behaviour drift Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/root.go | 19 + internal/cookie/cookie.go | 42 ++- internal/cookie/mfa_session.go | 56 ++- internal/graphql/provider.go | 4 + internal/graphql/signup.go | 387 +------------------- internal/http_handlers/graphql.go | 1 + internal/http_handlers/provider.go | 4 + internal/integration_tests/test_helper.go | 16 + internal/parsers/url.go | 31 +- internal/service/provider.go | 61 ++++ internal/service/sideeffects.go | 106 ++++++ internal/service/signup.go | 411 ++++++++++++++++++++++ 12 files changed, 724 insertions(+), 414 deletions(-) create mode 100644 internal/service/provider.go create mode 100644 internal/service/sideeffects.go create mode 100644 internal/service/signup.go diff --git a/cmd/root.go b/cmd/root.go index c331e09b..aba57b66 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,6 +27,7 @@ import ( "github.com/authorizerdev/authorizer/internal/oauth" "github.com/authorizerdev/authorizer/internal/rate_limit" "github.com/authorizerdev/authorizer/internal/server" + "github.com/authorizerdev/authorizer/internal/service" "github.com/authorizerdev/authorizer/internal/sms" "github.com/authorizerdev/authorizer/internal/storage" "github.com/authorizerdev/authorizer/internal/token" @@ -530,6 +531,23 @@ func runRoot(c *cobra.Command, args []string) { StorageProvider: storageProvider, }) + // Transport-agnostic service layer that hosts public-API operations + // (currently SignUp; more migrate over in subsequent phases). GraphQL, + // gRPC, and REST surfaces all delegate to this. + serviceProvider, err := service.New(&rootArgs.config, &service.Dependencies{ + Log: &log, + AuditProvider: auditProvider, + EmailProvider: emailProvider, + EventsProvider: eventsProvider, + MemoryStoreProvider: memoryStoreProvider, + SMSProvider: smsProvider, + StorageProvider: storageProvider, + TokenProvider: tokenProvider, + }) + if err != nil { + log.Fatal().Err(err).Msg("failed to create service provider") + } + httpProvider, err := http_handlers.New(&rootArgs.config, &http_handlers.Dependencies{ Log: &log, AuditProvider: auditProvider, @@ -543,6 +561,7 @@ func runRoot(c *cobra.Command, args []string) { OAuthProvider: oauthProvider, RateLimitProvider: rateLimitProvider, AuthorizationProvider: authorizationProvider, + ServiceProvider: serviceProvider, }) if err != nil { log.Fatal().Err(err).Msg("failed to create http provider") diff --git a/internal/cookie/cookie.go b/internal/cookie/cookie.go index dc4d1aa7..6cdd7fd0 100644 --- a/internal/cookie/cookie.go +++ b/internal/cookie/cookie.go @@ -25,22 +25,46 @@ func ParseSameSite(value string) http.SameSite { } } -// SetSession sets the session cookie in the response +// SetSession sets the session cookie in the response. func SetSession(gc *gin.Context, sessionID string, appCookieSecure bool, sameSite http.SameSite) { - secure := appCookieSecure - httpOnly := true - hostname := parsers.GetHost(gc) + for _, c := range BuildSessionCookies(parsers.GetHost(gc), sessionID, appCookieSecure, sameSite) { + gc.SetSameSite(c.SameSite) + gc.SetCookie(c.Name, c.Value, c.MaxAge, c.Path, c.Domain, c.Secure, c.HttpOnly) + } +} + +// BuildSessionCookies returns the pair of session cookies (host-scoped and +// domain-scoped) to set on the response. Transport-agnostic so non-gin +// callers (the service layer, gRPC handlers) can produce them as side-effects. +func BuildSessionCookies(hostname, sessionID string, appCookieSecure bool, sameSite http.SameSite) []*http.Cookie { host, _ := parsers.GetHostParts(hostname) domain := parsers.GetDomainName(hostname) if domain != "localhost" { domain = "." + domain } - - gc.SetSameSite(sameSite) day := 60 * 60 * 24 - - gc.SetCookie(constants.AppCookieName+"_session", sessionID, day, "/", host, secure, httpOnly) - gc.SetCookie(constants.AppCookieName+"_session_domain", sessionID, day, "/", domain, secure, httpOnly) + return []*http.Cookie{ + { + Name: constants.AppCookieName + "_session", + Value: sessionID, + MaxAge: day, + Path: "/", + Domain: host, + Secure: appCookieSecure, + HttpOnly: true, + SameSite: sameSite, + }, + { + Name: constants.AppCookieName + "_session_domain", + Value: sessionID, + MaxAge: day, + Path: "/", + Domain: domain, + Secure: appCookieSecure, + HttpOnly: true, + SameSite: sameSite, + }, + } } // DeleteSession sets session cookies to expire diff --git a/internal/cookie/mfa_session.go b/internal/cookie/mfa_session.go index bb09702e..3ebe8bb2 100644 --- a/internal/cookie/mfa_session.go +++ b/internal/cookie/mfa_session.go @@ -10,33 +10,55 @@ import ( "github.com/authorizerdev/authorizer/internal/parsers" ) -// SetMfaSession sets the mfa session cookie in the response +// SetMfaSession sets the mfa session cookie in the response. func SetMfaSession(gc *gin.Context, sessionID string, appCookieSecure bool) { - secure := appCookieSecure - httpOnly := true - hostname := parsers.GetHost(gc) + for _, c := range BuildMfaSessionCookies(parsers.GetHost(gc), sessionID, appCookieSecure) { + gc.SetSameSite(c.SameSite) + gc.SetCookie(c.Name, c.Value, c.MaxAge, c.Path, c.Domain, c.Secure, c.HttpOnly) + } +} + +// BuildMfaSessionCookies returns the MFA session cookies (host-scoped and +// domain-scoped) to set on the response. Transport-agnostic mirror of +// SetMfaSession. +// +// SameSite policy mirrors the gin path: Lax when insecure (so cross-site UI +// can still complete the flow), None when secure. See the SetMfaSession +// comment for the historical reasoning and the configurability TODO. +func BuildMfaSessionCookies(hostname, sessionID string, appCookieSecure bool) []*http.Cookie { host, _ := parsers.GetHostParts(hostname) domain := parsers.GetDomainName(hostname) if domain != "localhost" { domain = "." + domain } - - // Since app cookie can come from cross site it becomes important to set this in lax mode when insecure. - // Example person using custom UI on their app domain and making request to authorizer domain. - // For more information check: - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite - // https://github.com/gin-gonic/gin/blob/master/context.go#L86 - // TODO add ability to configure sameSite (none / lax / strict) via config + sameSite := http.SameSiteNoneMode if !appCookieSecure { - gc.SetSameSite(http.SameSiteLaxMode) - } else { - gc.SetSameSite(http.SameSiteNoneMode) + sameSite = http.SameSiteLaxMode } // TODO allow configuring cookie max-age via config age := 60 - - gc.SetCookie(constants.MfaCookieName+"_session", sessionID, age, "/", host, secure, httpOnly) - gc.SetCookie(constants.MfaCookieName+"_session_domain", sessionID, age, "/", domain, secure, httpOnly) + return []*http.Cookie{ + { + Name: constants.MfaCookieName + "_session", + Value: sessionID, + MaxAge: age, + Path: "/", + Domain: host, + Secure: appCookieSecure, + HttpOnly: true, + SameSite: sameSite, + }, + { + Name: constants.MfaCookieName + "_session_domain", + Value: sessionID, + MaxAge: age, + Path: "/", + Domain: domain, + Secure: appCookieSecure, + HttpOnly: true, + SameSite: sameSite, + }, + } } // DeleteMfaSession deletes the mfa session cookies to expire diff --git a/internal/graphql/provider.go b/internal/graphql/provider.go index 418c3147..c911e09b 100644 --- a/internal/graphql/provider.go +++ b/internal/graphql/provider.go @@ -13,6 +13,7 @@ import ( "github.com/authorizerdev/authorizer/internal/events" "github.com/authorizerdev/authorizer/internal/graph/model" "github.com/authorizerdev/authorizer/internal/memory_store" + "github.com/authorizerdev/authorizer/internal/service" "github.com/authorizerdev/authorizer/internal/sms" "github.com/authorizerdev/authorizer/internal/storage" "github.com/authorizerdev/authorizer/internal/token" @@ -41,6 +42,9 @@ type Dependencies struct { TokenProvider token.Provider // AuthorizationProvider is used for fine-grained authorization checks AuthorizationProvider authorization.Provider + // ServiceProvider hosts the transport-agnostic public-API operations. + // Resolvers for migrated ops (currently just SignUp) delegate here. + ServiceProvider service.Provider } // New constructs a new graphql provider with given arguments diff --git a/internal/graphql/signup.go b/internal/graphql/signup.go index cec217c2..83344742 100644 --- a/internal/graphql/signup.go +++ b/internal/graphql/signup.go @@ -2,35 +2,17 @@ package graphql import ( "context" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - "github.com/google/uuid" - "golang.org/x/crypto/bcrypt" - - "github.com/authorizerdev/authorizer/internal/audit" - "github.com/authorizerdev/authorizer/internal/constants" - "github.com/authorizerdev/authorizer/internal/cookie" - "github.com/authorizerdev/authorizer/internal/crypto" "github.com/authorizerdev/authorizer/internal/graph/model" - "github.com/authorizerdev/authorizer/internal/metrics" - "github.com/authorizerdev/authorizer/internal/parsers" - "github.com/authorizerdev/authorizer/internal/refs" - "github.com/authorizerdev/authorizer/internal/storage/schemas" - "github.com/authorizerdev/authorizer/internal/token" + "github.com/authorizerdev/authorizer/internal/service" "github.com/authorizerdev/authorizer/internal/utils" - "github.com/authorizerdev/authorizer/internal/validators" ) -// dummyHash is a precomputed bcrypt hash used to equalise the response time -// of the "user exists" path with the "new signup" path, preventing account -// enumeration via timing. -var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("dummy-password-for-timing"), bcrypt.DefaultCost) - -// SignUp is the method to singup user +// SignUp delegates to the transport-agnostic service layer. Resolvers in this +// package are thin transport adapters: pull gin.Context out of the GraphQL +// context, build RequestMetadata, call the service, apply any cookie +// side-effects back onto gin. +// // Permission: none func (g *graphqlProvider) SignUp(ctx context.Context, params *model.SignUpRequest) (*model.AuthResponse, error) { log := g.Log.With().Str("func", "SignUp").Logger() @@ -39,363 +21,10 @@ func (g *graphqlProvider) SignUp(ctx context.Context, params *model.SignUpReques log.Debug().Err(err).Msg("Failed to get GinContext") return nil, err } - - email := strings.TrimSpace(refs.StringValue(params.Email)) - phoneNumber := strings.TrimSpace(refs.StringValue(params.PhoneNumber)) - if email == "" && phoneNumber == "" { - log.Debug().Msg("Email or phone number is required") - return nil, fmt.Errorf(`email or phone number is required`) - } - - isSignupEnabled := g.Config.EnableSignup - if !isSignupEnabled { - log.Debug().Msg("Signup is disabled") - return nil, fmt.Errorf(`signup is disabled for this instance`) - } - - isBasicAuthEnabled := g.Config.EnableBasicAuthentication - isMobileBasicAuthEnabled := g.Config.EnableMobileBasicAuthentication - if params.ConfirmPassword != params.Password { - log.Debug().Msg("Passwords do not match") - return nil, fmt.Errorf(`password and confirm password does not match`) - } - if err := validators.IsValidPassword(params.Password, !g.Config.EnableStrongPassword); err != nil { - log.Debug().Msg("Invalid password") - return nil, err - } - - log = log.With().Str("email", email).Str("phone_number", phoneNumber).Logger() - isEmailSignup := email != "" - isMobileSignup := phoneNumber != "" - if !isBasicAuthEnabled && isEmailSignup { - log.Debug().Msg("Basic authentication is disabled") - return nil, fmt.Errorf(`basic authentication is disabled for this instance`) - } - if !isMobileBasicAuthEnabled && isMobileSignup { - log.Debug().Msg("Mobile basic authentication is disabled") - return nil, fmt.Errorf(`mobile basic authentication is disabled for this instance`) - } - if isEmailSignup && !validators.IsValidEmail(email) { - log.Debug().Msg("Invalid email") - return nil, fmt.Errorf(`invalid email address`) - } - if isMobileSignup && (phoneNumber == "" || len(phoneNumber) < 10) { - log.Debug().Msg("Invalid phone number") - return nil, fmt.Errorf(`invalid phone number`) - } - // find user with email / phone number - if isEmailSignup { - existingUser, err := g.StorageProvider.GetUserByEmail(ctx, email) - if err != nil { - log.Debug().Err(err).Msg("Failed to get user by email") - } - if existingUser != nil && (existingUser.EmailVerifiedAt != nil || existingUser.ID != "") { - log.Debug().Msg("Email is already signed up.") - bcrypt.CompareHashAndPassword(dummyHash, []byte("timing-equalization")) - return nil, fmt.Errorf("signup failed. please check your credentials or try a different method") - } - } else { - existingUser, err := g.StorageProvider.GetUserByPhoneNumber(ctx, phoneNumber) - if err != nil { - log.Debug().Err(err).Msg("Failed to get user by phone number") - } - if existingUser != nil && (existingUser.PhoneNumberVerifiedAt != nil || existingUser.ID != "") { - log.Debug().Msg("Phone number is already signed up.") - bcrypt.CompareHashAndPassword(dummyHash, []byte("timing-equalization")) - return nil, fmt.Errorf("signup failed. please check your credentials or try a different method") - } - } - - inputRoles := params.Roles - if len(inputRoles) > 0 { - // check if roles exists - roles := g.Config.Roles - if !validators.IsValidRoles(inputRoles, roles) { - log.Debug().Err(err).Strs("roles", params.Roles).Msg("Invalid roles") - return nil, fmt.Errorf(`invalid roles`) - } - } else { - inputRoles = g.Config.DefaultRoles - } - user := &schemas.User{} - user.Roles = strings.Join(inputRoles, ",") - password, _ := crypto.EncryptPassword(params.Password) - user.Password = &password - if email != "" { - user.SignupMethods = constants.AuthRecipeMethodBasicAuth - user.Email = &email - } - if params.GivenName != nil { - user.GivenName = params.GivenName - } - - if params.FamilyName != nil { - user.FamilyName = params.FamilyName - } - - if params.MiddleName != nil { - user.MiddleName = params.MiddleName - } - - if params.Nickname != nil { - user.Nickname = params.Nickname - } - - if params.Gender != nil { - user.Gender = params.Gender - } - - if params.Birthdate != nil { - user.Birthdate = params.Birthdate - } - - if phoneNumber != "" { - user.SignupMethods = constants.AuthRecipeMethodMobileBasicAuth - user.PhoneNumber = refs.NewStringRef(phoneNumber) - } - - if params.Picture != nil { - user.Picture = params.Picture - } - - if params.IsMultiFactorAuthEnabled != nil { - user.IsMultiFactorAuthEnabled = params.IsMultiFactorAuthEnabled - } - - isMFAEnforced := g.Config.EnforceMFA - if isMFAEnforced { - user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) - } - - if params.AppData != nil { - appDataString := "" - appDataBytes, err := json.Marshal(params.AppData) - if err != nil { - log.Debug().Msg("failed to marshall source app_data") - return nil, errors.New("malformed app_data") - } - appDataString = string(appDataBytes) - user.AppData = &appDataString - } - isEmailServiceEnabled := g.Config.IsEmailServiceEnabled - isEmailVerificationEnabled := g.Config.EnableEmailVerification && isEmailServiceEnabled - if !isEmailVerificationEnabled && isEmailSignup { - now := time.Now().Unix() - user.EmailVerifiedAt = &now - } - isSMSServiceEnabled := g.Config.IsSMSServiceEnabled - isPhoneVerificationEnabled := g.Config.EnablePhoneVerification && isSMSServiceEnabled - if !isPhoneVerificationEnabled && isMobileSignup { - now := time.Now().Unix() - user.PhoneNumberVerifiedAt = &now - } - user, err = g.StorageProvider.AddUser(ctx, user) - if err != nil { - log.Debug().Err(err).Msg("failed to add user") - return nil, err - } - roles := strings.Split(user.Roles, ",") - userToReturn := user.AsAPIUser() - hostname := parsers.GetHost(gc) - if isEmailVerificationEnabled && isEmailSignup { - // insert verification request - _, nonceHash, err := utils.GenerateNonce() - if err != nil { - log.Debug().Err(err).Msg("Failed to generate nonce") - return nil, err - } - verificationType := constants.VerificationTypeBasicAuthSignup - redirectURL := parsers.GetAppURL(gc) - if params.RedirectURI != nil { - redirectURL = *params.RedirectURI - if !validators.IsValidRedirectURI(redirectURL, g.Config.AllowedOrigins, hostname) { - log.Debug().Msg("Invalid redirect URI") - return nil, fmt.Errorf("invalid redirect URI") - } - } - verificationToken, err := g.TokenProvider.CreateVerificationToken(&token.AuthTokenConfig{ - Nonce: nonceHash, - HostName: hostname, - User: user, - LoginMethod: constants.AuthRecipeMethodBasicAuth, - }, redirectURL, verificationType) - if err != nil { - log.Debug().Err(err).Msg("Failed to create verification token") - return nil, err - } - _, err = g.StorageProvider.AddVerificationRequest(ctx, &schemas.VerificationRequest{ - Token: verificationToken, - Identifier: verificationType, - ExpiresAt: time.Now().Add(time.Minute * 30).Unix(), - Email: email, - Nonce: nonceHash, - RedirectURI: redirectURL, - }) - if err != nil { - log.Debug().Err(err).Msg("Failed to add verification request") - return nil, err - } - // exec it as go routine so that we can reduce the api latency - go func() { - // exec it as go routine so that we can reduce the api latency - g.EmailProvider.SendEmail([]string{email}, constants.VerificationTypeBasicAuthSignup, map[string]interface{}{ - "user": user.ToMap(), - "organization": utils.GetOrganization(g.Config), - "verification_url": utils.GetEmailVerificationURL(verificationToken, hostname, redirectURL), - }) - g.EventsProvider.RegisterEvent(ctx, constants.UserCreatedWebhookEvent, constants.AuthRecipeMethodBasicAuth, user) - }() - - return &model.AuthResponse{ - Message: `Verification email has been sent. Please check your inbox`, - }, nil - } else if isPhoneVerificationEnabled && isMobileSignup { - duration, _ := time.ParseDuration("10m") - smsCode, err := utils.GenerateOTP() - if err != nil { - log.Debug().Err(err).Msg("Failed to generate OTP") - return nil, err - } - smsBody := strings.Builder{} - smsBody.WriteString("Your verification code is: ") - smsBody.WriteString(smsCode) - expiresAt := time.Now().Add(duration).Unix() - // Store the HMAC digest of the OTP; smsCode (plaintext) is sent - // over SMS by the existing smsBody above. - _, err = g.StorageProvider.UpsertOTP(ctx, &schemas.OTP{ - PhoneNumber: phoneNumber, - Otp: crypto.HashOTP(smsCode, g.Config.JWTSecret), - ExpiresAt: expiresAt, - }) - if err != nil { - log.Debug().Err(err).Msg("error while upserting OTP") - return nil, err - } - mfaSession := uuid.NewString() - err = g.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, expiresAt) - if err != nil { - log.Debug().Err(err).Msg("Failed to add mfasession") - return nil, err - } - cookie.SetMfaSession(gc, mfaSession, g.Config.AppCookieSecure) - go func() { - g.SMSProvider.SendSMS(phoneNumber, smsBody.String()) - g.EventsProvider.RegisterEvent(ctx, constants.UserCreatedWebhookEvent, constants.AuthRecipeMethodMobileBasicAuth, user) - }() - return &model.AuthResponse{ - Message: "Please check the OTP in your inbox", - ShouldShowMobileOtpScreen: refs.NewBoolRef(true), - }, nil - } - scope := []string{"openid", "email", "profile"} - if params.Scope != nil && len(params.Scope) > 0 { - scope = params.Scope - } - - code := "" - codeChallenge := "" - nonce := "" - oidcNonce := "" - authorizeRedirectURI := "" - if params.State != nil { - // Get state from store - authorizeState, _ := g.MemoryStoreProvider.GetState(refs.StringValue(params.State)) - if authorizeState != "" { - authorizeStateSplit := strings.Split(authorizeState, "@@") - if len(authorizeStateSplit) > 1 { - code = authorizeStateSplit[0] - codeChallenge = authorizeStateSplit[1] - if len(authorizeStateSplit) > 2 { - oidcNonce = authorizeStateSplit[2] - } - if len(authorizeStateSplit) > 3 { - authorizeRedirectURI = authorizeStateSplit[3] - } - } else { - nonce = authorizeState - } - g.MemoryStoreProvider.RemoveState(refs.StringValue(params.State)) - } - } - - if nonce == "" { - nonce = uuid.New().String() - } - authToken, err := g.TokenProvider.CreateAuthToken(gc, &token.AuthTokenConfig{ - User: user, - Roles: roles, - Scope: scope, - Nonce: nonce, - OIDCNonce: oidcNonce, - Code: code, - LoginMethod: constants.AuthRecipeMethodBasicAuth, - HostName: hostname, - }) + res, side, err := g.ServiceProvider.SignUp(ctx, service.MetaFromGin(gc), params) if err != nil { - log.Debug().Err(err).Msg("Failed to create auth token") return nil, err } - - // Code challenge could be optional if PKCE flow is not used - if code != "" { - if err := g.MemoryStoreProvider.SetState(code, codeChallenge+"@@"+authToken.FingerPrintHash+"@@"+oidcNonce+"@@"+authorizeRedirectURI); err != nil { - log.Debug().Err(err).Msg("SetState failed") - return nil, err - } - } - - expiresIn := authToken.AccessToken.ExpiresAt - time.Now().Unix() - if expiresIn <= 0 { - expiresIn = 1 - } - - res := &model.AuthResponse{ - Message: `Signed up successfully.`, - AccessToken: &authToken.AccessToken.Token, - ExpiresIn: &expiresIn, - User: userToReturn, - } - - sessionKey := constants.AuthRecipeMethodBasicAuth + ":" + user.ID - cookie.SetSession(gc, authToken.FingerPrintHash, g.Config.AppCookieSecure, cookie.ParseSameSite(g.Config.AppCookieSameSite)) - g.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeSessionToken+"_"+authToken.FingerPrint, authToken.FingerPrintHash, authToken.SessionTokenExpiresAt) - g.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeAccessToken+"_"+authToken.FingerPrint, authToken.AccessToken.Token, authToken.AccessToken.ExpiresAt) - - if authToken.RefreshToken != nil { - res.RefreshToken = &authToken.RefreshToken.Token - g.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeRefreshToken+"_"+authToken.FingerPrint, authToken.RefreshToken.Token, authToken.RefreshToken.ExpiresAt) - } - - go func() { - g.EventsProvider.RegisterEvent(ctx, constants.UserCreatedWebhookEvent, constants.AuthRecipeMethodBasicAuth, user) - if isEmailSignup { - g.EventsProvider.RegisterEvent(ctx, constants.UserSignUpWebhookEvent, constants.AuthRecipeMethodBasicAuth, user) - g.EventsProvider.RegisterEvent(ctx, constants.UserLoginWebhookEvent, constants.AuthRecipeMethodBasicAuth, user) - } else { - g.EventsProvider.RegisterEvent(ctx, constants.UserSignUpWebhookEvent, constants.AuthRecipeMethodMobileBasicAuth, user) - g.EventsProvider.RegisterEvent(ctx, constants.UserLoginWebhookEvent, constants.AuthRecipeMethodMobileBasicAuth, user) - } - - if err := g.StorageProvider.AddSession(ctx, &schemas.Session{ - UserID: user.ID, - UserAgent: utils.GetUserAgent(gc.Request), - IP: utils.GetIP(gc.Request), - }); err != nil { - log.Debug().Err(err).Msg("Failed to add session") - } - }() - metrics.RecordAuthEvent(metrics.EventSignup, metrics.StatusSuccess) - metrics.ActiveSessions.Inc() - g.AuditProvider.LogEvent(audit.Event{ - Action: constants.AuditSignupEvent, - ActorID: user.ID, - ActorType: constants.AuditActorTypeUser, - ActorEmail: refs.StringValue(user.Email), - ResourceType: constants.AuditResourceTypeUser, - ResourceID: user.ID, - IPAddress: utils.GetIP(gc.Request), - UserAgent: utils.GetUserAgent(gc.Request), - }) - + service.ApplyToGin(gc, side) return res, nil } diff --git a/internal/http_handlers/graphql.go b/internal/http_handlers/graphql.go index ad6e5e97..1585da29 100644 --- a/internal/http_handlers/graphql.go +++ b/internal/http_handlers/graphql.go @@ -219,6 +219,7 @@ func (h *httpProvider) GraphqlHandler() gin.HandlerFunc { StorageProvider: h.StorageProvider, TokenProvider: h.TokenProvider, AuthorizationProvider: h.AuthorizationProvider, + ServiceProvider: h.ServiceProvider, }) if err != nil { h.Log.Error().Err(err).Msg("Failed to create graphql provider") diff --git a/internal/http_handlers/provider.go b/internal/http_handlers/provider.go index 966c2c3c..e26e16e3 100644 --- a/internal/http_handlers/provider.go +++ b/internal/http_handlers/provider.go @@ -13,6 +13,7 @@ import ( "github.com/authorizerdev/authorizer/internal/memory_store" "github.com/authorizerdev/authorizer/internal/oauth" "github.com/authorizerdev/authorizer/internal/rate_limit" + "github.com/authorizerdev/authorizer/internal/service" "github.com/authorizerdev/authorizer/internal/sms" "github.com/authorizerdev/authorizer/internal/storage" "github.com/authorizerdev/authorizer/internal/token" @@ -45,6 +46,9 @@ type Dependencies struct { RateLimitProvider rate_limit.Provider // AuthorizationProvider is used for fine-grained authorization checks AuthorizationProvider authorization.Provider + // ServiceProvider hosts the transport-agnostic public-API operations. + // Migrated GraphQL resolvers (currently SignUp) delegate here. + ServiceProvider service.Provider } // New constructs a new http provider with given arguments diff --git a/internal/integration_tests/test_helper.go b/internal/integration_tests/test_helper.go index f4005c35..3890e329 100644 --- a/internal/integration_tests/test_helper.go +++ b/internal/integration_tests/test_helper.go @@ -25,6 +25,7 @@ import ( "github.com/authorizerdev/authorizer/internal/memory_store" "github.com/authorizerdev/authorizer/internal/oauth" "github.com/authorizerdev/authorizer/internal/rate_limit" + "github.com/authorizerdev/authorizer/internal/service" "github.com/authorizerdev/authorizer/internal/sms" "github.com/authorizerdev/authorizer/internal/storage" "github.com/authorizerdev/authorizer/internal/token" @@ -207,6 +208,19 @@ func initTestSetup(t *testing.T, cfg *config.Config) *testSetup { StorageProvider: storageProvider, }) + // Transport-agnostic service layer for migrated public ops (SignUp etc.). + serviceProvider, err := service.New(cfg, &service.Dependencies{ + Log: &logger, + AuditProvider: auditProvider, + EmailProvider: emailProvider, + EventsProvider: eventsProvider, + MemoryStoreProvider: memoryStoreProvider, + SMSProvider: smsProvider, + StorageProvider: storageProvider, + TokenProvider: tokenProvider, + }) + require.NoError(t, err) + // Create dependencies struct gqlDeps := &graphql.Dependencies{ Log: &logger, @@ -219,6 +233,7 @@ func initTestSetup(t *testing.T, cfg *config.Config) *testSetup { SMSProvider: smsProvider, StorageProvider: storageProvider, TokenProvider: tokenProvider, + ServiceProvider: serviceProvider, } // Create dependencies struct @@ -234,6 +249,7 @@ func initTestSetup(t *testing.T, cfg *config.Config) *testSetup { TokenProvider: tokenProvider, RateLimitProvider: rateLimitProvider, OAuthProvider: oauthProvider, + ServiceProvider: serviceProvider, } // Create GraphQL provider diff --git a/internal/parsers/url.go b/internal/parsers/url.go index ddbfb0d8..24b29c70 100644 --- a/internal/parsers/url.go +++ b/internal/parsers/url.go @@ -1,17 +1,26 @@ package parsers import ( + "net/http" "net/url" "strings" "github.com/gin-gonic/gin" ) -// GetHost returns the authorizer host URL from the request context. -// Priority: X-Authorizer-URL header, then scheme (X-Forwarded-Proto) + host (X-Forwarded-Host or Request.Host). -// Headers are validated to prevent host header injection attacks. +// GetHost returns the authorizer host URL from the gin request context. +// Thin shim over GetHostFromRequest so non-gin transports (gRPC, plain HTTP) +// can reuse the same host-derivation logic. func GetHost(c *gin.Context) string { - authorizerURL := strings.TrimSpace(c.Request.Header.Get("X-Authorizer-URL")) + return GetHostFromRequest(c.Request) +} + +// GetHostFromRequest returns the authorizer host URL from a raw *http.Request. +// Priority: X-Authorizer-URL header, then scheme (X-Forwarded-Proto) + host +// (X-Forwarded-Host or Request.Host). Headers are validated to prevent host +// header injection attacks. +func GetHostFromRequest(r *http.Request) string { + authorizerURL := strings.TrimSpace(r.Header.Get("X-Authorizer-URL")) if authorizerURL != "" { if sanitized := sanitizeAuthorizerURL(authorizerURL); sanitized != "" { return sanitized @@ -19,13 +28,13 @@ func GetHost(c *gin.Context) string { // Invalid header value — fall through to standard host detection } - scheme := c.Request.Header.Get("X-Forwarded-Proto") + scheme := r.Header.Get("X-Forwarded-Proto") if scheme != "https" { scheme = "http" } - host := sanitizeHost(c.Request.Header.Get("X-Forwarded-Host")) + host := sanitizeHost(r.Header.Get("X-Forwarded-Host")) if host == "" { - host = sanitizeHost(c.Request.Host) + host = sanitizeHost(r.Host) } if host == "" { host = "localhost" @@ -131,6 +140,10 @@ func GetDomainName(uri string) string { // GetAppURL to get /app url if not configured by user func GetAppURL(gc *gin.Context) string { - envAppURL := GetHost(gc) + "/app" - return envAppURL + return GetAppURLFromRequest(gc.Request) +} + +// GetAppURLFromRequest is the transport-agnostic form of GetAppURL. +func GetAppURLFromRequest(r *http.Request) string { + return GetHostFromRequest(r) + "/app" } diff --git a/internal/service/provider.go b/internal/service/provider.go new file mode 100644 index 00000000..7cf204d0 --- /dev/null +++ b/internal/service/provider.go @@ -0,0 +1,61 @@ +package service + +import ( + "context" + + "github.com/rs/zerolog" + + "github.com/authorizerdev/authorizer/internal/audit" + "github.com/authorizerdev/authorizer/internal/config" + "github.com/authorizerdev/authorizer/internal/email" + "github.com/authorizerdev/authorizer/internal/events" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/memory_store" + "github.com/authorizerdev/authorizer/internal/sms" + "github.com/authorizerdev/authorizer/internal/storage" + "github.com/authorizerdev/authorizer/internal/token" +) + +// Dependencies are the subsystems a Provider needs. The set will grow as +// more operations migrate from internal/graphql into this package. +type Dependencies struct { + Log *zerolog.Logger + + AuditProvider audit.Provider + EmailProvider email.Provider + EventsProvider events.Provider + MemoryStoreProvider memory_store.Provider + SMSProvider sms.Provider + StorageProvider storage.Provider + TokenProvider token.Provider +} + +// Provider is the transport-agnostic API for Authorizer public operations. +// Each method takes the inbound RequestMetadata and returns a typed response +// plus a ResponseSideEffects describing cookies (and other transport +// artifacts) the caller must apply. +// +// During the staged migration from internal/graphql, this interface grows one +// method per phase. Operations not yet migrated continue to live as +// graphqlProvider methods until they're moved here. +type Provider interface { + // SignUp registers a new user. Public — no authentication required. + // Permissions: none. + SignUp(ctx context.Context, meta RequestMetadata, params *model.SignUpRequest) (*model.AuthResponse, *ResponseSideEffects, error) +} + +// New constructs a new service provider. +func New(cfg *config.Config, deps *Dependencies) (Provider, error) { + return &provider{ + Config: cfg, + Dependencies: *deps, + }, nil +} + +type provider struct { + *config.Config + Dependencies +} + +// Compile-time check that provider satisfies Provider. +var _ Provider = (*provider)(nil) diff --git a/internal/service/sideeffects.go b/internal/service/sideeffects.go new file mode 100644 index 00000000..db0405a7 --- /dev/null +++ b/internal/service/sideeffects.go @@ -0,0 +1,106 @@ +// Package service contains the transport-agnostic business logic for +// Authorizer's public API operations. Each operation accepts a context, a +// RequestMetadata describing the inbound request, and a typed request object; +// it returns a typed response, a ResponseSideEffects describing artifacts the +// transport must apply (cookies, etc.), and an error. +// +// GraphQL resolvers, gRPC handlers, and REST handlers all construct +// RequestMetadata from their transport and apply ResponseSideEffects back to +// it. The service layer itself never touches gin.Context, grpc.ServerStream, +// or any other transport-specific type. +package service + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/authorizerdev/authorizer/internal/parsers" + "github.com/authorizerdev/authorizer/internal/utils" +) + +// RequestMetadata is the transport-derived context every service method +// receives. Fields are populated by the transport (see MetaFromGin) and read +// by handlers; the underlying *http.Request is exposed for legacy helpers +// that haven't yet been refactored to take this struct directly. +type RequestMetadata struct { + // HostURL is the authorizer-server base URL as derived from the request + // (X-Authorizer-URL header, then X-Forwarded-Proto + X-Forwarded-Host, + // finally Request.Host). Always populated. + HostURL string + + // IPAddress is the best-effort client IP (honors X-Forwarded-For). + IPAddress string + + // UserAgent is the User-Agent header value. + UserAgent string + + // AuthorizationHeader is the raw `Authorization` header (typically + // "Bearer "). Empty when absent. + AuthorizationHeader string + + // Cookies sent on the request. Use the typed cookie helpers when reading + // session/mfa cookies — never reach for these unless you know what you + // want. + Cookies []*http.Cookie + + // Request is the raw inbound *http.Request. Provided as an escape hatch + // for token-provider helpers that still take a gin.Context internally; + // new service code should prefer the typed fields above. + Request *http.Request +} + +// ResponseSideEffects collects out-of-band artifacts produced by a service +// method that the transport must apply to its response. Today that's just +// cookies; future additions may include redirect targets or trailing headers. +type ResponseSideEffects struct { + // Cookies to set on the response. Each cookie's Domain, Path, Secure, + // SameSite, and MaxAge fields are honored as set; the transport adds them + // verbatim (gin: gc.SetSameSite + gc.SetCookie; net/http: http.SetCookie). + Cookies []*http.Cookie +} + +// AddCookie appends a cookie to the side-effects. Convenience over manual +// slice ops; safe on a zero-value receiver. +func (s *ResponseSideEffects) AddCookie(c *http.Cookie) { + if c == nil { + return + } + s.Cookies = append(s.Cookies, c) +} + +// MetaFromGin builds a RequestMetadata from a gin.Context. The gin-aware +// transport (GraphQL resolver, REST handler mounted under Gin) calls this +// once per request before invoking a service method. +func MetaFromGin(gc *gin.Context) RequestMetadata { + if gc == nil || gc.Request == nil { + return RequestMetadata{} + } + meta := RequestMetadata{ + HostURL: parsers.GetHostFromRequest(gc.Request), + IPAddress: utils.GetIP(gc.Request), + UserAgent: utils.GetUserAgent(gc.Request), + AuthorizationHeader: gc.Request.Header.Get("Authorization"), + Cookies: gc.Request.Cookies(), + Request: gc.Request, + } + return meta +} + +// ApplyToGin writes the response side-effects to a gin.Context. The gin-aware +// transport calls this once per request after a service method returns +// successfully. A nil receiver is a no-op (service methods that have no +// side-effects may return nil). +func ApplyToGin(gc *gin.Context, side *ResponseSideEffects) { + if side == nil || gc == nil { + return + } + for _, c := range side.Cookies { + if c == nil { + continue + } + // gin.SetSameSite is per-call and must be set before SetCookie. + gc.SetSameSite(c.SameSite) + gc.SetCookie(c.Name, c.Value, c.MaxAge, c.Path, c.Domain, c.Secure, c.HttpOnly) + } +} diff --git a/internal/service/signup.go b/internal/service/signup.go new file mode 100644 index 00000000..3336cb43 --- /dev/null +++ b/internal/service/signup.go @@ -0,0 +1,411 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + + "github.com/authorizerdev/authorizer/internal/audit" + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/cookie" + "github.com/authorizerdev/authorizer/internal/crypto" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/metrics" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" + "github.com/authorizerdev/authorizer/internal/token" + "github.com/authorizerdev/authorizer/internal/utils" + "github.com/authorizerdev/authorizer/internal/validators" +) + +// dummyHash is a precomputed bcrypt hash used to equalise the response time +// of the "user exists" path with the "new signup" path, preventing account +// enumeration via timing. +var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("dummy-password-for-timing"), bcrypt.DefaultCost) + +// SignUp registers a new user. Public — no authentication required. +// +// Transport-agnostic: takes a RequestMetadata (host, IP, UA) instead of +// reaching into gin.Context, and returns cookie side-effects for the +// transport to apply. +func (p *provider) SignUp(ctx context.Context, meta RequestMetadata, params *model.SignUpRequest) (*model.AuthResponse, *ResponseSideEffects, error) { + log := p.Log.With().Str("func", "SignUp").Logger() + side := &ResponseSideEffects{} + + email := strings.TrimSpace(refs.StringValue(params.Email)) + phoneNumber := strings.TrimSpace(refs.StringValue(params.PhoneNumber)) + if email == "" && phoneNumber == "" { + log.Debug().Msg("Email or phone number is required") + return nil, nil, fmt.Errorf(`email or phone number is required`) + } + + isSignupEnabled := p.Config.EnableSignup + if !isSignupEnabled { + log.Debug().Msg("Signup is disabled") + return nil, nil, fmt.Errorf(`signup is disabled for this instance`) + } + + isBasicAuthEnabled := p.Config.EnableBasicAuthentication + isMobileBasicAuthEnabled := p.Config.EnableMobileBasicAuthentication + if params.ConfirmPassword != params.Password { + log.Debug().Msg("Passwords do not match") + return nil, nil, fmt.Errorf(`password and confirm password does not match`) + } + if err := validators.IsValidPassword(params.Password, !p.Config.EnableStrongPassword); err != nil { + log.Debug().Msg("Invalid password") + return nil, nil, err + } + + log = log.With().Str("email", email).Str("phone_number", phoneNumber).Logger() + isEmailSignup := email != "" + isMobileSignup := phoneNumber != "" + if !isBasicAuthEnabled && isEmailSignup { + log.Debug().Msg("Basic authentication is disabled") + return nil, nil, fmt.Errorf(`basic authentication is disabled for this instance`) + } + if !isMobileBasicAuthEnabled && isMobileSignup { + log.Debug().Msg("Mobile basic authentication is disabled") + return nil, nil, fmt.Errorf(`mobile basic authentication is disabled for this instance`) + } + if isEmailSignup && !validators.IsValidEmail(email) { + log.Debug().Msg("Invalid email") + return nil, nil, fmt.Errorf(`invalid email address`) + } + if isMobileSignup && (phoneNumber == "" || len(phoneNumber) < 10) { + log.Debug().Msg("Invalid phone number") + return nil, nil, fmt.Errorf(`invalid phone number`) + } + // find user with email / phone number + if isEmailSignup { + existingUser, err := p.StorageProvider.GetUserByEmail(ctx, email) + if err != nil { + log.Debug().Err(err).Msg("Failed to get user by email") + } + if existingUser != nil && (existingUser.EmailVerifiedAt != nil || existingUser.ID != "") { + log.Debug().Msg("Email is already signed up.") + bcrypt.CompareHashAndPassword(dummyHash, []byte("timing-equalization")) + return nil, nil, fmt.Errorf("signup failed. please check your credentials or try a different method") + } + } else { + existingUser, err := p.StorageProvider.GetUserByPhoneNumber(ctx, phoneNumber) + if err != nil { + log.Debug().Err(err).Msg("Failed to get user by phone number") + } + if existingUser != nil && (existingUser.PhoneNumberVerifiedAt != nil || existingUser.ID != "") { + log.Debug().Msg("Phone number is already signed up.") + bcrypt.CompareHashAndPassword(dummyHash, []byte("timing-equalization")) + return nil, nil, fmt.Errorf("signup failed. please check your credentials or try a different method") + } + } + + inputRoles := params.Roles + if len(inputRoles) > 0 { + // check if roles exists + roles := p.Config.Roles + if !validators.IsValidRoles(inputRoles, roles) { + log.Debug().Strs("roles", params.Roles).Msg("Invalid roles") + return nil, nil, fmt.Errorf(`invalid roles`) + } + } else { + inputRoles = p.Config.DefaultRoles + } + user := &schemas.User{} + user.Roles = strings.Join(inputRoles, ",") + password, _ := crypto.EncryptPassword(params.Password) + user.Password = &password + if email != "" { + user.SignupMethods = constants.AuthRecipeMethodBasicAuth + user.Email = &email + } + if params.GivenName != nil { + user.GivenName = params.GivenName + } + + if params.FamilyName != nil { + user.FamilyName = params.FamilyName + } + + if params.MiddleName != nil { + user.MiddleName = params.MiddleName + } + + if params.Nickname != nil { + user.Nickname = params.Nickname + } + + if params.Gender != nil { + user.Gender = params.Gender + } + + if params.Birthdate != nil { + user.Birthdate = params.Birthdate + } + + if phoneNumber != "" { + user.SignupMethods = constants.AuthRecipeMethodMobileBasicAuth + user.PhoneNumber = refs.NewStringRef(phoneNumber) + } + + if params.Picture != nil { + user.Picture = params.Picture + } + + if params.IsMultiFactorAuthEnabled != nil { + user.IsMultiFactorAuthEnabled = params.IsMultiFactorAuthEnabled + } + + isMFAEnforced := p.Config.EnforceMFA + if isMFAEnforced { + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + } + + if params.AppData != nil { + appDataString := "" + appDataBytes, err := json.Marshal(params.AppData) + if err != nil { + log.Debug().Msg("failed to marshall source app_data") + return nil, nil, errors.New("malformed app_data") + } + appDataString = string(appDataBytes) + user.AppData = &appDataString + } + isEmailServiceEnabled := p.Config.IsEmailServiceEnabled + isEmailVerificationEnabled := p.Config.EnableEmailVerification && isEmailServiceEnabled + if !isEmailVerificationEnabled && isEmailSignup { + now := time.Now().Unix() + user.EmailVerifiedAt = &now + } + isSMSServiceEnabled := p.Config.IsSMSServiceEnabled + isPhoneVerificationEnabled := p.Config.EnablePhoneVerification && isSMSServiceEnabled + if !isPhoneVerificationEnabled && isMobileSignup { + now := time.Now().Unix() + user.PhoneNumberVerifiedAt = &now + } + user, err := p.StorageProvider.AddUser(ctx, user) + if err != nil { + log.Debug().Err(err).Msg("failed to add user") + return nil, nil, err + } + roles := strings.Split(user.Roles, ",") + userToReturn := user.AsAPIUser() + hostname := meta.HostURL + if isEmailVerificationEnabled && isEmailSignup { + // insert verification request + _, nonceHash, err := utils.GenerateNonce() + if err != nil { + log.Debug().Err(err).Msg("Failed to generate nonce") + return nil, nil, err + } + verificationType := constants.VerificationTypeBasicAuthSignup + redirectURL := hostname + "/app" + if params.RedirectURI != nil { + redirectURL = *params.RedirectURI + if !validators.IsValidRedirectURI(redirectURL, p.Config.AllowedOrigins, hostname) { + log.Debug().Msg("Invalid redirect URI") + return nil, nil, fmt.Errorf("invalid redirect URI") + } + } + verificationToken, err := p.TokenProvider.CreateVerificationToken(&token.AuthTokenConfig{ + Nonce: nonceHash, + HostName: hostname, + User: user, + LoginMethod: constants.AuthRecipeMethodBasicAuth, + }, redirectURL, verificationType) + if err != nil { + log.Debug().Err(err).Msg("Failed to create verification token") + return nil, nil, err + } + _, err = p.StorageProvider.AddVerificationRequest(ctx, &schemas.VerificationRequest{ + Token: verificationToken, + Identifier: verificationType, + ExpiresAt: time.Now().Add(time.Minute * 30).Unix(), + Email: email, + Nonce: nonceHash, + RedirectURI: redirectURL, + }) + if err != nil { + log.Debug().Err(err).Msg("Failed to add verification request") + return nil, nil, err + } + // exec it as go routine so that we can reduce the api latency + go func() { + p.EmailProvider.SendEmail([]string{email}, constants.VerificationTypeBasicAuthSignup, map[string]interface{}{ + "user": user.ToMap(), + "organization": utils.GetOrganization(p.Config), + "verification_url": utils.GetEmailVerificationURL(verificationToken, hostname, redirectURL), + }) + p.EventsProvider.RegisterEvent(ctx, constants.UserCreatedWebhookEvent, constants.AuthRecipeMethodBasicAuth, user) + }() + + return &model.AuthResponse{ + Message: `Verification email has been sent. Please check your inbox`, + }, side, nil + } else if isPhoneVerificationEnabled && isMobileSignup { + duration, _ := time.ParseDuration("10m") + smsCode, err := utils.GenerateOTP() + if err != nil { + log.Debug().Err(err).Msg("Failed to generate OTP") + return nil, nil, err + } + smsBody := strings.Builder{} + smsBody.WriteString("Your verification code is: ") + smsBody.WriteString(smsCode) + expiresAt := time.Now().Add(duration).Unix() + // Store the HMAC digest of the OTP; smsCode (plaintext) is sent + // over SMS by the existing smsBody above. + _, err = p.StorageProvider.UpsertOTP(ctx, &schemas.OTP{ + PhoneNumber: phoneNumber, + Otp: crypto.HashOTP(smsCode, p.Config.JWTSecret), + ExpiresAt: expiresAt, + }) + if err != nil { + log.Debug().Err(err).Msg("error while upserting OTP") + return nil, nil, err + } + mfaSession := uuid.NewString() + err = p.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, expiresAt) + if err != nil { + log.Debug().Err(err).Msg("Failed to add mfasession") + return nil, nil, err + } + for _, c := range cookie.BuildMfaSessionCookies(hostname, mfaSession, p.Config.AppCookieSecure) { + side.AddCookie(c) + } + go func() { + p.SMSProvider.SendSMS(phoneNumber, smsBody.String()) + p.EventsProvider.RegisterEvent(ctx, constants.UserCreatedWebhookEvent, constants.AuthRecipeMethodMobileBasicAuth, user) + }() + return &model.AuthResponse{ + Message: "Please check the OTP in your inbox", + ShouldShowMobileOtpScreen: refs.NewBoolRef(true), + }, side, nil + } + scope := []string{"openid", "email", "profile"} + if params.Scope != nil && len(params.Scope) > 0 { + scope = params.Scope + } + + code := "" + codeChallenge := "" + nonce := "" + oidcNonce := "" + authorizeRedirectURI := "" + if params.State != nil { + // Get state from store + authorizeState, _ := p.MemoryStoreProvider.GetState(refs.StringValue(params.State)) + if authorizeState != "" { + authorizeStateSplit := strings.Split(authorizeState, "@@") + if len(authorizeStateSplit) > 1 { + code = authorizeStateSplit[0] + codeChallenge = authorizeStateSplit[1] + if len(authorizeStateSplit) > 2 { + oidcNonce = authorizeStateSplit[2] + } + if len(authorizeStateSplit) > 3 { + authorizeRedirectURI = authorizeStateSplit[3] + } + } else { + nonce = authorizeState + } + p.MemoryStoreProvider.RemoveState(refs.StringValue(params.State)) + } + } + + if nonce == "" { + nonce = uuid.New().String() + } + // TokenProvider.CreateAuthToken takes *gin.Context but doesn't read from + // it (only AccessToken-getter and ID-token-getter helpers in the same + // file do). Synthesize a minimal gin.Context wrapping the inbound + // *http.Request so the call works for both gin and non-gin transports. + // TODO(grpc): refactor TokenProvider to take *http.Request directly. + gcShim := &gin.Context{Request: meta.Request} + authToken, err := p.TokenProvider.CreateAuthToken(gcShim, &token.AuthTokenConfig{ + User: user, + Roles: roles, + Scope: scope, + Nonce: nonce, + OIDCNonce: oidcNonce, + Code: code, + LoginMethod: constants.AuthRecipeMethodBasicAuth, + HostName: hostname, + }) + if err != nil { + log.Debug().Err(err).Msg("Failed to create auth token") + return nil, nil, err + } + + // Code challenge could be optional if PKCE flow is not used + if code != "" { + if err := p.MemoryStoreProvider.SetState(code, codeChallenge+"@@"+authToken.FingerPrintHash+"@@"+oidcNonce+"@@"+authorizeRedirectURI); err != nil { + log.Debug().Err(err).Msg("SetState failed") + return nil, nil, err + } + } + + expiresIn := authToken.AccessToken.ExpiresAt - time.Now().Unix() + if expiresIn <= 0 { + expiresIn = 1 + } + + res := &model.AuthResponse{ + Message: `Signed up successfully.`, + AccessToken: &authToken.AccessToken.Token, + ExpiresIn: &expiresIn, + User: userToReturn, + } + + sessionKey := constants.AuthRecipeMethodBasicAuth + ":" + user.ID + for _, c := range cookie.BuildSessionCookies(hostname, authToken.FingerPrintHash, p.Config.AppCookieSecure, cookie.ParseSameSite(p.Config.AppCookieSameSite)) { + side.AddCookie(c) + } + p.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeSessionToken+"_"+authToken.FingerPrint, authToken.FingerPrintHash, authToken.SessionTokenExpiresAt) + p.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeAccessToken+"_"+authToken.FingerPrint, authToken.AccessToken.Token, authToken.AccessToken.ExpiresAt) + + if authToken.RefreshToken != nil { + res.RefreshToken = &authToken.RefreshToken.Token + p.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeRefreshToken+"_"+authToken.FingerPrint, authToken.RefreshToken.Token, authToken.RefreshToken.ExpiresAt) + } + + ipAddress := meta.IPAddress + userAgent := meta.UserAgent + go func() { + p.EventsProvider.RegisterEvent(ctx, constants.UserCreatedWebhookEvent, constants.AuthRecipeMethodBasicAuth, user) + if isEmailSignup { + p.EventsProvider.RegisterEvent(ctx, constants.UserSignUpWebhookEvent, constants.AuthRecipeMethodBasicAuth, user) + p.EventsProvider.RegisterEvent(ctx, constants.UserLoginWebhookEvent, constants.AuthRecipeMethodBasicAuth, user) + } else { + p.EventsProvider.RegisterEvent(ctx, constants.UserSignUpWebhookEvent, constants.AuthRecipeMethodMobileBasicAuth, user) + p.EventsProvider.RegisterEvent(ctx, constants.UserLoginWebhookEvent, constants.AuthRecipeMethodMobileBasicAuth, user) + } + + if err := p.StorageProvider.AddSession(ctx, &schemas.Session{ + UserID: user.ID, + UserAgent: userAgent, + IP: ipAddress, + }); err != nil { + log.Debug().Err(err).Msg("Failed to add session") + } + }() + metrics.RecordAuthEvent(metrics.EventSignup, metrics.StatusSuccess) + metrics.ActiveSessions.Inc() + p.AuditProvider.LogEvent(audit.Event{ + Action: constants.AuditSignupEvent, + ActorID: user.ID, + ActorType: constants.AuditActorTypeUser, + ActorEmail: refs.StringValue(user.Email), + ResourceType: constants.AuditResourceTypeUser, + ResourceID: user.ID, + IPAddress: ipAddress, + UserAgent: userAgent, + }) + + return res, side, nil +}