Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
feat(oauth): add --oauth-base-url to auth login for first-time custom…
…-domain login

A new user targeting a non-default OAuth environment was stuck: the
global --oauth-base-url is not accepted on the auth command group,
config update needs an existing profile, and config add requires AK/SK.
auth login now takes its own --oauth-base-url, usable with no prior
config and persisted to the profile so later refreshes reuse it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • Loading branch information
Episkey-G and claude committed Jun 17, 2026
commit cbd1b0503569f3dd559e9ffc5cc316e2210535bf
2 changes: 2 additions & 0 deletions README-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ CLI 会打印授权 URL 而不是打开浏览器。在任意设备上打开该 U

默认模式下同样存在这条粘贴回退路径:如果自动捕获在 3 分钟内没有收到回调,CLI 会打印 "Automatic capture timed out. Paste the callback URL here as a fallback:" 并等待粘贴回调 URL。

针对非默认环境,可加 `--oauth-base-url <url>` 覆盖 OAuth 授权服务器地址。该参数在无任何已有配置时即可使用,并会保存到 profile,后续 token 刷新会沿用它。

### Token 存储与有效期

- Token 存储在 `~/.ucloud/credential.json`,文件权限 0600。
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,8 @@ The CLI prints the authorization URL instead of opening a browser. Open it on an

The same paste prompt is also used as a fallback in the default mode: if the automatic capture does not receive the callback within 3 minutes, the CLI prints "Automatic capture timed out. Paste the callback URL here as a fallback:" and waits for the pasted URL.

For non-default environments, pass `--oauth-base-url <url>` to override the OAuth authorization server URL. It works with no prior config and is saved to the profile, so later token refreshes reuse it.

### Token storage and lifetime

- Tokens are stored in `~/.ucloud/credential.json` with file mode 0600.
Expand Down
19 changes: 16 additions & 3 deletions cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"bufio"
"fmt"
"os"
"strings"
"time"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -53,30 +54,42 @@ func NewCmdAuth() *cobra.Command {
// NewCmdLogin ucloud auth login
func NewCmdLogin() *cobra.Command {
var noBrowser bool
var oauthBaseURL string
cmd := &cobra.Command{
Use: "login",
Short: "Log in to UCloud via browser (OAuth)",
Long: loginLongHelp,
Args: cobra.NoArgs,
Example: "ucloud auth login\nucloud auth login --no-browser",
Run: func(cmd *cobra.Command, args []string) {
runLogin(noBrowser)
runLogin(noBrowser, oauthBaseURL)
},
}
cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Print the authorization URL instead of opening a browser (for headless/SSH environments)")
cmd.Flags().StringVar(&oauthBaseURL, "oauth-base-url", "", "Override the OAuth authorization server URL (for non-default environments; persisted to the profile)")
cmd.SetHelpTemplate(oauthHelpTmpl)
return cmd
}

func runLogin(noBrowser bool) {
// resolveLoginOAuthBase 决定登录使用的 OAuth 域:--oauth-base-url flag 最优先,
// 给定时写回 cfg.OAuthBaseURL 以便登录成功后随 profile 持久化(后续刷新沿用);
// 未给定则回退到 profile 配置或内置默认(GetOAuthBaseURL)。
func resolveLoginOAuthBase(cfg *base.AggConfig, flagVal string) (string, error) {
if flagVal != "" {
cfg.OAuthBaseURL = strings.TrimSuffix(flagVal, "/")
}
return base.GetOAuthBaseURL(cfg)
}

func runLogin(noBrowser bool, oauthBaseURL string) {
// AP-1:非 TTY fail-fast
if !base.IsStdinTTY() {
fmt.Fprintln(os.Stderr, "'ucloud auth login' requires an interactive terminal. For automation/CI, use an AK/SK profile: ucloud config --profile <name> --public-key <pub> --private-key <pri>")
os.Exit(1)
}

cfg := base.ConfigIns
oauthBase, err := base.GetOAuthBaseURL(cfg)
oauthBase, err := resolveLoginOAuthBase(cfg, oauthBaseURL)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
Expand Down
46 changes: 46 additions & 0 deletions cmd/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,54 @@ import (
"testing"

"github.com/ucloud/ucloud-sdk-go/services/uaccount"

"github.com/ucloud/ucloud-cli/base"
)

// resolveLoginOAuthBase 决定登录使用的 OAuth 域:--oauth-base-url flag 最优先(去尾斜杠后
// 写回 cfg.OAuthBaseURL 以便登录成功后随 profile 持久化),未给定则回退到 profile 配置或内置默认。
func TestResolveLoginOAuthBase(t *testing.T) {
// case 1: 给了 flag → 去尾斜杠后返回,并写回 cfg(证明持久化接线)
cfg := &base.AggConfig{}
got, err := resolveLoginOAuthBase(cfg, "https://oauth-global.example/")
if err != nil {
t.Fatalf("flag given: unexpected error: %v", err)
}
if got != "https://oauth-global.example" {
t.Errorf("flag given: got = %q, want trailing slash trimmed", got)
}
if cfg.OAuthBaseURL != "https://oauth-global.example" {
t.Errorf("flag given: cfg.OAuthBaseURL = %q, want it set to trimmed flag value", cfg.OAuthBaseURL)
}

// case 2: flag 为空,cfg 预置 → 返回 profile 值,cfg 不变
cfg = &base.AggConfig{OAuthBaseURL: "https://oauth-profile.example"}
got, err = resolveLoginOAuthBase(cfg, "")
if err != nil {
t.Fatalf("flag empty, cfg preset: unexpected error: %v", err)
}
if got != "https://oauth-profile.example" {
t.Errorf("flag empty, cfg preset: got = %q, want profile value", got)
}
if cfg.OAuthBaseURL != "https://oauth-profile.example" {
t.Errorf("flag empty, cfg preset: cfg.OAuthBaseURL = %q, want unchanged", cfg.OAuthBaseURL)
}

// case 3: flag 为空,cfg 为空 → 返回内置默认(与 GetOAuthBaseURL 对空 cfg 的结果一致)
cfg = &base.AggConfig{}
got, err = resolveLoginOAuthBase(cfg, "")
if err != nil {
t.Fatalf("flag empty, cfg empty: unexpected error: %v", err)
}
want, _ := base.GetOAuthBaseURL(&base.AggConfig{})
if want == "" {
t.Fatal("flag empty, cfg empty: built-in default is empty, test precondition broken")
}
if got != want {
t.Errorf("flag empty, cfg empty: got = %q, want built-in default %q", got, want)
}
}

// 回归:auth login 后已有 project_id 必须用新账号的项目列表校验。
// 跨账号/跨站点遗留的 project_id 若原样保留,后续业务命令全部 RetCode 292 "Project not exists"。
func TestResolveLoginProject(t *testing.T) {
Expand Down