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
70 changes: 35 additions & 35 deletions cmd/admin/org_contacts.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 34 additions & 0 deletions cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ var loginCmd = &cobra.Command{
return err
}

// Offer to store OAuth credentials with this user account before saving the token.
// This allows each user to have independent client credentials for token refresh.
if clientID != "" {
promptStoreCredentials(&result.Token, clientID, clientSecret)
}

// Store token in keyring
if err := auth.SaveToken(result.Email, &result.Token); err != nil {
return fmt.Errorf("saving token: %w", err)
Expand Down Expand Up @@ -58,6 +64,34 @@ var loginCmd = &cobra.Command{
},
}

func promptStoreCredentials(tok *auth.StoredToken, clientID, clientSecret string) {
var choice string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Save OAuth credentials with this account?").
Description(
"Storing credentials per-account lets each user refresh tokens independently,\n" +
"even when multiple accounts are configured with different OAuth apps.",
).
Options(
huh.NewOption("Yes — save with this account (recommended for multiple users)", "yes"),
huh.NewOption("No — use global credentials only", "no"),
).
Value(&choice),
),
)

if err := form.Run(); err != nil {
return
}

if choice == "yes" {
tok.ClientID = clientID
tok.ClientSecret = clientSecret
}
}

func promptFolderAssociation(email, dir string) {
folderName := filepath.Base(dir)

Expand Down
8 changes: 8 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ var rootCmd = &cobra.Command{
paginate, _ := cmd.Flags().GetBool("paginate")
config.SetPaginate(paginate)

// Rate-limit retry controls
maxRetry, _ := cmd.Flags().GetInt("max-retry")
config.SetMaxRetry(maxRetry)
maxRetryTimer, _ := cmd.Flags().GetInt("max-retry-timer")
config.SetMaxRetryTimer(maxRetryTimer)

// Load app config early (safe local file read, needed by skipAuth-exempt commands like set-org)
cfg, err := appconfig.Load()
if err != nil {
Expand Down Expand Up @@ -172,6 +178,8 @@ func init() {
rootCmd.PersistentFlags().Bool("dry-run", false, "Print write requests without executing them")
rootCmd.PersistentFlags().String("user", "", "Use a specific authenticated user (email)")
rootCmd.PersistentFlags().String("organization", "", "Override organization ID for this command")
rootCmd.PersistentFlags().Int("max-retry", 3, "Max number of 429 retries before giving up (0 = no retries)")
rootCmd.PersistentFlags().Int("max-retry-timer", 60, "Max total seconds to wait across all 429 retries (0 = unlimited)")

// On flag errors (unknown flag, bad value), print usage with valid flags.
// SilenceUsage suppresses Cobra's automatic usage, so we print it ourselves.
Expand Down
9 changes: 6 additions & 3 deletions codegen/generate_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,11 +222,14 @@ def generate_command(ep, group_var, base_url_const, is_calling):
original_name = ep.get('original_name', cmd_name)
has_from = any(p['name'] == 'from' for p in query_params)

# Normalize orgId → orgid for CC commands so auto-populate in root.go works
# consistently (CC APIs use UUID format via the --orgid flag path).
# Normalize orgId → orgid so auto-populate in root.go resolves to UUID format.
# Applies to CC commands (is_calling=False) and to any endpoint under
# /contacts/organizations/ which explicitly requires UUID despite being in a
# non-CC collection (Postman note: "orgId used in path are the org UUIDs").
# For path params, rename both the flag and the param key (path uses {orgid}).
# For query params, only rename the flag; keep the original API key (orgId).
if not is_calling:
needs_uuid_orgid = not is_calling or '/contacts/organizations/' in path
if needs_uuid_orgid:
path = path.replace('{orgId}', '{orgid}')
for p in path_params:
if p['name'] == 'orgId':
Expand Down
2 changes: 2 additions & 0 deletions internal/auth/keyring.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ type StoredToken struct {
ExpiresAt time.Time `json:"expires_at"`
TokenType string `json:"token_type"`
IssuedAt time.Time `json:"issued_at"`
ClientID string `json:"client_id,omitempty"`
ClientSecret string `json:"client_secret,omitempty"`
}

// IsExpired returns true if the access token is expired or within 60s of expiry.
Expand Down
20 changes: 17 additions & 3 deletions internal/auth/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,14 @@ func ResolveToken(flagToken, envToken, userFlag, envUser string, cfg *appconfig.

// Auto-refresh if expired
if tok.IsExpired() {
clientID := cfg.EffectiveClientID()
clientSecret := cfg.EffectiveClientSecret()
clientID, clientSecret := effectiveCredentials(tok, cfg)
refreshed, err := RefreshAccessToken(clientID, clientSecret, tok)
if err != nil {
return nil, fmt.Errorf("token expired for %s and refresh failed: %w\nRun: webex login", email, err)
}
// Carry per-token credentials forward into the refreshed token
refreshed.ClientID = tok.ClientID
refreshed.ClientSecret = tok.ClientSecret
tok = refreshed
// Persist the refreshed token
if saveErr := SaveToken(email, tok); saveErr != nil {
Expand Down Expand Up @@ -105,13 +107,25 @@ func MakeRefresher(email string, cfg *appconfig.Config) func() (string, error) {
if err != nil {
return "", err
}
refreshed, err := RefreshAccessToken(cfg.EffectiveClientID(), cfg.EffectiveClientSecret(), tok)
clientID, clientSecret := effectiveCredentials(tok, cfg)
refreshed, err := RefreshAccessToken(clientID, clientSecret, tok)
if err != nil {
return "", err
}
refreshed.ClientID = tok.ClientID
refreshed.ClientSecret = tok.ClientSecret
if saveErr := SaveToken(email, refreshed); saveErr != nil {
fmt.Printf("Warning: could not save refreshed token: %v\n", saveErr)
}
return refreshed.AccessToken, nil
}
}

// effectiveCredentials returns the client ID and secret to use for token refresh.
// Per-token credentials (stored with the user account) take priority over global config.
func effectiveCredentials(tok *StoredToken, cfg *appconfig.Config) (clientID, clientSecret string) {
if tok.ClientID != "" {
return tok.ClientID, tok.ClientSecret
}
return cfg.EffectiveClientID(), cfg.EffectiveClientSecret()
}
Loading
Loading