Skip to content

Commit 079b242

Browse files
authored
feat: add sign in with ethereum (#2069)
Adds Sign in with Ethereum. Configure it by: ``` GOTRUE_EXTERNAL_WEB3_ETHEREUM_ENABLED="true" GOTRUE_EXTERNAL_WEB3_ETHEREUM_MAXIMUM_VALIDITY_DURATION="10m" ``` SIWS & SIWE are based off of EIP-4361, which is referenced here: https://eips.ethereum.org/EIPS/eip-4361, so they are close in implementation with slight differences between address/signature verification format & algorithm. For Ethereum, specifically the signature verification part, It requires recovering the public address from the signature, and then testing the signature against it, with the algorithm Ethereum uses, this is tedious to implement without using the https://github.com/ethereum/go-ethereum package, as the verification has some error correction that it does, would be hard to test/maintain without the dependency, let me know what you think.
1 parent 5ca4489 commit 079b242

File tree

14 files changed

+1310
-364
lines changed

14 files changed

+1310
-364
lines changed

example.env

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,10 @@ GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI="http://localhost:9999/callback"
173173
GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED="true"
174174
GOTRUE_EXTERNAL_WEB3_SOLANA_MAXIMUM_VALIDITY_DURATION="10m"
175175

176+
# Web3 Ethereum config
177+
GOTRUE_EXTERNAL_WEB3_ETHEREUM_ENABLED="true"
178+
GOTRUE_EXTERNAL_WEB3_ETHEREUM_MAXIMUM_VALIDITY_DURATION="10m"
179+
176180
# Anonymous auth config
177181
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED="false"
178182

go.mod

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,26 +26,32 @@ require (
2626
github.com/sebest/xff v0.0.0-20160910043805-6c115e0ffa35
2727
github.com/sethvargo/go-password v0.2.0
2828
github.com/sirupsen/logrus v1.9.3
29-
github.com/spf13/cobra v1.7.0
29+
github.com/spf13/cobra v1.8.1
3030
github.com/stretchr/testify v1.10.0
31-
golang.org/x/crypto v0.35.0
31+
golang.org/x/crypto v0.36.0
3232
golang.org/x/oauth2 v0.17.0
3333
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
3434
)
3535

3636
require (
37-
github.com/bits-and-blooms/bitset v1.13.0 // indirect
37+
github.com/bits-and-blooms/bitset v1.20.0 // indirect
38+
github.com/consensys/gnark-crypto v0.18.0 // indirect
39+
github.com/crate-crypto/go-eth-kzg v1.3.0 // indirect
40+
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect
3841
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
3942
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
43+
github.com/ethereum/c-kzg-4844/v2 v2.1.0 // indirect
44+
github.com/ethereum/go-verkle v0.2.2 // indirect
4045
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
4146
github.com/getkin/kin-openapi v0.131.0 // indirect
4247
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
4348
github.com/go-openapi/jsonpointer v0.21.0 // indirect
4449
github.com/go-openapi/swag v0.23.0 // indirect
4550
github.com/go-webauthn/x v0.1.12 // indirect
4651
github.com/gobuffalo/nulls v0.4.2 // indirect
47-
github.com/goccy/go-json v0.10.3 // indirect
52+
github.com/goccy/go-json v0.10.4 // indirect
4853
github.com/google/go-tpm v0.9.1 // indirect
54+
github.com/holiman/uint256 v1.3.2 // indirect
4955
github.com/jackc/pgx/v4 v4.18.2 // indirect
5056
github.com/josharian/intern v1.0.0 // indirect
5157
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
@@ -60,12 +66,13 @@ require (
6066
github.com/perimeterx/marshmallow v1.1.5 // indirect
6167
github.com/segmentio/asm v1.2.0 // indirect
6268
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
69+
github.com/supranational/blst v0.3.14 // indirect
6370
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
6471
github.com/x448/float16 v0.8.4 // indirect
6572
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
6673
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
67-
golang.org/x/mod v0.17.0 // indirect
68-
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
74+
golang.org/x/mod v0.22.0 // indirect
75+
golang.org/x/tools v0.29.0 // indirect
6976
google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de // indirect
7077
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect
7178
)
@@ -116,6 +123,7 @@ require (
116123
github.com/cespare/xxhash/v2 v2.3.0 // indirect
117124
github.com/crewjam/httperr v0.2.0 // indirect
118125
github.com/davecgh/go-spew v1.1.1 // indirect
126+
github.com/ethereum/go-ethereum v1.16.0
119127
github.com/fatih/color v1.16.0 // indirect
120128
github.com/felixge/httpsnoop v1.0.4 // indirect
121129
github.com/go-logr/logr v1.4.1 // indirect
@@ -158,15 +166,15 @@ require (
158166
github.com/sergi/go-diff v1.2.0 // indirect
159167
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d // indirect
160168
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect
161-
github.com/spf13/pflag v1.0.5 // indirect
169+
github.com/spf13/pflag v1.0.6 // indirect
162170
github.com/stretchr/objx v0.5.2 // indirect
163171
go.opentelemetry.io/proto/otlp v1.2.0 // indirect
164172
golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb
165-
golang.org/x/net v0.36.0 // indirect
166-
golang.org/x/sync v0.11.0
167-
golang.org/x/sys v0.30.0
168-
golang.org/x/text v0.22.0 // indirect
169-
golang.org/x/time v0.5.0
173+
golang.org/x/net v0.38.0 // indirect
174+
golang.org/x/sync v0.12.0
175+
golang.org/x/sys v0.31.0
176+
golang.org/x/text v0.23.0 // indirect
177+
golang.org/x/time v0.9.0
170178
google.golang.org/appengine v1.6.8 // indirect
171179
google.golang.org/grpc v1.63.2 // indirect
172180
google.golang.org/protobuf v1.34.2 // indirect

go.sum

Lines changed: 77 additions & 25 deletions
Large diffs are not rendered by default.

internal/api/web3.go

Lines changed: 139 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/supabase/auth/internal/models"
1313
"github.com/supabase/auth/internal/storage"
1414
"github.com/supabase/auth/internal/utilities"
15+
"github.com/supabase/auth/internal/utilities/siwe"
1516
"github.com/supabase/auth/internal/utilities/siws"
1617
)
1718

@@ -24,7 +25,7 @@ type Web3GrantParams struct {
2425
func (a *API) Web3Grant(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
2526
config := a.config
2627

27-
if !config.External.Web3Solana.Enabled {
28+
if !config.External.Web3Solana.Enabled && !config.External.Web3Ethereum.Enabled {
2829
return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeWeb3ProviderDisabled, "Web3 provider is disabled")
2930
}
3031

@@ -33,11 +34,21 @@ func (a *API) Web3Grant(ctx context.Context, w http.ResponseWriter, r *http.Requ
3334
return err
3435
}
3536

36-
if params.Chain != "solana" {
37+
switch params.Chain {
38+
case "solana":
39+
if !config.External.Web3Solana.Enabled {
40+
return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeWeb3ProviderDisabled, "Solana Web3 provider is disabled")
41+
}
42+
return a.web3GrantSolana(ctx, w, r, params)
43+
case "ethereum":
44+
if !config.External.Web3Ethereum.Enabled {
45+
return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeWeb3ProviderDisabled, "Ethereum Web3 provider is disabled")
46+
}
47+
return a.web3GrantEthereum(ctx, w, r, params)
48+
default:
3749
return apierrors.NewBadRequestError(apierrors.ErrorCodeWeb3UnsupportedChain, "Unsupported chain")
3850
}
3951

40-
return a.web3GrantSolana(ctx, w, r, params)
4152
}
4253

4354
func (a *API) web3GrantSolana(ctx context.Context, w http.ResponseWriter, r *http.Request, params *Web3GrantParams) error {
@@ -181,3 +192,128 @@ func (a *API) web3GrantSolana(ctx context.Context, w http.ResponseWriter, r *htt
181192

182193
return sendJSON(w, http.StatusOK, token)
183194
}
195+
196+
func (a *API) web3GrantEthereum(ctx context.Context, w http.ResponseWriter, r *http.Request, params *Web3GrantParams) error {
197+
config := a.config
198+
db := a.db.WithContext(ctx)
199+
200+
if len(params.Message) < 64 {
201+
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "message is too short")
202+
} else if len(params.Message) > 20*1024 {
203+
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "message must not exceed 20KB")
204+
}
205+
206+
if len(strings.TrimPrefix(params.Signature, "0x")) != 130 {
207+
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "signature must be 65 bytes long, encoded as a 130 character-long hexadecimal string")
208+
}
209+
210+
parsedMessage, err := siwe.ParseMessage(params.Message)
211+
if err != nil {
212+
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, err.Error())
213+
}
214+
215+
if !parsedMessage.VerifySignature(params.Signature) {
216+
return apierrors.NewOAuthError("invalid_grant", "Signature does not match address in message")
217+
}
218+
219+
if parsedMessage.URI.Scheme != "https" && parsedMessage.URI.Hostname() != "localhost" {
220+
return apierrors.NewOAuthError("invalid_grant", "Signed Ethereum message is using URI which does not use HTTPS")
221+
}
222+
223+
if !utilities.IsRedirectURLValid(config, parsedMessage.URI.String()) {
224+
return apierrors.NewOAuthError("invalid_grant", "Signed Ethereum message is using URI which is not allowed on this server, message was signed for another app")
225+
}
226+
227+
if parsedMessage.URI.Hostname() != "localhost" && (parsedMessage.URI.Host != parsedMessage.Domain || !utilities.IsRedirectURLValid(config, "https://"+parsedMessage.Domain+"/")) {
228+
return apierrors.NewOAuthError("invalid_grant", "Signed Ethereum message is using a Domain that does not match the one in URI which is not allowed on this server")
229+
}
230+
231+
now := a.Now()
232+
233+
if parsedMessage.NotBefore != nil && !parsedMessage.NotBefore.IsZero() && now.Before(*parsedMessage.NotBefore) {
234+
return apierrors.NewOAuthError("invalid_grant", "Signed Ethereum message becomes valid in the future")
235+
}
236+
237+
if parsedMessage.NotBefore != nil && parsedMessage.ExpirationTime != nil && !parsedMessage.ExpirationTime.IsZero() && now.After(*parsedMessage.ExpirationTime) {
238+
return apierrors.NewOAuthError("invalid_grant", "Signed Ethereum message is expired")
239+
}
240+
241+
latestExpiryAt := parsedMessage.IssuedAt.Add(config.External.Web3Ethereum.MaximumValidityDuration)
242+
243+
if now.After(latestExpiryAt) {
244+
return apierrors.NewOAuthError("invalid_grant", "Ethereum message was issued too long ago")
245+
}
246+
247+
earliestIssuedAt := parsedMessage.IssuedAt.Add(-config.External.Web3Ethereum.MaximumValidityDuration)
248+
249+
if now.Before(earliestIssuedAt) {
250+
return apierrors.NewOAuthError("invalid_grant", "Ethereum message was issued too far in the future")
251+
}
252+
253+
const providerType = "web3"
254+
providerId := strings.Join([]string{
255+
providerType,
256+
params.Chain,
257+
parsedMessage.Address,
258+
}, ":")
259+
260+
userData := provider.UserProvidedData{
261+
Metadata: &provider.Claims{
262+
CustomClaims: map[string]interface{}{
263+
"address": parsedMessage.Address,
264+
"chain": params.Chain,
265+
"network": parsedMessage.ChainID,
266+
"domain": parsedMessage.Domain,
267+
"statement": parsedMessage.Statement,
268+
},
269+
Subject: providerId,
270+
},
271+
Emails: []provider.Email{},
272+
}
273+
274+
var token *AccessTokenResponse
275+
var grantParams models.GrantParams
276+
grantParams.FillGrantParams(r)
277+
278+
if err := a.triggerBeforeUserCreatedExternal(r, db, &userData, providerType); err != nil {
279+
return err
280+
}
281+
282+
err = db.Transaction(func(tx *storage.Connection) error {
283+
user, terr := a.createAccountFromExternalIdentity(tx, r, &userData, providerType)
284+
if terr != nil {
285+
return terr
286+
}
287+
288+
if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.LoginAction, "", map[string]interface{}{
289+
"provider": providerType,
290+
"chain": params.Chain,
291+
"network": parsedMessage.ChainID,
292+
"address": parsedMessage.Address,
293+
"domain": parsedMessage.Domain,
294+
"uri": parsedMessage.URI,
295+
}); terr != nil {
296+
return terr
297+
}
298+
299+
token, terr = a.issueRefreshToken(r, tx, user, models.Web3, grantParams)
300+
if terr != nil {
301+
return terr
302+
}
303+
304+
return nil
305+
})
306+
307+
if err != nil {
308+
switch err.(type) {
309+
case *storage.CommitWithError:
310+
return err
311+
case *HTTPError:
312+
return err
313+
default:
314+
return apierrors.NewOAuthError("server_error", "Internal Server Error").WithInternalError(err)
315+
}
316+
}
317+
318+
return sendJSON(w, http.StatusOK, token)
319+
}

0 commit comments

Comments
 (0)