diff --git a/README.md b/README.md index 03fa64c..f290d5f 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,11 @@ cli: short: "Command-line tool for Acme services" auth: + login: + type: oauth_device + start_path: /auth/device/start + token_path: /auth/device/token + refresh_path: /auth/device/refresh validate: method: GET path: /api/v1/whoami @@ -272,6 +277,7 @@ Defines CLI identity and optional auth validation behavior. |---|---| | `cli.name` | Binary and command name, for example `acmectl`. | | `cli.short` | Root command summary. | +| `auth.login` | Optional OAuth device login endpoints used by `auth login --device-auth`. | | `auth.validate` | Optional endpoint used by `auth status` to display the logged-in user. | ### `specs/sources.yaml` diff --git a/docs/cli-usage.md b/docs/cli-usage.md index 9823b14..78f6742 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -60,6 +60,11 @@ cli: short: "Command-line tool for Acme services" auth: + login: + type: oauth_device + start_path: /auth/device/start + token_path: /auth/device/token + refresh_path: /auth/device/refresh validate: method: GET path: /api/v1/whoami @@ -75,6 +80,13 @@ and multiple modules use ` `. Set it to `namespaced` to always keep the module segment, or `flat` to require the single module flat path and fail codegen on root command conflicts. +`auth.login.type: oauth_device` enables `auth login --device-auth` and +`auth login --auth-type oauth`. The service must provide `start_path` and +`token_path` endpoints using OAuth 2.0 device-flow fields (`device_code`, +`verification_uri`, optional `user_code`, `interval`, `expires_in`, and later +`access_token`). `refresh_path` is optional; when present, generated commands +refresh expired bearer tokens before execution. + To customize generated Skill output, add an optional top-level `skill` block: ```yaml diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 7dd54d9..3608e0e 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -43,6 +43,7 @@ type oauthDeviceStartResponse struct { type oauthDeviceTokenResponse struct { Status string `json:"status"` + Error string `json:"error"` AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int64 `json:"expires_in"` @@ -132,18 +133,40 @@ func pollOAuthDeviceToken(cmd *cobra.Command, hostname string, tokenPath string, data, err := runtime.DoRaw(cmd.Context(), hostname, "POST", tokenPath, map[string]string{ "device_code": start.DeviceCode, }, runtime.ClientOptions{Insecure: insecure, Timeout: 10 * time.Second}) - if err != nil { - return oauthDeviceTokenResponse{}, fmt.Errorf("poll oauth login: %w", err) - } var token oauthDeviceTokenResponse - if err := json.Unmarshal(data, &token); err != nil { - return oauthDeviceTokenResponse{}, fmt.Errorf("decode oauth token response: %w", err) + if len(data) > 0 { + if decodeErr := json.Unmarshal(data, &token); decodeErr != nil { + if err != nil { + return oauthDeviceTokenResponse{}, fmt.Errorf("poll oauth login: %w", err) + } + return oauthDeviceTokenResponse{}, fmt.Errorf("decode oauth token response: %w", decodeErr) + } + } else if err != nil { + return oauthDeviceTokenResponse{}, fmt.Errorf("poll oauth login: %w", err) + } else { + return oauthDeviceTokenResponse{}, errors.New("decode oauth token response: empty response") } if token.AccessToken != "" { return token, nil } - switch token.Status { - case "pending", "": + state := token.Status + if state == "" { + state = token.Error + } + switch state { + case "pending", "authorization_pending", "": + if err != nil && state == "" { + return oauthDeviceTokenResponse{}, fmt.Errorf("poll oauth login: %w", err) + } + timer := time.NewTimer(time.Duration(interval) * time.Second) + select { + case <-cmd.Context().Done(): + timer.Stop() + return oauthDeviceTokenResponse{}, cmd.Context().Err() + case <-timer.C: + } + case "slow_down": + interval += 5 timer := time.NewTimer(time.Duration(interval) * time.Second) select { case <-cmd.Context().Done(): @@ -151,12 +174,15 @@ func pollOAuthDeviceToken(cmd *cobra.Command, hostname string, tokenPath string, return oauthDeviceTokenResponse{}, cmd.Context().Err() case <-timer.C: } - case "denied": + case "denied", "access_denied": return oauthDeviceTokenResponse{}, errors.New("oauth login denied") - case "expired": + case "expired", "expired_token": return oauthDeviceTokenResponse{}, errors.New("oauth login expired") default: - return oauthDeviceTokenResponse{}, fmt.Errorf("oauth login failed with status %q", token.Status) + if err != nil { + return oauthDeviceTokenResponse{}, fmt.Errorf("poll oauth login: %w", err) + } + return oauthDeviceTokenResponse{}, fmt.Errorf("oauth login failed with status %q", state) } } } @@ -166,6 +192,7 @@ func newLogin(m *config.Manifest) *cobra.Command { authType string provider string withToken bool + deviceAuth bool skipValidate bool ) cmd := &cobra.Command{ @@ -186,6 +213,13 @@ func newLogin(m *config.Manifest) *cobra.Command { return errors.New("hostname is required (use --hostname)") } hostname = config.NormalizeHostname(hostname) + authType = strings.ToLower(strings.TrimSpace(authType)) + if deviceAuth { + if authType != "" && authType != "oauth" { + return fmt.Errorf("--device-auth cannot be used with --auth-type %s", authType) + } + authType = "oauth" + } entry := config.HostEntry{AuthType: authType, Insecure: insecure} switch authType { @@ -280,6 +314,7 @@ func newLogin(m *config.Manifest) *cobra.Command { cmd.Flags().StringVar(&authType, "auth-type", "", "Authentication type: bearer (default), apikey, basic, oauth") cmd.Flags().StringVar(&provider, "provider", "", "OAuth provider hint passed to the service") cmd.Flags().BoolVar(&withToken, "with-token", false, "Read token/key from stdin") + cmd.Flags().BoolVar(&deviceAuth, "device-auth", false, "Use OAuth device login") cmd.Flags().BoolVar(&skipValidate, "skip-validate", false, "Do not validate credentials against the server") return cmd } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index fc61ac1..6e3bf35 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -94,3 +94,64 @@ func TestOAuthDeviceLoginSavesBearerHost(t *testing.T) { t.Fatalf("entry = %+v", entry) } } + +func TestOAuthDeviceLoginAcceptsAuthorizationPendingError(t *testing.T) { + var tokenCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/start": + _ = json.NewEncoder(w).Encode(map[string]any{ + "device_code": "device-1", + "verification_uri": "https://example.com/device", + "expires_in": 60, + "interval": 1, + }) + case "/token": + tokenCalls++ + if tokenCalls == 1 { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "authorization_pending"}) + return + } + _ = json.NewEncoder(w).Encode(map[string]string{"access_token": "access-1"}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + m := &config.Manifest{ + CLI: config.CLIInfo{Name: "demo", ConfigDir: "demo", ConfigDirEnv: "DEMO_CONFIG_DIR", HostEnv: "DEMO_HOST"}, + Auth: config.AuthInfo{Login: &config.AuthLogin{ + Type: config.AuthLoginOAuthDevice, + StartPath: "/start", + TokenPath: "/token", + }}, + } + config.Bind(m) + t.Setenv("DEMO_CONFIG_DIR", t.TempDir()) + + root := &cobra.Command{Use: "demo"} + root.PersistentFlags().String("hostname", srv.URL, "") + root.PersistentFlags().Bool("insecure", false, "") + root.AddCommand(NewCommand(m)) + root.SetArgs([]string{"auth", "login", "--device-auth"}) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if tokenCalls != 2 { + t.Fatalf("tokenCalls = %d, want 2", tokenCalls) + } + hosts, err := config.LoadHosts() + if err != nil { + t.Fatalf("LoadHosts: %v", err) + } + entry, ok := hosts.Get(srv.URL) + if !ok { + t.Fatal("host not saved") + } + if entry.AuthType != "bearer" || entry.OAuthToken != "access-1" { + t.Fatalf("entry = %+v", entry) + } +} diff --git a/internal/codegen/render/skill.go b/internal/codegen/render/skill.go index 899239c..4e6b347 100644 --- a/internal/codegen/render/skill.go +++ b/internal/codegen/render/skill.go @@ -498,7 +498,7 @@ func renderSkillMD(manifest *config.Manifest, refs []moduleRef) string { fmt.Fprintf(&b, "4. Execute only after flags, body, auth, HTTP path, and output hints are clear from `commands show`.\n\n") if manifest.Auth.Login != nil && manifest.Auth.Login.Type == config.AuthLoginOAuthDevice { b.WriteString("## Auth Login\n\n") - fmt.Fprintf(&b, "- Use `%s auth login --auth-type oauth --hostname --provider ` when the user needs browser-based OAuth login.\n", cli) + fmt.Fprintf(&b, "- Use `%s auth login --device-auth --hostname --provider ` when the user needs browser-based OAuth login.\n", cli) b.WriteString("- The saved host will use `auth_type: bearer`; OAuth is the login method, and the resulting API credential is a bearer token.\n\n") } b.WriteString("## General Commands\n\n") @@ -578,7 +578,7 @@ func renderCatalogReference(manifest *config.Manifest) string { b.WriteString("## Auth\n\n") fmt.Fprintf(&b, "If command detail returns `auth.required=true`, run `%s auth status --hostname ` before execution. Use `http.default_hostname` when present unless the user provides `--hostname` or `$%s`; if no matching host is logged in, stop and ask the user to authenticate.\n", cli, manifest.CLI.HostEnv) if manifest.Auth.Login != nil && manifest.Auth.Login.Type == config.AuthLoginOAuthDevice { - fmt.Fprintf(&b, "For browser-based OAuth login, run `%s auth login --auth-type oauth --hostname --provider `. `auth_type: bearer` in `hosts.yml` is expected after login because API requests use the issued bearer token.\n", cli) + fmt.Fprintf(&b, "For browser-based OAuth login, run `%s auth login --device-auth --hostname --provider `. `auth_type: bearer` in `hosts.yml` is expected after login because API requests use the issued bearer token.\n", cli) } return b.String() }