diff --git a/README.md b/README.md index 9ed224120..ea5e5a7e4 100644 --- a/README.md +++ b/README.md @@ -865,6 +865,22 @@ Returns: } ``` +Register a new user with a asymmetric key. + +```js +{ + "asymmetric_address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", +} +``` + +Returns: + +```js +{ + "challenge_token": "11111111-2222-3333-4444-5555555555555", +} +``` + if AUTOCONFIRM is enabled and the sign up is a duplicate, then the endpoint will return: ```json @@ -980,6 +996,27 @@ Returns: } ``` +Verify a asymmetric signup. + +```json +{ + "type": "asymmetric_signup", + "token": "confirmation-otp-delivered-in-sms", + "asymmetric_signature": "hex-encoded-signature", +} +``` + +Returns: + +```json +{ + "access_token": "jwt-token-representing-the-user", + "token_type": "bearer", + "expires_in": 3600, + "refresh_token": "a-refresh-token" +} +``` + ### **GET /verify** Verify a registration or a password recovery. Type can be `signup` or `recovery` or `magiclink` or `invite` diff --git a/internal/api/api.go b/internal/api/api.go index b6e71473e..700c393cc 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -40,6 +40,8 @@ type API struct { hibpClient *hibp.PwnedClient oauthServer *oauthserver.Server + ats *AsymmetricTokenStorage + // overrideTime can be used to override the clock used by handlers. Should only be used in tests! overrideTime func() time.Time @@ -89,6 +91,10 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne oauthServer: oauthserver.NewServer(globalConfig, db), } + if globalConfig.External.Asymmetric.Enabled && !globalConfig.DisableSignup { + api.ats = NewAsymmetricTokenStorage() + } + for _, o := range opt { o.apply(api) } @@ -174,7 +180,7 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne if err := retrieveRequestParams(r, params); err != nil { return err } - if params.Email == "" && params.Phone == "" { + if params.Email == "" && params.Phone == "" && params.AsymmetricAddress == "" { if !api.config.External.AnonymousUsers.Enabled { return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeAnonymousProviderDisabled, "Anonymous sign-ins are disabled") } diff --git a/internal/api/apierrors/errorcode.go b/internal/api/apierrors/errorcode.go index 710797548..328cb0359 100644 --- a/internal/api/apierrors/errorcode.go +++ b/internal/api/apierrors/errorcode.go @@ -36,6 +36,7 @@ const ( ErrorCodeIdentityAlreadyExists ErrorCode = "identity_already_exists" ErrorCodeEmailProviderDisabled ErrorCode = "email_provider_disabled" ErrorCodePhoneProviderDisabled ErrorCode = "phone_provider_disabled" + ErrorCodeAsymmetricProviderDisabled ErrorCode = "asymmetric_provider_disabled" ErrorCodeTooManyEnrolledMFAFactors ErrorCode = "too_many_enrolled_mfa_factors" ErrorCodeMFAFactorNameConflict ErrorCode = "mfa_factor_name_conflict" ErrorCodeMFAFactorNotFound ErrorCode = "mfa_factor_not_found" @@ -97,4 +98,5 @@ const ( ErrorCodeWeb3UnsupportedChain ErrorCode = "web3_unsupported_chain" ErrorCodeOAuthDynamicClientRegistrationDisabled ErrorCode = "oauth_dynamic_client_registration_disabled" ErrorCodeEmailAddressNotProvided ErrorCode = "email_address_not_provided" + ErrorCodeAsymmetricSignatureInvalid ErrorCode = "asymmetric_signature_invalid" ) diff --git a/internal/api/asymmetric.go b/internal/api/asymmetric.go new file mode 100644 index 000000000..d8dc4aefb --- /dev/null +++ b/internal/api/asymmetric.go @@ -0,0 +1,102 @@ +package api + +import ( + "fmt" + "regexp" + "sync" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/google/uuid" + "github.com/supabase/auth/internal/api/apierrors" +) + +type AsymmetricSignupResponse struct { + ChallengeToken string `json:"challenge_token"` +} + +type AsymmetricTokenStorage struct { + mu sync.RWMutex + tokens map[string]string +} + +func NewAsymmetricTokenStorage() *AsymmetricTokenStorage { + return &AsymmetricTokenStorage{ + tokens: make(map[string]string), + } +} + +// For now supports only EVM-style addresses +func (a *API) validateAsymmetricAddress(address string) (string, error) { + re := regexp.MustCompile("^0x[0-9a-fA-F]{40}$") + if !re.MatchString(address) { + return "", apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Asymmetric address must be a valid Ethereum address") + } + + return address, nil +} + +func (a *API) validateAsymmetricSignature(signature string) (string, error) { + re := regexp.MustCompile("^0x[0-9a-fA-F]{130}$") + if !re.MatchString(signature) { + return "", apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Asymmetric signature must be a valid Ethereum signature") + } + + return signature, nil +} + +func (a *API) generateAsymmetricToken() (string, error) { + token := uuid.New().String() + return token, nil +} + +// TODO: fix potential memleak here, unverified tokens will never be deleted +func (a *API) setAsymmetricAddressForToken(challengeToken, address string) { + a.ats.mu.Lock() + defer a.ats.mu.Unlock() + a.ats.tokens[challengeToken] = address +} + +func (a *API) getAsymmetricAddressForToken(challengeToken string) (string, bool) { + a.ats.mu.RLock() + defer a.ats.mu.RUnlock() + address, ok := a.ats.tokens[challengeToken] + + return address, ok +} + +func (a *API) deleteAsymmetricAddressForToken(challengeToken string) { + a.ats.mu.Lock() + defer a.ats.mu.Unlock() + delete(a.ats.tokens, challengeToken) +} + +func recoverAsymmetricAddress(message []byte, sig []byte) (string, error) { + if len(sig) != 65 { + return "", fmt.Errorf("invalid signature length: got %d, want 65", len(sig)) + } + + if sig[64] >= 27 { + sig[64] -= 27 + } + + msg := computeEthereumSignedMessageHash(message) + + pubkey, err := crypto.SigToPub(msg, sig) + if err != nil { + return "", fmt.Errorf("signature recovery failed: %w", err) + } + + addr := crypto.PubkeyToAddress(*pubkey) + return addr.Hex(), nil +} + +// computeEthereumSignedMessageHash accepts an arbitrary message, prepends a known message, +// and hashes the result using keccak256. The known message added to the input before hashing is +// "\x19Ethereum Signed Message:\n" + len(message). +func computeEthereumSignedMessageHash(message []byte) []byte { + return crypto.Keccak256( + []byte( + fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(message), string(message)), + ), + ) +} diff --git a/internal/api/signup.go b/internal/api/signup.go index 41892c64a..898b2c5bf 100644 --- a/internal/api/signup.go +++ b/internal/api/signup.go @@ -2,6 +2,7 @@ package api import ( "context" + "fmt" "net/http" "time" @@ -21,6 +22,7 @@ type SignupParams struct { Email string `json:"email"` Phone string `json:"phone"` Password string `json:"password"` + AsymmetricAddress string `json:"asymmetric_address"` Data map[string]interface{} `json:"data"` Provider string `json:"-"` Aud string `json:"-"` @@ -32,6 +34,12 @@ type SignupParams struct { func (a *API) validateSignupParams(ctx context.Context, p *SignupParams) error { config := a.config + if p.AsymmetricAddress != "" && (p.Password != "" || p.Email != "" || p.Phone != "") { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Signup cannot include both asymmetric address and email/phone") + } else if p.AsymmetricAddress != "" { + return nil // skip further validation + } + if p.Password == "" { return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Signup requires a valid password") } @@ -61,6 +69,8 @@ func (p *SignupParams) ConfigureDefaults() { p.Provider = "email" } else if p.Phone != "" { p.Provider = "phone" + } else if p.AsymmetricAddress != "" { + p.Provider = "asymmetric" } if p.Data == nil { p.Data = make(map[string]interface{}) @@ -78,6 +88,8 @@ func (params *SignupParams) ToUserModel(isSSOUser bool) (user *models.User, err user, err = models.NewUser("", params.Email, params.Password, params.Aud, params.Data) case "phone": user, err = models.NewUser(params.Phone, "", params.Password, params.Aud, params.Data) + case "asymmetric": + user, err = models.NewUserWithAsymmetricAddress(params.AsymmetricAddress, params.Aud, params.Data) case "anonymous": user, err = models.NewUser("", "", "", params.Aud, params.Data) user.IsAnonymous = true @@ -156,6 +168,15 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { return err } user, err = models.FindUserByPhoneAndAudience(db, params.Phone, params.Aud) + case "asymmetric": + if !config.External.Asymmetric.Enabled { + return apierrors.NewBadRequestError(apierrors.ErrorCodeAsymmetricProviderDisabled, "Asymmetric signups are disabled") + } + params.AsymmetricAddress, err = a.validateAsymmetricAddress(params.AsymmetricAddress) + if err != nil { + return err + } + user, err = models.FindUserByAsymmetricAddressAndAudience(db, params.AsymmetricAddress, params.Aud) default: msg := "" if config.External.Email.Enabled && config.External.Phone.Enabled { @@ -165,7 +186,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { } else if config.External.Phone.Enabled { msg = "Sign up only available with phone provider" } else { - msg = "Sign up with this provider not possible" + msg = fmt.Sprintf("Sign up with this (%s) provider not possible", params.Provider) } return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, msg) @@ -225,6 +246,14 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { } user.Identities = []models.Identity{*identity} + if params.Provider == "asymmetric" { + if terr = models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserSignedUpAction, "", map[string]interface{}{ + "provider": params.Provider, + }); terr != nil { + return terr + } + } + if params.Provider == "email" && !user.IsConfirmed() { if config.Mailer.Autoconfirm { if terr = models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserSignedUpAction, "", map[string]interface{}{ @@ -302,6 +331,20 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { return err } + // It's impossible to "confirm" asymmetric signups, that's why it will always send a challenge token + if params.Provider == "asymmetric" { + challengeToken, err := a.generateAsymmetricToken() + if err != nil { + return err + } + + a.setAsymmetricAddressForToken(challengeToken, user.AsymmetricAddress.String()) + + return sendJSON(w, http.StatusOK, AsymmetricSignupResponse{ + ChallengeToken: challengeToken, + }) + } + // handles case where Mailer.Autoconfirm is true or Phone.Autoconfirm is true if user.IsConfirmed() || user.IsPhoneConfirmed() { var token *AccessTokenResponse diff --git a/internal/api/verify.go b/internal/api/verify.go index 20a23b0ce..bc6b0bbe3 100644 --- a/internal/api/verify.go +++ b/internal/api/verify.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/fatih/structs" "github.com/sethvargo/go-password/password" "github.com/supabase/auth/internal/api/apierrors" @@ -24,8 +25,9 @@ import ( ) const ( - smsVerification = "sms" - phoneChangeVerification = "phone_change" + smsVerification = "sms" + phoneChangeVerification = "phone_change" + asymmetricSignupVerification = "asymmetric_signup" // includes signupVerification and magicLinkVerification ) @@ -39,12 +41,13 @@ const singleConfirmationAccepted = "Confirmation link accepted. Please proceed t // VerifyParams are the parameters the Verify endpoint accepts type VerifyParams struct { - Type string `json:"type"` - Token string `json:"token"` - TokenHash string `json:"token_hash"` - Email string `json:"email"` - Phone string `json:"phone"` - RedirectTo string `json:"redirect_to"` + Type string `json:"type"` + Token string `json:"token"` + TokenHash string `json:"token_hash"` + Email string `json:"email"` + Phone string `json:"phone"` + RedirectTo string `json:"redirect_to"` + AsymmetricSignature string `json:"asymmetric_signature"` } func (p *VerifyParams) Validate(r *http.Request, a *API) error { @@ -76,6 +79,11 @@ func (p *VerifyParams) Validate(r *http.Request, a *API) error { return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeValidationFailed, "Invalid email format").WithInternalError(err) } p.TokenHash = crypto.GenerateTokenHash(p.Email, p.Token) + } else if isAsymmetricSignupVerification(p) { + p.AsymmetricSignature, err = a.validateAsymmetricSignature(p.AsymmetricSignature) + if err != nil { + return err + } } else { return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Only an email address or phone number should be provided on verify") } @@ -264,6 +272,8 @@ func (a *API) verifyPost(w http.ResponseWriter, r *http.Request, params *VerifyP } case smsVerification, phoneChangeVerification: user, terr = a.smsVerify(r, tx, user, params) + case asymmetricSignupVerification: + user, terr = a.asymmetricSignupVerify(r, tx, user, params) default: return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Unsupported verification type") } @@ -459,6 +469,32 @@ func (a *API) smsVerify(r *http.Request, conn *storage.Connection, user *models. return user, nil } +func (a *API) asymmetricSignupVerify(r *http.Request, conn *storage.Connection, user *models.User, params *VerifyParams) (*models.User, error) { + config := a.config + + err := conn.Transaction(func(tx *storage.Connection) error { + var terr error + + if terr = user.Recover(tx); terr != nil { + return terr + } + + if terr = models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserAsymmetricVerifyAction, "", map[string]interface{}{ + "provider": EmailProvider, + }); terr != nil { + return terr + } + return nil + }) + + a.deleteAsymmetricAddressForToken(params.Token) + + if err != nil { + return nil, apierrors.NewInternalServerError("Database error updating user").WithInternalError(err) + } + return user, nil +} + func (a *API) prepErrorRedirectURL(err *HTTPError, r *http.Request, rurl string, flowType models.FlowType) (string, error) { u, perr := url.Parse(rurl) if perr != nil { @@ -668,6 +704,7 @@ func (a *API) verifyUserAndToken(conn *storage.Connection, params *VerifyParams, var user *models.User var err error tokenHash := params.TokenHash + token := params.Token switch params.Type { case phoneChangeVerification: @@ -678,6 +715,12 @@ func (a *API) verifyUserAndToken(conn *storage.Connection, params *VerifyParams, // Since the email change could be trigger via the implicit or PKCE flow, // the query used has to also check if the token saved in the db contains the pkce_ prefix user, err = models.FindUserForEmailChange(conn, params.Email, tokenHash, aud, config.Mailer.SecureEmailChangeEnabled) + case asymmetricSignupVerification: + address, ok := a.getAsymmetricAddressForToken(token) + if !ok { + return nil, apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid token") + } + user, err = models.FindUserByAsymmetricAddressAndAudience(conn, address, aud) default: user, err = models.FindUserByEmailAndAudience(conn, params.Email, aud) } @@ -738,6 +781,22 @@ func (a *API) verifyUserAndToken(conn *storage.Connection, params *VerifyParams, return user, nil } isValid = isOtpValid(tokenHash, expectedToken, sentAt, config.Sms.OtpExp) + case asymmetricSignupVerification: + sigBytes, err := hexutil.Decode(params.AsymmetricSignature) + if err != nil { + return nil, apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid signature") + } + + recoverAsymmetricAddress, err := recoverAsymmetricAddress([]byte(token), sigBytes) + if err != nil { + return nil, apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid signture") + } + + if recoverAsymmetricAddress != user.AsymmetricAddress.String() { + return nil, apierrors.NewForbiddenError(apierrors.ErrorCodeAsymmetricSignatureInvalid, "Token has expired or is invalid").WithInternalMessage("asymmetric signature is invalid") + } else { + isValid = true + } } if !isValid { @@ -768,6 +827,10 @@ func isEmailOtpVerification(params *VerifyParams) bool { return params.Phone == "" && params.Email != "" } +func isAsymmetricSignupVerification(params *VerifyParams) bool { + return params.AsymmetricSignature != "" +} + func isUsingTokenHash(params *VerifyParams) bool { return params.TokenHash != "" && params.Token == "" && params.Phone == "" && params.Email == "" } diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index d3e971923..824097715 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -330,37 +330,38 @@ type EmailContentConfiguration struct { } type ProviderConfiguration struct { - AnonymousUsers AnonymousProviderConfiguration `json:"anonymous_users" split_words:"true"` - Apple OAuthProviderConfiguration `json:"apple"` - Azure OAuthProviderConfiguration `json:"azure"` - Bitbucket OAuthProviderConfiguration `json:"bitbucket"` - Discord OAuthProviderConfiguration `json:"discord"` - Facebook OAuthProviderConfiguration `json:"facebook"` - Snapchat OAuthProviderConfiguration `json:"snapchat"` - Figma OAuthProviderConfiguration `json:"figma"` - Fly OAuthProviderConfiguration `json:"fly"` - Github OAuthProviderConfiguration `json:"github"` - Gitlab OAuthProviderConfiguration `json:"gitlab"` - Google OAuthProviderConfiguration `json:"google"` - Kakao OAuthProviderConfiguration `json:"kakao"` - Notion OAuthProviderConfiguration `json:"notion"` - Keycloak OAuthProviderConfiguration `json:"keycloak"` - Linkedin OAuthProviderConfiguration `json:"linkedin"` - 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"` - VercelMarketplace OAuthProviderConfiguration `json:"vercel_marketplace" split_words:"true"` - WorkOS OAuthProviderConfiguration `json:"workos"` - Email EmailProviderConfiguration `json:"email"` - Phone PhoneProviderConfiguration `json:"phone"` - Zoom OAuthProviderConfiguration `json:"zoom"` - IosBundleId string `json:"ios_bundle_id" split_words:"true"` - RedirectURL string `json:"redirect_url"` - AllowedIdTokenIssuers []string `json:"allowed_id_token_issuers" split_words:"true"` - FlowStateExpiryDuration time.Duration `json:"flow_state_expiry_duration" split_words:"true"` + AnonymousUsers AnonymousProviderConfiguration `json:"anonymous_users" split_words:"true"` + Apple OAuthProviderConfiguration `json:"apple"` + Azure OAuthProviderConfiguration `json:"azure"` + Bitbucket OAuthProviderConfiguration `json:"bitbucket"` + Discord OAuthProviderConfiguration `json:"discord"` + Facebook OAuthProviderConfiguration `json:"facebook"` + Snapchat OAuthProviderConfiguration `json:"snapchat"` + Figma OAuthProviderConfiguration `json:"figma"` + Fly OAuthProviderConfiguration `json:"fly"` + Github OAuthProviderConfiguration `json:"github"` + Gitlab OAuthProviderConfiguration `json:"gitlab"` + Google OAuthProviderConfiguration `json:"google"` + Kakao OAuthProviderConfiguration `json:"kakao"` + Notion OAuthProviderConfiguration `json:"notion"` + Keycloak OAuthProviderConfiguration `json:"keycloak"` + Linkedin OAuthProviderConfiguration `json:"linkedin"` + 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"` + VercelMarketplace OAuthProviderConfiguration `json:"vercel_marketplace" split_words:"true"` + WorkOS OAuthProviderConfiguration `json:"workos"` + Email EmailProviderConfiguration `json:"email"` + Phone PhoneProviderConfiguration `json:"phone"` + Asymmetric AsymmetricProviderConfiguration `json:"asymmetric"` + Zoom OAuthProviderConfiguration `json:"zoom"` + IosBundleId string `json:"ios_bundle_id" split_words:"true"` + RedirectURL string `json:"redirect_url"` + AllowedIdTokenIssuers []string `json:"allowed_id_token_issuers" split_words:"true"` + FlowStateExpiryDuration time.Duration `json:"flow_state_expiry_duration" split_words:"true"` Web3Solana SolanaConfiguration `json:"web3_solana" split_words:"true"` Web3Ethereum EthereumConfiguration `json:"web3_ethereum" split_words:"true"` @@ -491,6 +492,10 @@ type PhoneProviderConfiguration struct { Enabled bool `json:"enabled" default:"false"` } +type AsymmetricProviderConfiguration struct { + Enabled bool `json:"enabled" default:"true"` +} + type SmsProviderConfiguration struct { Autoconfirm bool `json:"autoconfirm"` MaxFrequency time.Duration `json:"max_frequency" split_words:"true"` diff --git a/internal/models/audit_log_entry.go b/internal/models/audit_log_entry.go index 22d1b8049..0b0167cfd 100644 --- a/internal/models/audit_log_entry.go +++ b/internal/models/audit_log_entry.go @@ -33,6 +33,7 @@ const ( UserConfirmationRequestedAction AuditAction = "user_confirmation_requested" UserRepeatedSignUpAction AuditAction = "user_repeated_signup" UserUpdatePasswordAction AuditAction = "user_updated_password" + UserAsymmetricVerifyAction AuditAction = "user_asymmetric_verified" TokenRevokedAction AuditAction = "token_revoked" TokenRefreshedAction AuditAction = "token_refreshed" GenerateRecoveryCodesAction AuditAction = "generate_recovery_codes" @@ -103,6 +104,10 @@ func NewAuditLogEntry(config conf.AuditLogConfiguration, r *http.Request, tx *st username = actor.GetPhone() } + if actor.GetAsymmetricAddress() != "" { + username = actor.GetAsymmetricAddress() + } + payload := map[string]interface{}{ "actor_id": actor.ID, "actor_via_sso": actor.IsSSOUser, diff --git a/internal/models/user.go b/internal/models/user.go index 69e76b336..2a742c7bf 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -37,6 +37,8 @@ type User struct { ConfirmationToken string `json:"-" db:"confirmation_token"` ConfirmationSentAt *time.Time `json:"confirmation_sent_at,omitempty" db:"confirmation_sent_at"` + AsymmetricAddress storage.NullString `json:"asymmetric_address,omitempty" db:"asymmetric_address"` + // For backward compatibility only. Use EmailConfirmedAt or PhoneConfirmedAt instead. ConfirmedAt *time.Time `json:"confirmed_at,omitempty" db:"confirmed_at" rw:"r"` @@ -132,6 +134,17 @@ func NewUser(phone, email, password, aud string, userData map[string]interface{} return user, nil } +func NewUserWithAsymmetricAddress(address, aud string, userData map[string]interface{}) (*User, error) { + id := uuid.Must(uuid.NewV4()) + user := &User{ + ID: id, + Aud: aud, + AsymmetricAddress: storage.NullString(address), + UserMetaData: userData, + } + return user, nil +} + // TableName overrides the table name used by pop func (User) TableName() string { tableName := "users" @@ -221,6 +234,10 @@ func (u *User) GetPhone() string { return string(u.Phone) } +func (u *User) GetAsymmetricAddress() string { + return string(u.AsymmetricAddress) +} + // UpdateUserMetaData sets all user data from a map of updates, // ensuring that it doesn't override attributes that are not // in the provided map. @@ -624,6 +641,11 @@ func FindUserByPhoneAndAudience(tx *storage.Connection, phone, aud string) (*Use return findUser(tx, "instance_id = ? and phone = ? and aud = ? and is_sso_user = false", uuid.Nil, phone, aud) } +// FindUserByAsymmetricAddressAndAudience finds a user with the matching asymmetric address and audience. +func FindUserByAsymmetricAddressAndAudience(tx *storage.Connection, address, aud string) (*User, error) { + return findUser(tx, "instance_id = ? and asymmetric_address = ? and aud = ? and is_sso_user = false", uuid.Nil, address, aud) +} + // FindUserByID finds a user matching the provided ID. func FindUserByID(tx *storage.Connection, id uuid.UUID) (*User, error) { return findUser(tx, "instance_id = ? and id = ?", uuid.Nil, id) diff --git a/migrations/20250822123048_add_asymmetric_address.up.sql b/migrations/20250822123048_add_asymmetric_address.up.sql new file mode 100644 index 000000000..1e85713f4 --- /dev/null +++ b/migrations/20250822123048_add_asymmetric_address.up.sql @@ -0,0 +1,4 @@ +-- adds asymmetric_address column to auth.users + +alter table {{ index .Options "Namespace" }}.users +add column if not exists asymmetric_address varchar(255) null;