From c679e8158347b549284f05a4a78255b10e2c3973 Mon Sep 17 00:00:00 2001 From: RxVince Date: Sun, 8 Nov 2026 23:39:28 +0100 Subject: [PATCH 1/2] feat(web3): add atomic existing-user-only authentication Introduce a challenge-bound Web3 grant branch that authenticates pre-existing identities without user creation, with transactional nonce consumption and regression tests for concurrency and rollback. Co-authored-by: Cursor --- .../web3-atomic-existing-user-only-auth.md | 74 +++ docs/web3-existing-user-only.md | 91 ++++ internal/api/api.go | 3 + internal/api/helpers.go | 2 +- internal/api/web3.go | 54 ++- internal/api/web3_existing.go | 212 ++++++++ internal/api/web3_existing_test.go | 455 ++++++++++++++++++ ...20000_web3_existing_user_challenges.up.sql | 21 + openapi.yaml | 45 ++ 9 files changed, 945 insertions(+), 12 deletions(-) create mode 100644 docs/rfc/web3-atomic-existing-user-only-auth.md create mode 100644 docs/web3-existing-user-only.md create mode 100644 internal/api/web3_existing.go create mode 100644 internal/api/web3_existing_test.go create mode 100644 migrations/20261108220000_web3_existing_user_challenges.up.sql diff --git a/docs/rfc/web3-atomic-existing-user-only-auth.md b/docs/rfc/web3-atomic-existing-user-only-auth.md new file mode 100644 index 0000000000..0f9924f0ad --- /dev/null +++ b/docs/rfc/web3-atomic-existing-user-only-auth.md @@ -0,0 +1,74 @@ +# RFC: Atomic existing-user-only authentication for Web3 sign-in + +## 1. Problem statement + +Applications using Web3 sign-in often need **re-authentication** for an account that already has a provider identity, without any chance of creating a new user if resolution races with deletion, replacement, or concurrent sign-in attempts. + +Today, the Web3 grant can follow paths that **create** users when no matching identity is found. A client-side check before calling Auth does not provide transactional guarantees against concurrent identity changes. + +## 2. Current GoTrue behavior + +`POST /token?grant_type=web3` validates SIWE/SIWS messages and either signs in an existing identity or **registers** a new user when policy allows. There is no first-class, server-issued challenge that binds a subsequent proof exclusively to a pre-resolved identity and user UUID. + +## 3. Race condition / concurrency risk + +Between a client-side “user exists” check and token issuance, another transaction may delete the identity, replace it, or complete a competing login. The standard grant may then enter account-creation logic. Downstream systems that require **UUID continuity** and **zero creation** on professional re-auth cannot rely on pre-checks alone. + +## 4. Existing-user-only contract + +Introduce an explicit mode: + +1. **`POST /web3/challenge`** — resolves and locks an existing verified Web3 identity and active user; returns a one-time `nonce`, `request_id: WEB3_EXISTING_USER_ONLY`, and expiry. No session is created. +2. Wallet signs SIWE/SIWS including `Request ID: WEB3_EXISTING_USER_ONLY` and the server nonce. +3. **`POST /token?grant_type=web3`** with `existing_user_only: true` (and optional `expected_user_id` as an assertion, never a selector) consumes the challenge and issues tokens **only** if snapshots still match. + +Failure modes refuse login; they do **not** fall back to signup. + +## 5. Proposed transaction semantics + +After cryptographic validation: + +- `SELECT … FOR UPDATE` on challenge, identity, and user rows in one database transaction. +- Revalidate identity snapshot and user status against challenge binding. +- Verify expiration using database time after locks are acquired. +- Consume the nonce (single-use). +- Call the existing session issuance path; roll back nonce consumption and Auth writes if hooks or issuance fail. + +The strict branch must not call user-creation or identity-creation helpers. + +## 6. API compatibility + +- Default `existing_user_only: false` preserves current Web3 signup/login. +- New endpoint and fields are additive. +- OpenAPI documents challenge request/response and token fields. + +## 7. Security invariants + +- Optional `expected_user_id` is checked against server-resolved identity; it cannot select an account. +- Signed `Request ID` prevents downgrading a strict proof by omitting the HTTP flag. +- Nonce replay and concurrent duplicate proofs: at most one session per consumed nonce. +- Identity replacement/transfer after challenge issuance does not satisfy the snapshot. + +## 8. Regression coverage + +- New scenario suite for the strict branch (success, refusal, concurrency, hook rollback). +- Existing Web3, OTP, and token tests unchanged on the default path. +- Migration adds a private challenge table with RLS; no cascading FK that would erase snapshots on identity deletion. + +## 9. Migration / rollout considerations + +- Requires a new Auth schema migration before the endpoint is enabled. +- Hosted Supabase needs a released Auth version including the migration and handlers. +- Client SDKs should expose challenge + strict grant explicitly; older issuers must not silently treat failures as signup. + +## 10. Open questions for maintainers + +- Preferred naming: `existing_user_only` vs `login_only` / `allow_create: false`? +- Challenge retention, cleanup, and wallet-enumeration privacy trade-offs. +- Rate limits and CAPTCHA parity with `/otp` vs `/verify`. +- Whether `expected_user_id` should be supported in v1 or deferred. +- JS/SDK surface and capability detection for hosted projects. + +--- + +Implementation reference: branch `feat/web3-atomic-existing-user-only` (draft PR to follow). Design detail: `docs/web3-existing-user-only.md`. diff --git a/docs/web3-existing-user-only.md b/docs/web3-existing-user-only.md new file mode 100644 index 0000000000..f196a82d98 --- /dev/null +++ b/docs/web3-existing-user-only.md @@ -0,0 +1,91 @@ +# Proposed Web3 existing-user-only grant + +Local prototype based on Auth v2.197.0. Not available on hosted Supabase merely +because this patch exists. No managed project or SDK configuration is changed. + +## Contract + +Initial registration remains `POST /token?grant_type=web3` with the existing +`chain`, `message`, and `signature` fields. Do not fall back to registration +when an existing-only login fails. + +1. `POST /web3/challenge` with `chain` (`ethereum` or `solana`), `address`, + `chain_id`, `domain`, `uri` and optionally `expected_user_id`. +2. The server resolves and locks an existing, active, non-anonymous user and a + previously authenticated provider identity. Ambiguous Ethereum case variants, + inconsistent provider metadata, banned/deleted users, and UUID mismatches fail. + The response contains only `nonce`, `issued_at`, `expires_at` and + `request_id: "WEB3_EXISTING_USER_ONLY"`. It is not an authentication result. +3. Sign a SIWE/SIWS message containing those fields, the same address/domain/URI/ + network, an explicit expiration no later than the server expiration, and the + signed `Request ID: WEB3_EXISTING_USER_ONLY`. +4. `POST /token?grant_type=web3` with `chain`, `message`, `signature`, + `existing_user_only: true`, optionally `expected_user_id`. + +The optional expected UUID is an assertion, never an account selector or proof +of authority. The signature and server-resolved identity are authoritative. The +challenge always binds the original identity row UUID and user UUID, even when +the optional assertion is omitted. Removing the HTTP mode flag from a signed +existing-only proof is refused, rather than interpreted as signup. + +## Transaction and replay + +The existing signature/domain validators run before the strict transaction. +Inside the transaction: lock the nonce row, lock/revalidate identity and user, +verify the snapshot binding, verify expiration against database wall time after +lock waits, consume the nonce, then call official Auth session issuance. There is +no user or identity creation call on this branch. The before/after-user-created +hooks are not invoked for an operation which cannot create a user. + +Two requests with the same nonce serialize; at most one commits a new session. +Deletion or retargeting before the identity lock causes refusal. Deletion after +the issuer owns the identity/user locks waits until that login commits, giving a +defined serialization order. Session issuance errors roll back nonce consumption +and Auth writes. Unknown commit outcomes must not trigger signup or a different +identity: a committed proof remains consumed; a rolled-back proof has no session. + +The refresh contract remains the official existing session contract. Refresh +does not change the UUID or renew `last_sign_in_at`; it is not reauthentication. +This change does not retroactively revoke already committed sessions on later +identity deletion. Account/session revocation is a separate existing Auth policy. + +## Schema and operation + +The additive engine migration creates an Auth-private challenge table with RLS, +no PUBLIC privileges, unique nonce digests, expiration index, and snapshot IDs. +There is deliberately no cascading FK that could erase a snapshot on deletion +and allow a replacement identity to satisfy it. No historic user backfill, +trigger, outbox, cron, worker, or email operation is added. + +Challenges expire after five minutes. Expired rows may be removed by an +operator-owned bounded maintenance task; deleting an expired tombstone never +makes its nonce valid again because redemption requires an unexpired row. No +background task is enabled by this patch. Operators must provision retention and +capacity for this new table. Challenge requests use existing CAPTCHA and verify +rate-limiting middleware. Their generic refusal response intentionally excludes +UUIDs and account data, but observable wallet-existence differences must still be +considered in the upstream threat review. + +No guarantee is claimed against a malicious database owner or Auth hook that +deliberately inserts accounts. The invariant concerns this grant under the trusted +Auth configuration, including the tested normal hook rejection path. + +## Verification + +`TestR8Web3ExistingOnly` executes real Ethereum/Solana signatures through Auth's +HTTP handlers and a real PostgreSQL 17 database, including actual lock waits and +user/identity/session counters. It is not a mocked resolver or JWT generator. +Run alongside the existing `TestWeb3`, `TestOtp`, and `TestToken` suites. + +The Winstory transport sketch is packaged separately, not wired into its hosted +Application. The current JavaScript SDK does not serialize the new flag; it must +gain support or use an explicit supported transport for this proposed endpoint. + +## Upstream and hosted deployment + +The patch targets v2.197.0 as requested, not current master. Upstream contribution +requires a feature RFC/design discussion, review of API/retention/rate-limit +semantics, adaptation to current master, full upstream CI, SDK support and release +approval. No publication, acceptance date or managed deployment date is assumed. +Do not apply this engine migration by hand to a managed project's Auth schema. +Do not connect this local fork to a hosted project's Auth database. diff --git a/internal/api/api.go b/internal/api/api.go index 6c26fe5571..c3c913bdc5 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -264,6 +264,9 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne r.With(api.limitHandler(api.limiterOpts.Otp)). With(api.verifyCaptcha).Post("/otp", api.Otp) + // Existing-only challenges never create a user or session. + r.With(api.limitHandler(api.limiterOpts.Verify)).With(api.verifyCaptcha).Post("/web3/challenge", api.Web3ExistingChallenge) + // rate limiting applied in handler r.With(api.verifyCaptcha).Post("/token", api.Token) diff --git a/internal/api/helpers.go b/internal/api/helpers.go index bd6b0f13be..90db5d3433 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -64,7 +64,7 @@ type RequestParams interface { SignupParams | SingleSignOnParams | SmsParams | - Web3GrantParams | + Web3GrantParams | Web3ChallengeParams | UserUpdateParams | VerifyFactorParams | VerifyParams | diff --git a/internal/api/web3.go b/internal/api/web3.go index 4a88cd6da9..a5e6b1e721 100644 --- a/internal/api/web3.go +++ b/internal/api/web3.go @@ -17,9 +17,11 @@ import ( ) type Web3GrantParams struct { - Message string `json:"message,omitempty"` - Signature string `json:"signature,omitempty"` - Chain string `json:"chain,omitempty"` + ExistingUserOnly bool `json:"existing_user_only,omitempty"` + ExpectedUserID string `json:"expected_user_id,omitempty"` + Message string `json:"message,omitempty"` + Signature string `json:"signature,omitempty"` + Chain string `json:"chain,omitempty"` } func (a *API) Web3Grant(ctx context.Context, w http.ResponseWriter, r *http.Request) error { @@ -34,6 +36,9 @@ func (a *API) Web3Grant(ctx context.Context, w http.ResponseWriter, r *http.Requ return err } + if params.ExpectedUserID != "" && !params.ExistingUserOnly { + return web3ExistingDenied() + } switch params.Chain { case "solana": if !config.External.Web3Solana.Enabled { @@ -76,6 +81,10 @@ func (a *API) web3GrantSolana(ctx context.Context, w http.ResponseWriter, r *htt return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "%s", err.Error()) } + if params.ExistingUserOnly != (parsedMessage.RequestID == web3ExistingRequestID) { + return web3ExistingDenied() + } + if !parsedMessage.VerifySignature(signatureBytes) { return apierrors.NewOAuthError("invalid_grant", "Signature does not match address in message") } @@ -140,19 +149,28 @@ func (a *API) web3GrantSolana(ctx context.Context, w http.ResponseWriter, r *htt var grantParams models.GrantParams grantParams.FillGrantParams(r) - if err := a.triggerBeforeUserCreatedExternal(r, db, &userData, providerType); err != nil { - return err + if !params.ExistingUserOnly { + if err := a.triggerBeforeUserCreatedExternal(r, db, &userData, providerType); err != nil { + return err + } } var user *models.User err = db.Transaction(func(tx *storage.Connection) error { var terr error var decision models.AccountLinkingDecision - decision, user, terr = a.createAccountFromExternalIdentity(tx, r, &userData, providerType, true) + if params.ExistingUserOnly { + if parsedMessage.ExpirationTime.IsZero() { + return web3ExistingDenied() + } + user, terr = consumeExistingWeb3(tx, params, providerId, parsedMessage.Nonce, parsedMessage.Domain, parsedMessage.URI.String(), parsedMessage.ChainID, parsedMessage.IssuedAt, parsedMessage.ExpirationTime) + } else { + decision, user, terr = a.createAccountFromExternalIdentity(tx, r, &userData, providerType, true) + } if terr != nil { return terr } - createdUser = decision == models.CreateAccount + createdUser = !params.ExistingUserOnly && decision == models.CreateAccount if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.LoginAction, "", map[string]interface{}{ "provider": providerType, @@ -222,6 +240,11 @@ func (a *API) web3GrantEthereum(ctx context.Context, w http.ResponseWriter, r *h return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "%s", err.Error()) } + signedExistingIntent := parsedMessage.RequestID != nil && *parsedMessage.RequestID == web3ExistingRequestID + if params.ExistingUserOnly != signedExistingIntent { + return web3ExistingDenied() + } + if !parsedMessage.VerifySignature(params.Signature) { return apierrors.NewOAuthError("invalid_grant", "Signature does not match address in message") } @@ -286,19 +309,28 @@ func (a *API) web3GrantEthereum(ctx context.Context, w http.ResponseWriter, r *h var grantParams models.GrantParams grantParams.FillGrantParams(r) - if err := a.triggerBeforeUserCreatedExternal(r, db, &userData, providerType); err != nil { - return err + if !params.ExistingUserOnly { + if err := a.triggerBeforeUserCreatedExternal(r, db, &userData, providerType); err != nil { + return err + } } var user *models.User err = db.Transaction(func(tx *storage.Connection) error { var terr error var decision models.AccountLinkingDecision - decision, user, terr = a.createAccountFromExternalIdentity(tx, r, &userData, providerType, true) + if params.ExistingUserOnly { + if parsedMessage.ExpirationTime == nil { + return web3ExistingDenied() + } + user, terr = consumeExistingWeb3(tx, params, providerId, parsedMessage.Nonce, parsedMessage.Domain, parsedMessage.URI.String(), parsedMessage.ChainID, parsedMessage.IssuedAt, *parsedMessage.ExpirationTime) + } else { + decision, user, terr = a.createAccountFromExternalIdentity(tx, r, &userData, providerType, true) + } if terr != nil { return terr } - createdUser = decision == models.CreateAccount + createdUser = !params.ExistingUserOnly && decision == models.CreateAccount if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.LoginAction, "", map[string]interface{}{ "provider": providerType, diff --git a/internal/api/web3_existing.go b/internal/api/web3_existing.go new file mode 100644 index 0000000000..5be5874c8e --- /dev/null +++ b/internal/api/web3_existing.go @@ -0,0 +1,212 @@ +package api + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/btcsuite/btcutil/base58" + "github.com/ethereum/go-ethereum/common" + "github.com/gobuffalo/pop/v6" + "github.com/gofrs/uuid" + "github.com/supabase/auth/internal/api/apierrors" + "github.com/supabase/auth/internal/models" + "github.com/supabase/auth/internal/storage" + "github.com/supabase/auth/internal/utilities" +) + +// Signed intent prevents downgrading an existing-only proof to ordinary signup. +const web3ExistingRequestID = "WEB3_EXISTING_USER_ONLY" +const web3ExistingChallengeTTL = 5 * time.Minute + +type web3ExistingChallenge struct { + ID uuid.UUID `db:"id"` + NonceHash string `db:"nonce_hash"` + ProviderID string `db:"provider_id"` + IdentityID uuid.UUID `db:"identity_id"` + UserID uuid.UUID `db:"user_id"` + Chain string `db:"chain"` + ChainID string `db:"chain_id"` + Domain string `db:"domain"` + URI string `db:"uri"` + CreatedAt time.Time `db:"created_at"` + ExpiresAt time.Time `db:"expires_at"` + ConsumedAt *time.Time `db:"consumed_at"` +} + +func (web3ExistingChallenge) TableName() string { return "web3_login_challenges" } + +func web3ExistingDenied() error { + return apierrors.NewOAuthError("invalid_grant", "Existing Web3 identity or challenge is unavailable") +} + +func web3ProviderID(chain, address string) (string, bool) { + switch chain { + case "ethereum": + if !common.IsHexAddress(address) { + return "", false + } + return "web3:ethereum:" + strings.ToLower(address), true + case "solana": + if len(base58.Decode(address)) != 32 { + return "", false + } + return "web3:solana:" + address, true + default: + return "", false + } +} + +func web3SameProviderID(chain, a, b string) bool { + if chain == "ethereum" { + return strings.EqualFold(a, b) + } + return a == b +} + +// Locks both identity and user before returning a principal. Per-user locking +// also avoids upgrading simultaneous SHARE locks during session issuance. +func lockExistingWeb3(tx *storage.Connection, chain, providerID string) (*models.Identity, *models.User, error) { + identities := []models.Identity{} + identityTable := (&pop.Model{Value: models.Identity{}}).TableName() + userTable := (&pop.Model{Value: models.User{}}).TableName() + match := "i.provider_id = ?" + if chain == "ethereum" { + match = "lower(i.provider_id) = lower(?)" + } + query := "select i.* from " + identityTable + " i join " + userTable + " u on u.id=i.user_id where i.provider='web3' and " + match + " order by i.id for update of i,u" + if err := tx.RawQuery(query, providerID).All(&identities); err != nil { + return nil, nil, err + } + // Refuse case-folding ambiguities instead of choosing an arbitrary account. + if len(identities) != 1 { + return nil, nil, web3ExistingDenied() + } + i := &identities[0] + u, err := models.FindUserByID(tx, i.UserID) + if err != nil { + return nil, nil, web3ExistingDenied() + } + sub, _ := i.IdentityData["sub"].(string) + claims, _ := i.IdentityData["custom_claims"].(map[string]interface{}) + address, _ := claims["address"].(string) + storedChain, _ := claims["chain"].(string) + storedID, valid := web3ProviderID(storedChain, address) + if u.DeletedAt != nil || u.IsBanned() || u.IsAnonymous || i.LastSignInAt == nil || + !valid || storedChain != chain || !web3SameProviderID(chain, sub, providerID) || + !web3SameProviderID(chain, storedID, providerID) { + return nil, nil, web3ExistingDenied() + } + return i, u, nil +} + +type Web3ChallengeParams struct { + Chain string `json:"chain"` + Address string `json:"address"` + ChainID string `json:"chain_id"` + Domain string `json:"domain"` + URI string `json:"uri"` + ExpectedUserID string `json:"expected_user_id"` +} + +// Web3ExistingChallenge issues only a nonce, never a user or session. Request +// fields are constraints, not evidence of authority; the signed proof is still +// checked by the existing Web3 grant validators before challenge consumption. +func (a *API) Web3ExistingChallenge(w http.ResponseWriter, r *http.Request) error { + var input Web3ChallengeParams + if err := retrieveRequestParams(r, &input); err != nil { + return err + } + providerID, valid := web3ProviderID(input.Chain, input.Address) + if !valid || (input.Chain == "ethereum" && !a.config.External.Web3Ethereum.Enabled) || + (input.Chain == "solana" && !a.config.External.Web3Solana.Enabled) { + return web3ExistingDenied() + } + uri, err := url.Parse(input.URI) + if err != nil || uri.Host == "" || uri.Host != input.Domain || uri.User != nil || + (uri.Scheme != "https" && !(uri.Scheme == "http" && uri.Hostname() == "localhost")) || + !utilities.IsRedirectURLValid(a.config, uri.String()) || input.ChainID == "" || len(input.ChainID) > 64 { + return web3ExistingDenied() + } + if input.Chain == "ethereum" { + chainID, err := strconv.ParseUint(input.ChainID, 10, 64) + if err != nil || chainID == 0 || strconv.FormatUint(chainID, 10) != input.ChainID { + return web3ExistingDenied() + } + } + if input.ExpectedUserID != "" { + if _, err := uuid.FromString(input.ExpectedUserID); err != nil { + return web3ExistingDenied() + } + } + nonceBytes := make([]byte, 32) + if _, err := rand.Read(nonceBytes); err != nil { + return err + } + nonce := hex.EncodeToString(nonceBytes) + hash := sha256.Sum256([]byte(nonce)) + now := a.Now().UTC().Truncate(time.Second) + challenge := &web3ExistingChallenge{ID: uuid.Must(uuid.NewV4()), NonceHash: hex.EncodeToString(hash[:]), + ProviderID: providerID, Chain: input.Chain, ChainID: input.ChainID, + Domain: input.Domain, URI: input.URI, CreatedAt: now, ExpiresAt: now.Add(web3ExistingChallengeTTL)} + err = a.db.WithContext(r.Context()).Transaction(func(tx *storage.Connection) error { + if err := tx.RawQuery("set local lock_timeout='3s'").Exec(); err != nil { + return err + } + i, u, err := lockExistingWeb3(tx, input.Chain, providerID) + if err != nil { + return err + } + if input.ExpectedUserID != "" && input.ExpectedUserID != u.ID.String() { + return web3ExistingDenied() + } + challenge.IdentityID, challenge.UserID = i.ID, u.ID + return tx.Create(challenge) + }) + if err != nil { + return err + } + return sendJSON(w, http.StatusOK, map[string]interface{}{ + "nonce": nonce, "request_id": web3ExistingRequestID, + "issued_at": now, "expires_at": challenge.ExpiresAt, + }) +} + +// consumeExistingWeb3 runs only after the official cryptographic validators. +// It has no account/identity creation call, including on missing or changed rows. +func consumeExistingWeb3(tx *storage.Connection, params *Web3GrantParams, providerID, nonce, domain, uri, chainID string, issuedAt, expiresAt time.Time) (*models.User, error) { + if err := tx.RawQuery("set local lock_timeout='3s'").Exec(); err != nil { + return nil, err + } + hash := sha256.Sum256([]byte(nonce)) + challenge := &web3ExistingChallenge{} + table := (&pop.Model{Value: web3ExistingChallenge{}}).TableName() + if err := tx.RawQuery("select * from "+table+" where nonce_hash=? for update", hex.EncodeToString(hash[:])).First(challenge); err != nil { + return nil, web3ExistingDenied() + } + if challenge.ConsumedAt != nil || params.Chain != challenge.Chain || domain != challenge.Domain || + uri != challenge.URI || chainID != challenge.ChainID || !web3SameProviderID(params.Chain, providerID, challenge.ProviderID) || + issuedAt.Before(challenge.CreatedAt.Add(-30*time.Second)) || expiresAt.IsZero() || expiresAt.Before(issuedAt) || expiresAt.After(challenge.ExpiresAt) || + (params.ExpectedUserID != "" && params.ExpectedUserID != challenge.UserID.String()) { + return nil, web3ExistingDenied() + } + i, u, err := lockExistingWeb3(tx, params.Chain, providerID) + if err != nil { + return nil, err + } + if i.ID != challenge.IdentityID || u.ID != challenge.UserID { + return nil, web3ExistingDenied() + } + // Check database wall time after row-lock waits. Consumption and session + // issuance commit together; an issuer/hook error rolls consumption back. + var consumed string + if err := tx.RawQuery("update "+table+" set consumed_at=clock_timestamp() where nonce_hash=? and consumed_at is null and expires_at>clock_timestamp() and ?>clock_timestamp() and ?<=clock_timestamp()+interval '30 seconds' returning nonce_hash", challenge.NonceHash, expiresAt, issuedAt).First(&consumed); err != nil { + return nil, web3ExistingDenied() + } + return u, nil +} diff --git a/internal/api/web3_existing_test.go b/internal/api/web3_existing_test.go new file mode 100644 index 0000000000..38eaff6ac4 --- /dev/null +++ b/internal/api/web3_existing_test.go @@ -0,0 +1,455 @@ +package api + +import ( + "bytes" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcutil/base58" + "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/storage" +) + +type r8Wallet struct { + chain, chainID, address string + eth *ecdsa.PrivateKey + solana ed25519.PrivateKey +} + +func r8NewWallet(t *testing.T, chain string) r8Wallet { + if chain == "ethereum" { + key, err := crypto.GenerateKey() + require.NoError(t, err) + return r8Wallet{chain: chain, chainID: "1", address: crypto.PubkeyToAddress(key.PublicKey).Hex(), eth: key} + } + pub, key, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + return r8Wallet{chain: chain, chainID: "solana:mainnet", address: base58.Encode(pub), solana: key} +} + +func r8HTTP(api *API, path string, body interface{}) *httptest.ResponseRecorder { + b, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "http://localhost"+path, bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + api.handler.ServeHTTP(w, r) + return w +} + +func r8Proof(t *testing.T, wallet r8Wallet, nonce string, issued, expires time.Time, strict bool) map[string]interface{} { + network := "Ethereum" + if wallet.chain == "solana" { + network = "Solana" + } + message := fmt.Sprintf("example.netlify.com wants you to sign in with your %s account:\n%s\n\nSign in to the isolated R8 test.\n\nURI: https://example.netlify.com\nVersion: 1\nChain ID: %s\nNonce: %s\nIssued At: %s\nExpiration Time: %s", network, wallet.address, wallet.chainID, nonce, issued.Format(time.RFC3339Nano), expires.Format(time.RFC3339Nano)) + if strict { + message += "\nRequest ID: " + web3ExistingRequestID + } + signature := r8Signature(t, wallet, message) + return map[string]interface{}{"chain": wallet.chain, "message": message, "signature": signature, "existing_user_only": strict} +} + +func r8Signature(t *testing.T, wallet r8Wallet, message string) string { + var signature string + if wallet.chain == "ethereum" { + sig, err := crypto.Sign(accounts.TextHash([]byte(message)), wallet.eth) + require.NoError(t, err) + sig[64] += 27 + signature = hexutil.Encode(sig) + } else { + signature = base64.StdEncoding.EncodeToString(ed25519.Sign(wallet.solana, []byte(message))) + } + return signature +} + +type r8Counts struct { + Users, Identities, Sessions int + IdentityBindings, UserState string + Audits, RefreshTokens int +} + +func r8Count(t *testing.T, db *storage.Connection) r8Counts { + var result struct { + Users int `db:"users"` + Identities int `db:"identities"` + Sessions int `db:"sessions"` + IdentityBindings string `db:"identity_bindings"` + UserState string `db:"user_state"` + Audits int `db:"audits"` + RefreshTokens int `db:"refresh_tokens"` + } + require.NoError(t, db.RawQuery("select (select count(*) from auth.users) as users, (select count(*) from auth.identities) as identities, (select count(*) from auth.sessions) as sessions, (select md5(coalesce(string_agg(id::text || user_id::text || provider_id || identity_data::text,'|' order by id),'')) from auth.identities) as identity_bindings, (select md5(coalesce(string_agg(to_jsonb(u)::text,'|' order by id),'')) from auth.users u) as user_state, (select count(*) from auth.audit_log_entries) as audits, (select count(*) from auth.refresh_tokens) as refresh_tokens").First(&result)) + return r8Counts{result.Users, result.Identities, result.Sessions, result.IdentityBindings, result.UserState, result.Audits, result.RefreshTokens} +} + +func r8NoCreate(t *testing.T, before, after r8Counts) { + t.Logf("AUTH_COUNTS before_users=%d after_users=%d before_identities=%d after_identities=%d before_sessions=%d after_sessions=%d", before.Users, after.Users, before.Identities, after.Identities, before.Sessions, after.Sessions) + require.Equal(t, before.Users, after.Users) + require.Equal(t, before.Identities, after.Identities) + require.Equal(t, before.IdentityBindings, after.IdentityBindings) + t.Log("IDENTITY_AND_WALLET_BINDINGS_UNCHANGED=true") +} + +func r8Seed(t *testing.T, api *API, wallet r8Wallet) string { + now := time.Now().UTC() + w := r8HTTP(api, "/token?grant_type=web3", r8Proof(t, wallet, "ordinarysignupnonce", now, now.Add(4*time.Minute), false)) + require.Equal(t, 200, w.Code, "ordinary fixture signup must succeed") + var response struct { + User struct { + ID string `json:"id"` + } `json:"user"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.NotEmpty(t, response.User.ID) + return response.User.ID +} + +func r8Challenge(t *testing.T, api *API, wallet r8Wallet, userID string) map[string]interface{} { + w := r8HTTP(api, "/web3/challenge", map[string]interface{}{"chain": wallet.chain, "address": wallet.address, "chain_id": wallet.chainID, "domain": "example.netlify.com", "uri": "https://example.netlify.com", "expected_user_id": userID}) + require.Equal(t, 200, w.Code, "existing-only challenge must succeed") + var response struct { + Nonce string `json:"nonce"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt time.Time `json:"expires_at"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + return r8Proof(t, wallet, response.Nonce, response.IssuedAt, response.ExpiresAt, true) +} + +// A real PostgreSQL lock wait is the synchronization barrier, not a sleep +// masquerading as concurrent execution and not a mocked resolver. +func r8WaitLock(t *testing.T, db *storage.Connection) { + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + var waiting int + require.NoError(t, db.RawQuery("select count(*) from pg_stat_activity where datname=current_database() and wait_event_type='Lock'").First(&waiting)) + if waiting > 0 { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("real PostgreSQL lock waiter was not observed") +} + +func TestR8Web3ExistingOnly(t *testing.T) { + api, config, err := setupAPIForTest() + require.NoError(t, err) + defer api.db.Close() + config.External.Web3Ethereum.Enabled = true + config.External.Web3Solana.Enabled = true + + reset := func(t *testing.T) { + require.NoError(t, api.db.RawQuery("truncate auth.users cascade").Exec()) + require.NoError(t, api.db.RawQuery("truncate auth.web3_login_challenges").Exec()) + } + for _, chain := range []string{"ethereum", "solana"} { + t.Run("positive_"+chain, func(t *testing.T) { + reset(t) + wallet := r8NewWallet(t, chain) + id := r8Seed(t, api, wallet) + require.NoError(t, api.db.RawQuery("update auth.users set last_sign_in_at=clock_timestamp()-interval '1 hour' where id=?", id).Exec()) + proof := r8Challenge(t, api, wallet, id) + proof["expected_user_id"] = id + before := r8Count(t, api.db) + w := r8HTTP(api, "/token?grant_type=web3", proof) + require.Equal(t, 200, w.Code) + var response struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + User struct { + ID string `json:"id"` + LastSignInAt time.Time `json:"last_sign_in_at"` + } `json:"user"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Equal(t, id, response.User.ID) + require.NotEmpty(t, response.AccessToken) + require.True(t, time.Since(response.User.LastSignInAt) < time.Minute) + r8NoCreate(t, before, r8Count(t, api.db)) + // Verify the real issued access token through GoTrue, not a decoded mock. + r := httptest.NewRequest(http.MethodGet, "http://localhost/user", nil) + r.Header.Set("Authorization", "Bearer "+response.AccessToken) + verified := httptest.NewRecorder() + api.handler.ServeHTTP(verified, r) + require.Equal(t, 200, verified.Code) + var principal struct { + ID string `json:"id"` + } + require.NoError(t, json.Unmarshal(verified.Body.Bytes(), &principal)) + require.Equal(t, id, principal.ID) + refreshed := r8HTTP(api, "/token?grant_type=refresh_token", map[string]interface{}{"refresh_token": response.RefreshToken}) + require.Equal(t, 200, refreshed.Code) + var refreshResponse struct { + User struct { + ID string `json:"id"` + LastSignInAt time.Time `json:"last_sign_in_at"` + } `json:"user"` + } + require.NoError(t, json.Unmarshal(refreshed.Body.Bytes(), &refreshResponse)) + require.Equal(t, id, refreshResponse.User.ID) + require.Equal(t, response.User.LastSignInAt.UnixMicro(), refreshResponse.User.LastSignInAt.UnixMicro()) + r8NoCreate(t, before, r8Count(t, api.db)) + }) + } + + t.Run("absent_identity_never_signs_up", func(t *testing.T) { + reset(t) + wallet := r8NewWallet(t, "ethereum") + before := r8Count(t, api.db) + now := time.Now().UTC() + w := r8HTTP(api, "/token?grant_type=web3", r8Proof(t, wallet, "notaservernonce123", now, now.Add(time.Minute), true)) + require.NotEqual(t, 200, w.Code) + r8NoCreate(t, before, r8Count(t, api.db)) + }) + + negativeCases := []string{"deleted_user", "deleted_identity", "replacement_identity", "different_owner", "expected_uuid_mismatch", "unverified_identity", "invalid_signature", "expired_nonce", "wrong_domain", "wrong_chain", "downgrade", "missing_signed_intent", "banned_user", "expired_proof", "future_proof", "missing_expiration", "other_wallet_proof", "soft_deleted_user", "identity_subject_mismatch"} + for _, name := range negativeCases { + t.Run(name, func(t *testing.T) { + reset(t) + wallet := r8NewWallet(t, "ethereum") + id := r8Seed(t, api, wallet) + proof := r8Challenge(t, api, wallet, id) + switch name { + case "soft_deleted_user": + require.NoError(t, api.db.RawQuery("update auth.users set deleted_at=clock_timestamp() where id=?", id).Exec()) + case "identity_subject_mismatch": + require.NoError(t, api.db.RawQuery(`update auth.identities set identity_data=jsonb_set(identity_data,'{sub}','"wrong-subject"') where user_id=?`, id).Exec()) + case "deleted_user": + require.NoError(t, api.db.RawQuery("delete from auth.users where id=?", id).Exec()) + case "deleted_identity": + require.NoError(t, api.db.RawQuery("delete from auth.identities where user_id=?", id).Exec()) + case "replacement_identity": + require.NoError(t, api.db.RawQuery("update auth.identities set id=gen_random_uuid() where user_id=?", id).Exec()) + case "different_owner": + other := r8Seed(t, api, r8NewWallet(t, "ethereum")) + require.NoError(t, api.db.RawQuery("update auth.identities set user_id=? where user_id=?", other, id).Exec()) + case "expected_uuid_mismatch": + proof["expected_user_id"] = "00000000-0000-0000-0000-000000000001" + case "unverified_identity": + require.NoError(t, api.db.RawQuery("update auth.identities set last_sign_in_at=null where user_id=?", id).Exec()) + case "invalid_signature": + proof["signature"] = "0x" + strings.Repeat("00", 65) + case "expired_nonce": + require.NoError(t, api.db.RawQuery("update auth.web3_login_challenges set created_at=clock_timestamp()-interval '10 minutes', expires_at=clock_timestamp()-interval '1 minute'").Exec()) + case "wrong_domain": + proof["message"] = strings.ReplaceAll(proof["message"].(string), "example.netlify.com", "attacker.example") + case "wrong_chain": + proof["message"] = strings.Replace(proof["message"].(string), "Chain ID: 1", "Chain ID: 5", 1) + case "downgrade": + proof["existing_user_only"] = false + case "missing_signed_intent": + proof["message"] = strings.Replace(proof["message"].(string), "\nRequest ID: "+web3ExistingRequestID, "", 1) + case "expired_proof", "future_proof", "missing_expiration": + lines := strings.Split(proof["message"].(string), "\n") + changed := []string{} + for _, line := range lines { + if strings.HasPrefix(line, "Expiration Time:") && name == "missing_expiration" { + continue + } + if strings.HasPrefix(line, "Expiration Time:") && name == "expired_proof" { + line = "Expiration Time: " + time.Now().Add(-time.Minute).UTC().Format(time.RFC3339) + } + if strings.HasPrefix(line, "Issued At:") && name == "expired_proof" { + line = "Issued At: " + time.Now().Add(-2*time.Minute).UTC().Format(time.RFC3339) + } + if strings.HasPrefix(line, "Issued At:") && name == "future_proof" { + line = "Issued At: " + time.Now().Add(time.Minute).UTC().Format(time.RFC3339) + } + changed = append(changed, line) + } + proof["message"] = strings.Join(changed, "\n") + proof["signature"] = r8Signature(t, wallet, proof["message"].(string)) + case "other_wallet_proof": + otherWallet := r8NewWallet(t, "ethereum") + r8Seed(t, api, otherWallet) + proof["message"] = strings.Replace(proof["message"].(string), wallet.address, otherWallet.address, 1) + proof["signature"] = r8Signature(t, otherWallet, proof["message"].(string)) + case "banned_user": + require.NoError(t, api.db.RawQuery("update auth.users set banned_until=clock_timestamp()+interval '1 day' where id=?", id).Exec()) + } + if name == "wrong_domain" || name == "wrong_chain" || name == "missing_signed_intent" { + proof["signature"] = r8Signature(t, wallet, proof["message"].(string)) + } + before := r8Count(t, api.db) + w := r8HTTP(api, "/token?grant_type=web3", proof) + require.Equal(t, http.StatusBadRequest, w.Code) + after := r8Count(t, api.db) + r8NoCreate(t, before, after) + require.Equal(t, before.Sessions, after.Sessions) + }) + } + + t.Run("ten_concurrent_same_proofs_one_session_no_user", func(t *testing.T) { + reset(t) + wallet := r8NewWallet(t, "ethereum") + id := r8Seed(t, api, wallet) + proof := r8Challenge(t, api, wallet, id) + before := r8Count(t, api.db) + results := make(chan int, 10) + var workers sync.WaitGroup + for range 10 { + workers.Add(1) + go func() { defer workers.Done(); results <- r8HTTP(api, "/token?grant_type=web3", proof).Code }() + } + workers.Wait() + close(results) + accepted := 0 + for code := range results { + if code == 200 { + accepted++ + } + } + require.Equal(t, 1, accepted) + after := r8Count(t, api.db) + r8NoCreate(t, before, after) + require.Equal(t, before.Sessions+1, after.Sessions) + // The exact successful proof cannot produce another session later either. + require.NotEqual(t, 200, r8HTTP(api, "/token?grant_type=web3", proof).Code) + require.Equal(t, after, r8Count(t, api.db)) + }) + + for _, mutation := range []string{"delete", "transfer"} { + t.Run("concurrent_identity_"+mutation, func(t *testing.T) { + reset(t) + wallet := r8NewWallet(t, "ethereum") + id := r8Seed(t, api, wallet) + other := r8Seed(t, api, r8NewWallet(t, "ethereum")) + proof := r8Challenge(t, api, wallet, id) + result := make(chan int, 1) + before := r8Count(t, api.db) + require.NoError(t, api.db.Transaction(func(tx *storage.Connection) error { + if err := tx.RawQuery("lock table auth.identities in access exclusive mode").Exec(); err != nil { + return err + } + go func() { result <- r8HTTP(api, "/token?grant_type=web3", proof).Code }() + r8WaitLock(t, api.db) + if mutation == "delete" { + return tx.RawQuery("delete from auth.identities where user_id=?", id).Exec() + } + return tx.RawQuery("update auth.identities set user_id=? where user_id=?", other, id).Exec() + })) + require.NotEqual(t, 200, <-result) + after := r8Count(t, api.db) + require.Equal(t, before.Users, after.Users) + expectedIdentities := before.Identities + if mutation == "delete" { + expectedIdentities-- + } + require.Equal(t, expectedIdentities, after.Identities) + require.Equal(t, before.Sessions, after.Sessions) + t.Logf("CONCURRENCY users_before=%d users_after=%d identities_before=%d identities_after=%d", before.Users, after.Users, before.Identities, after.Identities) + }) + } + + t.Run("hook_rejection_rolls_back_session_and_nonce", func(t *testing.T) { + reset(t) + wallet := r8NewWallet(t, "ethereum") + id := r8Seed(t, api, wallet) + proof := r8Challenge(t, api, wallet, id) + before := r8Count(t, api.db) + require.NoError(t, api.db.RawQuery(`create or replace function auth.r8_reject_token(event jsonb) returns jsonb language sql as $$ select '{"error":{"http_code":403,"message":"R8 local rejection"}}'::jsonb $$`).Exec()) + config.Hook.CustomAccessToken.Enabled = true + config.Hook.CustomAccessToken.URI = "pg-functions://postgres/auth/r8_reject_token" + require.NoError(t, config.Hook.CustomAccessToken.PopulateExtensibilityPoint()) + // Hook manager resolves URI configuration once per API construction. + rejectAPI := NewAPIWithVersion(config, api.db, "r8") + w := r8HTTP(rejectAPI, "/token?grant_type=web3", proof) + config.Hook.CustomAccessToken.Enabled = false + require.NotEqual(t, 200, w.Code) + require.Contains(t, w.Body.String(), "R8 local rejection") + require.Equal(t, before, r8Count(t, api.db)) + var consumed int + require.NoError(t, api.db.RawQuery("select count(*) from auth.web3_login_challenges where consumed_at is not null").First(&consumed)) + require.Zero(t, consumed) + require.Equal(t, 200, r8HTTP(api, "/token?grant_type=web3", proof).Code) + r8NoCreate(t, before, r8Count(t, api.db)) + }) + + t.Run("ordinary_signup_and_login_unchanged", func(t *testing.T) { + reset(t) + wallet := r8NewWallet(t, "ethereum") + id := r8Seed(t, api, wallet) + before := r8Count(t, api.db) + now := time.Now().UTC() + w := r8HTTP(api, "/token?grant_type=web3", r8Proof(t, wallet, "ordinaryloginnonce", now, now.Add(time.Minute), false)) + require.Equal(t, 200, w.Code) + var response struct { + User struct { + ID string `json:"id"` + } `json:"user"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Equal(t, id, response.User.ID) + r8NoCreate(t, before, r8Count(t, api.db)) + }) + t.Run("grant_then_deletion_serializes_after_commit", func(t *testing.T) { + reset(t) + wallet := r8NewWallet(t, "ethereum") + id := r8Seed(t, api, wallet) + proof := r8Challenge(t, api, wallet, id) + before := r8Count(t, api.db) + granted := make(chan int, 1) + deleted := make(chan error, 1) + require.NoError(t, api.db.Transaction(func(tx *storage.Connection) error { + if err := tx.RawQuery("lock table auth.sessions in access exclusive mode").Exec(); err != nil { + return err + } + go func() { granted <- r8HTTP(api, "/token?grant_type=web3", proof).Code }() + r8WaitLock(t, api.db) + go func() { deleted <- api.db.RawQuery("delete from auth.identities where user_id=?", id).Exec() }() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + var count int + if err := api.db.RawQuery("select count(*) from pg_stat_activity where datname=current_database() and wait_event_type='Lock'").First(&count); err != nil { + return err + } + if count >= 2 { + return nil + } + time.Sleep(10 * time.Millisecond) + } + return fmt.Errorf("deletion did not block behind the real issuer identity lock") + })) + require.Equal(t, 200, <-granted) + require.NoError(t, <-deleted) + after := r8Count(t, api.db) + require.Equal(t, before.Users, after.Users) + require.Equal(t, before.Identities-1, after.Identities) + require.Equal(t, before.Sessions+1, after.Sessions) + t.Logf("SERIAL_ORDER grant_committed_before_identity_deletion users_before=%d users_after=%d", before.Users, after.Users) + }) + t.Run("challenge_uuid_mismatch_no_auth_writes", func(t *testing.T) { + reset(t) + wallet := r8NewWallet(t, "ethereum") + r8Seed(t, api, wallet) + before := r8Count(t, api.db) + response := r8HTTP(api, "/web3/challenge", map[string]interface{}{"chain": wallet.chain, "address": wallet.address, "chain_id": wallet.chainID, "domain": "example.netlify.com", "uri": "https://example.netlify.com", "expected_user_id": "00000000-0000-0000-0000-000000000001"}) + require.Equal(t, http.StatusBadRequest, response.Code) + require.Equal(t, before, r8Count(t, api.db)) + }) + t.Run("signup_disabled_does_not_disable_existing_only", func(t *testing.T) { + reset(t) + wallet := r8NewWallet(t, "ethereum") + id := r8Seed(t, api, wallet) + proof := r8Challenge(t, api, wallet, id) + before := r8Count(t, api.db) + config.DisableSignup = true + defer func() { config.DisableSignup = false }() + require.Equal(t, 200, r8HTTP(api, "/token?grant_type=web3", proof).Code) + r8NoCreate(t, before, r8Count(t, api.db)) + }) + +} diff --git a/migrations/20261108220000_web3_existing_user_challenges.up.sql b/migrations/20261108220000_web3_existing_user_challenges.up.sql new file mode 100644 index 0000000000..63738eaf9f --- /dev/null +++ b/migrations/20261108220000_web3_existing_user_challenges.up.sql @@ -0,0 +1,21 @@ +/* auth_migration: 20261108220000 */ +create table {{ index .Options "Namespace" }}.web3_login_challenges ( + id uuid primary key, + nonce_hash text not null unique, + provider_id text not null, + identity_id uuid not null, + user_id uuid not null, + chain text not null check (chain in ('ethereum', 'solana')), + chain_id text not null, + domain text not null, + uri text not null, + created_at timestamptz not null, + expires_at timestamptz not null, + consumed_at timestamptz, + check (expires_at > created_at) +); +-- Deliberately no cascading FK: an identity deletion must not erase the +-- original binding or permit a replacement identity to reuse its challenge. +create index web3_login_challenges_expiry on {{ index .Options "Namespace" }}.web3_login_challenges (expires_at); +alter table {{ index .Options "Namespace" }}.web3_login_challenges enable row level security; +revoke all on {{ index .Options "Namespace" }}.web3_login_challenges from public; diff --git a/openapi.yaml b/openapi.yaml index 5851d4bc25..4aeefa0c32 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -59,6 +59,43 @@ tags: - name: general description: General APIs. paths: + /web3/challenge: + post: + summary: Issue a one-use challenge for an existing Web3 identity + description: No user or session is created. Requires an enabled Web3 provider and existing verified provider identity. Expires in five minutes. Subject to CAPTCHA and rate limits. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [chain, address, chain_id, domain, uri] + properties: + chain: {type: string, enum: [ethereum, solana]} + address: {type: string} + chain_id: {type: string} + domain: {type: string} + uri: {type: string, format: uri} + expected_user_id: {type: string, format: uuid} + gotrue_meta_security: + $ref: "#/components/schemas/GoTrueSecurity" + responses: + 200: + description: A challenge bound to an existing identity and user, not a login or ownership attestation. + content: + application/json: + schema: + type: object + required: [nonce, request_id, issued_at, expires_at] + properties: + nonce: {type: string} + request_id: {type: string, enum: [WEB3_EXISTING_USER_ONLY]} + issued_at: {type: string, format: date-time} + expires_at: {type: string, format: date-time} + 400: + $ref: "#/components/responses/BadRequestResponse" + 429: + description: Too many requests /token: post: summary: Issues access and refresh tokens based on grant type. @@ -166,6 +203,14 @@ paths: The signature of the message for Web3 authentication. For Solana: Base64 or Base64-URL encoded. For Ethereum: hexadecimal string with 0x prefix. + existing_user_only: + type: boolean + default: false + description: Require a pre-existing Web3 identity and consume a server challenge; never enter signup. Sign Request ID WEB3_EXISTING_USER_ONLY. + expected_user_id: + type: string + format: uuid + description: Optional identity assertion, allowed only with existing_user_only. It is checked against the signed identity and challenge, never used to select an account. chain: type: string description: What blockchain is the Web3 message and signature for. From dc5c001a7f2fe02476dbf6899f5683b81559b292 Mon Sep 17 00:00:00 2001 From: RxVince Date: Sun, 8 Nov 2026 23:40:22 +0100 Subject: [PATCH 2/2] docs(web3): remove product-specific references from design note Co-authored-by: Cursor --- docs/web3-existing-user-only.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/web3-existing-user-only.md b/docs/web3-existing-user-only.md index f196a82d98..0a54b6ebbb 100644 --- a/docs/web3-existing-user-only.md +++ b/docs/web3-existing-user-only.md @@ -77,15 +77,13 @@ HTTP handlers and a real PostgreSQL 17 database, including actual lock waits and user/identity/session counters. It is not a mocked resolver or JWT generator. Run alongside the existing `TestWeb3`, `TestOtp`, and `TestToken` suites. -The Winstory transport sketch is packaged separately, not wired into its hosted -Application. The current JavaScript SDK does not serialize the new flag; it must +The current JavaScript SDK does not serialize the new flag; it must gain support or use an explicit supported transport for this proposed endpoint. ## Upstream and hosted deployment -The patch targets v2.197.0 as requested, not current master. Upstream contribution -requires a feature RFC/design discussion, review of API/retention/rate-limit -semantics, adaptation to current master, full upstream CI, SDK support and release -approval. No publication, acceptance date or managed deployment date is assumed. +Upstream contribution requires maintainer review of API/retention/rate-limit +semantics, full upstream CI, SDK support and release approval. No acceptance +date or managed deployment date is assumed. Do not apply this engine migration by hand to a managed project's Auth schema. Do not connect this local fork to a hosted project's Auth database.