diff --git a/example.env b/example.env index e645c96e9c..448ad3da51 100644 --- a/example.env +++ b/example.env @@ -168,6 +168,17 @@ 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_STATEMENT="Sign this message to verify your identity" +GOTRUE_EXTERNAL_WEB3_VERSION="1" +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/external_eip4361_siws_example.go b/external_eip4361_siws_example.go new file mode 100644 index 0000000000..ceed90a370 --- /dev/null +++ b/external_eip4361_siws_example.go @@ -0,0 +1,75 @@ +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "time" + + "github.com/btcsuite/btcutil/base58" + siws "github.com/supabase/auth/internal/utilities/solana" +) + +func LogSIWSExample() { + // Configuration + domain := "localhost:9999" + statement := "Sign in with your Solana account" + version := "1" + chain := "solana:mainnet" + + // Generate keys + pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + fmt.Println("Error generating keys:", err) + return + } + pubKeyBase58 := base58.Encode(pubKey) + + // Generate nonce + nonce, err := siws.GenerateNonce() + if err != nil { + fmt.Println("Error generating nonce:", err) + return + } + + // Create SIWS message + msg := siws.SIWSMessage{ + Domain: domain, + Address: pubKeyBase58, + Statement: statement, + URI: "https://example.com", + Version: version, + Nonce: nonce, + IssuedAt: time.Now().UTC(), + } + + rawMessage := siws.ConstructMessage(msg) + + // Sign the message + signature := ed25519.Sign(privKey, []byte(rawMessage)) + signatureBase64 := base64.StdEncoding.EncodeToString(signature) + + // Generate JSON payload + payload := map[string]string{ + "grant_type": "web3", + "message": rawMessage, + "signature": signatureBase64, + "address": pubKeyBase58, + "chain": chain, + } + + payloadJSON, err := json.Marshal(payload) + if err != nil { + fmt.Println("Error generating payload JSON:", err) + return + } + + // Print JavaScript fetch code + fmt.Println(string(payloadJSON)) +} + +// func main() { +// LogSIWSExample() +// } 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/internal/api/external_eip4361_siws_test.go b/internal/api/external_eip4361_siws_test.go new file mode 100644 index 0000000000..927fe7b553 --- /dev/null +++ b/internal/api/external_eip4361_siws_test.go @@ -0,0 +1,98 @@ +package api + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "time" + + "github.com/btcsuite/btcutil/base58" + "github.com/supabase/auth/internal/conf" + siws "github.com/supabase/auth/internal/utilities/solana" +) + +const ( + siwsValidUser string = `{"address":"12345abcde","chain":"solana:mainnet"}` + siwsWrongChain string = `{"address":"12345abcde","chain":"ethereum:1"}` + siwsInvalidUser string = `{"address":"","chain":"solana:mainnet"}` +) + +func SIWSTestSignupSetup(ts *ExternalTestSuite) { + ts.Config.External.Web3 = conf.Web3Configuration{ + Enabled: true, + Domain: "test.example.com", + Statement: "Sign in with your Solana account", + Version: "1", + Timeout: 5 * time.Minute, + SupportedChains: "solana:mainnet", + DefaultChain: "solana:mainnet", + } +} + +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 (ts *ExternalTestSuite) TestSignupExternalSIWS() { + SIWSTestSignupSetup(ts) + ts.Config.DisableSignup = false + + // Generate test keys for Solana + pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) + ts.Require().NoError(err) + pubKeyBase58 := base58.Encode(pubKey) + + nonce, err := siws.GenerateNonce() + ts.Require().NoError(err) + + // Create test message + msg := siws.SIWSMessage{ + Domain: ts.Config.External.Web3.Domain, + Address: pubKeyBase58, + Statement: ts.Config.External.Web3.Statement, + URI: "https://example.com", + Version: ts.Config.External.Web3.Version, + Nonce: nonce, + IssuedAt: time.Now().UTC(), + } + + rawMessage := siws.ConstructMessage(msg) + signature := ed25519.Sign(privKey, []byte(rawMessage)) + signatureBase64 := base64.StdEncoding.EncodeToString(signature) + + // Create JSON request body + tokenRequest := TokenRequest{ + GrantType: "web3", + Message: rawMessage, + Signature: signatureBase64, + Address: pubKeyBase58, + Chain: "solana:mainnet", + } + + jsonBody, err := json.Marshal(tokenRequest) + ts.Require().NoError(err) + + req := httptest.NewRequest(http.MethodPost, "/token", bytes.NewReader(jsonBody)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + 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) +} diff --git a/internal/api/helpers.go b/internal/api/helpers.go index 8a9f3267ed..2318013792 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -74,6 +74,7 @@ type RequestParams interface { SignupParams | SingleSignOnParams | SmsParams | + Web3GrantParams | UserUpdateParams | VerifyFactorParams | VerifyParams | @@ -81,6 +82,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..0afbbd1e2f --- /dev/null +++ b/internal/api/provider/eip4361.go @@ -0,0 +1,190 @@ +package provider + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "time" + + "github.com/supabase/auth/internal/conf" + "github.com/supabase/auth/internal/crypto" + 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 SignedMessage struct { + Message string `json:"message"` + Signature string `json:"signature"` + Address string `json:"address"` + 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(msg *SignedMessage) (*UserProvidedData, error) { + chain, ok := p.chains[msg.Chain] + if !ok { + return nil, fmt.Errorf("unsupported blockchain: %s", msg.Chain) + } + + var err error + switch chain.NetworkName { + case BlockchainEthereum: + err = p.verifyEthereumSignature(msg) + case BlockchainSolana: + err = p.verifySolanaSignature(msg) + default: + return nil, fmt.Errorf("signature verification not implemented for %s", chain.NetworkName) + } + + if err != nil { + return nil, fmt.Errorf("signature verification failed: %w", err) + } + + // Construct the provider_id as chain:address to make it unique + providerId := fmt.Sprintf("%s:%s", msg.Chain, msg.Address) + + return &UserProvidedData{ + Metadata: &Claims{ + CustomClaims: map[string]interface{}{ + "address": msg.Address, + "chain": msg.Chain, + "role": "authenticated", + }, + Subject: providerId, // This becomes the provider_id in the identity + }, + Emails: []Email{}, + }, nil +} + +func (p *Web3Provider) verifyEthereumSignature(msg *SignedMessage) error { + return crypto.VerifyEthereumSignature(msg.Message, msg.Signature, msg.Address) +} + +func (p *Web3Provider) verifySolanaSignature(msg *SignedMessage) error { + parsedMessage, err := siws.ParseSIWSMessage(msg.Message) + if err != nil { + return fmt.Errorf("failed to parse SIWS message: %w", err) + } + + // Decode base64 signature into bytes + sigBytes, err := base64.StdEncoding.DecodeString(msg.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(msg.Message, sigBytes, parsedMessage, params); err != nil { + return fmt.Errorf("SIWS verification failed: %w", 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.SecureToken() + + 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..4323303b7b 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -13,11 +13,14 @@ import ( "github.com/golang-jwt/jwt/v5" "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" "github.com/supabase/auth/internal/observability" "github.com/supabase/auth/internal/storage" + siws "github.com/supabase/auth/internal/utilities/solana" ) // AccessTokenClaims is a struct thats used for JWT claims @@ -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,159 @@ func (a *API) PKCE(ctx context.Context, w http.ResponseWriter, r *http.Request) return sendJSON(w, http.StatusOK, token) } +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"` +} + +const NonceExpiration = 5 * time.Minute + +// GetNonce handles nonce generation requests +func (a *API) GetNonce(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + + nonce := crypto.SecureToken() + + storedNonce := &StoredNonce{ + ID: uuid.Must(uuid.NewV4()), + Nonce: nonce, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(NonceExpiration), + Used: false, + } + + err := db.Transaction(func(tx *storage.Connection) error { + // Store the nonce + _, err := tx.TX.Exec(` + INSERT INTO auth.nonces (id, nonce, created_at, expires_at, used) + VALUES ($1, $2, $3, $4, $5) + `, storedNonce.ID, storedNonce.Nonce, storedNonce.CreatedAt, + storedNonce.ExpiresAt, storedNonce.Used) + return err + }) + + if err != nil { + return internalServerError("Error storing nonce").WithInternalError(err) + } + + return sendJSON(w, http.StatusOK, map[string]interface{}{ + "nonce": nonce, + "expiresAt": storedNonce.ExpiresAt, + }) +} + +func (a *API) verifyAndConsumeNonce(ctx context.Context, nonce string, address string) error { + db := a.db.WithContext(ctx) + + var storedNonce StoredNonce + err := db.Transaction(func(tx *storage.Connection) error { + // Find the nonce + err := tx.TX.QueryRow(` + SELECT id, nonce, address, created_at, expires_at, used + FROM auth.nonces + WHERE nonce = $1 AND used = false + `, nonce).Scan(&storedNonce.ID, &storedNonce.Nonce, + &storedNonce.Address, &storedNonce.CreatedAt, + &storedNonce.ExpiresAt, &storedNonce.Used) + if err != nil { + return err + } + + // Check expiration + if time.Now().After(storedNonce.ExpiresAt) { + return fmt.Errorf("nonce expired") + } + + // Mark as used + _, err = tx.TX.Exec(` + UPDATE auth.nonces + SET used = true, address = $1 + WHERE id = $2 + `, address, storedNonce.ID) + return err + }) + + return err +} + + +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 + } + + // Verify and consume nonce first + if err := a.verifyAndConsumeNonce(ctx, params.Nonce, params.Address); err != nil { + return siws.ErrorCodeInvalidNonce + } + + web3Provider, err := provider.NewWeb3Provider(ctx, a.config.External.Web3) + if err != nil { + return err + } + + // Convert params to SignedMessage + msg := &provider.SignedMessage{ + Message: params.Message, + Signature: params.Signature, + Address: params.Address, + Chain: params.Chain, + } + + userData, err := web3Provider.VerifySignedMessage(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": msg.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 +663,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..f53bc66419 --- /dev/null +++ b/internal/api/web3.go @@ -0,0 +1,9 @@ +package api + +type Web3GrantParams struct { + Message string `json:"message"` + Signature string `json:"signature"` + Address string `json:"address"` + Chain string `json:"chain"` + Nonce string `json:"nonce"` // Added nonce field +} 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..ec4b6ecc62 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -6,15 +6,25 @@ import ( "crypto/rand" "crypto/sha256" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "io" "math" "math/big" + "net/url" "strconv" "strings" + "crypto/ed25519" + "time" + "golang.org/x/crypto/hkdf" + + "github.com/btcsuite/btcutil/base58" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + siws "github.com/supabase/auth/internal/utilities/solana" ) // SecureToken creates a new random token @@ -157,3 +167,152 @@ 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 { + // 1) Basic input validation + if rawMessage == "" { + return siws.ErrEmptyRawMessage + } + if len(signature) == 0 { + return siws.ErrEmptySignature + } + if msg == nil { + return siws.ErrNilMessage + } + + // 2) Domain validation + if params.ExpectedDomain == "" { + return siws.ErrMissingDomain + } + if !siws.IsValidDomain(msg.Domain) { + return siws.ErrInvalidDomainFormat + } + if msg.Domain != params.ExpectedDomain { + return siws.ErrDomainMismatch + } + + // 3) Address/Public Key validation (combined checks) + pubKey := base58.Decode(msg.Address) + if !siws.IsBase58PubKey(pubKey) { + return siws.ErrInvalidPubKeySize + } + + // 4) Version validation + if msg.Version != "1" { + return siws.ErrInvalidVersion + } + + // 5) Chain ID validation (using helper) + if msg.ChainID != "" { + if !siws.IsValidSolanaNetwork(msg.ChainID) { + + return siws.ErrInvalidChainID + } + } + + // 6) Nonce validation (consolidated) + if msg.Nonce != "" { + if len(msg.Nonce) < 8 { + return siws.ErrNonceTooShort + } + } + + // 7) URI and Resources validation + if msg.URI != "" { + if _, err := url.Parse(msg.URI); err != nil { + return siws.ErrInvalidURI + } + } + + for _, resource := range msg.Resources { + if _, err := url.Parse(resource); err != nil { + return siws.ErrInvalidResourceURI + } + } + + // 8) Signature verification + if !ed25519.Verify(pubKey, []byte(rawMessage), signature) { + return siws.ErrSignatureVerification + } + + // 9) Time validations (consolidated) + now := time.Now().UTC() + + if !msg.IssuedAt.IsZero() { + if now.Before(msg.IssuedAt) { + return siws.ErrFutureMessage + } + + if params.CheckTime && params.TimeDuration > 0 { + expiry := msg.IssuedAt.Add(params.TimeDuration) + if now.After(expiry) { + return siws.ErrMessageExpired + } + } + } + + if !msg.NotBefore.IsZero() && now.Before(msg.NotBefore) { + return siws.ErrNotYetValid + } + + if !msg.ExpirationTime.IsZero() && now.After(msg.ExpirationTime) { + return siws.ErrMessageExpired + } + + return nil +} + +func VerifyEthereumSignature(message string, signature string, address 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 +} + + diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go index f1c8e67518..474e307ef2 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) { @@ -106,3 +112,244 @@ func TestEncryptedStringDecryptNegative(t *testing.T) { func TestSecureToken(t *testing.T) { assert.Equal(t, len(SecureToken()), 22) } + + +// package crypto + + + +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/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..3ec9c1fddc 100644 --- a/internal/reloader/testdata/50_example.env +++ b/internal/reloader/testdata/50_example.env @@ -168,6 +168,17 @@ 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_STATEMENT="Sign this message to verify your identity" +GOTRUE_EXTERNAL_WEB3_VERSION="1" +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..4d82b2cab6 --- /dev/null +++ b/internal/utilities/solana/helpers.go @@ -0,0 +1,128 @@ +package siws + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "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) + 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) + + +) + +// GenerateNonce creates a random 16-byte nonce, returning a hex-encoded string. +func GenerateNonce() (string, error) { + b := make([]byte, 16) + _, err := rand.Read(b) + if err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// 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 + regex := `^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$` + 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..ed9e91413e --- /dev/null +++ b/internal/utilities/solana/parser.go @@ -0,0 +1,118 @@ +package siws + +import ( + "strings" + "time" +) + +func ParseSIWSMessage(raw string) (*SIWSMessage, error) { + lines := strings.Split(raw, "\n") + + // Remove empty lines at the end + var cleaned []string + for _, line := range lines { + l := strings.TrimSpace(line) + if l != "" { + cleaned = append(cleaned, l) + } + } + + if len(cleaned) < 2 { + return nil, ErrorMalformedMessage + } + + // Parse domain line + matches := strings.Split(cleaned[0], " wants you to sign in with your Solana account:") + if len(matches) != 2 || matches[0] == "" { + return nil, ErrInvalidDomainFormat + } + domain := matches[0] + + // Parse address line + address := strings.TrimSpace(cleaned[1]) + + // Initialize message struct + msg := &SIWSMessage{ + Domain: domain, + Address: address, + } + + // Parse optional statement - must be preceded by double newline + lineIndex := 2 + if lineIndex+1 < len(cleaned) { + for i := 2; i < len(lines)-1; i++ { + if lines[i] == "" && lines[i+1] != "" && + !strings.Contains(lines[i+1], ": ") { + msg.Statement = cleaned[lineIndex+1] + lineIndex = lineIndex + 2 + break + } + } + } + + // Parse key-value fields + for lineIndex < len(cleaned) { + line := cleaned[lineIndex] + + switch { + case strings.HasPrefix(line, "URI: "): + msg.URI = strings.TrimSpace(strings.TrimPrefix(line, "URI:")) + + case strings.HasPrefix(line, "Version: "): + msg.Version = strings.TrimSpace(strings.TrimPrefix(line, "Version:")) + + case strings.HasPrefix(line, "Chain ID: "): + msg.ChainID = strings.TrimSpace(strings.TrimPrefix(line, "Chain ID:")) + + case strings.HasPrefix(line, "Nonce: "): + msg.Nonce = strings.TrimSpace(strings.TrimPrefix(line, "Nonce:")) + + case strings.HasPrefix(line, "Issued At: "): + tsString := strings.TrimSpace(strings.TrimPrefix(line, "Issued At:")) + ts, err := time.Parse(time.RFC3339, tsString) + if err != nil { + return nil, ErrInvalidIssuedAtFormat + } + msg.IssuedAt = ts + + case strings.HasPrefix(line, "Expiration Time: "): + tsString := strings.TrimSpace(strings.TrimPrefix(line, "Expiration Time:")) + ts, err := time.Parse(time.RFC3339, tsString) + if err != nil { + return nil, ErrInvalidExpirationTimeFormat + } + msg.ExpirationTime = ts + + case strings.HasPrefix(line, "Not Before: "): + tsString := strings.TrimSpace(strings.TrimPrefix(line, "Not Before:")) + ts, err := time.Parse(time.RFC3339, tsString) + if err != nil { + return nil, ErrInvalidNotBeforeFormat + } + msg.NotBefore = ts + + case strings.HasPrefix(line, "Request ID: "): + msg.RequestID = strings.TrimSpace(strings.TrimPrefix(line, "Request ID:")) + + case strings.HasPrefix(line, "Resources:"): + lineIndex++ + for lineIndex < len(cleaned) { + resourceLine := cleaned[lineIndex] + if !strings.HasPrefix(resourceLine, "- ") { + break + } + resource := strings.TrimSpace(strings.TrimPrefix(resourceLine, "-")) + msg.Resources = append(msg.Resources, resource) + lineIndex++ + } + continue + + default: + return nil, ErrUnrecognizedLine + } + lineIndex++ + } + + return msg, nil +} + diff --git a/internal/utilities/solana/types.go b/internal/utilities/solana/types.go new file mode 100644 index 0000000000..01fe3760ba --- /dev/null +++ b/internal/utilities/solana/types.go @@ -0,0 +1,32 @@ +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/20250119111000_add_web3_nonce_table.sql b/migrations/20250119111000_add_web3_nonce_table.sql new file mode 100644 index 0000000000..450c229f37 --- /dev/null +++ b/migrations/20250119111000_add_web3_nonce_table.sql @@ -0,0 +1,26 @@ +-- 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, + created_at timestamp with time zone not null default now(), + expires_at timestamp with time zone not null, + used boolean not null default false +); + +-- 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