Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down
8 changes: 7 additions & 1 deletion internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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")
}
Expand Down
2 changes: 2 additions & 0 deletions internal/api/apierrors/errorcode.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
)
102 changes: 102 additions & 0 deletions internal/api/asymmetric.go
Original file line number Diff line number Diff line change
@@ -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)),
),
)
}
45 changes: 44 additions & 1 deletion internal/api/signup.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

import (
"context"
"fmt"
"net/http"
"time"

Expand All @@ -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:"-"`
Expand All @@ -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")
}
Expand Down Expand Up @@ -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{})
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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{}{
Expand Down Expand Up @@ -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
Expand Down
Loading