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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down
12 changes: 12 additions & 0 deletions docs/cli-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -75,6 +80,13 @@ and multiple modules use `<cli> <module> <group> <operation>`. 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
Expand Down
55 changes: 45 additions & 10 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -132,31 +133,56 @@ 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():
timer.Stop()
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)
}
}
}
Expand All @@ -166,6 +192,7 @@ func newLogin(m *config.Manifest) *cobra.Command {
authType string
provider string
withToken bool
deviceAuth bool
skipValidate bool
)
cmd := &cobra.Command{
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
61 changes: 61 additions & 0 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
4 changes: 2 additions & 2 deletions internal/codegen/render/skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host> --provider <provider>` when the user needs browser-based OAuth login.\n", cli)
fmt.Fprintf(&b, "- Use `%s auth login --device-auth --hostname <host> --provider <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")
Expand Down Expand Up @@ -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 <host>` 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 <host> --provider <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 <host> --provider <provider>`. `auth_type: bearer` in `hosts.yml` is expected after login because API requests use the issued bearer token.\n", cli)
}
return b.String()
}
Expand Down