Skip to content
Draft
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
74 changes: 74 additions & 0 deletions docs/rfc/web3-atomic-existing-user-only-auth.md
Original file line number Diff line number Diff line change
@@ -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`.
89 changes: 89 additions & 0 deletions docs/web3-existing-user-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# 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 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

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.
3 changes: 3 additions & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion internal/api/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ type RequestParams interface {
SignupParams |
SingleSignOnParams |
SmsParams |
Web3GrantParams |
Web3GrantParams | Web3ChallengeParams |
UserUpdateParams |
VerifyFactorParams |
VerifyParams |
Expand Down
54 changes: 43 additions & 11 deletions internal/api/web3.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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,
Expand Down
Loading