diff --git a/example.env b/example.env index e645c96e9..30646ea4f 100644 --- a/example.env +++ b/example.env @@ -168,6 +168,10 @@ GOTRUE_EXTERNAL_ZOOM_CLIENT_ID="" GOTRUE_EXTERNAL_ZOOM_SECRET="" GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI="http://localhost:9999/callback" +# Web3 Solana config +GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED="true" +GOTRUE_EXTERNAL_WEB3_SOLANA_MAXIMUM_VALIDITY_DURATION="10m" + # Anonymous auth config GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED="false" diff --git a/go.mod b/go.mod index 7c404ba7b..f2ecc55b1 100644 --- a/go.mod +++ b/go.mod @@ -34,7 +34,7 @@ require ( ) require ( - github.com/bits-and-blooms/bitset v1.10.0 // indirect + github.com/bits-and-blooms/bitset v1.13.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect @@ -86,6 +86,7 @@ require ( require ( github.com/bits-and-blooms/bloom/v3 v3.6.0 + github.com/btcsuite/btcutil v1.0.2 github.com/crewjam/saml v0.4.14 github.com/fatih/structs v1.1.0 github.com/fsnotify/fsnotify v1.7.0 @@ -111,10 +112,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 @@ -167,7 +168,7 @@ require ( golang.org/x/time v0.5.0 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 7477786a0..9d75b1423 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= @@ -20,8 +21,9 @@ github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= 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 +31,20 @@ 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/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -49,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= @@ -59,8 +72,9 @@ github.com/didip/tollbooth/v5 v5.1.1/go.mod h1:d9rzwOULswrD3YIrAQmP3bfjxab32Df4I github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= -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= @@ -229,6 +243,7 @@ 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= @@ -237,12 +252,14 @@ github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9q github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +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= @@ -315,11 +332,13 @@ github.com/oapi-codegen/oapi-codegen/v2 v2.4.2-0.20250102212541-8bbe226927c9/go. github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= 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/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= @@ -472,11 +491,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= @@ -621,8 +642,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 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= diff --git a/hack/coverage.sh b/hack/coverage.sh index df9c856ac..1eb284dac 100755 --- a/hack/coverage.sh +++ b/hack/coverage.sh @@ -1,6 +1,6 @@ FAIL=false -for PKG in "crypto" "reloader" +for PKG in "crypto" "reloader" "utilities/siws" do UNCOVERED_FUNCS=$(go tool cover -func=coverage.out | grep "^github.com/supabase/auth/internal/$PKG/" | grep -v '100.0%$') UNCOVERED_FUNCS_COUNT=$(echo "$UNCOVERED_FUNCS" | wc -l) diff --git a/hack/test.env b/hack/test.env index 35e4b61c8..9ed4c3d58 100644 --- a/hack/test.env +++ b/hack/test.env @@ -13,7 +13,7 @@ API_EXTERNAL_URL="http://localhost:9999" GOTRUE_LOG_SQL=none GOTRUE_LOG_LEVEL=warn GOTRUE_SITE_URL=https://example.netlify.com -GOTRUE_URI_ALLOW_LIST="http://localhost:3000" +GOTRUE_URI_ALLOW_LIST="http://localhost:3000,https://supabase.com/" GOTRUE_OPERATOR_TOKEN=foobar GOTRUE_EXTERNAL_APPLE_ENABLED=true GOTRUE_EXTERNAL_APPLE_CLIENT_ID=testclientid @@ -105,6 +105,7 @@ 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_SOLANA_ENABLED="true" 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 709a9943e..d852b5c65 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -138,6 +138,7 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne }) r.Route("/", func(r *router) { + r.Use(api.isValidExternalHost) r.Get("/settings", api.Settings) diff --git a/internal/api/apierrors/errorcode.go b/internal/api/apierrors/errorcode.go index e2acb1a2e..2a151924b 100644 --- a/internal/api/apierrors/errorcode.go +++ b/internal/api/apierrors/errorcode.go @@ -92,4 +92,6 @@ const ( ErrorCodeInvalidCredentials ErrorCode = "invalid_credentials" ErrorCodeEmailAddressNotAuthorized ErrorCode = "email_address_not_authorized" ErrorCodeEmailAddressInvalid ErrorCode = "email_address_invalid" + ErrorCodeWeb3ProviderDisabled ErrorCode = "web3_provider_disabled" + ErrorCodeWeb3UnsupportedChain ErrorCode = "web3_unsupported_chain" ) diff --git a/internal/api/external.go b/internal/api/external.go index 46873f6cb..f6dc2384d 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -360,7 +360,10 @@ func (a *API) createAccountFromExternalIdentity(tx *storage.Connection, r *http. return nil, forbiddenError(apierrors.ErrorCodeUserBanned, "User is banned") } - if !user.IsConfirmed() { + // TODO(hf): Expand this boolean with all providers that may not have emails (like X/Twitter, Discord). + hasEmails := providerType != "web3" // intentionally not using len(userData.Emails) != 0 for better backward compatibility control + + if hasEmails && !user.IsConfirmed() { // The user may have other unconfirmed email + password // combination, phone or oauth identities. These identities // need to be removed when a new oauth identity is being added @@ -379,6 +382,10 @@ func (a *API) createAccountFromExternalIdentity(tx *storage.Connection, r *http. return nil, internalServerError("Error updating user").WithInternalError(terr) } } else { + // Some providers, like web3 don't have email data. + // Treat these as if a confirmation email has been + // sent, although the user will be created without an + // email address. emailConfirmationSent := false if decision.CandidateEmail.Email != "" { if terr = a.sendConfirmation(r, tx, user, models.ImplicitFlow); terr != nil { diff --git a/internal/api/helpers.go b/internal/api/helpers.go index 768061838..965e51f81 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -11,6 +11,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" ) @@ -75,6 +76,7 @@ type RequestParams interface { SignupParams | SingleSignOnParams | SmsParams | + Web3GrantParams | UserUpdateParams | VerifyFactorParams | VerifyParams | @@ -82,6 +84,7 @@ type RequestParams interface { adminUserDeleteParams | security.GotrueRequest | ChallengeFactorParams | + struct { Email string `json:"email"` Phone string `json:"phone"` diff --git a/internal/api/token.go b/internal/api/token.go index 90d00c7d7..b67601213 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -2,13 +2,12 @@ package api import ( "context" + "fmt" "net/http" "net/url" "strconv" "time" - "fmt" - "github.com/gofrs/uuid" "github.com/golang-jwt/jwt/v5" "github.com/xeipuuv/gojsonschema" @@ -80,6 +79,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) @@ -89,6 +89,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(apierrors.ErrorCodeInvalidCredentials, "unsupported_grant_type") } diff --git a/internal/api/web3.go b/internal/api/web3.go new file mode 100644 index 000000000..6dc2c7c81 --- /dev/null +++ b/internal/api/web3.go @@ -0,0 +1,170 @@ +package api + +import ( + "context" + "encoding/base64" + "net/http" + "strings" + + "github.com/supabase/auth/internal/api/apierrors" + "github.com/supabase/auth/internal/api/provider" + "github.com/supabase/auth/internal/models" + "github.com/supabase/auth/internal/storage" + "github.com/supabase/auth/internal/utilities" + "github.com/supabase/auth/internal/utilities/siws" +) + +type Web3GrantParams struct { + Message string `json:"message,omitempty"` + Signature string `json:"signature,omitempty"` + Chain string `json:"chain,omitempty"` +} + +func (a *API) Web3Grant(ctx context.Context, w http.ResponseWriter, r *http.Request) error { + config := a.config + + if !config.External.Web3Solana.Enabled { + return unprocessableEntityError(apierrors.ErrorCodeWeb3ProviderDisabled, "Web3 provider is disabled") + } + + params := &Web3GrantParams{} + if err := retrieveRequestParams(r, params); err != nil { + return err + } + + if params.Chain != "solana" { + return badRequestError(apierrors.ErrorCodeWeb3UnsupportedChain, "Unsupported chain") + } + + return a.web3GrantSolana(ctx, w, r, params) +} + +func (a *API) web3GrantSolana(ctx context.Context, w http.ResponseWriter, r *http.Request, params *Web3GrantParams) error { + config := a.config + db := a.db.WithContext(ctx) + + if len(params.Message) < 64 { + return badRequestError(apierrors.ErrorCodeValidationFailed, "message is too short") + } else if len(params.Message) > 20*1024 { + return badRequestError(apierrors.ErrorCodeValidationFailed, "message must not exceed 20KB") + } + + if len(params.Signature) != 86 && len(params.Signature) != 88 { + return badRequestError(apierrors.ErrorCodeValidationFailed, "signature must be 64 bytes encoded as base64 with or without padding") + } + + base64URLSignature := strings.ReplaceAll(strings.ReplaceAll(strings.TrimRight(params.Signature, "="), "+", "-"), "/", "_") + signatureBytes, err := base64.RawURLEncoding.DecodeString(base64URLSignature) + if err != nil { + return badRequestError(apierrors.ErrorCodeValidationFailed, "signature does not contain valid base64 characters") + } + + parsedMessage, err := siws.ParseMessage(params.Message) + if err != nil { + return badRequestError(apierrors.ErrorCodeValidationFailed, err.Error()) + } + + if !parsedMessage.VerifySignature(signatureBytes) { + return oauthError("invalid_grant", "Signature does not match address in message") + } + + if parsedMessage.URI.Scheme != "https" { + if parsedMessage.URI.Scheme == "http" && parsedMessage.URI.Hostname() != "localhost" { + return oauthError("invalid_grant", "Signed Solana message is using URI which uses HTTP and hostname is not localhost, only HTTPS is allowed") + } else { + return oauthError("invalid_grant", "Signed Solana message is using URI which does not use HTTPS") + } + } + + if !utilities.IsRedirectURLValid(config, parsedMessage.URI.String()) { + return oauthError("invalid_grant", "Signed Solana message is using URI which is not allowed on this server, message was signed for another app") + } + + if parsedMessage.URI.Host != parsedMessage.Domain || !utilities.IsRedirectURLValid(config, "https://"+parsedMessage.Domain+"/") { + return oauthError("invalid_grant", "Signed Solana message is using a Domain that does not match the one in URI which is not allowed on this server") + } + + now := a.Now() + + if !parsedMessage.NotBefore.IsZero() && now.Before(parsedMessage.NotBefore) { + return oauthError("invalid_grant", "Signed Solana message becomes valid in the future") + } + + if !parsedMessage.ExpirationTime.IsZero() && now.After(parsedMessage.ExpirationTime) { + return oauthError("invalid_grant", "Signed Solana message is expired") + } + + latestExpiryAt := parsedMessage.IssuedAt.Add(config.External.Web3Solana.MaximumValidityDuration) + + if now.After(latestExpiryAt) { + return oauthError("invalid_grant", "Solana message was issued too long ago") + } + + earliestIssuedAt := parsedMessage.IssuedAt.Add(-config.External.Web3Solana.MaximumValidityDuration) + + if now.Before(earliestIssuedAt) { + return oauthError("invalid_grant", "Solana message was issued too far in the future") + } + + providerId := strings.Join([]string{ + "web3", + params.Chain, + parsedMessage.Address, + }, ":") + + userData := provider.UserProvidedData{ + Metadata: &provider.Claims{ + CustomClaims: map[string]interface{}{ + "address": parsedMessage.Address, + "chain": params.Chain, + "network": parsedMessage.ChainID, + "domain": parsedMessage.Domain, + "statement": parsedMessage.Statement, + }, + Subject: providerId, + }, + Emails: []provider.Email{}, + } + + 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": params.Chain, + "network": parsedMessage.ChainID, + "address": parsedMessage.Address, + "domain": parsedMessage.Domain, + "uri": parsedMessage.URI, + }); 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) +} diff --git a/internal/api/web3_test.go b/internal/api/web3_test.go new file mode 100644 index 000000000..42d3a00b7 --- /dev/null +++ b/internal/api/web3_test.go @@ -0,0 +1,607 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "github.com/supabase/auth/internal/api/apierrors" + "github.com/supabase/auth/internal/conf" +) + +type Web3TestSuite struct { + suite.Suite + API *API + Config *conf.GlobalConfiguration +} + +func TestWeb3(t *testing.T) { + api, config, err := setupAPIForTest() + require.NoError(t, err) + + ts := &Web3TestSuite{ + API: api, + Config: config, + } + defer api.db.Close() + + suite.Run(t, ts) +} + +func (ts *Web3TestSuite) TestNonSolana() { + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "chain": "blockchain", + })) + + req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=web3", &buffer) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + var firstResult struct { + ErrorCode string `json:"error_code"` + Message string `json:"msg"` + } + + assert.NoError(ts.T(), json.NewDecoder(w.Result().Body).Decode(&firstResult)) + assert.Equal(ts.T(), apierrors.ErrorCodeWeb3UnsupportedChain, firstResult.ErrorCode) + assert.Equal(ts.T(), "Unsupported chain", firstResult.Message) +} + +func (ts *Web3TestSuite) TestDisabled() { + defer func() { + ts.Config.External.Web3Solana.Enabled = true + }() + + ts.Config.External.Web3Solana.Enabled = false + + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "chain": "solana", + })) + + req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=web3", &buffer) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + var firstResult struct { + ErrorCode string `json:"error_code"` + Message string `json:"msg"` + } + + assert.NoError(ts.T(), json.NewDecoder(w.Result().Body).Decode(&firstResult)) + assert.Equal(ts.T(), apierrors.ErrorCodeWeb3ProviderDisabled, firstResult.ErrorCode) + assert.Equal(ts.T(), "Web3 provider is disabled", firstResult.Message) +} + +func (ts *Web3TestSuite) TestHappyPath_FullMessage() { + defer func() { + ts.API.overrideTime = nil + }() + + ts.API.overrideTime = func() time.Time { + t, _ := time.Parse(time.RFC3339, "2025-03-29T00:09:59Z") + return t + } + + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "chain": "solana", + "message": "supabase.com wants you to sign in with your Solana account:\n2EZEiBdw47VHT6SpZSW9VnuSvBe7DxuYHBTxj19gxvv8\n\nStatement\n\nURI: https://supabase.com/\nVersion: 1\nIssued At: 2025-03-29T00:00:00Z\nExpiration Time: 2025-03-29T00:10:00Z\nNot Before: 2025-03-29T00:00:00Z", + "signature": "aiKn+PAoB1OoXxS8H34HrB456YD4sKAVjeTjsxgkaQy3bkdV51WBTmUUE9lBU9kuXr0hTLI+1aTn5TFRbIF8CA==", + })) + + req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=web3", &buffer) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + assert.Equal(ts.T(), http.StatusOK, w.Code) + + var firstResult struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + } + + assert.NoError(ts.T(), json.NewDecoder(w.Result().Body).Decode(&firstResult)) + + assert.NotEmpty(ts.T(), firstResult.AccessToken) + assert.NotEmpty(ts.T(), firstResult.RefreshToken) +} + +func (ts *Web3TestSuite) TestHappyPath_MinimalMessage() { + defer func() { + ts.API.overrideTime = nil + }() + + ts.API.overrideTime = func() time.Time { + t, _ := time.Parse(time.RFC3339, "2025-03-29T00:09:59Z") + return t + } + + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "chain": "solana", + "message": "supabase.com wants you to sign in with your Solana account:\n2EZEiBdw47VHT6SpZSW9VnuSvBe7DxuYHBTxj19gxvv8\n\nStatement\n\nURI: https://supabase.com/\nVersion: 1\nIssued At: 2025-03-29T00:00:00Z", + "signature": "BQxBJ+g2xbMh0LqwYR4ULJ4l7jXFmz33urmp534MS0x7nrGRe2xYdFq41FiGrySX6RipzGqX4kS2vkQmi/+JCg==", + })) + + req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=web3", &buffer) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + assert.Equal(ts.T(), http.StatusOK, w.Code) + + var firstResult struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + } + + assert.NoError(ts.T(), json.NewDecoder(w.Result().Body).Decode(&firstResult)) + + assert.NotEmpty(ts.T(), firstResult.AccessToken) + assert.NotEmpty(ts.T(), firstResult.RefreshToken) +} + +func (ts *Web3TestSuite) TestValidationRules_URINotHTTPSButIsHTTP() { + defer func() { + ts.API.overrideTime = nil + }() + + ts.API.overrideTime = func() time.Time { + t, _ := time.Parse(time.RFC3339, "2025-03-29T00:00:00Z") + return t + } + + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "chain": "solana", + "message": "supabase.com wants you to sign in with your Solana account:\n2EZEiBdw47VHT6SpZSW9VnuSvBe7DxuYHBTxj19gxvv8\n\nStatement\n\nURI: http://supaabse.com\nVersion: 1\nIssued At: 2025-03-29T00:00:00Z", + "signature": "zkCDPRAgy3N6KaYJrFgoTGuR+DDn1T6WiC70/m4GSIKMN3rIIDRUHjX/+bDCRyPTq/nC8N9HkMUvoD86gpVKCw==", + })) + + req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=web3", &buffer) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + assert.Equal(ts.T(), http.StatusBadRequest, w.Code) + + var firstResult struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + + assert.NoError(ts.T(), json.NewDecoder(w.Result().Body).Decode(&firstResult)) + + assert.Equal(ts.T(), firstResult.Error, "invalid_grant") + assert.Equal(ts.T(), firstResult.ErrorDescription, "Signed Solana message is using URI which uses HTTP and hostname is not localhost, only HTTPS is allowed") +} + +func (ts *Web3TestSuite) TestValidationRules_URINotAllowed() { + defer func() { + ts.API.overrideTime = nil + }() + + ts.API.overrideTime = func() time.Time { + t, _ := time.Parse(time.RFC3339, "2025-03-29T00:00:00Z") + return t + } + + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "chain": "solana", + "message": "supabase.green wants you to sign in with your Solana account:\n2EZEiBdw47VHT6SpZSW9VnuSvBe7DxuYHBTxj19gxvv8\n\nStatement\n\nURI: https://supabase.green/\nVersion: 1\nIssued At: 2025-03-29T00:00:00Z\nExpiration Time: 2025-03-29T00:10:00Z", + "signature": "HlwIlZNfJO2yVqnJfeTz1sEHEbU0pag5yyfWVjmoL6wAXNshOlmQCgbzM8AvdF3/JpeWru2FUsC9cKHchHStDw==", + })) + + req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=web3", &buffer) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + assert.Equal(ts.T(), http.StatusBadRequest, w.Code) + + var firstResult struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + + assert.NoError(ts.T(), json.NewDecoder(w.Result().Body).Decode(&firstResult)) + + assert.Equal(ts.T(), "invalid_grant", firstResult.Error) + assert.Equal(ts.T(), "Signed Solana message is using URI which is not allowed on this server, message was signed for another app", firstResult.ErrorDescription) +} + +func (ts *Web3TestSuite) TestValidationRules_URINotHTTPS() { + defer func() { + ts.API.overrideTime = nil + }() + + ts.API.overrideTime = func() time.Time { + t, _ := time.Parse(time.RFC3339, "2025-03-29T00:00:00Z") + return t + } + + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "chain": "solana", + "message": "supabase.com wants you to sign in with your Solana account:\n2EZEiBdw47VHT6SpZSW9VnuSvBe7DxuYHBTxj19gxvv8\n\nStatement\n\nURI: ftp://supaabse.com\nVersion: 1\nIssued At: 2025-03-29T00:00:00Z", + "signature": "jalHCMtaGNUy5q7BIZRXjdtMJDVDk+ABj/bsIISdbzxc4bjt643llZfjQ3qJJmV1CsnNRgoIyVt8HmGHkIu9CA==", + })) + + req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=web3", &buffer) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + assert.Equal(ts.T(), http.StatusBadRequest, w.Code) + + var firstResult struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + + assert.NoError(ts.T(), json.NewDecoder(w.Result().Body).Decode(&firstResult)) + + assert.Equal(ts.T(), "invalid_grant", firstResult.Error) + assert.Equal(ts.T(), "Signed Solana message is using URI which does not use HTTPS", firstResult.ErrorDescription) +} + +func (ts *Web3TestSuite) TestValidationRules_InvalidDomain() { + defer func() { + ts.API.overrideTime = nil + }() + + ts.API.overrideTime = func() time.Time { + t, _ := time.Parse(time.RFC3339, "2025-03-29T00:00:00Z") + return t + } + + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "chain": "solana", + "message": "supabase.green wants you to sign in with your Solana account:\n2EZEiBdw47VHT6SpZSW9VnuSvBe7DxuYHBTxj19gxvv8\n\nStatement\n\nURI: https://supabase.com/\nVersion: 1\nIssued At: 2025-03-29T00:00:00Z", + "signature": "gB9SNz/fxpWir6ZV/oI3pJIYEce5FjSMkbHzDxMH7k6as2jYBVutMU50/UTH59jx3ULZeW3Xt7pDH+9qJCDjAQ==", + })) + + req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=web3", &buffer) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + assert.Equal(ts.T(), http.StatusBadRequest, w.Code) + + var firstResult struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + + assert.NoError(ts.T(), json.NewDecoder(w.Result().Body).Decode(&firstResult)) + + assert.Equal(ts.T(), "invalid_grant", firstResult.Error) + assert.Equal(ts.T(), "Signed Solana message is using a Domain that does not match the one in URI which is not allowed on this server", firstResult.ErrorDescription) +} + +func (ts *Web3TestSuite) TestValidationRules_MismatchedDomainAndURIHostname() { + defer func() { + ts.API.overrideTime = nil + }() + + ts.API.overrideTime = func() time.Time { + t, _ := time.Parse(time.RFC3339, "2025-03-29T00:00:00Z") + return t + } + + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "chain": "solana", + "message": "supabase.green wants you to sign in with your Solana account:\n2EZEiBdw47VHT6SpZSW9VnuSvBe7DxuYHBTxj19gxvv8\n\nStatement\n\nURI: https://supabase.com/\nVersion: 1\nIssued At: 2025-03-29T00:00:00Z\nExpiration Time: 2025-03-29T00:10:00Z", + "signature": "KmRa5LqZnwLE5c+PX45QBhuIY2AXWtD8zi3O5lROKJYho8iIt8vZaVo/2utQ5C77LWNL3nI42q/cC8N80hYKAw==", + })) + + req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=web3", &buffer) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + assert.Equal(ts.T(), http.StatusBadRequest, w.Code) + + var firstResult struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + + assert.NoError(ts.T(), json.NewDecoder(w.Result().Body).Decode(&firstResult)) + + assert.Equal(ts.T(), "invalid_grant", firstResult.Error) + assert.Equal(ts.T(), "Signed Solana message is using a Domain that does not match the one in URI which is not allowed on this server", firstResult.ErrorDescription) +} + +func (ts *Web3TestSuite) TestValidationRules_ValidatedBeforeNotBefore() { + defer func() { + ts.API.overrideTime = nil + }() + + ts.API.overrideTime = func() time.Time { + t, _ := time.Parse(time.RFC3339, "2025-03-29T00:00:59Z") + return t + } + + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "chain": "solana", + "message": "supabase.com wants you to sign in with your Solana account:\n2EZEiBdw47VHT6SpZSW9VnuSvBe7DxuYHBTxj19gxvv8\n\nStatement\n\nURI: https://supabase.com/\nVersion: 1\nIssued At: 2025-03-29T00:00:00Z\nNot Before: 2025-03-29T00:01:00Z", + "signature": "Pe2PpPEK+SIsO3i26SsWNHeFyLKNdcms4Gf7jy8GGR6EvPlWfKNwAtRGMnQa9MvQHgY7QmVOUDSKmYQlvU2sAA==", + })) + + req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=web3", &buffer) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + assert.Equal(ts.T(), http.StatusBadRequest, w.Code) + + var firstResult struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + + assert.NoError(ts.T(), json.NewDecoder(w.Result().Body).Decode(&firstResult)) + + assert.Equal(ts.T(), "invalid_grant", firstResult.Error) + assert.Equal(ts.T(), "Signed Solana message becomes valid in the future", firstResult.ErrorDescription) +} + +func (ts *Web3TestSuite) TestValidationRules_Expired() { + defer func() { + ts.API.overrideTime = nil + }() + + ts.API.overrideTime = func() time.Time { + t, _ := time.Parse(time.RFC3339, "2025-03-29T00:10:01Z") + return t + } + + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "chain": "solana", + "message": "supabase.com wants you to sign in with your Solana account:\n2EZEiBdw47VHT6SpZSW9VnuSvBe7DxuYHBTxj19gxvv8\n\nStatement\n\nURI: https://supabase.com/\nVersion: 1\nIssued At: 2025-03-29T00:00:00Z\nExpiration Time: 2025-03-29T00:10:00Z\nNot Before: 2025-03-29T00:00:00Z", + "signature": "aiKn+PAoB1OoXxS8H34HrB456YD4sKAVjeTjsxgkaQy3bkdV51WBTmUUE9lBU9kuXr0hTLI+1aTn5TFRbIF8CA==", + })) + + req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=web3", &buffer) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + assert.Equal(ts.T(), http.StatusBadRequest, w.Code) + + var firstResult struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + + assert.NoError(ts.T(), json.NewDecoder(w.Result().Body).Decode(&firstResult)) + + assert.Equal(ts.T(), "invalid_grant", firstResult.Error) + assert.Equal(ts.T(), "Signed Solana message is expired", firstResult.ErrorDescription) +} + +func (ts *Web3TestSuite) TestValidationRules_Future() { + defer func() { + ts.API.overrideTime = nil + }() + + ts.API.overrideTime = func() time.Time { + t, _ := time.Parse(time.RFC3339, "2025-03-28T23:49:59Z") + return t + } + + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "chain": "solana", + "message": "supabase.com wants you to sign in with your Solana account:\n2EZEiBdw47VHT6SpZSW9VnuSvBe7DxuYHBTxj19gxvv8\n\nStatement\n\nURI: https://supabase.com/\nVersion: 1\nIssued At: 2025-03-29T00:00:00Z", + "signature": "BQxBJ+g2xbMh0LqwYR4ULJ4l7jXFmz33urmp534MS0x7nrGRe2xYdFq41FiGrySX6RipzGqX4kS2vkQmi/+JCg==", + })) + + req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=web3", &buffer) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + assert.Equal(ts.T(), http.StatusBadRequest, w.Code) + + var firstResult struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + + assert.NoError(ts.T(), json.NewDecoder(w.Result().Body).Decode(&firstResult)) + + assert.Equal(ts.T(), "invalid_grant", firstResult.Error) + assert.Equal(ts.T(), "Solana message was issued too far in the future", firstResult.ErrorDescription) +} + +func (ts *Web3TestSuite) TestValidationRules_IssedTooLongAgo() { + defer func() { + ts.API.overrideTime = nil + }() + + ts.API.overrideTime = func() time.Time { + t, _ := time.Parse(time.RFC3339, "2025-03-29T00:00:00Z") + d, _ := time.ParseDuration("10m1s") + + return t.Add(d) + } + + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "chain": "solana", + "message": "supabase.com wants you to sign in with your Solana account:\n2EZEiBdw47VHT6SpZSW9VnuSvBe7DxuYHBTxj19gxvv8\n\nStatement\n\nURI: https://supabase.com/\nVersion: 1\nIssued At: 2025-03-29T00:00:00Z\nNot Before: 2025-03-29T00:00:00Z", + "signature": "ds3yyRoevZ0CuyUFOfuAJV/QAA+m302JJjnkOQO3ou5AHPQBNdbwYDj2JzF/5Ox6qyAqN/phU8NnmK8eUtzMDw==", + })) + + req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=web3", &buffer) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + assert.Equal(ts.T(), http.StatusBadRequest, w.Code) + + var firstResult struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + + assert.NoError(ts.T(), json.NewDecoder(w.Result().Body).Decode(&firstResult)) + + assert.Equal(ts.T(), firstResult.Error, "invalid_grant") + assert.Equal(ts.T(), firstResult.ErrorDescription, "Solana message was issued too long ago") +} + +func (ts *Web3TestSuite) TestValidationRules_InvalidSignature() { + defer func() { + ts.API.overrideTime = nil + }() + + ts.API.overrideTime = func() time.Time { + t, _ := time.Parse(time.RFC3339, "2025-03-29T00:00:00Z") + return t + } + + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "chain": "solana", + "message": "supabase.com wants you to sign in with your Solana account:\n2EZEiBdw47VHT6SpZSW9VnuSvBe7DxuYHBTxj19gxvv8\n\nStatement\n\nURI: https://supabase.com/\nVersion: 1\nIssued At: 2025-03-29T00:00:00Z\nExpiration Time: 2025-03-29T00:10:00Z\nNot Before: 2025-03-29T00:00:00Z", + "signature": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx==", + })) + + req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=web3", &buffer) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + assert.Equal(ts.T(), http.StatusBadRequest, w.Code) + + var firstResult struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + + assert.NoError(ts.T(), json.NewDecoder(w.Result().Body).Decode(&firstResult)) + + assert.Equal(ts.T(), firstResult.Error, "invalid_grant") + assert.Equal(ts.T(), firstResult.ErrorDescription, "Signature does not match address in message") +} + +func (ts *Web3TestSuite) TestValidationRules_BasicValidation() { + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "chain": "solana", + "message": strings.Repeat(" ", 63), + "signature": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx==", + })) + + req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=web3", &buffer) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + assert.Equal(ts.T(), http.StatusBadRequest, w.Code) + + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "chain": "solana", + "message": strings.Repeat(" ", 64), + "signature": strings.Repeat("x", 85), + })) + + req = httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=web3", &buffer) + req.Header.Set("Content-Type", "application/json") + + w = httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + assert.Equal(ts.T(), http.StatusBadRequest, w.Code) + + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "chain": "solana", + "message": strings.Repeat(" ", 64), + "signature": strings.Repeat("x", 89), + })) + + req = httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=web3", &buffer) + req.Header.Set("Content-Type", "application/json") + + w = httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + assert.Equal(ts.T(), http.StatusBadRequest, w.Code) + + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "chain": "solana", + "message": strings.Repeat(" ", 20*1024+1), + "signature": strings.Repeat("x", 86), + })) + + req = httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=web3", &buffer) + req.Header.Set("Content-Type", "application/json") + + w = httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + assert.Equal(ts.T(), http.StatusBadRequest, w.Code) + + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "chain": "solana", + "message": strings.Repeat(" ", 64), + "signature": strings.Repeat("\x00", 86), + })) + + req = httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=web3", &buffer) + req.Header.Set("Content-Type", "application/json") + + w = httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + assert.Equal(ts.T(), http.StatusBadRequest, w.Code) + + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "chain": "solana", + "message": strings.Repeat(" ", 64), + "signature": strings.Repeat("x", 86), + })) + + req = httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=web3", &buffer) + req.Header.Set("Content-Type", "application/json") + + w = httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + assert.Equal(ts.T(), http.StatusBadRequest, w.Code) +} diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 5ac871c9e..6117a3643 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -339,6 +339,13 @@ 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"` + + Web3Solana SolanaConfiguration `json:"web3_solana" split_words:"true"` +} + +type SolanaConfiguration struct { + Enabled bool `json:"enabled,omitempty" split_words:"true"` + MaximumValidityDuration time.Duration `json:"maximum_validity_duration,omitempty" default:"10m" split_words:"true"` } type SMTPConfiguration struct { diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index 6fc2b71ac..bb8688bb6 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -5,6 +5,7 @@ import ( "crypto/cipher" "crypto/rand" "crypto/sha256" + "encoding/base32" "encoding/base64" "encoding/json" "fmt" @@ -17,14 +18,6 @@ import ( "golang.org/x/crypto/hkdf" ) -// SecureToken creates a new random token -func SecureToken() string { - b := make([]byte, 16) - must(io.ReadFull(rand.Reader, b)) - - return base64.RawURLEncoding.EncodeToString(b) -} - // GenerateOtp generates a random n digit otp func GenerateOtp(digits int) string { upper := math.Pow10(digits) @@ -157,3 +150,20 @@ func NewEncryptedString(id string, data []byte, keyID string, keyBase64URL strin return &es, nil } + +// 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] +} diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go index f1c8e6751..4541c91fc 100644 --- a/internal/crypto/crypto_test.go +++ b/internal/crypto/crypto_test.go @@ -104,5 +104,5 @@ func TestEncryptedStringDecryptNegative(t *testing.T) { } func TestSecureToken(t *testing.T) { - assert.Equal(t, len(SecureToken()), 22) + assert.Equal(t, len(SecureAlphanumeric(22)), 22) } diff --git a/internal/models/factor.go b/internal/models/factor.go index a88874d73..72309a855 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 c5fea83dd..b683a8c77 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 000000000..6fc274e16 --- /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 1002d8be1..8ec6039f6 100644 --- a/internal/reloader/testdata/50_example.env +++ b/internal/reloader/testdata/50_example.env @@ -168,6 +168,10 @@ GOTRUE_EXTERNAL_ZOOM_CLIENT_ID="" GOTRUE_EXTERNAL_ZOOM_SECRET="" GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI="http://localhost:9999/callback" +# Sign in with Solana +GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED="true" +GOTRUE_EXTERNAL_WEB3_SOLANA_MAXIMUM_VALIDITY_DURATION="10m" + # Anonymous auth config GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED="false" diff --git a/internal/utilities/siws/helpers.go b/internal/utilities/siws/helpers.go new file mode 100644 index 000000000..65bcca0df --- /dev/null +++ b/internal/utilities/siws/helpers.go @@ -0,0 +1,17 @@ +package siws + +import ( + "regexp" +) + +var domainPattern = regexp.MustCompile(`^(localhost|(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,})(?::\d{1,5})?$`) + +func IsValidDomain(domain string) bool { + return domainPattern.MatchString(domain) +} + +var validSolanaNetworksPattern = regexp.MustCompile("^solana:(main|dev|test|local)net$") + +func IsValidSolanaNetwork(network string) bool { + return validSolanaNetworksPattern.MatchString(network) +} diff --git a/internal/utilities/siws/helpers_test.go b/internal/utilities/siws/helpers_test.go new file mode 100644 index 000000000..57bb5fdc7 --- /dev/null +++ b/internal/utilities/siws/helpers_test.go @@ -0,0 +1 @@ +package siws diff --git a/internal/utilities/siws/parser.go b/internal/utilities/siws/parser.go new file mode 100644 index 000000000..dcb3fb36e --- /dev/null +++ b/internal/utilities/siws/parser.go @@ -0,0 +1,199 @@ +package siws + +import ( + "crypto/ed25519" + "errors" + "fmt" + "net/url" + "regexp" + "strings" + "time" + + "github.com/btcsuite/btcutil/base58" +) + +// SIWSMessage is the final structured form of a parsed SIWS message. +type SIWSMessage struct { + Raw string + + Domain string + Address string + Statement string + URI *url.URL + Version string + Nonce string + IssuedAt time.Time + ChainID string + NotBefore time.Time + RequestID string + ExpirationTime time.Time + Resources []*url.URL +} + +const headerSuffix = " wants you to sign in with your Solana account:" + +var addressPattern = regexp.MustCompile("^[a-zA-Z0-9]{32,44}$") + +func ParseMessage(raw string) (*SIWSMessage, error) { + lines := strings.Split(raw, "\n") + if len(lines) < 6 { + return nil, errors.New("siws: message needs at least 6 lines") + } + + // Parse first line exactly + header := lines[0] + if !strings.HasSuffix(header, headerSuffix) { + return nil, fmt.Errorf("siws: message first line does not end in %q", headerSuffix) + } + + domain := strings.TrimSpace(strings.TrimSuffix(header, headerSuffix)) + if !IsValidDomain(domain) { + return nil, errors.New("siws: domain in first line of message is not valid") + } + + address := strings.TrimSpace(lines[1]) + if !addressPattern.MatchString(address) { + return nil, errors.New("siws: wallet address is not in base58 format") + } + + msg := &SIWSMessage{ + Raw: raw, + Domain: domain, + Address: address, + } + + if lines[2] != "" { + return nil, errors.New("siws: third line must be empty") + } + + startIndex := 3 + if lines[3] != "" && lines[4] == "" { + msg.Statement = lines[3] + startIndex = 5 + } + + inResources := false + for i := startIndex; i < len(lines); i += 1 { + line := strings.TrimSpace(lines[i]) + + if inResources { + if strings.HasPrefix(line, "- ") { + resource := strings.TrimSpace(strings.TrimPrefix(line, "- ")) + + resourceURL, err := url.ParseRequestURI(resource) + if err != nil { + return nil, fmt.Errorf("siws: Resource at position %d has invalid URI", len(msg.Resources)) + } + + msg.Resources = append(msg.Resources, resourceURL) + continue + } else { + inResources = false + } + } + + if line == "Resources:" { + inResources = true + continue + } + + if line == "" { + continue + } + + key, value, found := strings.Cut(line, ":") + if !found { + return nil, fmt.Errorf("siws: encountered unparsable line at index %d", i) + } + + value = strings.TrimSpace(value) + + switch key { + case "URI": + uri, err := url.ParseRequestURI(value) + if err != nil { + return nil, errors.New("siws: URI is not valid") + } + + msg.URI = uri + + 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, errors.New("siws: Issued At is not a valid ISO8601 timestamp") + } + } + 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, errors.New("siws: Expiration Time is not a valid ISO8601 timestamp") + } + } + 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, errors.New("siws: Not Before is not a valid ISO8601 timestamp") + } + } + msg.NotBefore = ts + + case "Request ID": + msg.RequestID = value + } + } + + if msg.Version != "1" { + return nil, fmt.Errorf("siws: Version value is not supported, expected 1 got %q", msg.Version) + } + + if msg.IssuedAt.IsZero() { + return nil, errors.New("siws: Issued At is not specified") + } + + if msg.URI == nil { + return nil, errors.New("siws: URI is not specified") + } + + if msg.ChainID != "" && !IsValidSolanaNetwork(msg.ChainID) { + return nil, errors.New("siws: Chain ID is not valid") + } + + if !msg.IssuedAt.IsZero() && !msg.ExpirationTime.IsZero() { + if msg.IssuedAt.After(msg.ExpirationTime) { + return nil, errors.New("siws: Issued At is after Expiration Time") + } + } + + if !msg.NotBefore.IsZero() && !msg.ExpirationTime.IsZero() { + if msg.NotBefore.After(msg.ExpirationTime) { + return nil, errors.New("siws: Not Before is after Expiration Time") + } + } + + return msg, nil +} + +func (m *SIWSMessage) VerifySignature(signature []byte) bool { + pubKey := base58.Decode(m.Address) + + return ed25519.Verify(pubKey, []byte(m.Raw), signature) +} diff --git a/internal/utilities/siws/parser_test.go b/internal/utilities/siws/parser_test.go new file mode 100644 index 000000000..f979fb426 --- /dev/null +++ b/internal/utilities/siws/parser_test.go @@ -0,0 +1,126 @@ +package siws + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseMessage(t *testing.T) { + negativeExamples := []struct { + example string + error string + }{ + { + example: "", + error: "message needs at least 6 lines", + }, + { + example: "\n\n\n\n", + error: "message needs at least 6 lines", + }, + { + example: "domain.com whatever\n\n\n\n\n\n", + error: "message first line does not end in \" wants you to sign in with your Solana account:\"", + }, + { + example: "******* wants you to sign in with your Solana account:\n\n\n\n\n\n", + error: "domain in first line of message is not valid", + }, + { + example: "domain.com wants you to sign in with your Solana account:\n***************************************\n\n\n\n\n", + error: "wallet address is not in base58 format", + }, + { + example: "domain.com wants you to sign in with your Solana account:\n4Cw1koUQtqybLFem7uqhzMBznMPGARbFS4cjaYbM9RnR\nURI: https://google.com\n\n\n", + error: "third line must be empty", + }, + { + example: "domain.com wants you to sign in with your Solana account:\n4Cw1koUQtqybLFem7uqhzMBznMPGARbFS4cjaYbM9RnR\n\nStatement\n\nNot Parsable\n", + error: "encountered unparsable line at index 5", + }, + { + example: "domain.com wants you to sign in with your Solana account:\n4Cw1koUQtqybLFem7uqhzMBznMPGARbFS4cjaYbM9RnR\n\nStatement\n\nVersion: 1\nURI: ***\nIssued At: 2025-01-01T00:00:00Z", + error: "URI is not valid", + }, + { + example: "domain.com wants you to sign in with your Solana account:\n4Cw1koUQtqybLFem7uqhzMBznMPGARbFS4cjaYbM9RnR\n\nStatement\n\nVersion: 1\nURI: https://google.com\nIssued At: not-a-timestamp", + error: "Issued At is not a valid ISO8601 timestamp", + }, + { + example: "domain.com wants you to sign in with your Solana account:\n4Cw1koUQtqybLFem7uqhzMBznMPGARbFS4cjaYbM9RnR\n\nStatement\n\nVersion: 1\nURI: https://google.com\nIssued At: 2025-01-01T00:00:00Z\nExpiration Time: not-a-timestamp", + error: "Expiration Time is not a valid ISO8601 timestamp", + }, + { + example: "domain.com wants you to sign in with your Solana account:\n4Cw1koUQtqybLFem7uqhzMBznMPGARbFS4cjaYbM9RnR\n\nStatement\n\nVersion: 1\nURI: https://google.com\nIssued At: 2025-01-01T00:00:00Z\nNot Before: not-a-timestamp", + error: "Not Before is not a valid ISO8601 timestamp", + }, + { + example: "domain.com wants you to sign in with your Solana account:\n4Cw1koUQtqybLFem7uqhzMBznMPGARbFS4cjaYbM9RnR\n\nStatement\n\nVersion: 2\nIssued At: 2025-01-01T00:00:00Z\nURI: https://google.com\n", + error: "Version value is not supported, expected 1 got \"2\"", + }, + { + example: "domain.com wants you to sign in with your Solana account:\n4Cw1koUQtqybLFem7uqhzMBznMPGARbFS4cjaYbM9RnR\n\nStatement\n\nVersion: 1\nIssued At: 2025-01-01T00:00:00Z\n\n", + error: "URI is not specified", + }, + { + example: "domain.com wants you to sign in with your Solana account:\n4Cw1koUQtqybLFem7uqhzMBznMPGARbFS4cjaYbM9RnR\n\nStatement\n\nVersion: 1\nURI: https://domain.com\nResources:\n- https://google.com\n", + error: "Issued At is not specified", + }, + { + example: "domain.com wants you to sign in with your Solana account:\n4Cw1koUQtqybLFem7uqhzMBznMPGARbFS4cjaYbM9RnR\n\nStatement\n\nVersion: 1\nURI: https://domain.com\nIssued At: 2025-01-02T00:00:00Z\nExpiration Time: 2025-01-01T00:00:00Z\n", + error: "Issued At is after Expiration Time", + }, + { + example: "domain.com wants you to sign in with your Solana account:\n4Cw1koUQtqybLFem7uqhzMBznMPGARbFS4cjaYbM9RnR\n\nStatement\n\nVersion: 1\nURI: https://domain.com\nIssued At: 2025-01-01T00:00:00Z\nExpiration Time: 2025-01-02T00:00:00Z\nNot Before: 2025-01-03T00:00:00Z\n", + error: "Not Before is after Expiration Time", + }, + { + example: "domain.com wants you to sign in with your Solana account:\n4Cw1koUQtqybLFem7uqhzMBznMPGARbFS4cjaYbM9RnR\n\nStatement\n\nVersion: 1\nURI: https://domain.com\nIssued At: 2025-01-01T00:00:00Z\nResources:\n- https://google.com\n- ***\n", + error: "Resource at position 1 has invalid URI", + }, + { + example: "domain.com wants you to sign in with your Solana account:\n4Cw1koUQtqybLFem7uqhzMBznMPGARbFS4cjaYbM9RnR\n\nVersion: 1\nURI: https://domain.com\nIssued At: 2025-01-01T00:00:00Z\nChain ID: random:mainnet", + error: "Chain ID is not valid", + }, + } + + for i, example := range negativeExamples { + _, err := ParseMessage(example.example) + + t.Run(fmt.Sprintf("negative example %d", i), func(t *testing.T) { + require.NotNil(t, err) + require.Equal(t, "siws: "+example.error, err.Error()) + }) + } + + positiveExamples := []string{ + "domain.com wants you to sign in with your Solana account:\n4Cw1koUQtqybLFem7uqhzMBznMPGARbFS4cjaYbM9RnR\n\nStatement\n\nVersion: 1\nURI: https://domain.com\nIssued At: 2025-01-01T00:00:00Z\nNonce: 123\nRequest ID: abcdef\nChain ID: solana:testnet", + "domain.com wants you to sign in with your Solana account:\n4Cw1koUQtqybLFem7uqhzMBznMPGARbFS4cjaYbM9RnR\n\nVersion: 1\nURI: https://domain.com\nIssued At: 2025-01-01T00:00:00Z\nNonce: 123\nRequest ID: abcdef\nChain ID: solana:testnet", + } + + for i, example := range positiveExamples { + t.Run(fmt.Sprintf("positive example %d", i), func(t *testing.T) { + parsed, err := ParseMessage(example) + + require.Nil(t, err) + require.Equal(t, "domain.com", parsed.Domain) + require.Equal(t, "4Cw1koUQtqybLFem7uqhzMBznMPGARbFS4cjaYbM9RnR", parsed.Address) + + if i == 0 { + require.Equal(t, "Statement", parsed.Statement) + } else { + require.Equal(t, "", parsed.Statement) + } + + require.Equal(t, "2025-01-01 00:00:00 +0000 UTC", parsed.IssuedAt.String()) + require.Equal(t, "https://domain.com", parsed.URI.String()) + require.Equal(t, "solana:testnet", parsed.ChainID) + require.Equal(t, "123", parsed.Nonce) + require.Equal(t, "abcdef", parsed.RequestID) + + require.Equal(t, false, parsed.VerifySignature(make([]byte, 64))) + }) + } +} diff --git a/openapi.yaml b/openapi.yaml index 009a140f5..7996f817f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -76,6 +76,7 @@ paths: - refresh_token - id_token - pkce + - web3 security: - APIKeyAuth: [] requestBody: @@ -93,12 +94,18 @@ paths: value: auth_code: 009e5066-fc11-4eca-8c8c-6fd82aa263f2 code_verifier: ktPNXpR65N6JtgzQA8_5HHtH6PBSAahMNoLKRzQEa0Tzgl.vdV~b6lPk004XOd.4lR0inCde.NoQx5K63xPfzL8o7tJAjXncnhw5Niv9ycQ.QRV9JG.y3VapqbgLfIrJ + grant_type=web3: + value: + message: "example.com wants you to sign in with your Solana account:\n0x1234...5678\n\nSign in with Solana\n\nURI: https://example.com\nVersion: 1\nNonce: abc123def456\nIssued At: 2023-09-19T12:00:00Z" + signature: "base64_encoded_signature_string" + chain: "solana" schema: type: object description: |- For the refresh token flow, supply only `refresh_token`. For the email/phone with password flow, supply `email`, `phone` and `password` with an optional `gotrue_meta_security`. For the OIDC ID token flow, supply `id_token`, `nonce`, `provider`, `client_id`, `issuer` with an optional `gotrue_meta_security`. + For the Web3 flow, supply `message`, `signature`, and `chain`. properties: refresh_token: type: string @@ -137,6 +144,19 @@ paths: format: uuid code_verifier: type: string + message: + type: string + description: | + Signed message for Web3 authentication following the Sign in with Solana standard. Must include: `Issued At`, `URI`, `Version`. + signature: + type: string + description: The signature of the message for Web3 authentication encoded as Base64 or Base64-URL. + chain: + type: string + description: What blockchain is the Web3 message and signature for. + enum: + - solana + example: solana responses: 200: description: >