diff --git a/example.env b/example.env index e645c96e9c..58ea3c9d02 100644 --- a/example.env +++ b/example.env @@ -168,6 +168,15 @@ GOTRUE_EXTERNAL_ZOOM_CLIENT_ID="" GOTRUE_EXTERNAL_ZOOM_SECRET="" GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI="http://localhost:9999/callback" +# EIP-4361 OAuth config +GOTRUE_EXTERNAL_WEB3_ENABLED="true" +GOTRUE_EXTERNAL_WEB3_TIMEOUT="300s" +GOTRUE_EXTERNAL_WEB3_DOMAIN="localhost:9999" + +# Supported Chains Configuration +GOTRUE_EXTERNAL_WEB3_SUPPORTED_CHAINS="ethereum:1,ethereum:137,solana:mainnet,solana:devnet" +GOTRUE_EXTERNAL_WEB3_DEFAULT_CHAIN="ethereum:1" + # Anonymous auth config GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED="false" diff --git a/go.mod b/go.mod index a99b2b688c..232cc1a81b 100644 --- a/go.mod +++ b/go.mod @@ -34,14 +34,17 @@ require ( ) require ( - github.com/bits-and-blooms/bitset v1.10.0 // indirect + github.com/bits-and-blooms/bitset v1.13.0 // indirect + github.com/btcsuite/btcutil v1.0.2 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect + github.com/ethereum/go-ethereum v1.14.12 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-jose/go-jose/v3 v3.0.3 // indirect github.com/go-webauthn/x v0.1.12 // indirect github.com/gobuffalo/nulls v0.4.2 // indirect github.com/goccy/go-json v0.10.3 // indirect github.com/google/go-tpm v0.9.1 // indirect + github.com/holiman/uint256 v1.3.1 // indirect github.com/jackc/pgx/v4 v4.18.2 // indirect github.com/lestrrat-go/blackmagic v1.0.2 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect @@ -98,10 +101,10 @@ require ( github.com/beevik/etree v1.1.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/crewjam/httperr v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/fatih/color v1.13.0 // indirect + github.com/fatih/color v1.16.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -130,7 +133,7 @@ require ( github.com/luna-duclos/instrumentedsql v1.1.3 // indirect github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.16 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -146,15 +149,15 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/stretchr/objx v0.5.2 // indirect go.opentelemetry.io/proto/otlp v1.2.0 // indirect - golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb - golang.org/x/net v0.23.0 // indirect + golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa + golang.org/x/net v0.24.0 // indirect golang.org/x/sync v0.10.0 golang.org/x/sys v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect - golang.org/x/time v0.0.0-20220411224347-583f2d630306 // indirect + golang.org/x/time v0.5.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/grpc v1.63.2 // indirect - google.golang.org/protobuf v1.33.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 827144acd1..217cd60e38 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,7 @@ github.com/XSAM/otelsql v0.26.0 h1:UhAGVBD34Ctbh2aYcm/JAdL+6T6ybrP+YMWYkHqCdmo= github.com/XSAM/otelsql v0.26.0/go.mod h1:5ciw61eMSh+RtTPN8spvPEPLJpAErZw8mFFPNfYiaxA= github.com/aaronarduino/goqrsvg v0.0.0-20220419053939-17e843f1dd40 h1:uz4N2yHL4MF8vZX+36n+tcxeUf8D/gL4aJkyouhDw4A= github.com/aaronarduino/goqrsvg v0.0.0-20220419053939-17e843f1dd40/go.mod h1:dytw+5qs+pdi61fO/S4OmXR7AuEq/HvNCuG03KxQHT4= +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= @@ -22,6 +23,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.10.0 h1:ePXTeiPEazB5+opbv5fr8umg2R/1NlzgDsyepwsSr88= github.com/bits-and-blooms/bitset v1.10.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE= +github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bits-and-blooms/bloom/v3 v3.6.0 h1:dTU0OVLJSoOhz9m68FTXMFfA39nR8U/nTCs1zb26mOI= github.com/bits-and-blooms/bloom/v3 v3.6.0/go.mod h1:VKlUSvp0lFIYqxJjzdnSsZEw4iHb1kOL2tfHTgyJBHg= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= @@ -29,10 +32,22 @@ github.com/bombsimon/logrusr/v3 v3.0.0 h1:tcAoLfuAhKP9npBxWzSdpsvKPQt1XV02nSf2lZ github.com/bombsimon/logrusr/v3 v3.0.0/go.mod h1:PksPPgSFEL2I52pla2glgCyyd2OqOHAnFF5E+g8Ixco= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts= +github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/coreos/go-oidc/v3 v3.6.0 h1:AKVxfYw1Gmkn/w96z0DbT/B/xFnzTd3MkZvWLjF4n/o= @@ -46,6 +61,7 @@ github.com/crewjam/httperr v0.2.0 h1:b2BfXR8U3AlIHwNeFFvZ+BV1LFvKLlzMjzaTnZMybNo github.com/crewjam/httperr v0.2.0/go.mod h1:Jlz+Sg/XqBQhyMjdDiC+GNNRzZTD7x39Gu3pglZ5oH4= github.com/crewjam/saml v0.4.14 h1:g9FBNx62osKusnFzs3QTN5L9CVA/Egfgm+stJShzw/c= github.com/crewjam/saml v0.4.14/go.mod h1:UVSZCf18jJkk6GpWNVqcyQJMD5HsRugBPf4I1nl2mME= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -55,12 +71,17 @@ github.com/deepmap/oapi-codegen v1.12.4 h1:pPmn6qI9MuOtCz82WY2Xaw46EQjgvxednXXrP github.com/deepmap/oapi-codegen v1.12.4/go.mod h1:3lgHGMu6myQ2vqbbTXH2H1o4eXFTGnFiDaOaKKl5yas= github.com/didip/tollbooth/v5 v5.1.1 h1:QpKFg56jsbNuQ6FFj++Z1gn2fbBsvAc1ZPLUaDOYW5k= github.com/didip/tollbooth/v5 v5.1.1/go.mod h1:d9rzwOULswrD3YIrAQmP3bfjxab32Df4IaO6+D25l9g= +github.com/ethereum/go-ethereum v1.14.12 h1:8hl57x77HSUo+cXExrURjU/w1VhL+ShCTJrTwcCQSe4= +github.com/ethereum/go-ethereum v1.14.12/go.mod h1:RAC2gVMWJ6FkxSPESfbshrcKpIokgQKsVKmAuqdekDY= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= @@ -123,6 +144,7 @@ github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQg github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -142,6 +164,9 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 h1:/c3QmbOGMGTOumP2iT/rCwB7b0Q github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= +github.com/holiman/uint256 v1.3.1 h1:JfTzmih28bittyHM8z360dCjIA9dbPIBlcTI6lmctQs= +github.com/holiman/uint256 v1.3.1/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= @@ -198,18 +223,21 @@ github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0f github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -255,6 +283,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= @@ -269,6 +299,9 @@ github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 h1:j2kD3MT1z4PXCiUll github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450/go.mod h1:skjdDftzkFALcuGzYSklqYd8gvat6F1gZJ4YPVbkZpM= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/patrickmn/go-cache v0.0.0-20170418232947-7ac151875ffb/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= @@ -404,11 +437,13 @@ go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9E go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= @@ -420,6 +455,8 @@ golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb h1:PaBZQdo+iSDyHT053FjUCgZQ/9uqVwPOcl7KSWhKn6w= golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= @@ -429,6 +466,7 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20161007143504-f4b625ec9b21/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -443,8 +481,11 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ= golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -453,6 +494,7 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -474,6 +516,7 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= @@ -500,6 +543,8 @@ golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.0.0-20160926182426-711ca1cb8763/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220411224347-583f2d630306 h1:+gHMid33q6pen7kv9xvT+JRinntgeXO2AeZVd0AWD3w= golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= @@ -532,6 +577,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -540,11 +587,14 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/hack/test.env b/hack/test.env index 35e4b61c81..354433d3c9 100644 --- a/hack/test.env +++ b/hack/test.env @@ -105,6 +105,11 @@ GOTRUE_EXTERNAL_ZOOM_CLIENT_ID=testclientid GOTRUE_EXTERNAL_ZOOM_SECRET=testsecret GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI=https://identity.services.netlify.com/callback GOTRUE_EXTERNAL_FLOW_STATE_EXPIRY_DURATION="300s" +GOTRUE_EXTERNAL_WEB3_ENABLED="true" +GOTRUE_EXTERNAL_WEB3_TIMEOUT="300s" +GOTRUE_EXTERNAL_WEB3_DOMAIN="example.com" +GOTRUE_EXTERNAL_WEB3_SUPPORTED_CHAINS="ethereum:1,ethereum:137,solana:mainnet,solana:devnet" +GOTRUE_EXTERNAL_WEB3_DEFAULT_CHAIN="ethereum:1" GOTRUE_RATE_LIMIT_VERIFY="100000" GOTRUE_RATE_LIMIT_TOKEN_REFRESH="30" GOTRUE_RATE_LIMIT_ANONYMOUS_USERS="5" diff --git a/internal/api/api.go b/internal/api/api.go index aafcff22f3..ddda032916 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -133,8 +133,12 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne }) r.Route("/", func(r *router) { + + r.Use(api.isValidExternalHost) + r.Post("/nonce", api.GetNonce) + r.Get("/settings", api.Settings) r.Get("/authorize", api.ExternalProviderRedirect) diff --git a/internal/api/external_web3_test.go b/internal/api/external_web3_test.go new file mode 100644 index 0000000000..c143ca0c43 --- /dev/null +++ b/internal/api/external_web3_test.go @@ -0,0 +1,460 @@ +package api + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcutil/base58" + "github.com/gofrs/uuid" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "github.com/supabase/auth/internal/conf" + "github.com/supabase/auth/internal/models" + "github.com/supabase/auth/internal/storage" + siws "github.com/supabase/auth/internal/utilities/solana" +) + +const ( + // Chain identifiers + chainSolanaMainnet = "solana:mainnet" + + // Grant type + grantTypeWeb3 = "web3" + + // Error responses + errorInvalidGrant = "invalid_grant" + + // Test values + defaultTestURI = "https://example.com" + + // Test nonces + expiredNonceValue = "expired-nonce" + invalidNonceValue = "invalid-nonce" + testNonceValue = "test-nonce" + + // Endpoints + nonceEndpoint = "/nonce" + tokenEndpoint = "/token" +) + +type Web3TestSuite struct { + suite.Suite + API *API + Config *conf.GlobalConfiguration + pubKey ed25519.PublicKey + privKey ed25519.PrivateKey + pubKeyBase58 string + + // Test configuration + testURI string +} + +type TokenRequest struct { + GrantType string `json:"grant_type"` + Message string `json:"message"` + Signature string `json:"signature"` + Address string `json:"address"` + Chain string `json:"chain"` +} + +func TestWeb3(t *testing.T) { + api, config, err := setupAPIForTest() + require.NoError(t, err) + + ts := &Web3TestSuite{ + API: api, + Config: config, + testURI: defaultTestURI, + } + defer api.db.Close() + + suite.Run(t, ts) +} + +func (ts *Web3TestSuite) SetupTest() { + models.TruncateAll(ts.API.db) + + ts.Config.DisableSignup = false + ts.Config.Mailer.AllowUnverifiedEmailSignIns = true + + // Generate test keys for Solana + var err error + ts.pubKey, ts.privKey, err = ed25519.GenerateKey(rand.Reader) + ts.Require().NoError(err) + ts.pubKeyBase58 = base58.Encode(ts.pubKey) +} + +func newNonceRequest(t *testing.T, address string) *http.Request { + body := map[string]string{"address": address} + jsonBody, err := json.Marshal(body) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, nonceEndpoint, bytes.NewReader(jsonBody)) + req.Header.Set("Content-Type", "application/json") + return req +} + +func newSIWSRequest(t *testing.T, grantType, message, signature, address, chain string) *http.Request { + tokenRequest := TokenRequest{ + Message: message, + Signature: signature, + Address: address, + Chain: chain, + } + jsonBody, err := json.Marshal(tokenRequest) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s?grant_type=%s", tokenEndpoint, grantType), bytes.NewReader(jsonBody)) + req.Header.Set("Content-Type", "application/json") + return req +} + +func (ts *Web3TestSuite) generateSIWSMessageAndSignature(nonce string) (string, string) { + msg := siws.SIWSMessage{ + Domain: ts.Config.External.Web3.Domain, + Address: ts.pubKeyBase58, + Statement: ts.Config.External.Web3.Statement, + URI: ts.testURI, + Version: ts.Config.External.Web3.Version, + Nonce: nonce, + IssuedAt: time.Now().UTC(), + } + rawMessage := siws.ConstructMessage(msg) + signature := ed25519.Sign(ts.privKey, []byte(rawMessage)) + signatureBase64 := base64.StdEncoding.EncodeToString(signature) + return rawMessage, signatureBase64 +} + +func (ts *Web3TestSuite) assertTokenResponse(w *httptest.ResponseRecorder) AccessTokenResponse { + ts.Require().Equal(http.StatusOK, w.Code) + var token AccessTokenResponse + ts.Require().NoError(json.NewDecoder(w.Body).Decode(&token)) + ts.Require().NotEmpty(token.Token) + ts.Require().NotEmpty(token.RefreshToken) + ts.Require().Equal("bearer", token.TokenType) + ts.Require().NotNil(token.User) + return token +} + +func (ts *Web3TestSuite) assertErrorResponse(w *httptest.ResponseRecorder, expectedCode int, expectedError string) { + ts.Require().Equal(expectedCode, w.Code) + var errorResponse map[string]interface{} + err := json.NewDecoder(w.Body).Decode(&errorResponse) + ts.Require().NoError(err) + ts.Require().Equal(expectedError, errorResponse["error"]) +} + +func (ts *Web3TestSuite) TestSignupWeb3_SuccessfulLogin() { + nonceReq := newNonceRequest(ts.T(), ts.pubKeyBase58) + nonceW := httptest.NewRecorder() + ts.API.handler.ServeHTTP(nonceW, nonceReq) + ts.Require().Equal(http.StatusOK, nonceW.Code) + + var nonceResp map[string]interface{} + ts.Require().NoError(json.NewDecoder(nonceW.Body).Decode(&nonceResp)) + nonce, ok := nonceResp["nonce"].(string) + ts.Require().True(ok) + ts.Require().NotEmpty(nonce) + + rawMessage, signatureBase64 := ts.generateSIWSMessageAndSignature(nonce) + + req := newSIWSRequest(ts.T(), grantTypeWeb3, rawMessage, signatureBase64, ts.pubKeyBase58, chainSolanaMainnet) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.assertTokenResponse(w) + + storedNonce, err := FindStoredNonceByAddressAndNonce(ts.API.db, ts.pubKeyBase58, nonce) + ts.Require().NoError(err) + ts.Require().NotNil(storedNonce) + ts.Require().True(storedNonce.Used) +} + +func (ts *Web3TestSuite) TestSignupWeb3_ReplayAttack() { + nonceReq := newNonceRequest(ts.T(), ts.pubKeyBase58) + nonceW := httptest.NewRecorder() + ts.API.handler.ServeHTTP(nonceW, nonceReq) + ts.Require().Equal(http.StatusOK, nonceW.Code) + + var nonceResp map[string]interface{} + ts.Require().NoError(json.NewDecoder(nonceW.Body).Decode(&nonceResp)) + nonce, _ := nonceResp["nonce"].(string) + + rawMessage, signatureBase64 := ts.generateSIWSMessageAndSignature(nonce) + req := newSIWSRequest(ts.T(), grantTypeWeb3, rawMessage, signatureBase64, ts.pubKeyBase58, chainSolanaMainnet) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.assertTokenResponse(w) + + replayReq := newSIWSRequest(ts.T(), grantTypeWeb3, rawMessage, signatureBase64, ts.pubKeyBase58, chainSolanaMainnet) + replayW := httptest.NewRecorder() + ts.API.handler.ServeHTTP(replayW, replayReq) + ts.assertErrorResponse(replayW, http.StatusBadRequest, errorInvalidGrant) +} + +func (ts *Web3TestSuite) TestSignupWeb3_ExpiredNonce() { + expiredNonce := &StoredNonce{ + ID: uuid.Must(uuid.NewV4()), + Address: ts.pubKeyBase58, + Nonce: expiredNonceValue, + CreatedAt: time.Now().Add(-2 * ts.Config.External.Web3.Timeout), + ExpiresAt: time.Now().Add(-1 * ts.Config.External.Web3.Timeout), + Used: false, + } + err := ts.API.db.Create(expiredNonce) + ts.Require().NoError(err) + + rawMessage, signatureBase64 := ts.generateSIWSMessageAndSignature(expiredNonceValue) + + expiredReq := newSIWSRequest(ts.T(), grantTypeWeb3, rawMessage, signatureBase64, ts.pubKeyBase58, chainSolanaMainnet) + expiredW := httptest.NewRecorder() + ts.API.handler.ServeHTTP(expiredW, expiredReq) + ts.assertErrorResponse(expiredW, http.StatusBadRequest, errorInvalidGrant) +} + +func (ts *Web3TestSuite) TestSignupWeb3_InvalidNonce() { + rawMessage, signatureBase64 := ts.generateSIWSMessageAndSignature(invalidNonceValue) + + tamperedReq := newSIWSRequest(ts.T(), grantTypeWeb3, rawMessage, signatureBase64, ts.pubKeyBase58, chainSolanaMainnet) + tamperedW := httptest.NewRecorder() + ts.API.handler.ServeHTTP(tamperedW, tamperedReq) + ts.assertErrorResponse(tamperedW, http.StatusBadRequest, errorInvalidGrant) +} + +func (ts *Web3TestSuite) TestSignupWeb3_UsedNonceDifferentAddress() { + pubKey2, privKey2, err := ed25519.GenerateKey(rand.Reader) + ts.Require().NoError(err) + pubKeyBase58_2 := base58.Encode(pubKey2) + + nonceReq2 := newNonceRequest(ts.T(), pubKeyBase58_2) + nonceW2 := httptest.NewRecorder() + ts.API.handler.ServeHTTP(nonceW2, nonceReq2) + ts.Require().Equal(http.StatusOK, nonceW2.Code) + + var nonceResp2 map[string]interface{} + ts.Require().NoError(json.NewDecoder(nonceW2.Body).Decode(&nonceResp2)) + nonce2, ok := nonceResp2["nonce"].(string) + ts.Require().True(ok) + + msg2 := siws.SIWSMessage{ + Domain: ts.Config.External.Web3.Domain, + Address: pubKeyBase58_2, + Statement: ts.Config.External.Web3.Statement, + URI: ts.testURI, + Version: ts.Config.External.Web3.Version, + Nonce: nonce2, + IssuedAt: time.Now().UTC(), + } + + rawMessage2 := siws.ConstructMessage(msg2) + signature2 := ed25519.Sign(privKey2, []byte(rawMessage2)) + signatureBase64_2 := base64.StdEncoding.EncodeToString(signature2) + + req2 := newSIWSRequest(ts.T(), grantTypeWeb3, rawMessage2, signatureBase64_2, pubKeyBase58_2, chainSolanaMainnet) + w2 := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w2, req2) + ts.assertTokenResponse(w2) + + msg3 := siws.SIWSMessage{ + Domain: ts.Config.External.Web3.Domain, + Address: ts.pubKeyBase58, + Statement: ts.Config.External.Web3.Statement, + URI: ts.testURI, + Version: ts.Config.External.Web3.Version, + Nonce: nonce2, + IssuedAt: time.Now().UTC(), + } + rawMessage3 := siws.ConstructMessage(msg3) + signature3 := ed25519.Sign(ts.privKey, []byte(rawMessage3)) + signatureBase64_3 := base64.StdEncoding.EncodeToString(signature3) + + req3 := newSIWSRequest(ts.T(), grantTypeWeb3, rawMessage3, signatureBase64_3, ts.pubKeyBase58, chainSolanaMainnet) + w3 := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w3, req3) + ts.assertErrorResponse(w3, http.StatusBadRequest, errorInvalidGrant) +} + +func (ts *Web3TestSuite) TestSignupWeb3_InvalidSignature() { + nonceReq := newNonceRequest(ts.T(), ts.pubKeyBase58) + nonceW := httptest.NewRecorder() + ts.API.handler.ServeHTTP(nonceW, nonceReq) + ts.Require().Equal(http.StatusOK, nonceW.Code) + + var nonceResp map[string]interface{} + ts.Require().NoError(json.NewDecoder(nonceW.Body).Decode(&nonceResp)) + nonce, ok := nonceResp["nonce"].(string) + ts.Require().True(ok) + + rawMessage, _ := ts.generateSIWSMessageAndSignature(nonce) + + invalidSignature := base64.StdEncoding.EncodeToString(make([]byte, 64)) + + req := newSIWSRequest(ts.T(), grantTypeWeb3, rawMessage, invalidSignature, ts.pubKeyBase58, chainSolanaMainnet) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.assertErrorResponse(w, http.StatusBadRequest, errorInvalidGrant) +} + +func (ts *Web3TestSuite) TestSignupWeb3_MalformedMessage() { + nonceReq := newNonceRequest(ts.T(), ts.pubKeyBase58) + nonceW := httptest.NewRecorder() + ts.API.handler.ServeHTTP(nonceW, nonceReq) + nonceResp := map[string]interface{}{} + _ = json.NewDecoder(nonceW.Body).Decode(&nonceResp) + nonce, _ := nonceResp["nonce"].(string) + + malformedMessage := fmt.Sprintf(`{ + "domain": "%s", + "uri": "%s", + "version": "1", + "nonce": "%s" + }`, ts.Config.External.Web3.Domain, ts.testURI, nonce) + + _, signatureBase64 := ts.generateSIWSMessageAndSignature(nonce) + + req := newSIWSRequest(ts.T(), grantTypeWeb3, malformedMessage, signatureBase64, ts.pubKeyBase58, chainSolanaMainnet) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.assertErrorResponse(w, http.StatusBadRequest, errorInvalidGrant) +} + +func (ts *Web3TestSuite) TestSignupWeb3_InvalidChain() { + nonceReq := newNonceRequest(ts.T(), ts.pubKeyBase58) + nonceW := httptest.NewRecorder() + ts.API.handler.ServeHTTP(nonceW, nonceReq) + ts.Require().Equal(http.StatusOK, nonceW.Code) + + var nonceResp map[string]interface{} + ts.Require().NoError(json.NewDecoder(nonceW.Body).Decode(&nonceResp)) + nonce, ok := nonceResp["nonce"].(string) + ts.Require().True(ok) + + rawMessage, signatureBase64 := ts.generateSIWSMessageAndSignature(nonce) + + invalidChain := "invalid-chain" + req := newSIWSRequest(ts.T(), grantTypeWeb3, rawMessage, signatureBase64, ts.pubKeyBase58, invalidChain) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.assertErrorResponse(w, http.StatusBadRequest, errorInvalidGrant) +} + +func FindStoredNonceByAddressAndNonce(db *storage.Connection, address, nonce string) (*StoredNonce, error) { + storedNonce := &StoredNonce{} + err := db.RawQuery(` + SELECT * FROM auth.nonces + WHERE address = ? AND nonce = ? + `, address, nonce).First(storedNonce) + + if models.IsNotFoundError(err) { + return nil, nil + } else if err != nil { + return nil, err + } + return storedNonce, nil +} + +func (ts *Web3TestSuite) TestNonceExpiryConstraint() { + pubKey, _, err := ed25519.GenerateKey(rand.Reader) + ts.Require().NoError(err) + pubKeyBase58 := base58.Encode(pubKey) + + invalidNonce := &StoredNonce{ + ID: uuid.Must(uuid.NewV4()), + Address: pubKeyBase58, + Nonce: testNonceValue, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(-1 * time.Hour), + Used: false, + } + + err = ts.API.db.Create(invalidNonce) + ts.Require().Error(err) + ts.Require().Contains(err.Error(), "nonces_expiry_check") + + validNonce := &StoredNonce{ + ID: uuid.Must(uuid.NewV4()), + Address: pubKeyBase58, + Nonce: testNonceValue, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(1 * time.Hour), + Used: false, + } + + err = ts.API.db.Create(validNonce) + ts.Require().NoError(err) + + storedNonce, err := FindStoredNonceByAddressAndNonce(ts.API.db, pubKeyBase58, testNonceValue) + ts.Require().NoError(err) + ts.Require().NotNil(storedNonce) + ts.Require().True(storedNonce.ExpiresAt.After(storedNonce.CreatedAt)) +} + +func (ts *Web3TestSuite) TestSignupWeb3_ConcurrentNonceUsage() { + nonceReq := newNonceRequest(ts.T(), ts.pubKeyBase58) + nonceW := httptest.NewRecorder() + ts.API.handler.ServeHTTP(nonceW, nonceReq) + ts.Require().Equal(http.StatusOK, nonceW.Code) + + var nonceResp map[string]interface{} + ts.Require().NoError(json.NewDecoder(nonceW.Body).Decode(&nonceResp)) + nonce, ok := nonceResp["nonce"].(string) + ts.Require().True(ok) + ts.Require().NotEmpty(nonce) + + rawMessage, signatureBase64 := ts.generateSIWSMessageAndSignature(nonce) + + const numConcurrentRequests = 10 + + type result struct { + statusCode int + response string + } + results := make(chan result, numConcurrentRequests) + + var wg sync.WaitGroup + wg.Add(numConcurrentRequests) + + for i := 0; i < numConcurrentRequests; i++ { + go func() { + defer wg.Done() + + req := newSIWSRequest(ts.T(), grantTypeWeb3, rawMessage, signatureBase64, ts.pubKeyBase58, chainSolanaMainnet) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + results <- result{ + statusCode: w.Code, + response: w.Body.String(), + } + }() + } + + wg.Wait() + close(results) + + successCount := 0 + failureCount := 0 + + for r := range results { + if r.statusCode == http.StatusOK { + successCount++ + } else { + failureCount++ + } + } + + ts.Require().Equal(1, successCount, "Expected exactly one successful nonce usage") + ts.Require().Equal(numConcurrentRequests-1, failureCount, "Expected all other requests to fail") + + storedNonce, err := FindStoredNonceByAddressAndNonce(ts.API.db, ts.pubKeyBase58, nonce) + ts.Require().NoError(err) + ts.Require().NotNil(storedNonce) + ts.Require().True(storedNonce.Used) +} \ No newline at end of file diff --git a/internal/api/helpers.go b/internal/api/helpers.go index 8a9f3267ed..73d458d926 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -10,6 +10,7 @@ import ( "github.com/supabase/auth/internal/conf" "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/security" + "github.com/supabase/auth/internal/utilities" ) @@ -74,6 +75,7 @@ type RequestParams interface { SignupParams | SingleSignOnParams | SmsParams | + Web3GrantParams | UserUpdateParams | VerifyFactorParams | VerifyParams | @@ -81,6 +83,7 @@ type RequestParams interface { adminUserDeleteParams | security.GotrueRequest | ChallengeFactorParams | + struct { Email string `json:"email"` Phone string `json:"phone"` diff --git a/internal/api/provider/eip4361.go b/internal/api/provider/eip4361.go new file mode 100644 index 0000000000..d19f859534 --- /dev/null +++ b/internal/api/provider/eip4361.go @@ -0,0 +1,201 @@ +package provider + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/supabase/auth/internal/conf" + "github.com/supabase/auth/internal/crypto" + "github.com/supabase/auth/internal/storage" + siws "github.com/supabase/auth/internal/utilities/solana" + "golang.org/x/oauth2" +) + +const ( + BlockchainEthereum = "ethereum" + BlockchainSolana = "solana" +) + +// Web3Provider implements Web3 authentication following EIP-4361 spec +type Web3Provider struct { + config conf.Web3Configuration + chains map[string]conf.BlockchainConfig + defaultChain string +} + +type Web3GrantParams struct { + Message string `json:"message"` + Signature string `json:"signature"` + Chain string `json:"chain"` +} + +func NewWeb3Provider(ctx context.Context, config conf.Web3Configuration) (*Web3Provider, error) { + if !config.Enabled { + return nil, errors.New("Web3 provider is not enabled") + } + + // Parse chains + chains, err := config.ParseSupportedChains() + if err != nil { + return nil, err + } + + // Validate default chain + if config.DefaultChain != "" { + if _, ok := chains[config.DefaultChain]; !ok { + return nil, fmt.Errorf("default chain %s not in supported chains", config.DefaultChain) + } + } + + return &Web3Provider{ + config: config, + chains: chains, + defaultChain: config.DefaultChain, + }, nil +} + +func (p *Web3Provider) AuthCodeURL(state string, args ...oauth2.AuthCodeOption) string { + return "" // Web3 auth doesn't use OAuth flow +} + +func (p *Web3Provider) GetOAuthToken(code string) (*oauth2.Token, error) { + return nil, errors.New("GetOAuthToken not implemented for Web3") +} + +func (p *Web3Provider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { + return nil, errors.New("GetUserData not implemented for Web3") +} + +// VerifySignedMessage verifies a signed Web3 message based on the blockchain +func (p *Web3Provider) VerifySignedMessage(db *storage.Connection, params *Web3GrantParams) (*UserProvidedData, error) { + var err error + + parsedMessage, err := siws.ParseSIWSMessage(params.Message) + + if err != nil { + return nil, siws.ErrorMalformedMessage + } + + + // Verify and consume nonce first + if err := crypto.VerifyAndConsumeNonce(db, parsedMessage.Nonce, parsedMessage.Address); err != nil { + return nil, siws.ErrorCodeInvalidNonce + } + network := strings.Split(params.Chain, ":") + if len(network) != 2 { + return nil, siws.ErrInvalidChainID + } + chain := network[0] + if chain == "" { + return nil, siws.ErrInvalidChainID + } + + switch chain { + case BlockchainEthereum: + return nil, httpError(http.StatusNotImplemented, "signature verification not implemented for %s", network) + + case BlockchainSolana: + err = p.verifySolanaSignature(params.Signature, params.Message, parsedMessage) + default: + return nil, httpError(http.StatusNotImplemented, "signature verification not implemented for %s", network) + } + + if err != nil { + return nil, fmt.Errorf("signature verification failed: %w", err) + } + + // Construct the provider_id as network:chain:address to make it unique + providerId := fmt.Sprintf("%s:%s", params.Chain, parsedMessage.Address) + + return &UserProvidedData{ + Metadata: &Claims{ + CustomClaims: map[string]interface{}{ + "address": parsedMessage.Address, + "chain": parsedMessage.ChainID, + "role": "authenticated", + }, + Subject: providerId, // This becomes the provider_id in the identity + }, + Emails: []Email{}, + }, nil +} + + +func (p *Web3Provider) verifySolanaSignature(signature string, rawMessage string, msg *siws.SIWSMessage) error { + // Decode base64 signature into bytes + sigBytes, err := base64.StdEncoding.DecodeString(string(signature)) + if err != nil { + return fmt.Errorf("invalid signature encoding: %w", err) + } + + params := siws.SIWSVerificationParams{ + ExpectedDomain: p.config.Domain, + CheckTime: true, + TimeDuration: p.config.Timeout, + } + + if err := crypto.VerifySIWS(rawMessage, sigBytes, msg, params); err != nil { + return err + } + + return nil +} + +func (p *Web3Provider) GenerateSignMessage(address string, chain string, uri string) (string, error) { + if chain == "" { + chain = p.defaultChain + } + + chainCfg, ok := p.chains[chain] + if !ok { + return "", fmt.Errorf("unsupported chain: %s", chain) + } + + // Generate nonce for message uniqueness + nonce := crypto.SecureAlphanumeric(12) + + now := time.Now().UTC() + + switch chainCfg.NetworkName { + case BlockchainSolana: + msg := siws.SIWSMessage{ + Domain: p.config.Domain, + Address: address, + Statement: p.config.Statement, + URI: uri, + Version: p.config.Version, + Nonce: nonce, + IssuedAt: now, + } + return siws.ConstructMessage(msg), nil + + case BlockchainEthereum: + return fmt.Sprintf(`%s wants you to sign in with your %s account: +%s + +URI: %s +Version: %s +Chain ID: %s +Nonce: %d +Issued At: %s +Expiration Time: %s`, + p.config.Domain, + chainCfg.NetworkName, + address, + uri, + p.config.Version, + chainCfg.ChainID, + now.UnixNano(), + now.Format(time.RFC3339), + now.Add(p.config.Timeout).Format(time.RFC3339)), nil + + default: + return "", fmt.Errorf("message generation not implemented for %s", chainCfg.NetworkName) + } +} + diff --git a/internal/api/token.go b/internal/api/token.go index cc945f2e13..56c018e109 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -2,17 +2,20 @@ package api import ( "context" + "encoding/json" + "fmt" "net/http" "net/url" "strconv" "time" - "fmt" - "github.com/gofrs/uuid" "github.com/golang-jwt/jwt/v5" + "github.com/sirupsen/logrus" "github.com/xeipuuv/gojsonschema" + "github.com/supabase/auth/internal/api/provider" + "github.com/supabase/auth/internal/crypto" "github.com/supabase/auth/internal/hooks" "github.com/supabase/auth/internal/metering" "github.com/supabase/auth/internal/models" @@ -79,6 +82,7 @@ const InvalidLoginMessage = "Invalid login credentials" func (a *API) Token(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() grantType := r.FormValue("grant_type") + switch grantType { case "password": return a.ResourceOwnerPasswordGrant(ctx, w, r) @@ -88,6 +92,8 @@ func (a *API) Token(w http.ResponseWriter, r *http.Request) error { return a.IdTokenGrant(ctx, w, r) case "pkce": return a.PKCE(ctx, w, r) + case "web3": + return a.Web3Grant(ctx, w, r) default: return badRequestError(ErrorCodeInvalidCredentials, "unsupported_grant_type") } @@ -307,6 +313,154 @@ func (a *API) PKCE(ctx context.Context, w http.ResponseWriter, r *http.Request) return sendJSON(w, http.StatusOK, token) } +// GetNonce handles nonce generation requests +func (a *API) GetNonce(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + logEntry := observability.GetLogEntry(r) // Get the log entry + + var body struct { + Address string `json:"address"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + logEntry.Entry.WithError(err).Error("Invalid request body in GetNonce") // Use .Entry + return badRequestError(ErrorCodeBadJSON, "Invalid request body: %v", err) + } + + logEntry.Entry.WithFields(logrus.Fields{ // Use .Entry and WithFields + "address": body.Address, + }).Info("GetNonce request received") + + if body.Address == "" { + return badRequestError(ErrorCodeBadJSON, "Missing required field: address") + } + + nonce := crypto.SecureAlphanumeric(12) + id, err := uuid.NewV4() + if err != nil { + logEntry.Entry.WithError(err).Error("Failed to generate UUID in GetNonce") // Use .Entry + return internalServerError("Failed to generate UUID: %v", err) + } + + storedNonce := &StoredNonce{ + ID: id, + Address: body.Address, + Nonce: nonce, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(a.config.External.Web3.Timeout), + Used: false, + } + + logEntry.Entry.WithFields(logrus.Fields{ // Use .Entry and WithFields + "nonce": nonce, + "uuid": id, + "address": body.Address, + }).Info("Generated nonce and UUID") + + err = db.Transaction(func(tx *storage.Connection) error { + // Store the nonce + query := ` + INSERT INTO auth.nonces (id, address, nonce, created_at, expires_at, used) + VALUES ($1, $2, $3, $4, $5, $6) + ` + logEntry.Entry.WithFields(logrus.Fields{ // Use .Entry and WithFields + "query": query, + "id": storedNonce.ID, + "address": storedNonce.Address, + "nonce": storedNonce.Nonce, + "createdAt": storedNonce.CreatedAt, + "expiresAt": storedNonce.ExpiresAt, + "used": storedNonce.Used, + }).Debug("Executing SQL query") + + _, err := tx.TX.Exec(query, + storedNonce.ID, storedNonce.Address, storedNonce.Nonce, + storedNonce.CreatedAt, storedNonce.ExpiresAt, storedNonce.Used) + if err != nil { + logEntry.Entry.WithError(err).Error("DB error while storing nonce") // Use .Entry + } + return err + }) + + if err != nil { + return internalServerError("DB error while storing nonce: %v", err) + } + + return sendJSON(w, http.StatusOK, map[string]interface{}{ + "nonce": nonce, + "expiresAt": storedNonce.ExpiresAt, + }) +} + + + + +func (a *API) Web3Grant(ctx context.Context, w http.ResponseWriter, r *http.Request) error { + db := a.db.WithContext(ctx) + + params := &Web3GrantParams{} + if err := retrieveRequestParams(r, params); err != nil { + return err + } + + web3Provider, err := provider.NewWeb3Provider(ctx, a.config.External.Web3) + if err != nil { + return err + } + + // Convert params to SignedMessage + msg := &provider.Web3GrantParams{ + Message: params.Message, + Signature: params.Signature, + Chain: params.Chain, + } + + userData, err := web3Provider.VerifySignedMessage(db, msg) + if err != nil { + return oauthError("invalid_grant", "Signature verification failed").WithInternalError(err) + } + + var token *AccessTokenResponse + var grantParams models.GrantParams + grantParams.FillGrantParams(r) + + err = db.Transaction(func(tx *storage.Connection) error { + user, terr := a.createAccountFromExternalIdentity(tx, r, userData, "web3") + if terr != nil { + return terr + } + + if terr := models.NewAuditLogEntry(r, tx, user, models.LoginAction, "", map[string]interface{}{ + "provider": "web3", + "chain": msg.Chain, + "address": userData.Metadata.CustomClaims["address"], + }); terr != nil { + return terr + } + + token, terr = a.issueRefreshToken(r, tx, user, models.Web3, grantParams) + if terr != nil { + return terr + } + + return nil + }) + + if err != nil { + switch err.(type) { + case *storage.CommitWithError: + return err + case *HTTPError: + return err + default: + return oauthError("server_error", "Internal Server Error").WithInternalError(err) + } + } + + return sendJSON(w, http.StatusOK, token) +} + + func (a *API) generateAccessToken(r *http.Request, tx *storage.Connection, user *models.User, sessionId *uuid.UUID, authenticationMethod models.AuthenticationMethod) (string, int64, error) { config := a.config if sessionId == nil { @@ -504,3 +658,5 @@ func validateTokenClaims(outputClaims map[string]interface{}) error { return nil } + + diff --git a/internal/api/token_test.go b/internal/api/token_test.go index fc89d4f8bf..42f0006384 100644 --- a/internal/api/token_test.go +++ b/internal/api/token_test.go @@ -855,3 +855,5 @@ $$;` }) } } + + diff --git a/internal/api/web3.go b/internal/api/web3.go new file mode 100644 index 0000000000..8cc7670ac6 --- /dev/null +++ b/internal/api/web3.go @@ -0,0 +1,28 @@ +package api + +import ( + "time" + + "github.com/gofrs/uuid" +) + + +type Web3GrantParams struct { + Message string `json:"message"` + Signature string `json:"signature"` + Chain string `json:"chain"` +} + +type StoredNonce struct { + ID uuid.UUID `db:"id"` + Nonce string `db:"nonce"` + Address string `db:"address"` // Optional: can be empty until signature verification + CreatedAt time.Time `db:"created_at"` + ExpiresAt time.Time `db:"expires_at"` + Used bool `db:"used"` +} + +func (StoredNonce) TableName() string { + tableName := "nonces" + return tableName +} \ No newline at end of file diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index c4d910d991..3538fbf6ca 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "math/big" "net/url" "os" "path/filepath" @@ -19,6 +20,7 @@ import ( "github.com/joho/godotenv" "github.com/kelseyhightower/envconfig" "github.com/lestrrat-go/jwx/v2/jwk" + siws "github.com/supabase/auth/internal/utilities/solana" "gopkg.in/gomail.v2" ) @@ -339,6 +341,66 @@ type ProviderConfiguration struct { 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"` + Web3 Web3Configuration `json:"web3" envconfig:"WEB3"` +} + +type Web3Configuration struct { + Enabled bool `json:"enabled" default:"false" split_words:"true"` + Domain string `json:"domain" required:"true" split_words:"true"` + Statement string `json:"statement" split_words:"true"` + Version string `json:"version" default:"1" split_words:"true"` + Timeout time.Duration `json:"timeout" default:"300s" split_words:"true"` + + // Comma-separated list of supported chains (e.g. "ethereum:1,ethereum:137,solana:mainnet") + SupportedChains string `json:"supported_chains" split_words:"true"` + DefaultChain string `json:"default_chain" split_words:"true"` +} + +type BlockchainConfig struct { + ChainID string + NetworkName string +} + +// ParseSupportedChains processes and validates the SupportedChains string. +func (c *Web3Configuration) ParseSupportedChains() (map[string]BlockchainConfig, error) { + chainMap := make(map[string]BlockchainConfig) + + // Split comma-separated chains + chains := strings.Split(c.SupportedChains, ",") + for _, chain := range chains { + chain = strings.TrimSpace(chain) + parts := strings.Split(chain, ":") + + // Ensure proper : format + if len(parts) != 2 { + return nil, fmt.Errorf("invalid chain format: %s, expected :", chain) + } + + network := strings.ToLower(parts[0]) + chainID := parts[1] + + // Validate network type + switch network { + case "ethereum": + if _, ok := new(big.Int).SetString(chainID, 10); !ok { + return nil, fmt.Errorf("invalid Ethereum chain ID: %s", chainID) + } + case "solana": + if !siws.IsValidSolanaNetwork(chainID) { + return nil, fmt.Errorf("invalid Solana network: %s", chainID) + } + default: + return nil, fmt.Errorf("unsupported network: %s", network) + } + + // Add to chain map + chainMap[chain] = BlockchainConfig{ + NetworkName: network, + ChainID: chainID, + } + } + + return chainMap, nil } type SMTPConfiguration struct { diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index 6fc2b71ace..8d79d860a8 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -5,25 +5,30 @@ import ( "crypto/cipher" "crypto/rand" "crypto/sha256" + "database/sql" + "encoding/base32" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "io" + "log" "math" "math/big" + "net/url" "strconv" "strings" - "golang.org/x/crypto/hkdf" -) + "crypto/ed25519" + "time" -// SecureToken creates a new random token -func SecureToken() string { - b := make([]byte, 16) - must(io.ReadFull(rand.Reader, b)) + "golang.org/x/crypto/hkdf" - return base64.RawURLEncoding.EncodeToString(b) -} + "github.com/btcsuite/btcutil/base58" + "github.com/gofrs/uuid" + "github.com/supabase/auth/internal/storage" + siws "github.com/supabase/auth/internal/utilities/solana" +) // GenerateOtp generates a random n digit otp func GenerateOtp(digits int) string { @@ -157,3 +162,257 @@ func NewEncryptedString(id string, data []byte, keyID string, keyBase64URL strin return &es, nil } + +func VerifySIWS( + rawMessage string, + signature []byte, + msg *siws.SIWSMessage, + params siws.SIWSVerificationParams, +) error { + log.Printf("[DEBUG] Starting SIWS verification - Signature length: %d", len(signature)) + + // 1) Basic input validation + if rawMessage == "" { + log.Printf("[ERROR] Empty raw message") + return siws.ErrEmptyRawMessage + } + if len(signature) == 0 { + log.Printf("[ERROR] Empty signature") + return siws.ErrEmptySignature + } + if msg == nil { + log.Printf("[ERROR] Nil message") + return siws.ErrNilMessage + } + + log.Printf("[DEBUG] Basic validation passed - Message length: %d", len(rawMessage)) + + // 2) Domain validation + log.Printf("[DEBUG] Validating domain - Expected: %s, Actual: %s", params.ExpectedDomain, msg.Domain) + if params.ExpectedDomain == "" { + log.Printf("[ERROR] Missing expected domain") + return siws.ErrMissingDomain + } + if !siws.IsValidDomain(msg.Domain) { + log.Printf("[ERROR] Invalid domain format: %s", msg.Domain) + return siws.ErrInvalidDomainFormat + } + if msg.Domain != params.ExpectedDomain { + log.Printf("[ERROR] Domain mismatch - Expected: %s, Got: %s", params.ExpectedDomain, msg.Domain) + return siws.ErrDomainMismatch + } + + // 3) Address/Public Key validation + pubKey := base58.Decode(msg.Address) + log.Printf("[DEBUG] Validating public key - Address: %s, Decoded length: %d", msg.Address, len(pubKey)) + if !siws.IsBase58PubKey(pubKey) { + log.Printf("[ERROR] Invalid public key size: %d", len(pubKey)) + return siws.ErrInvalidPubKeySize + } + + // 4) Version validation + log.Printf("[DEBUG] Checking version: %s", msg.Version) + if msg.Version != "1" { + log.Printf("[ERROR] Invalid version: %s", msg.Version) + return siws.ErrInvalidVersion + } + + // 5) Chain ID validation + if msg.ChainID != "" { + log.Printf("[DEBUG] Validating chain ID: %s", msg.ChainID) + if !siws.IsValidSolanaNetwork(msg.ChainID) { + log.Printf("[ERROR] Invalid chain ID: %s", msg.ChainID) + return siws.ErrInvalidChainID + } + } + + // 6) Nonce validation + if msg.Nonce != "" { + log.Printf("[DEBUG] Checking nonce length: %d", len(msg.Nonce)) + if len(msg.Nonce) < 8 { + log.Printf("[ERROR] Nonce too short: %d chars", len(msg.Nonce)) + return siws.ErrNonceTooShort + } + } + + // 7) URI validation + if msg.URI != "" { + log.Printf("[DEBUG] Validating URI: %s", msg.URI) + if _, err := url.Parse(msg.URI); err != nil { + log.Printf("[ERROR] Invalid URI: %s - %v", msg.URI, err) + return siws.ErrInvalidURI + } + } + + // Resources validation + for _, resource := range msg.Resources { + log.Printf("[DEBUG] Validating resource URI: %s", resource) + if _, err := url.Parse(resource); err != nil { + log.Printf("[ERROR] Invalid resource URI: %s - %v", resource, err) + return siws.ErrInvalidResourceURI + } + } + + // 8) Signature verification + log.Printf("[DEBUG] Verifying ed25519 signature") + log.Printf("[DEBUG] Verification inputs - Message bytes: %v", []byte(rawMessage)) + log.Printf("[DEBUG] Verification inputs - Signature bytes: %v", signature) + if !ed25519.Verify(pubKey, []byte(rawMessage), signature) { + log.Printf("[ERROR] Signature verification failed") + return siws.ErrSignatureVerification + } + + // 9) Time validations + now := time.Now().UTC() + log.Printf("[DEBUG] Time validation - Current time: %s", now) + + if !msg.IssuedAt.IsZero() { + log.Printf("[DEBUG] Checking issuedAt: %s", msg.IssuedAt) + if now.Before(msg.IssuedAt) { + log.Printf("[ERROR] Message from future - IssuedAt: %s", msg.IssuedAt) + return siws.ErrFutureMessage + } + + if params.CheckTime && params.TimeDuration > 0 { + expiry := msg.IssuedAt.Add(params.TimeDuration) + log.Printf("[DEBUG] Checking message expiry - Expiry: %s", expiry) + if now.After(expiry) { + log.Printf("[ERROR] Message expired - Expiry: %s", expiry) + return siws.ErrMessageExpired + } + } + } + + if !msg.NotBefore.IsZero() { + log.Printf("[DEBUG] Checking notBefore: %s", msg.NotBefore) + if now.Before(msg.NotBefore) { + log.Printf("[ERROR] Message not yet valid - NotBefore: %s", msg.NotBefore) + return siws.ErrNotYetValid + } + } + + if !msg.ExpirationTime.IsZero() { + log.Printf("[DEBUG] Checking expirationTime: %s", msg.ExpirationTime) + if now.After(msg.ExpirationTime) { + log.Printf("[ERROR] Message expired - ExpirationTime: %s", msg.ExpirationTime) + return siws.ErrMessageExpired + } + } + + log.Printf("[INFO] SIWS verification successful") + return nil +} + +func VerifyEthereumSignature(message string, signature string) error { + // Remove 0x prefix if present + signature = removeHexPrefix(signature) + // address = removeHexPrefix(address) + + // Convert signature hex to bytes + sigBytes, err := hex.DecodeString(signature) + if err != nil { + return fmt.Errorf("siwe: invalid signature hex: %w", err) + } + + // Adjust V value in signature (Ethereum specific) + if len(sigBytes) != 65 { + return fmt.Errorf("siwe: invalid signature length") + } + if sigBytes[64] < 27 { + sigBytes[64] += 27 + } + + // Hash the message according to EIP-191 + // prefixedMessage := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(message), message) + // hash := crypto.Keccak256Hash([]byte(prefixedMessage)) + + // Recover public key from signature + // pubKey, err := crypto.SigToPub(hash.Bytes(), sigBytes) + // if err != nil { + // return fmt.Errorf("siwe: error recovering public key: %w", err) + // } + + // Derive Ethereum address from public key + // recoveredAddr := crypto.PubkeyToAddress(*pubKey) + // checkAddr := common.HexToAddress(address) + + // Compare addresses + // if recoveredAddr != checkAddr { + // return fmt.Errorf("siwe: signature not from expected address") + // } + + return nil +} + +func removeHexPrefix(signature string) string { + if strings.HasPrefix(signature, "0x") { + return strings.TrimPrefix(signature, "0x") + } + return signature +} + +// SecureAlphanumeric generates a secure random alphanumeric string using standard library +func SecureAlphanumeric(length int) string { + if length < 8 { + length = 8 + } + + // Calculate bytes needed for desired length + // base32 encoding: 5 bytes -> 8 chars + numBytes := (length * 5 + 7) / 8 + + b := make([]byte, numBytes) + must(io.ReadFull(rand.Reader, b)) + + // Use standard library's base32 without padding + return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b))[:length] +} + + +type StoredNonce struct { + ID uuid.UUID `db:"id"` + Nonce string `db:"nonce"` + Address string `db:"address"` + CreatedAt time.Time `db:"created_at"` + ExpiresAt time.Time `db:"expires_at"` + Used bool `db:"used"` +} + +func (StoredNonce) TableName() string { + tableName := "nonces" + return tableName +} + +func VerifyAndConsumeNonce(db *storage.Connection, nonce string, address string) error { + + +var storedNonce StoredNonce +err := db.Transaction(func(tx *storage.Connection) error { + // Find the nonce + log.Printf("Executing query for nonce: %s", nonce) + err := tx.TX.QueryRow(` + SELECT id, nonce, address, created_at, expires_at, used + FROM auth.nonces + WHERE nonce = $1 AND used = false AND address = $2 + `, nonce, address).Scan(&storedNonce.ID, &storedNonce.Nonce, + &storedNonce.Address, &storedNonce.CreatedAt, + &storedNonce.ExpiresAt, &storedNonce.Used) + if err != nil { + log.Printf("Error looking up nonce: %v", err) + return err + } + // Check expiration + if time.Now().After(storedNonce.ExpiresAt) { + return siws.ErrExpiredNonce + } + // Mark as used + _, err = tx.TX.Exec(` + UPDATE auth.nonces + SET used = true, address = $1 + WHERE id = $2 + `, sql.NullString{String: address, Valid: true}, storedNonce.ID) + return err +}) + +return err +} \ No newline at end of file diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go index f1c8e67518..5820e4fa3b 100644 --- a/internal/crypto/crypto_test.go +++ b/internal/crypto/crypto_test.go @@ -1,10 +1,16 @@ package crypto import ( + "crypto/ed25519" + "fmt" + "strings" "testing" + "time" + "github.com/btcsuite/btcutil/base58" "github.com/gofrs/uuid" "github.com/stretchr/testify/assert" + siws "github.com/supabase/auth/internal/utilities/solana" ) func TestEncryptedStringPositive(t *testing.T) { @@ -104,5 +110,241 @@ func TestEncryptedStringDecryptNegative(t *testing.T) { } func TestSecureToken(t *testing.T) { - assert.Equal(t, len(SecureToken()), 22) + assert.Equal(t, len(SecureAlphanumeric(22)), 22) } + +func TestVerifySIWS(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("Failed to generate keypair: %v", err) + } + + now := time.Now().UTC() + issuedAt := now.Add(-5 * time.Minute) + expiresAt := now.Add(55 * time.Minute) + + // Base test message + validMessage := fmt.Sprintf(`example.com wants you to sign in with your Solana account: +%s + +I accept the ServiceOrg Terms of Service + +URI: https://example.com/login +Version: 1 +Chain ID: solana:mainnet +Nonce: 8lb3dW3F +Issued At: %s +Expiration Time: %s +Resources: +- https://example.com/profile +- https://example.com/settings`, + base58.Encode(pub), + issuedAt.Format(time.RFC3339), + expiresAt.Format(time.RFC3339)) + + parsedMsg, err := siws.ParseSIWSMessage(validMessage) + if err != nil { + t.Fatalf("Failed to parse valid message: %v", err) + } + + validSignature := ed25519.Sign(priv, []byte(validMessage)) + + // Helper function to create a valid base message + createBaseMsg := func() *siws.SIWSMessage { + return &siws.SIWSMessage{ + Domain: "example.com", + Address: base58.Encode(pub), + Version: "1", + URI: "https://example.com/login", + ChainID: "solana:mainnet", + Nonce: "8lb3dW3F", + } + } + + params := siws.SIWSVerificationParams{ + ExpectedDomain: "example.com", + CheckTime: true, + TimeDuration: time.Hour, + } + + testCases := []struct { + name string + message string + signature []byte + msg *siws.SIWSMessage + params siws.SIWSVerificationParams + expectedErr string + }{ + { + name: "valid message", + message: validMessage, + signature: validSignature, + msg: parsedMsg, + params: params, + expectedErr: "", + }, + { + name: "empty message", + message: "", + signature: validSignature, + msg: parsedMsg, + params: params, + expectedErr: siws.ErrEmptyRawMessage.Message, + }, + { + name: "empty signature", + message: validMessage, + signature: []byte{}, + msg: parsedMsg, + params: params, + expectedErr: siws.ErrEmptySignature.Message, + }, + { + name: "nil message struct", + message: validMessage, + signature: validSignature, + msg: nil, + params: params, + expectedErr: siws.ErrNilMessage.Message, + }, + { + name: "invalid address characters", + message: validMessage, + signature: validSignature, + msg: func() *siws.SIWSMessage { + msg := createBaseMsg() + // Create a 32-character address with invalid characters + msg.Address = "Invalid@Address!123" + strings.Repeat("1", 19) + return msg + }(), + params: params, + expectedErr: siws.ErrInvalidPubKeySize.Message, + }, + { + name: "address too short", + message: validMessage, + signature: validSignature, + msg: func() *siws.SIWSMessage { + msg := createBaseMsg() + msg.Address = "abc123" + return msg + }(), + params: params, + expectedErr: siws.ErrInvalidPubKeySize.Message, + }, + { + name: "invalid version", + message: validMessage, + signature: validSignature, + msg: func() *siws.SIWSMessage { + msg := createBaseMsg() + msg.Version = "2" + return msg + }(), + params: params, + expectedErr: siws.ErrInvalidVersion.Message, + }, + { + name: "invalid chain ID", + message: validMessage, + signature: validSignature, + msg: func() *siws.SIWSMessage { + msg := createBaseMsg() + msg.ChainID = "invalid-chain" + return msg + }(), + params: params, + expectedErr: siws.ErrInvalidChainID.Message, + }, + { + name: "short nonce", + message: validMessage, + signature: validSignature, + msg: func() *siws.SIWSMessage { + msg := createBaseMsg() + msg.Nonce = "abc123" + return msg + }(), + params: params, + expectedErr: siws.ErrNonceTooShort.Message, + }, + + { + name: "invalid URI format", + message: validMessage, + signature: validSignature, + msg: func() *siws.SIWSMessage { + msg := createBaseMsg() + msg.URI = "://invalid-uri-format" // Invalid URI scheme + return msg + }(), + params: params, + expectedErr: siws.ErrInvalidURI.Message, + }, + { + name: "invalid resource URI", + message: validMessage, + signature: validSignature, + msg: func() *siws.SIWSMessage { + msg := createBaseMsg() + msg.Resources = []string{"://invalid-resource-uri"} // Invalid URI scheme + return msg + }(), + params: params, + expectedErr: siws.ErrInvalidResourceURI.Message, + }, + { + name: "future timestamp", + message: validMessage, + signature: validSignature, + msg: func() *siws.SIWSMessage { + msg := createBaseMsg() + msg.IssuedAt = now.Add(10 * time.Minute) + return msg + }(), + params: params, + expectedErr: siws.ErrFutureMessage.Message, + }, + { + name: "expired message", + message: validMessage, + signature: validSignature, + msg: func() *siws.SIWSMessage { + msg := createBaseMsg() + msg.IssuedAt = now.Add(-2 * time.Hour) + return msg + }(), + params: params, + expectedErr: siws.ErrMessageExpired.Message, + }, + { + name: "not yet valid", + message: validMessage, + signature: validSignature, + msg: func() *siws.SIWSMessage { + msg := createBaseMsg() + msg.NotBefore = now.Add(1 * time.Hour) + return msg + }(), + params: params, + expectedErr: siws.ErrNotYetValid.Message, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := VerifySIWS(tc.message, tc.signature, tc.msg, tc.params) + if tc.expectedErr == "" { + if err != nil { + t.Errorf("expected success, got error: %v", err) + } + } else { + if err == nil { + t.Errorf("expected error containing %q, got nil", tc.expectedErr) + } else if !strings.Contains(err.Error(), tc.expectedErr) { + t.Errorf("expected error containing %q, got %q", tc.expectedErr, err.Error()) + } + } + }) + } +} \ No newline at end of file diff --git a/internal/models/factor.go b/internal/models/factor.go index a88874d734..72309a8557 100644 --- a/internal/models/factor.go +++ b/internal/models/factor.go @@ -54,6 +54,7 @@ const ( EmailChange TokenRefresh Anonymous + Web3 ) func (authMethod AuthenticationMethod) String() string { @@ -86,6 +87,8 @@ func (authMethod AuthenticationMethod) String() string { return "mfa/phone" case MFAWebAuthn: return "mfa/webauthn" + case Web3: + return "web3" } return "" } @@ -121,6 +124,9 @@ func ParseAuthenticationMethod(authMethod string) (AuthenticationMethod, error) return MFAPhone, nil case "mfa/webauthn": return MFAWebAuthn, nil + case "web3": + return Web3, nil + } return 0, fmt.Errorf("unsupported authentication method %q", authMethod) } diff --git a/internal/models/refresh_token.go b/internal/models/refresh_token.go index c5fea83ddd..b683a8c779 100644 --- a/internal/models/refresh_token.go +++ b/internal/models/refresh_token.go @@ -118,7 +118,7 @@ func FindTokenBySessionID(tx *storage.Connection, sessionId *uuid.UUID) (*Refres func createRefreshToken(tx *storage.Connection, user *User, oldToken *RefreshToken, params *GrantParams) (*RefreshToken, error) { token := &RefreshToken{ UserID: user.ID, - Token: crypto.SecureToken(), + Token: crypto.SecureAlphanumeric(12), Parent: "", } if oldToken != nil { diff --git a/internal/models/web3.go b/internal/models/web3.go new file mode 100644 index 0000000000..6fc274e16d --- /dev/null +++ b/internal/models/web3.go @@ -0,0 +1,4 @@ +package models + +const Web3Provider = "web3" +const Web3Grant = "web3" diff --git a/internal/reloader/testdata/50_example.env b/internal/reloader/testdata/50_example.env index 1002d8be17..6c4fed22b9 100644 --- a/internal/reloader/testdata/50_example.env +++ b/internal/reloader/testdata/50_example.env @@ -168,6 +168,15 @@ GOTRUE_EXTERNAL_ZOOM_CLIENT_ID="" GOTRUE_EXTERNAL_ZOOM_SECRET="" GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI="http://localhost:9999/callback" +# EIP-4361 OAuth config +GOTRUE_EXTERNAL_WEB3_ENABLED="true" +GOTRUE_EXTERNAL_WEB3_TIMEOUT="300s" +GOTRUE_EXTERNAL_WEB3_DOMAIN="localhost:9999" + +# Supported Chains Configuration +GOTRUE_EXTERNAL_WEB3_SUPPORTED_CHAINS="ethereum:1,ethereum:137,solana:mainnet,solana:devnet" +GOTRUE_EXTERNAL_WEB3_DEFAULT_CHAIN="ethereum:1" + # Anonymous auth config GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED="false" diff --git a/internal/utilities/solana/helpers.go b/internal/utilities/solana/helpers.go new file mode 100644 index 0000000000..016fcffacd --- /dev/null +++ b/internal/utilities/solana/helpers.go @@ -0,0 +1,119 @@ +package siws + +import ( + "crypto/ed25519" + "errors" + "fmt" + "net/http" + "regexp" + "strings" +) + +var ( + // Input validation errors + ErrEmptyRawMessage = NewSIWSError("empty raw message", http.StatusBadRequest) + ErrEmptySignature = NewSIWSError("empty signature", http.StatusBadRequest) + ErrNilMessage = NewSIWSError("nil message", http.StatusBadRequest) + + // Domain errors + ErrMissingDomain = NewSIWSError("expected domain is not specified", http.StatusInternalServerError) + ErrDomainMismatch = NewSIWSError("domain mismatch", http.StatusForbidden) + + // Address errors + ErrAddressLength = NewSIWSError("address length invalid", http.StatusBadRequest) + ErrAddressCharacter = NewSIWSError("invalid address character", http.StatusBadRequest) + ErrInvalidPubKeySize = NewSIWSError("invalid public key size", http.StatusBadRequest) + + // Version errors + ErrInvalidVersion = NewSIWSError("invalid version", http.StatusBadRequest) + + // Chain ID errors + ErrInvalidChainID = NewSIWSError("invalid chain ID", http.StatusBadRequest) + + // Nonce errors + ErrNonceTooShort = NewSIWSError("nonce too short", http.StatusBadRequest) + ErrInvalidNonceChar = NewSIWSError("invalid nonce character", http.StatusBadRequest) + + // URI errors + ErrInvalidURI = NewSIWSError("invalid URI", http.StatusBadRequest) + ErrInvalidResourceURI = NewSIWSError("invalid resource URI", http.StatusBadRequest) + + // Signature errors + ErrSignatureVerification = NewSIWSError("signature verification failed", http.StatusUnauthorized) + + // Time validation errors + ErrFutureMessage = NewSIWSError("message is issued in the future", http.StatusBadRequest) + ErrMessageExpired = NewSIWSError("message is expired", http.StatusUnauthorized) + ErrNotYetValid = NewSIWSError("message not yet valid", http.StatusUnauthorized) + ErrorCodeInvalidNonce = NewSIWSError("invalid nonce", http.StatusBadRequest) + ErrorCodeInvalidSignature = NewSIWSError("invalid signature", http.StatusBadRequest) + ErrorMalformedMessage = NewSIWSError("malformed message", http.StatusBadRequest) + ErrInvalidDomainFormat = NewSIWSError("invalid domain format", http.StatusBadRequest) + ErrMessageDomainMismatch = NewSIWSError("domain's header domain and body domain are mismatched.", http.StatusBadRequest) + ErrInvalidStatementFormat = NewSIWSError("invalid statement format", http.StatusBadRequest) + ErrInvalidIssuedAtFormat = NewSIWSError("invalid issued at format", http.StatusBadRequest) + ErrInvalidExpirationTimeFormat = NewSIWSError("invalid expiration time format", http.StatusBadRequest) + ErrInvalidNotBeforeFormat = NewSIWSError("invalid not before format", http.StatusBadRequest) + ErrUnrecognizedLine = NewSIWSError("unrecognized line", http.StatusBadRequest) + ErrExpiredNonce = NewSIWSError("expired nonce", http.StatusBadRequest) + ErrInvalidNonce = NewSIWSError("invalid nonce", http.StatusBadRequest) + + +) + +// ValidateDomain checks if a domain is valid or not. This can be expanded with +// real domain validation logic. Here, we do a simple parse check. +func IsValidDomain(domain string) bool { + // Regular expression to validate domain name including localhost and ports + regex := `^(localhost|(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,})(?::\d{1,5})?$` + match, _ := regexp.MatchString(regex, domain) + return match +} + +// IsBase58PubKey checks if the input is a plausible base58 Solana public key. +func IsBase58PubKey(address []byte) bool { + return len(address) == ed25519.PublicKeySize // ed25519.PublicKeySize is 32 +} + +// Add these functions to your existing helpers.go +func IsValidSolanaNetwork(network string) bool { + // Handle optional "solana:" prefix + network = strings.TrimPrefix(network, "solana:") + + switch network { + case "mainnet", "devnet", "testnet", "localnet": + return true + default: + return false + } +} + +// ValidateChainConfig ensures the Solana network configuration is valid +func ValidateChainConfig(chainStr string) error { + if chainStr == "" { + return errors.New("siws: chain configuration cannot be empty") + } + + network := strings.TrimSpace(strings.ToLower(chainStr)) + if !IsValidSolanaNetwork(network) { + return fmt.Errorf("invalid Solana network: %s", network) + } + + return nil +} + +type SIWSError struct { + Message string + StatusCode int +} + +func (e *SIWSError) Error() string { + return e.Message +} + +func NewSIWSError(message string, statusCode int) *SIWSError { + return &SIWSError{ + Message: fmt.Sprintf("siws: %s", message), + StatusCode: statusCode, + } +} \ No newline at end of file diff --git a/internal/utilities/solana/message.go b/internal/utilities/solana/message.go new file mode 100644 index 0000000000..faa50450d5 --- /dev/null +++ b/internal/utilities/solana/message.go @@ -0,0 +1,40 @@ +package siws + +import ( + "fmt" + "strings" + "time" +) + +// ConstructMessage creates the textual message to be signed, following +// an ABNF-like structure for "Sign in with Solana." +func ConstructMessage(msg SIWSMessage) string { + var sb strings.Builder + + // 1) Domain request line + sb.WriteString(fmt.Sprintf("%s wants you to sign in with your Solana account:\n", msg.Domain)) + + // 2) Address + sb.WriteString(fmt.Sprintf("%s\n", msg.Address)) + + // 3) Optional statement + if msg.Statement != "" { + sb.WriteString(fmt.Sprintf("\n%s\n", msg.Statement)) + } + + // 4) Additional metadata (URI, Version, Nonce, IssuedAt) + if msg.URI != "" { + sb.WriteString(fmt.Sprintf("URI: %s\n", msg.URI)) + } + if msg.Version != "" { + sb.WriteString(fmt.Sprintf("Version: %s\n", msg.Version)) + } + if msg.Nonce != "" { + sb.WriteString(fmt.Sprintf("Nonce: %s\n", msg.Nonce)) + } + if !msg.IssuedAt.IsZero() { + sb.WriteString(fmt.Sprintf("Issued At: %s\n", msg.IssuedAt.UTC().Format(time.RFC3339))) + } + + return sb.String() +} diff --git a/internal/utilities/solana/parser.go b/internal/utilities/solana/parser.go new file mode 100644 index 0000000000..3290c8d18c --- /dev/null +++ b/internal/utilities/solana/parser.go @@ -0,0 +1,112 @@ +package siws + +import ( + "strings" + "time" + "fmt" +) + +func ParseSIWSMessage(raw string) (*SIWSMessage, error) { + // First split the message into lines + lines := strings.Split(strings.TrimSpace(raw), "\n") + if len(lines) < 2 { + return nil, ErrorMalformedMessage + } + + // Parse first line exactly + header := lines[0] + if !strings.HasSuffix(header, " wants you to sign in with your Solana account:") { + return nil, ErrorMalformedMessage + } + domain := strings.TrimSuffix(header, " wants you to sign in with your Solana account:") + + msg := &SIWSMessage{ + Domain: domain, + Address: strings.TrimSpace(lines[1]), + } + + // Look for statement (double newline section) + inResources := false + for i := 2; i < len(lines); i++ { + line := strings.TrimSpace(lines[i]) + + if inResources { + if strings.HasPrefix(line, "- ") { + resource := strings.TrimSpace(strings.TrimPrefix(line, "- ")) + msg.Resources = append(msg.Resources, resource) + continue + } else { + inResources = false + } + } + + if line == "Resources:" { + inResources = true + continue + } + + if line == "" { + continue + } + + parts := strings.SplitN(line, ": ", 2) + if len(parts) != 2 { + // If we see a line without ": ", it might be a statement + if !strings.Contains(line, ":") { + msg.Statement = line + continue + } + continue + } + + key, value := parts[0], strings.TrimSpace(parts[1]) + switch key { + case "URI": + msg.URI = value + case "Version": + msg.Version = value + case "Chain ID": + msg.ChainID = value + case "Nonce": + msg.Nonce = value + case "Issued At": + ts, err := time.Parse(time.RFC3339, value) + if err != nil { + ts, err = time.Parse(time.RFC3339Nano, value) + if err != nil { + return nil, ErrInvalidIssuedAtFormat + } + } + msg.IssuedAt = ts + case "Expiration Time": + ts, err := time.Parse(time.RFC3339, value) + if err != nil { + ts, err = time.Parse(time.RFC3339Nano, value) + if err != nil { + return nil, ErrInvalidExpirationTimeFormat + } + } + msg.ExpirationTime = ts + case "Not Before": + ts, err := time.Parse(time.RFC3339, value) + if err != nil { + ts, err = time.Parse(time.RFC3339Nano, value) + if err != nil { + return nil, ErrInvalidNotBeforeFormat + } + } + msg.NotBefore = ts + case "Request ID": + msg.RequestID = value + case "Domain": + // Debug prints + fmt.Printf("Header domain: '%s'\n", msg.Domain) + fmt.Printf("Field domain: '%s'\n", value) + if value != msg.Domain { + return nil, ErrMessageDomainMismatch + } + } + } + + return msg, nil +} diff --git a/internal/utilities/solana/types.go b/internal/utilities/solana/types.go new file mode 100644 index 0000000000..e7e4a08608 --- /dev/null +++ b/internal/utilities/solana/types.go @@ -0,0 +1,33 @@ +package siws + +import ( + "time" +) + +// SIWSMessage is the final structured form of a parsed SIWS message. +type SIWSMessage struct { + Domain string // e.g. "example.com" + Address string // base58-encoded Solana public key + Statement string // optional + URI string // optional + Version string // recommended (e.g. "1") + Nonce string // random nonce + IssuedAt time.Time // "Issued At" timestamp + ChainID string // e.g. "solana:mainnet" + NotBefore time.Time // "Not Before" timestamp + RequestID string // optional + // ExpirationTime is optional. If set, it should be checked against the current time. + ExpirationTime time.Time + Resources []string // optional +} + +// SIWSVerificationParams holds parameters needed to verify an SIWS message. +type SIWSVerificationParams struct { + // The domain we expect. Must match message.Domain. + ExpectedDomain string + + // Whether or not to enforce time validity (IssuedAt <= now <= IssuedAt + TimeDuration). + CheckTime bool + TimeDuration time.Duration +} + diff --git a/migrations/20250101111726_add_web3_nonce_table.up.sql b/migrations/20250101111726_add_web3_nonce_table.up.sql new file mode 100644 index 0000000000..ca8452351a --- /dev/null +++ b/migrations/20250101111726_add_web3_nonce_table.up.sql @@ -0,0 +1,29 @@ +-- Add nonces table for Web3 authentication +create table if not exists {{ index .Options "Namespace" }}.nonces ( + id uuid primary key, + nonce text not null, + address text not null, + created_at timestamp with time zone not null default now(), + expires_at timestamp with time zone not null, + used boolean not null default false, + constraint nonces_expiry_check check (expires_at > created_at) + constraint nonces_address_nonce_unique UNIQUE (address, nonce) + +); + +-- Create index for nonce lookup +create index if not exists idx_nonces_nonce on {{ index .Options "Namespace" }}.nonces (nonce); + +-- Create index for cleanup of expired nonces +create index if not exists idx_nonces_expires_at on {{ index .Options "Namespace" }}.nonces (expires_at); + +-- Add comment on table +comment on table {{ index .Options "Namespace" }}.nonces is 'Stores nonces for Web3 authentication'; + +-- Add comments on columns +comment on column {{ index .Options "Namespace" }}.nonces.id is 'Unique identifier for the nonce record'; +comment on column {{ index .Options "Namespace" }}.nonces.nonce is 'The actual nonce value used for authentication'; +comment on column {{ index .Options "Namespace" }}.nonces.address is 'The wallet address that used this nonce (set after use)'; +comment on column {{ index .Options "Namespace" }}.nonces.created_at is 'When this nonce was created'; +comment on column {{ index .Options "Namespace" }}.nonces.expires_at is 'When this nonce expires'; +comment on column {{ index .Options "Namespace" }}.nonces.used is 'Whether this nonce has been used'; \ No newline at end of file