github.com/mysteriumnetwork/node@v0.0.0-20240516044423-365054f76801/tequilapi/sso/mystnodes.go (about) 1 /* 2 * Copyright (C) 2023 The "MysteriumNetwork/node" Authors. 3 * 4 * This program is free software: you can redistribute it and/or modify 5 * it under the terms of the GNU General Public License as published by 6 * the Free Software Foundation, either version 3 of the License, or 7 * (at your option) any later version. 8 * 9 * This program is distributed in the hope that it will be useful, 10 * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 * GNU General Public License for more details. 13 * 14 * You should have received a copy of the GNU General Public License 15 * along with this program. If not, see <http://www.gnu.org/licenses/>. 16 */ 17 18 package sso 19 20 import ( 21 "encoding/base64" 22 "encoding/json" 23 "fmt" 24 "io" 25 "net/http" 26 "net/url" 27 "sync" 28 29 "github.com/mysteriumnetwork/node/config" 30 "github.com/mysteriumnetwork/node/eventbus" 31 "github.com/mysteriumnetwork/node/identity" 32 "github.com/mysteriumnetwork/node/requests" 33 "github.com/mysteriumnetwork/node/tequilapi/contract" 34 "github.com/mysteriumnetwork/node/tequilapi/pkce" 35 "github.com/pkg/errors" 36 ) 37 38 // ErrRedirectMissing host can't be blank 39 var ErrRedirectMissing = errors.New("host must not be empty") 40 41 // ErrNoUnlockedIdentity no unlocked identity 42 var ErrNoUnlockedIdentity = errors.New("lastUnlockedIdentity must not be empty") 43 44 // ErrCodeVerifierMissing code verifier is missing 45 var ErrCodeVerifierMissing = errors.New("no code verifier generated") 46 47 // ErrAuthorizationGrantTokenMissing blank authorization token 48 var ErrAuthorizationGrantTokenMissing = errors.New("token must be set") 49 50 // ErrMystnodesAuthorizationFail authorization failed against mystnodes 51 var ErrMystnodesAuthorizationFail = errors.New("mystnodes SSO grant authorization verification failed") 52 53 type httpClient interface { 54 Do(req *http.Request) (*http.Response, error) 55 } 56 57 // Mystnodes SSO support 58 type Mystnodes struct { 59 baseUrl string 60 ssoPath string 61 signer identity.SignerFactory 62 lastUnlockedIdentity identity.Identity 63 client httpClient 64 lastCodeVerifierBase64url string 65 lock sync.Mutex 66 } 67 68 // NewMystnodes constructor 69 func NewMystnodes(signer identity.SignerFactory, client httpClient) *Mystnodes { 70 return &Mystnodes{ 71 baseUrl: config.GetString(config.FlagMMNAddress), 72 ssoPath: "/login-sso", 73 signer: signer, 74 client: client, 75 } 76 } 77 78 // Subscribe unlocked identity is required in order to sign request 79 func (m *Mystnodes) Subscribe(eventBus eventbus.EventBus) error { 80 if err := eventBus.SubscribeAsync(identity.AppTopicIdentityUnlock, m.onIdentityUnlocked); err != nil { 81 return err 82 } 83 return nil 84 } 85 86 func (m *Mystnodes) onIdentityUnlocked(ev identity.AppEventIdentityUnlock) { 87 m.lastUnlockedIdentity = ev.ID 88 } 89 90 func (m *Mystnodes) message(info pkce.Info, redirectURL string) MystnodesMessage { 91 return MystnodesMessage{ 92 CodeChallenge: info.CodeChallenge, 93 Identity: m.lastUnlockedIdentity.Address, 94 RedirectURL: redirectURL, 95 } 96 } 97 98 func (m *Mystnodes) sign(msg []byte) (identity.Signature, error) { 99 return m.signer(m.lastUnlockedIdentity).Sign(msg) 100 } 101 102 // SSOLink build SSO link to begin authentication via mystnodes.com 103 func (m *Mystnodes) SSOLink(redirectURL *url.URL) (*url.URL, error) { 104 m.lock.Lock() 105 defer m.lock.Unlock() 106 107 if redirectURL == nil { 108 return nil, ErrRedirectMissing 109 } 110 111 if len(m.lastUnlockedIdentity.Address) == 0 { 112 return nil, ErrNoUnlockedIdentity 113 } 114 115 u, err := url.Parse(m.baseUrl) 116 if err != nil { 117 return &url.URL{}, err 118 } 119 120 u = u.JoinPath(m.ssoPath) 121 122 info, err := pkce.New(128) 123 if err != nil { 124 return nil, err 125 } 126 127 m.lastCodeVerifierBase64url = info.Base64URLCodeVerifier() 128 messageJson, err := m.message(info, fmt.Sprint(redirectURL)).JSON() 129 if err != nil { 130 return &url.URL{}, err 131 } 132 133 signature, err := m.sign(messageJson) 134 if err != nil { 135 return &url.URL{}, err 136 } 137 138 q := u.Query() 139 q.Set("message", base64.RawURLEncoding.EncodeToString(messageJson)) 140 q.Set("signature", base64.RawURLEncoding.EncodeToString(signature.Bytes())) 141 u.RawQuery = q.Encode() 142 143 return u, nil 144 } 145 146 func (m *Mystnodes) consumeCodeVerifier() { 147 m.lastCodeVerifierBase64url = "" 148 } 149 150 // VerifyInfo information gathered during grant verification 151 type VerifyInfo struct { 152 APIkey string `json:"apiKey"` 153 WalletAddress string `json:"walletAddress"` 154 IsEligibleForFreeRegistration bool `json:"isEligibleForFreeRegistration"` 155 } 156 157 // DefaultVerificationOptions default options 158 var DefaultVerificationOptions = VerificationOptions{SkipNodeClaimCheck: false} 159 160 // VerificationOptions options 161 type VerificationOptions struct { 162 SkipNodeClaimCheck bool 163 } 164 165 // VerifyAuthorizationGrant verifies authorization grant against mystnodes.com using PKCE workflow 166 func (m *Mystnodes) VerifyAuthorizationGrant(authorizationGrantToken string, opts VerificationOptions) (VerifyInfo, error) { 167 m.lock.Lock() 168 defer m.lock.Unlock() 169 defer m.consumeCodeVerifier() 170 171 if len(m.lastCodeVerifierBase64url) == 0 { 172 return VerifyInfo{}, ErrCodeVerifierMissing 173 } 174 175 if len(authorizationGrantToken) == 0 { 176 return VerifyInfo{}, ErrAuthorizationGrantTokenMissing 177 } 178 179 req, err := requests.NewPostRequest(config.GetString(config.FlagMMNAPIAddress), "auth/sso-verify-grant", contract.MystnodesSSOGrantVerificationRequest{ 180 AuthorizationGrant: authorizationGrantToken, 181 CodeVerifierBase64url: m.lastCodeVerifierBase64url, 182 }) 183 184 req.Header.Add("mmn-skip-node-claim-check", fmt.Sprint(opts.SkipNodeClaimCheck)) 185 186 if err != nil { 187 return VerifyInfo{}, err 188 } 189 190 res, err := m.client.Do(req) 191 if err != nil { 192 return VerifyInfo{}, err 193 } 194 195 if res.StatusCode < 200 || res.StatusCode > 299 { 196 return VerifyInfo{}, errors.Wrap(ErrMystnodesAuthorizationFail, fmt.Sprintf("mystnodes SSO grant verification responded with %d", res.StatusCode)) 197 } 198 199 defer res.Body.Close() 200 var vi VerifyInfo 201 202 bytes, err := io.ReadAll(res.Body) 203 if err != nil { 204 return VerifyInfo{}, err 205 } 206 207 err = json.Unmarshal(bytes, &vi) 208 if err != nil { 209 return VerifyInfo{}, err 210 } 211 212 return vi, nil 213 }