// Copyright 2022 Molecula Corp. (DBA FeatureBase). // SPDX-License-Identifier: Apache-2.0 package ctl import ( "context" "crypto/tls" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" pilosa "github.com/featurebasedb/featurebase/v3" "github.com/featurebasedb/featurebase/v3/logger" "github.com/featurebasedb/featurebase/v3/server" "golang.org/x/oauth2" ) // AuthTokenCommand represents a command for retrieving an auth-token from a // FeatureBase node. type AuthTokenCommand struct { // nolint: maligned // TLS configuration. TLS server.TLSConfig tlsConfig *tls.Config // Host is the host and port of the FeatureBase node to authenticate with. Host string `json:"host"` // Reusable client. client *pilosa.InternalClient // Standard input/output. logDest logger.Logger } // Logger returns the command's associated Logger to maintain CommandWithTLSSupport interface compatibility func (cmd *AuthTokenCommand) Logger() logger.Logger { return cmd.logDest } // NewAuthTokenCommand returns a new instance of AuthTokenCommand. func NewAuthTokenCommand(logdest logger.Logger) *AuthTokenCommand { return &AuthTokenCommand{ logDest: logdest, } } type deviceAuthResponse struct { DeviceCode string `json:"device_code"` UserCode string `json:"user_code"` VerificationURI string `json:"verification_uri"` VerificationURIComplete string `json:"verification_uri_complete"` ExpiresIn int `json:"expires_in"` Interval int `json:"interval"` } // deviceAuthRequest makes a Device Authorization Request and parses the Device // Authorization Response as defined in RFC 8628 sections 3.1 and 3.2. func deviceAuthRequest(cli *http.Client, config oauth2.Config) (rsp *deviceAuthResponse, err error) { // build the request req, err := http.NewRequest( http.MethodPost, pilosa.ReplaceFirstFromBack(config.Endpoint.AuthURL, "authorize", "devicecode"), strings.NewReader( url.Values{ "client_id": {config.ClientID}, "scope": {strings.Join(config.Scopes, " ")}, }.Encode(), ), ) if err != nil { return nil, fmt.Errorf("building request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") // do the request dar := &deviceAuthResponse{} if resp, err := cli.Do(req); err != nil { return nil, fmt.Errorf("making request with status %d: %v", resp.StatusCode, err) } else if !(resp.StatusCode >= 200 && resp.StatusCode < 300) { body, _ := io.ReadAll(resp.Body) // ignore the error bc it's ok if there's no body return nil, fmt.Errorf( "unsuccessful with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)), ) } else if err := json.NewDecoder(resp.Body).Decode(dar); err != nil { return nil, fmt.Errorf("decoding device auth response body: %w", err) } return dar, nil } // successResponse represents a successful device auth token reponse. type successResponse struct { Access string `json:"access_token"` Refresh string `json:"refresh_token"` Type string `json:"token_type"` ExpiresIn int `json:"expires_in"` Scope string `json:"scope"` } // errorResponse represents a device auth token error reponse. type errorResponse struct { Err error Error string `json:"error"` Description string `json:"error_description"` URI string `json:"error_uri"` } // waitResponse represents a device auth token reponse that indicates the client // should continue polling. type waitResponse struct { SlowDown bool } // parseResponse parses a device auth grant response from the /token endpoint. It // returns a successResponse on success, an errorResponse on error, or a // waitResponse if the authorization is still pending or we are requested to slow // down. func parseResponse(resp *http.Response) interface{} { if s := resp.StatusCode; s >= 200 && s < 300 { rsp := &successResponse{} if err := json.NewDecoder(resp.Body).Decode(&rsp); err != nil { return errorResponse{Err: fmt.Errorf("reading response body: %w", err)} } return *rsp } rsp := &errorResponse{} if err := json.NewDecoder(resp.Body).Decode(&rsp); err != nil { return errorResponse{Err: fmt.Errorf("reading response body: %w", err)} } switch rsp.Error { case "slow_down": return waitResponse{SlowDown: true} case "authorization_pending": return waitResponse{} default: rsp.Err = fmt.Errorf("error: %s, description: %s, uri: %s", rsp.Error, rsp.Description, rsp.URI) return *rsp } } // Run executes the main program execution. func (cmd *AuthTokenCommand) Run(ctx context.Context) (err error) { // Parse TLS configuration for node-specific clients. tls := cmd.TLSConfiguration() if cmd.tlsConfig, err = server.GetTLSConfig(&tls, cmd.Logger()); err != nil { return fmt.Errorf("parsing tls config: %w", err) } // Create an internal client. client, err := commandClient(cmd) if err != nil { return fmt.Errorf("creating client: %w", err) } // Get the OAuth config. var access, refresh string config, err := client.OAuthConfig() if err != nil { return fmt.Errorf("getting oauth config: %w", err) } cli := &http.Client{} // Make the device authorization request. dar, err := deviceAuthRequest(cli, config) if err != nil { return fmt.Errorf("making device auth request: %w", err) } // Prompt the user to visit verification_uri and enter code. fmt.Print(formatPromptBox(dar.VerificationURI, dar.UserCode)) // Request a token until success or error response, slowing down if requested. interval := dar.Interval var exit bool for !exit { time.Sleep(time.Duration(interval) * time.Second) // Make a device access token request to the token endpoint. req, err := http.NewRequest( http.MethodPost, config.Endpoint.TokenURL, strings.NewReader( url.Values{ "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, "client_id": {config.ClientID}, "device_code": {dar.DeviceCode}, }.Encode(), ), ) if err != nil { return fmt.Errorf("building request to %s: %w", config.Endpoint.TokenURL, err) } resp, err := cli.Do(req) if err != nil { return fmt.Errorf("making request to %s: %w", config.Endpoint.TokenURL, err) } output := parseResponse(resp) switch o := output.(type) { case successResponse: access = o.Access refresh = o.Refresh exit = true case errorResponse: return o.Err case waitResponse: if o.SlowDown { interval += 5 } } } // Convey the response to the user. fmt.Printf("\nauth-token: %s\n", access) fmt.Printf("\nrefresh-token: %s\n", refresh) return nil } // TLSHost implements the CommandWithTLSSupport interface. func (cmd *AuthTokenCommand) TLSHost() string { return cmd.Host } // TLSConfiguration implements the CommandWithTLSSupport interface. func (cmd *AuthTokenCommand) TLSConfiguration() server.TLSConfig { return cmd.TLS } // formatPromptBox makes a nice little prompt box to hold the url and code. func formatPromptBox(url, code string) string { top := "+-----------------------------------------------" buffer := "| " width := len(top) + 1 if len(url) >= width { width = len(url) + 5 } if len(code) >= width { width = len(code) + 5 } urlStr := fmt.Sprintf("| %s", url) for i := len(urlStr); i < width-1; i++ { urlStr += " " } urlStr += "|" codeStr := fmt.Sprintf("| %s", code) for i := len(codeStr); i < width-1; i++ { codeStr += " " } codeStr += "|" for i := len(top); i < width-1; i++ { top += "-" buffer += " " } top += "+\n" buffer += "|\n" pls := "| Please visit:" for i := len(pls); i < width-1; i++ { pls += " " } pls += "|\n" and := "| And enter the code:" for i := len(and); i < width-1; i++ { and += " " } and += "|\n" return fmt.Sprintf("%s%s%s%s\n%s%s%s\n%s%s", top, buffer, pls, urlStr, buffer, and, codeStr, buffer, top) }