From cb827f1307e8186e18d8d0470b7c7127770098aa Mon Sep 17 00:00:00 2001 From: Bhautik Date: Sun, 4 Oct 2026 10:46:34 +0530 Subject: [PATCH 1/3] feat(auth): add device authorization login --- README.md | 23 ++++- cmd/auth/login.go | 60 +++++++++++- internal/oauth/device.go | 172 ++++++++++++++++++++++++++++++++++ internal/oauth/device_test.go | 131 ++++++++++++++++++++++++++ internal/oauth/exec.go | 2 +- internal/oauth/oauth.go | 14 ++- 6 files changed, 391 insertions(+), 11 deletions(-) create mode 100644 internal/oauth/device.go create mode 100644 internal/oauth/device_test.go diff --git a/README.md b/README.md index 8143010..fc1bfff 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,23 @@ createos login This opens your browser to complete sign in. Your session is saved automatically. -**Option B — API token** +**Option B — Device code (remote terminals)** + +```bash +createos login --device +``` + +Open the displayed link on any device, confirm the code, sign in, and approve access. +The CLI waits for approval and saves the same refreshable session as browser login. +No local callback port is needed. Press Ctrl+C to cancel. You can also select +"Sign in with a device code (remote terminal)" from the interactive login menu. + +Device login requires the identity server to advertise a device authorization +endpoint and the CLI's registered public client to allow +`urn:ietf:params:oauth:grant-type:device_code` with `openid offline_access` scopes +and the `refresh_token` grant. + +**Option C — API token** Get your API token from your [CreateOS dashboard](https://createos.nodeops.network/profile), then run: @@ -117,7 +133,8 @@ 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`. Explicit `--device` also works without a TTY, +> but a person must complete browser approval. **2. Confirm your account** @@ -143,7 +160,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..96ade40 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 a device code (remote terminal)", "Sign in with API token", } selected, err := pterm.DefaultInteractiveSelect. @@ -58,6 +72,9 @@ func NewLoginCommand() *cli.Command { } if selected == options[1] { + 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/device_test.go b/internal/oauth/device_test.go new file mode 100644 index 0000000..70944c5 --- /dev/null +++ b/internal/oauth/device_test.go @@ -0,0 +1,131 @@ +package oauth + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" +) + +func TestStartDeviceAuthorization(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 4096) + if r.Method != http.MethodPost || r.FormValue("client_id") != "cli" || r.FormValue("scope") != "openid offline_access" { + t.Error("incorrect device authorization request") + } + fmt.Fprint(w, `{"device_code":"private-code","user_code":"ABCD","verification_uri":"https://auth.example/verify","expires_in":600}`) + })) + defer server.Close() + auth, err := StartDeviceAuthorization(context.Background(), server.URL, "cli") + if err != nil { + t.Fatal(err) + } + if auth.Interval != 5 || auth.UserCode != "ABCD" || time.Until(auth.expiresAt) > 600*time.Second { + t.Fatalf("incorrect authorization metadata: interval=%d", auth.Interval) + } +} + +func TestDevicePolling(t *testing.T) { + for _, tc := range []struct { + name string + responses []string + intervals []time.Duration + wantError string + }{ + {"approval", []string{`{"error":"authorization_pending"}`, `{"access_token":"access","refresh_token":"refresh","token_type":"bearer","expires_in":3600}`}, []time.Duration{5 * time.Second, 5 * time.Second}, ""}, + {"slow down persists", []string{`{"error":"slow_down"}`, `{"error":"authorization_pending"}`, `{"error":"slow_down"}`, `{"access_token":"access","token_type":"bearer","expires_in":3600}`}, []time.Duration{5 * time.Second, 10 * time.Second, 10 * time.Second, 15 * time.Second}, ""}, + {"denied", []string{`{"error":"access_denied"}`}, []time.Duration{5 * time.Second}, "denied"}, + {"expired", []string{`{"error":"expired_token"}`}, []time.Duration{5 * time.Second}, "expired"}, + {"already used", []string{`{"error":"invalid_grant"}`}, []time.Duration{5 * time.Second}, "no longer valid"}, + {"unregistered", []string{`{"error":"unauthorized_client"}`}, []time.Duration{5 * time.Second}, "not enabled"}, + {"malformed", []string{`broken`}, []time.Duration{5 * time.Second}, "could not complete"}, + {"empty token", []string{`{"access_token":"","token_type":"bearer","expires_in":3600}`}, []time.Duration{5 * time.Second}, "invalid session"}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 4096) + if r.FormValue("device_code") != "private-code" || r.FormValue("grant_type") != deviceGrantType || r.FormValue("client_id") != "cli" { + t.Error("incorrect token request") + } + if calls >= len(tc.responses) { + t.Error("polled after terminal response") + w.WriteHeader(500) + return + } + body := tc.responses[calls] + calls++ + if !strings.Contains(body, "access_token") { + w.WriteHeader(400) + } + fmt.Fprint(w, body) + })) + defer server.Close() + var waits []time.Duration + wait := func(ctx context.Context, interval time.Duration) error { + waits = append(waits, interval) + return ctx.Err() + } + auth := &DeviceAuthorization{DeviceCode: "private-code", Interval: 5, expiresAt: time.Now().Add(time.Minute)} + token, err := pollDeviceToken(context.Background(), server.URL, "cli", auth, wait) + if tc.wantError == "" { + if err != nil || token == nil || token.AccessToken != "access" { + t.Fatalf("approval failed: %v", err) + } + } else if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("expected %q, got %v", tc.wantError, err) + } + if !reflect.DeepEqual(waits, tc.intervals) { + t.Fatalf("poll intervals: %v; want %v", waits, tc.intervals) + } + }) + } +} + +func TestDeviceCancellationAndExpiry(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + auth := &DeviceAuthorization{DeviceCode: "private-code", Interval: 5, expiresAt: time.Now().Add(time.Minute)} + if _, err := PollDeviceToken(ctx, "http://127.0.0.1:1", "cli", auth); err == nil || !strings.Contains(err.Error(), "cancelled") { + t.Fatalf("unexpected cancellation: %v", err) + } + auth.expiresAt = time.Now().Add(-time.Second) + if _, err := PollDeviceToken(context.Background(), "http://127.0.0.1:1", "cli", auth); err == nil || !strings.Contains(err.Error(), "expired") { + t.Fatalf("unexpected expiry: %v", err) + } +} + +func TestDeviceInvalidResponses(t *testing.T) { + for _, body := range []string{ + `{}`, + `{"device_code":"secret","user_code":"code","verification_uri":"javascript:alert(1)","expires_in":600}`, + `{"device_code":"secret","user_code":"code","verification_uri":"https://auth.example/verify","expires_in":0}`, + `{"device_code":"secret","user_code":"code","verification_uri":"https://auth.example/verify","expires_in":600,"interval":-1}`, + } { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, body) })) + _, err := StartDeviceAuthorization(context.Background(), server.URL, "cli") + server.Close() + if err == nil { + t.Fatal("accepted invalid response") + } + } +} + +func TestDeviceConnectionFailureDoesNotReplay(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls++ + w.WriteHeader(502) + fmt.Fprint(w, "upstream unavailable") + })) + defer server.Close() + auth := &DeviceAuthorization{DeviceCode: "private-code", Interval: 5, expiresAt: time.Now().Add(time.Minute)} + _, err := pollDeviceToken(context.Background(), server.URL, "cli", auth, func(context.Context, time.Duration) error { return nil }) + if err == nil || calls != 1 { + t.Fatalf("unexpected retries: %d, %v", calls, err) + } +} 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") } From 7db88f7c60b0de4fdacaa4f8ba4ef58927f65ba1 Mon Sep 17 00:00:00 2001 From: Bhautik Date: Sun, 4 Oct 2026 11:32:13 +0530 Subject: [PATCH 2/3] feat(auth): make device login the default menu choice --- README.md | 22 ++++++++++------------ cmd/auth/login.go | 6 +++--- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index fc1bfff..db8a004 100644 --- a/README.md +++ b/README.md @@ -99,30 +99,28 @@ 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. +Device-code login is selected by default in the interactive menu. Open the displayed +link on any device, confirm the code, sign in, and approve access. The CLI waits for +approval and saves your session automatically. Press Ctrl+C to cancel. -**Option B — Device code (remote terminals)** - -```bash -createos login --device -``` - -Open the displayed link on any device, confirm the code, sign in, and approve access. -The CLI waits for approval and saves the same refreshable session as browser login. -No local callback port is needed. Press Ctrl+C to cancel. You can also select -"Sign in with a device code (remote terminal)" from the interactive login menu. +Use `createos login --device` to skip the menu and start device login directly. Device login requires the identity server to advertise a device authorization endpoint and the CLI's registered public client to allow `urn:ietf:params:oauth:grant-type:device_code` with `openid offline_access` scopes and the `refresh_token` grant. +**Option B — Browser login** + +Run `createos login` and select "Sign in with browser". This opens your local +browser to complete sign in and saves the same refreshable session. + **Option C — API token** Get your API token from your [CreateOS dashboard](https://createos.nodeops.network/profile), then run: diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 96ade40..af1cb17 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -60,8 +60,8 @@ func NewLoginCommand() *cli.Command { // Interactive: let user choose auth method options := []string{ - "Sign in with browser (recommended)", - "Sign in with a device code (remote terminal)", + "Sign in with device code", + "Sign in with browser", "Sign in with API token", } selected, err := pterm.DefaultInteractiveSelect. @@ -71,7 +71,7 @@ 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] { From 0eee3ffd5d9629a8f402b0afe90386ae2ed38c2f Mon Sep 17 00:00:00 2001 From: Bhautik Date: Sun, 4 Oct 2026 11:35:17 +0530 Subject: [PATCH 3/3] chore(auth): simplify login docs and remove device tests --- README.md | 18 ++--- internal/oauth/device_test.go | 131 ---------------------------------- 2 files changed, 4 insertions(+), 145 deletions(-) delete mode 100644 internal/oauth/device_test.go diff --git a/README.md b/README.md index db8a004..0f178ab 100644 --- a/README.md +++ b/README.md @@ -105,21 +105,12 @@ Choose one of the following methods: createos login ``` -Device-code login is selected by default in the interactive menu. Open the displayed -link on any device, confirm the code, sign in, and approve access. The CLI waits for -approval and saves your session automatically. Press Ctrl+C to cancel. - -Use `createos login --device` to skip the menu and start device login directly. - -Device login requires the identity server to advertise a device authorization -endpoint and the CLI's registered public client to allow -`urn:ietf:params:oauth:grant-type:device_code` with `openid offline_access` scopes -and the `refresh_token` grant. +Open the displayed link and follow the instructions to sign in. +Use `createos login --device` to skip the menu. **Option B — Browser login** -Run `createos login` and select "Sign in with browser". This opens your local -browser to complete sign in and saves the same refreshable session. +Run `createos login` and select "Sign in with browser". **Option C — API token** @@ -131,8 +122,7 @@ createos login --token Or run `createos login` interactively and select "Sign in with API token" when prompted. -> For unattended CI, use `--token`. Explicit `--device` also works without a TTY, -> but a person must complete browser approval. +> For unattended CI, use `--token`. **2. Confirm your account** diff --git a/internal/oauth/device_test.go b/internal/oauth/device_test.go deleted file mode 100644 index 70944c5..0000000 --- a/internal/oauth/device_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package oauth - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "reflect" - "strings" - "testing" - "time" -) - -func TestStartDeviceAuthorization(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, 4096) - if r.Method != http.MethodPost || r.FormValue("client_id") != "cli" || r.FormValue("scope") != "openid offline_access" { - t.Error("incorrect device authorization request") - } - fmt.Fprint(w, `{"device_code":"private-code","user_code":"ABCD","verification_uri":"https://auth.example/verify","expires_in":600}`) - })) - defer server.Close() - auth, err := StartDeviceAuthorization(context.Background(), server.URL, "cli") - if err != nil { - t.Fatal(err) - } - if auth.Interval != 5 || auth.UserCode != "ABCD" || time.Until(auth.expiresAt) > 600*time.Second { - t.Fatalf("incorrect authorization metadata: interval=%d", auth.Interval) - } -} - -func TestDevicePolling(t *testing.T) { - for _, tc := range []struct { - name string - responses []string - intervals []time.Duration - wantError string - }{ - {"approval", []string{`{"error":"authorization_pending"}`, `{"access_token":"access","refresh_token":"refresh","token_type":"bearer","expires_in":3600}`}, []time.Duration{5 * time.Second, 5 * time.Second}, ""}, - {"slow down persists", []string{`{"error":"slow_down"}`, `{"error":"authorization_pending"}`, `{"error":"slow_down"}`, `{"access_token":"access","token_type":"bearer","expires_in":3600}`}, []time.Duration{5 * time.Second, 10 * time.Second, 10 * time.Second, 15 * time.Second}, ""}, - {"denied", []string{`{"error":"access_denied"}`}, []time.Duration{5 * time.Second}, "denied"}, - {"expired", []string{`{"error":"expired_token"}`}, []time.Duration{5 * time.Second}, "expired"}, - {"already used", []string{`{"error":"invalid_grant"}`}, []time.Duration{5 * time.Second}, "no longer valid"}, - {"unregistered", []string{`{"error":"unauthorized_client"}`}, []time.Duration{5 * time.Second}, "not enabled"}, - {"malformed", []string{`broken`}, []time.Duration{5 * time.Second}, "could not complete"}, - {"empty token", []string{`{"access_token":"","token_type":"bearer","expires_in":3600}`}, []time.Duration{5 * time.Second}, "invalid session"}, - } { - t.Run(tc.name, func(t *testing.T) { - calls := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, 4096) - if r.FormValue("device_code") != "private-code" || r.FormValue("grant_type") != deviceGrantType || r.FormValue("client_id") != "cli" { - t.Error("incorrect token request") - } - if calls >= len(tc.responses) { - t.Error("polled after terminal response") - w.WriteHeader(500) - return - } - body := tc.responses[calls] - calls++ - if !strings.Contains(body, "access_token") { - w.WriteHeader(400) - } - fmt.Fprint(w, body) - })) - defer server.Close() - var waits []time.Duration - wait := func(ctx context.Context, interval time.Duration) error { - waits = append(waits, interval) - return ctx.Err() - } - auth := &DeviceAuthorization{DeviceCode: "private-code", Interval: 5, expiresAt: time.Now().Add(time.Minute)} - token, err := pollDeviceToken(context.Background(), server.URL, "cli", auth, wait) - if tc.wantError == "" { - if err != nil || token == nil || token.AccessToken != "access" { - t.Fatalf("approval failed: %v", err) - } - } else if err == nil || !strings.Contains(err.Error(), tc.wantError) { - t.Fatalf("expected %q, got %v", tc.wantError, err) - } - if !reflect.DeepEqual(waits, tc.intervals) { - t.Fatalf("poll intervals: %v; want %v", waits, tc.intervals) - } - }) - } -} - -func TestDeviceCancellationAndExpiry(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - auth := &DeviceAuthorization{DeviceCode: "private-code", Interval: 5, expiresAt: time.Now().Add(time.Minute)} - if _, err := PollDeviceToken(ctx, "http://127.0.0.1:1", "cli", auth); err == nil || !strings.Contains(err.Error(), "cancelled") { - t.Fatalf("unexpected cancellation: %v", err) - } - auth.expiresAt = time.Now().Add(-time.Second) - if _, err := PollDeviceToken(context.Background(), "http://127.0.0.1:1", "cli", auth); err == nil || !strings.Contains(err.Error(), "expired") { - t.Fatalf("unexpected expiry: %v", err) - } -} - -func TestDeviceInvalidResponses(t *testing.T) { - for _, body := range []string{ - `{}`, - `{"device_code":"secret","user_code":"code","verification_uri":"javascript:alert(1)","expires_in":600}`, - `{"device_code":"secret","user_code":"code","verification_uri":"https://auth.example/verify","expires_in":0}`, - `{"device_code":"secret","user_code":"code","verification_uri":"https://auth.example/verify","expires_in":600,"interval":-1}`, - } { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, body) })) - _, err := StartDeviceAuthorization(context.Background(), server.URL, "cli") - server.Close() - if err == nil { - t.Fatal("accepted invalid response") - } - } -} - -func TestDeviceConnectionFailureDoesNotReplay(t *testing.T) { - calls := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - calls++ - w.WriteHeader(502) - fmt.Fprint(w, "upstream unavailable") - })) - defer server.Close() - auth := &DeviceAuthorization{DeviceCode: "private-code", Interval: 5, expiresAt: time.Now().Add(time.Minute)} - _, err := pollDeviceToken(context.Background(), server.URL, "cli", auth, func(context.Context, time.Duration) error { return nil }) - if err == nil || calls != 1 { - t.Fatalf("unexpected retries: %d, %v", calls, err) - } -}