mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 16:01:28 +00:00
152 lines
5.2 KiB
Go
152 lines
5.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/warmbly/warmbly/internal/cli/api"
|
|
"github.com/warmbly/warmbly/internal/cli/config"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/version"
|
|
)
|
|
|
|
// The browser half of `warmbly auth login`, RFC 8628 shaped: ask for a code,
|
|
// show it, wait for a member to approve it in the browser, collect the key.
|
|
|
|
type deviceStart struct {
|
|
DeviceCode string `json:"device_code"`
|
|
UserCode string `json:"user_code"`
|
|
VerificationURL string `json:"verification_uri"`
|
|
VerificationURLComplete string `json:"verification_uri_complete"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
Interval int `json:"interval"`
|
|
}
|
|
|
|
type devicePoll struct {
|
|
Status string `json:"status"`
|
|
Token string `json:"token"`
|
|
APIKeyID string `json:"api_key_id"`
|
|
ScopeNames []string `json:"scope_names"`
|
|
UserID string `json:"user_id"`
|
|
UserEmail string `json:"user_email"`
|
|
UserName string `json:"user_name"`
|
|
OrganizationID string `json:"organization_id"`
|
|
OrganizationName string `json:"organization_name"`
|
|
}
|
|
|
|
// startDeviceFlow opens the handshake. The client is anonymous: there is
|
|
// nothing to authenticate with yet.
|
|
func startDeviceFlow(ctx context.Context, client *api.Client, hostname string, scopes uint64) (*deviceStart, error) {
|
|
body, err := json.Marshal(models.CLIAuthStartRequest{
|
|
ClientName: "Warmbly CLI",
|
|
Hostname: hostname,
|
|
CLIVersion: version.String(),
|
|
Scopes: scopes,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := client.Do(ctx, api.Request{Method: http.MethodPost, Path: "/auth/cli/code", Body: body, Anonymous: true})
|
|
if err != nil {
|
|
if api.StatusOf(err) == http.StatusNotFound || api.StatusOf(err) == http.StatusNotImplemented {
|
|
return nil, fmt.Errorf("%s does not support browser sign-in for the CLI.\nUse `warmbly auth login --with-token` with an API key from Settings > API keys instead.", client.BaseURL)
|
|
}
|
|
return nil, err
|
|
}
|
|
var out deviceStart
|
|
if err := json.Unmarshal(resp.Body, &out); err != nil {
|
|
return nil, fmt.Errorf("the sign-in handshake returned something unexpected: %w", err)
|
|
}
|
|
if out.DeviceCode == "" || out.UserCode == "" {
|
|
return nil, errors.New("the sign-in handshake returned no code")
|
|
}
|
|
if out.Interval <= 0 {
|
|
out.Interval = 3
|
|
}
|
|
if out.ExpiresIn <= 0 {
|
|
out.ExpiresIn = 600
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// pollDeviceFlow waits for the browser half. It stops on approval, denial,
|
|
// expiry, or the context being cancelled, and never faster than the interval
|
|
// the server asked for.
|
|
func pollDeviceFlow(ctx context.Context, client *api.Client, start *deviceStart) (*devicePoll, error) {
|
|
body, err := json.Marshal(map[string]string{"device_code": start.DeviceCode})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
interval := time.Duration(start.Interval) * time.Second
|
|
deadline := time.Now().Add(time.Duration(start.ExpiresIn) * time.Second)
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(interval):
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return nil, fmt.Errorf("the code %s expired before it was approved. Run `warmbly auth login` again.", start.UserCode)
|
|
}
|
|
|
|
resp, err := client.Do(ctx, api.Request{Method: http.MethodPost, Path: "/auth/cli/poll", Body: body, Anonymous: true})
|
|
if err != nil {
|
|
// The code is gone: expired, or someone else claimed it.
|
|
if api.StatusOf(err) == http.StatusNotFound {
|
|
return nil, fmt.Errorf("the code %s is no longer valid. Run `warmbly auth login` again.", start.UserCode)
|
|
}
|
|
// A rate limit or a blip must not end a sign-in someone is
|
|
// standing at; back off and keep waiting.
|
|
if api.StatusOf(err) == http.StatusTooManyRequests || api.StatusOf(err) == 0 {
|
|
interval += time.Second
|
|
continue
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var out devicePoll
|
|
if err := json.Unmarshal(resp.Body, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
switch out.Status {
|
|
case string(models.CLIAuthCodeApproved):
|
|
if out.Token == "" {
|
|
return nil, errors.New("the approval returned no token. Run `warmbly auth login` again.")
|
|
}
|
|
return &out, nil
|
|
case string(models.CLIAuthCodeDenied):
|
|
return nil, errors.New("the request was declined in the browser. Nothing was created.")
|
|
case string(models.CLIAuthCodeClaimed):
|
|
return nil, errors.New("that code was already used. Run `warmbly auth login` again.")
|
|
}
|
|
}
|
|
}
|
|
|
|
// openBrowser opens a URL, honouring the browser config key and then BROWSER.
|
|
// A failure is never fatal: the URL has already been printed.
|
|
func openBrowser(cfg *config.Config, url string) error {
|
|
if custom := strings.TrimSpace(cfg.Browser); custom != "" {
|
|
parts, err := splitArgs(custom)
|
|
if err != nil || len(parts) == 0 {
|
|
return fmt.Errorf("the browser config key is not a usable command")
|
|
}
|
|
return exec.Command(parts[0], append(parts[1:], url)...).Start()
|
|
}
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
return exec.Command("open", url).Start()
|
|
case "windows":
|
|
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
|
default:
|
|
return exec.Command("xdg-open", url).Start()
|
|
}
|
|
}
|