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  }