diff --git a/README.md b/README.md index 8143010..0f178ab 100644 --- a/README.md +++ b/README.md @@ -99,15 +99,20 @@ detect-secrets audit .secrets.baseline Choose one of the following methods: -**Option A — Browser login (OAuth, recommended)** +**Option A — Device code (default)** ```bash createos login ``` -This opens your browser to complete sign in. Your session is saved automatically. +Open the displayed link and follow the instructions to sign in. +Use `createos login --device` to skip the menu. -**Option B — API token** +**Option B — Browser login** + +Run `createos login` and select "Sign in with browser". + +**Option C — API token** Get your API token from your [CreateOS dashboard](https://createos.nodeops.network/profile), then run: @@ -117,7 +122,7 @@ createos login --token Or run `createos login` interactively and select "Sign in with API token" when prompted. -> In CI or non-interactive environments, you must use the `--token` flag. +> For unattended CI, use `--token`. **2. Confirm your account** @@ -143,7 +148,7 @@ createos --help | Command | Description | | ----------------- | ------------------------------------- | -| `createos login` | Sign in with browser or API token | +| `createos login` | Sign in with browser, device code, or API token | | `createos logout` | Sign out | | `createos whoami` | Show the currently authenticated user | diff --git a/cmd/auth/login.go b/cmd/auth/login.go index bf2fb56..af1cb17 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -3,6 +3,8 @@ package auth import ( "fmt" + "os" + "os/signal" "time" "github.com/pterm/pterm" @@ -24,6 +26,10 @@ func NewLoginCommand() *cli.Command { Name: "login", Usage: "Sign in to your CreateOS account", Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "device", + Usage: "Sign in with a device code using a browser on any device", + }, &cli.StringFlag{ Name: "token", Aliases: []string{"t"}, @@ -31,6 +37,9 @@ func NewLoginCommand() *cli.Command { }, }, Action: func(c *cli.Context) error { + if c.Bool("device") && c.String("token") != "" { + return fmt.Errorf("choose either --device or --token to sign in") + } // --token flag: API key flow (works in both TTY and non-TTY) if token := c.String("token"); token != "" { if err := config.SaveToken(token); err != nil { @@ -40,14 +49,19 @@ func NewLoginCommand() *cli.Command { return nil } - // Non-interactive (CI/script): require --token flag + if c.Bool("device") { + return loginWithDevice(c) + } + + // Non-interactive (CI/script): require an explicit login method if !terminal.IsInteractive() { - return fmt.Errorf("non-interactive mode: use --token flag to sign in\n\n Example:\n createos login --token ") + return fmt.Errorf("non-interactive mode: use --token for automation or --device to sign in using another browser\n\n Example:\n createos login --token ") } // Interactive: let user choose auth method options := []string{ - "Sign in with browser (recommended)", + "Sign in with device code", + "Sign in with browser", "Sign in with API token", } selected, err := pterm.DefaultInteractiveSelect. @@ -57,7 +71,10 @@ func NewLoginCommand() *cli.Command { return fmt.Errorf("sign in cancelled") } - if selected == options[1] { + if selected == options[0] { + return loginWithDevice(c) + } + if selected == options[2] { return loginWithAPIToken() } return loginWithBrowser() @@ -139,6 +156,43 @@ func loginWithBrowser() error { return fmt.Errorf("could not complete sign in: %w", err) } + return saveLoginSession(tokenResp, meta.TokenEndpoint) +} + +func loginWithDevice(c *cli.Context) error { + ctx, stop := signal.NotifyContext(c.Context, os.Interrupt) + defer stop() + pterm.Info.Println("Starting device login...") + meta, err := internaloauth.FetchServerMetadataContext(ctx, config.OAuthIssuerURL) + if err != nil { + return fmt.Errorf("could not reach authorization server: %w", err) + } + if meta.DeviceAuthorizationEndpoint == "" || meta.TokenEndpoint == "" { + return fmt.Errorf("device sign in is unavailable — use browser login or 'createos login --token'") + } + auth, err := internaloauth.StartDeviceAuthorization(ctx, meta.DeviceAuthorizationEndpoint, config.OAuthClientID) + if err != nil { + return err + } + fmt.Println() + pterm.Println(" Open this URL in a browser on this or another device:") + pterm.Println(" " + auth.VerificationURI) + pterm.Printf(" Enter code: %s\n", auth.UserCode) + if auth.VerificationURIComplete != "" { + pterm.Println(" Or open this link and confirm the same code:") + pterm.Println(" " + auth.VerificationURIComplete) + } + pterm.Printf(" Code expires in %s. Press Ctrl+C to cancel.\n", (time.Duration(auth.ExpiresIn) * time.Second).Round(time.Second)) + fmt.Println() + pterm.Info.Println("Waiting for you to approve sign in...") + tokenResp, err := internaloauth.PollDeviceToken(ctx, meta.TokenEndpoint, config.OAuthClientID, auth) + if err != nil { + return err + } + return saveLoginSession(tokenResp, meta.TokenEndpoint) +} + +func saveLoginSession(tokenResp *internaloauth.TokenResponse, tokenEndpoint string) error { expiresAt := time.Now().Unix() + int64(tokenResp.ExpiresIn) if tokenResp.ExpiresIn <= 0 { expiresAt = time.Now().Unix() + 3600 @@ -147,7 +201,7 @@ func loginWithBrowser() error { AccessToken: tokenResp.AccessToken, RefreshToken: tokenResp.RefreshToken, ExpiresAt: expiresAt, - TokenEndpoint: meta.TokenEndpoint, + TokenEndpoint: tokenEndpoint, } if err := config.SaveOAuthSession(session); err != nil { return fmt.Errorf("could not save your session: %w", err) diff --git a/internal/oauth/device.go b/internal/oauth/device.go new file mode 100644 index 0000000..33e7f4f --- /dev/null +++ b/internal/oauth/device.go @@ -0,0 +1,172 @@ +package oauth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const deviceGrantType = "urn:ietf:params:oauth:grant-type:device_code" + +// DeviceAuthorization holds the codes and browser URLs for a pending device login. +// DeviceCode is a credential: never display it or write it to logs. +type DeviceAuthorization struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int64 `json:"expires_in"` + Interval int64 `json:"interval"` + expiresAt time.Time +} + +// StartDeviceAuthorization requests a device code from the public OAuth endpoint. +func StartDeviceAuthorization(ctx context.Context, endpoint, clientID string) (*DeviceAuthorization, error) { + started := time.Now() + status, body, err := devicePost(ctx, endpoint, url.Values{ + "client_id": {clientID}, "scope": {"openid offline_access"}, + }) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, deviceResponseError(body) + } + var auth DeviceAuthorization + if json.Unmarshal(body, &auth) != nil || auth.DeviceCode == "" || auth.UserCode == "" || + !validDeviceURL(auth.VerificationURI) || (auth.VerificationURIComplete != "" && !validDeviceURL(auth.VerificationURIComplete)) || + auth.ExpiresIn <= 0 || auth.ExpiresIn > int64((1<<63-1)/time.Second) || auth.Interval < 0 || auth.Interval > auth.ExpiresIn { + return nil, fmt.Errorf("sign in returned an invalid response — run 'createos login' to try again") + } + if auth.Interval == 0 { + auth.Interval = 5 + } + auth.expiresAt = started.Add(time.Duration(auth.ExpiresIn) * time.Second) + return &auth, nil +} + +// PollDeviceToken waits for browser approval, respecting the server's polling interval. +func PollDeviceToken(ctx context.Context, endpoint, clientID string, auth *DeviceAuthorization) (*TokenResponse, error) { + return pollDeviceToken(ctx, endpoint, clientID, auth, waitDevicePoll) +} + +func pollDeviceToken(ctx context.Context, endpoint, clientID string, auth *DeviceAuthorization, wait func(context.Context, time.Duration) error) (*TokenResponse, error) { + if auth == nil || auth.DeviceCode == "" || auth.expiresAt.IsZero() { + return nil, fmt.Errorf("no pending sign in — run 'createos login' to start again") + } + ctx, cancel := context.WithDeadline(ctx, auth.expiresAt) + defer cancel() + interval := time.Duration(auth.Interval) * time.Second + form := url.Values{"client_id": {clientID}, "device_code": {auth.DeviceCode}, "grant_type": {deviceGrantType}} + for { + if err := wait(ctx, interval); err != nil { + return nil, deviceContextError(err) + } + status, body, err := devicePost(ctx, endpoint, form) + if err != nil { + // A lost response may already have issued tokens. Do not replay the code. + return nil, err + } + if status == http.StatusOK { + var token TokenResponse + if json.Unmarshal(body, &token) != nil || token.AccessToken == "" || !strings.EqualFold(token.TokenType, "bearer") || token.ExpiresIn <= 0 { + return nil, fmt.Errorf("sign in returned an invalid session — run 'createos login' to start again") + } + return &token, nil + } + var failure struct { + Error string `json:"error"` + } + if json.Unmarshal(body, &failure) == nil && status == http.StatusBadRequest { + switch failure.Error { + case "authorization_pending": + continue + case "slow_down": + if interval > time.Until(auth.expiresAt)-5*time.Second { + return nil, deviceContextError(context.DeadlineExceeded) + } + interval += 5 * time.Second + continue + } + } + return nil, deviceResponseError(body) + } +} + +func waitDevicePoll(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return ctx.Err() + } +} + +func devicePost(ctx context.Context, endpoint string, form url.Values) (int, []byte, error) { + if !validDeviceURL(endpoint) { + return 0, nil, fmt.Errorf("device sign in is unavailable — use 'createos login --token' or contact support") + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return 0, nil, fmt.Errorf("could not start sign in — run 'createos login' to try again") + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + client := &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Do(req) + if err != nil { + if ctx.Err() != nil { + return 0, nil, deviceContextError(ctx.Err()) + } + return 0, nil, fmt.Errorf("lost connection during sign in — run 'createos login' to start a new attempt") + } + defer resp.Body.Close() //nolint:errcheck + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return 0, nil, fmt.Errorf("could not read the sign-in response — run 'createos login' to start again") + } + return resp.StatusCode, body, nil +} + +func validDeviceURL(raw string) bool { + u, err := url.Parse(raw) + if err != nil || u.Host == "" || u.User != nil { + return false + } + return u.Scheme == "https" || (u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" || u.Hostname() == "::1")) +} + +func deviceContextError(err error) error { + if errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("sign-in code expired — run 'createos login' to get a new code") + } + return fmt.Errorf("sign in cancelled — run 'createos login' when you're ready") +} + +func deviceResponseError(body []byte) error { + var failure struct { + Error string `json:"error"` + } + if err := json.Unmarshal(body, &failure); err != nil { + return fmt.Errorf("could not complete sign in — run 'createos login' to start again") + } + switch failure.Error { + case "access_denied": + return fmt.Errorf("sign in was denied — run 'createos login' to try again") + case "expired_token": + return deviceContextError(context.DeadlineExceeded) + case "invalid_grant": + return fmt.Errorf("sign-in code is no longer valid — run 'createos login' to get a new code") + case "unauthorized_client", "invalid_client", "unsupported_grant_type", "invalid_scope": + return fmt.Errorf("device sign in is not enabled for this CLI — use 'createos login --token' or contact support") + default: + return fmt.Errorf("could not complete sign in — run 'createos login' to start again") + } +} diff --git a/internal/oauth/exec.go b/internal/oauth/exec.go index 831adeb..4913e3f 100644 --- a/internal/oauth/exec.go +++ b/internal/oauth/exec.go @@ -1,4 +1,4 @@ -// Package oauth implements the OAuth 2.0 authorization code flow with PKCE. +// Package oauth implements the OAuth 2.0 authorization code (PKCE) and device authorization flows. package oauth import ( diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 6b7ec38..577a9a4 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -19,8 +19,9 @@ import ( // ServerMetadata holds the OAuth authorization server metadata (RFC 8414) type ServerMetadata struct { - AuthorizationEndpoint string `json:"authorization_endpoint"` - TokenEndpoint string `json:"token_endpoint"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"` } // TokenResponse holds the token endpoint response @@ -39,12 +40,17 @@ type PKCEPair struct { // FetchServerMetadata fetches OAuth server metadata from {baseURL}/.well-known/openid-configuration func FetchServerMetadata(baseURL string) (*ServerMetadata, error) { + return FetchServerMetadataContext(context.Background(), baseURL) +} + +// FetchServerMetadataContext fetches discovery metadata with cancellation and a timeout. +func FetchServerMetadataContext(ctx context.Context, baseURL string) (*ServerMetadata, error) { metaURL := strings.TrimRight(baseURL, "/") + "/.well-known/openid-configuration" - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, metaURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, metaURL, nil) if err != nil { return nil, fmt.Errorf("could not reach authorization server") } - resp, err := http.DefaultClient.Do(req) + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) if err != nil { return nil, fmt.Errorf("could not reach authorization server — check your internet connection") }