Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -117,7 +122,7 @@ createos login --token <your-api-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**

Expand All @@ -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 |

Expand Down
64 changes: 59 additions & 5 deletions cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package auth

import (
"fmt"
"os"
"os/signal"
"time"

"github.com/pterm/pterm"
Expand All @@ -24,13 +26,20 @@ 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"},
Usage: "Your API token (from your CreateOS dashboard) — skips browser login",
},
},
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 {
Expand All @@ -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 <your-api-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 <your-api-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.
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
172 changes: 172 additions & 0 deletions internal/oauth/device.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
2 changes: 1 addition & 1 deletion internal/oauth/exec.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
14 changes: 10 additions & 4 deletions internal/oauth/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
}
Expand Down