From 637b195e4e74ec8b5640687f320e01e16ca08717 Mon Sep 17 00:00:00 2001 From: createitv Date: Tue, 13 Oct 2026 11:21:15 +0800 Subject: [PATCH] Add auth whoami command --- CHANGELOG.md | 4 ++ README.en.md | 1 + README.md | 1 + cmd/agc/command/root.go | 81 +++++++++++++++++++++++++++++++++++- cmd/agc/command/root_test.go | 53 ++++++++++++++++++++++- docs/CLI_USAGE.en.md | 16 +++++-- docs/CLI_USAGE.md | 16 +++++-- pkg/agcapi/identity.go | 55 ++++++++++++++++++++++++ pkg/agcapi/identity_test.go | 55 ++++++++++++++++++++++++ 9 files changed, 274 insertions(+), 8 deletions(-) create mode 100644 pkg/agcapi/identity.go create mode 100644 pkg/agcapi/identity_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e27516..e8f698f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ User-visible changes are listed here. Published binaries and versions are availa ## Unreleased +### Added + +- 增加 `agc auth whoami`,安全显示当前凭据身份;完成华为 OAuth Client 用户授权后,可通过 `AGC_OAUTH2_TOKEN` 与 `--teams` 查询官方团队账号列表。/ Added `agc auth whoami` for secret-free local identity output and optional official team-account lookup through `AGC_OAUTH2_TOKEN` and `--teams` after Huawei OAuth Client user authorization. + ## v0.3.0 — 2026-10-13 ### Added diff --git a/README.en.md b/README.en.md index ae12f75..583a0b5 100644 --- a/README.en.md +++ b/README.en.md @@ -22,6 +22,7 @@ brew tap createitv/tap && brew install agc-cli # Cross-platform CI: npm install --global @createitv/agc-cli agc auth login --service-account-file ~/.agc/service-account.json --name production +agc auth whoami agc init --app-id YOUR_APP_ID --default-profile production # Preview the query; add --dry-run=false to send it diff --git a/README.md b/README.md index 94ce78f..a0d6645 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ brew tap createitv/tap && brew install agc-cli # 跨平台 CI:npm install --global @createitv/agc-cli agc auth login --service-account-file ~/.agc/service-account.json --name production +agc auth whoami agc init --app-id YOUR_APP_ID --default-profile production # 预览查询;添加 --dry-run=false 才会发送请求 diff --git a/cmd/agc/command/root.go b/cmd/agc/command/root.go index 316f878..eba0f13 100644 --- a/cmd/agc/command/root.go +++ b/cmd/agc/command/root.go @@ -32,6 +32,21 @@ type options struct { project string } +type serviceAccountIdentity struct { + ProjectID string `json:"projectId,omitempty"` + SubAccount string `json:"subAccount"` +} + +type authIdentity struct { + Credential *agcapi.CredentialView `json:"credential,omitempty"` + ServiceAccount *serviceAccountIdentity `json:"serviceAccount,omitempty"` + Project *project.Config `json:"project,omitempty"` + Teams []map[string]any `json:"teams,omitempty"` + TeamResult json.RawMessage `json:"teamResult,omitempty"` + TeamLookup string `json:"teamLookup"` + Message string `json:"message,omitempty"` +} + func NewRootCommand() *cobra.Command { opts := &options{} cmd := &cobra.Command{ @@ -119,6 +134,8 @@ func authCommand(opts *options) *cobra.Command { var credentialsPath string var remote bool var checkBaseURL string + var includeTeams bool + var whoamiBaseURL string cmd := &cobra.Command{Use: "auth", Short: "Manage AppGallery Connect credentials"} login := &cobra.Command{ Use: "login", @@ -269,6 +286,68 @@ func authCommand(opts *options) *cobra.Command { check.Flags().StringVar(&credentialsPath, "credentials-path", "", "Override credentials file path") check.Flags().BoolVar(&remote, "remote", false, "Verify token and available app or project read permission") check.Flags().StringVar(&checkBaseURL, "base-url", "https://connect-api.cloud.huawei.com", "Connect API base URL for remote verification") + whoami := &cobra.Command{ + Use: "whoami", + Short: "Show the current credential identity and optional Huawei team accounts", + RunE: func(cmd *cobra.Command, args []string) error { + path, err := credentialPath(credentialsPath) + if err != nil { + return err + } + store, err := agcapi.LoadCredentials(path) + if err != nil { + return err + } + credential, ok, err := resolveCredential(opts, store) + if err != nil { + return err + } + identity := authIdentity{ + TeamLookup: "oauth-required", + Message: "Huawei team account names require OAuth client authorization; set AGC_OAUTH2_TOKEN and pass --teams.", + } + if ok { + view := credential.View() + identity.Credential = &view + if credential.Mode == "service-account" { + account, err := agcapi.LoadServiceAccount(credential.ServiceAccountFile) + if err != nil { + return err + } + identity.ServiceAccount = &serviceAccountIdentity{ProjectID: account.ProjectID, SubAccount: account.SubAccount} + } + } + config, err := project.Load(opts.project) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("load project context: %w", err) + } + if err == nil { + identity.Project = &config + } + if includeTeams { + oauth2Token := os.Getenv("AGC_OAUTH2_TOKEN") + if oauth2Token == "" { + return fmt.Errorf("--teams requires AGC_OAUTH2_TOKEN from a Huawei OAuth client authorization") + } + ctx, cancel := context.WithTimeout(cmd.Context(), opts.timeout) + defer cancel() + teamList, err := agcapi.UserTeamList(ctx, nil, whoamiBaseURL, oauth2Token) + if err != nil { + return fmt.Errorf("query Huawei team accounts: %w", err) + } + identity.Teams = teamList.Teams + identity.TeamResult = teamList.Ret + identity.TeamLookup = "verified" + identity.Message = "" + } else if !ok { + return fmt.Errorf("no active credential profile; run agc auth login or use AGC_OAUTH2_TOKEN with --teams") + } + return output.Write(cmd.OutOrStdout(), domain.Envelope[authIdentity]{Data: identity}, output.Format(opts.output), opts.pretty) + }, + } + whoami.Flags().StringVar(&credentialsPath, "credentials-path", "", "Override credentials file path") + whoami.Flags().BoolVar(&includeTeams, "teams", false, "Query Huawei's OAuth-only team account list using AGC_OAUTH2_TOKEN") + whoami.Flags().StringVar(&whoamiBaseURL, "base-url", "https://connect-api.cloud.huawei.com", "Connect API base URL") token := &cobra.Command{ Use: "token", Short: "Create an AppGallery Connect authorization token for the active credential", @@ -298,7 +377,7 @@ func authCommand(opts *options) *cobra.Command { }, } token.Flags().StringVar(&credentialsPath, "credentials-path", "", "Override credentials file path") - cmd.AddCommand(login, list, check, token) + cmd.AddCommand(login, list, check, whoami, token) return cmd } diff --git a/cmd/agc/command/root_test.go b/cmd/agc/command/root_test.go index 87c3c03..566eba2 100644 --- a/cmd/agc/command/root_test.go +++ b/cmd/agc/command/root_test.go @@ -23,7 +23,7 @@ import ( // Tests must never inherit a developer's real local credentials. func TestMain(m *testing.M) { - for _, name := range []string{"AGC_PROFILE", "AGC_CLIENT_ID", "AGC_CLIENT_KEY", "AGC_CLIENT_SECRET", "AGC_SERVICE_ACCOUNT_FILE", "AGC_CREDENTIALS_PATH", "AGC_ACCESS_TOKEN", "AGC_SERVER_TOKEN"} { + for _, name := range []string{"AGC_PROFILE", "AGC_CLIENT_ID", "AGC_CLIENT_KEY", "AGC_CLIENT_SECRET", "AGC_SERVICE_ACCOUNT_FILE", "AGC_CREDENTIALS_PATH", "AGC_ACCESS_TOKEN", "AGC_OAUTH2_TOKEN", "AGC_SERVER_TOKEN"} { if err := os.Unsetenv(name); err != nil { os.Exit(1) } @@ -342,6 +342,57 @@ func TestAuthLoginAndCheck(t *testing.T) { } } +func TestAuthWhoAmIShowsActiveIdentityWithoutSecrets(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials.json") + if _, err := execute("auth", "login", "--client-id", "developer-client", "--client-key", "test-secret", "--name", "production", "--credentials-path", path); err != nil { + t.Fatal(err) + } + out, err := execute("auth", "whoami", "--credentials-path", path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, `"name":"production"`) || !strings.Contains(out, `"clientId":"developer-client"`) || !strings.Contains(out, `"teamLookup":"oauth-required"`) { + t.Fatalf("output = %s", out) + } + if strings.Contains(out, "test-secret") || strings.Contains(out, "clientKey") { + t.Fatal("whoami output exposes client key") + } +} + +func TestAuthWhoAmIReadsOfficialOAuthTeamList(t *testing.T) { + t.Setenv("AGC_OAUTH2_TOKEN", "oauth-test-secret") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/ups/user-permission-service/v1/user-team-list" { + t.Errorf("path = %s", r.URL.Path) + } + if r.Header.Get("oauth2Token") != "oauth-test-secret" { + t.Error("missing OAuth team-list token") + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ret":{"code":0,"msg":"success"},"teams":[{"teamId":"team-1","teamName":"Example Studio"}]}`)) + })) + defer srv.Close() + + out, err := execute("auth", "whoami", "--teams", "--base-url", srv.URL, "--credentials-path", filepath.Join(t.TempDir(), "missing.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, `"teamLookup":"verified"`) || !strings.Contains(out, `"teamName":"Example Studio"`) { + t.Fatalf("output = %s", out) + } + if strings.Contains(out, "oauth-test-secret") { + t.Fatal("whoami output exposes OAuth token") + } +} + +func TestAuthWhoAmITeamsRequiresOAuthToken(t *testing.T) { + t.Setenv("AGC_OAUTH2_TOKEN", "") + _, err := execute("auth", "whoami", "--teams", "--credentials-path", filepath.Join(t.TempDir(), "missing.json")) + if err == nil || !strings.Contains(err.Error(), "AGC_OAUTH2_TOKEN") { + t.Fatalf("error = %v", err) + } +} + func TestResolveCredentialUsesProjectProfileAndCLIOverride(t *testing.T) { projectDir := t.TempDir() if err := project.Save(projectDir, project.Config{AppID: "123", Profile: "production"}); err != nil { diff --git a/docs/CLI_USAGE.en.md b/docs/CLI_USAGE.en.md index d69902b..0612930 100644 --- a/docs/CLI_USAGE.en.md +++ b/docs/CLI_USAGE.en.md @@ -24,10 +24,10 @@ Keep your Service Account JSON at a stable local path: ```bash agc auth login --service-account-file ~/.agc/service-account.json --name production -agc auth check +agc auth whoami ``` -`auth check` displays local credential configuration. It does not verify that Huawei accepts those credentials. See [authentication and multiple accounts](#authentication-and-multiple-accounts) for alternatives. +`auth whoami` displays the current local credential identity without secret material. `auth check --remote` verifies the token and an available app or project read permission. See [authentication and multiple accounts](#authentication-and-multiple-accounts) for alternatives. ### 3. Bind your project @@ -169,9 +169,19 @@ agc --project ../another-app auth check | Environment variable | Purpose | | --- | --- | | `AGC_ACCESS_TOKEN` | Bearer token for endpoint requests; below `--token`, above profile credentials | +| `AGC_OAUTH2_TOKEN` | Huawei OAuth Client user token used only by `agc auth whoami --teams` for the official team account list | | `AGC_CREDENTIALS_PATH` | Override `~/.agc/credentials.json`; below `--credentials-path` | -`agc auth token` outputs a token. Currently, API Client output from `auth login`, `auth list`, and `auth check` may include `clientKey`. Do not publish that output in issues or public CI logs. Keep credential files, Service Account private keys, and access tokens locally or in CI secret storage. +Inspect the current identity: + +```bash +agc auth whoami +AGC_OAUTH2_TOKEN="$HUAWEI_USER_TOKEN" agc auth whoami --teams +``` + +The normal command reports the profile, API Client ID, Service Account `sub_account`, and local project context without Client Key or private-key material. Huawei's [Project Management API](https://developer.huawei.com/consumer/en/doc/appgallery-connect-Guides/agcapi-project-overview-0000001111685232) makes its team account list OAuth Client-only; Service Account and API Client credentials cannot directly disclose the developer or team name. After user OAuth authorization, place the access token temporarily in `AGC_OAUTH2_TOKEN` and add `--teams` to query the official team list. Never put that token in command arguments, a repository, an issue, or public CI logs. + +`agc auth token` prints a token to the terminal. Keep credential files, Service Account private keys, and all access tokens locally or in CI secret storage. ## Command discovery and request flags diff --git a/docs/CLI_USAGE.md b/docs/CLI_USAGE.md index 02a4c4a..b6d03c1 100644 --- a/docs/CLI_USAGE.md +++ b/docs/CLI_USAGE.md @@ -24,10 +24,10 @@ Windows 和 Linux 用户见[安装与升级](#安装与升级)。NPM 安装需 ```bash agc auth login --service-account-file ~/.agc/service-account.json --name production -agc auth check +agc auth whoami ``` -`auth check` 显示当前本地凭据配置;它不验证华为服务器是否接受该凭据。其他登录方式见[鉴权与多账号](#鉴权与多账号)。 +`auth whoami` 显示当前本地凭据身份,不输出密钥。`auth check --remote` 可验证令牌和可用的应用或项目读取权限。其他登录方式见[鉴权与多账号](#鉴权与多账号)。 ### 3. 绑定项目 @@ -169,9 +169,19 @@ agc --project ../another-app auth check | 环境变量 | 用途 | | --- | --- | | `AGC_ACCESS_TOKEN` | 接口调用的 Bearer token,优先级低于 `--token`,高于凭据 profile | +| `AGC_OAUTH2_TOKEN` | 华为 OAuth Client 用户授权令牌,仅供 `agc auth whoami --teams` 查询官方团队账号列表 | | `AGC_CREDENTIALS_PATH` | 覆盖默认凭据文件 `~/.agc/credentials.json`;低于 `--credentials-path` | -`agc auth token` 输出令牌。当前 `auth login`、`auth list`、`auth check` 在 API Client 模式下可能输出 `clientKey`;这些输出不应直接上传到 Issue 或公开 CI 日志。凭据文件、Service Account 私钥和访问令牌应保存在本机或 CI secret store 中。 +查询当前身份: + +```bash +agc auth whoami +AGC_OAUTH2_TOKEN="$HUAWEI_USER_TOKEN" agc auth whoami --teams +``` + +普通调用显示 profile、API Client ID、Service Account `sub_account` 和本地项目上下文,不输出 Client Key 或私钥。华为[项目管理 API](https://developer.huawei.com/consumer/cn/doc/appgallery-connect-Guides/agcapi-project-overview-0000001111685232) 规定团队账号列表仅支持 OAuth Client 模式;Service Account 与 API Client 不能直接读取开发者/团队名称。完成用户 OAuth 授权后,将访问令牌临时放入 `AGC_OAUTH2_TOKEN` 并添加 `--teams`,命令会请求官方团队列表。不要将令牌写入命令参数、仓库、Issue 或公开 CI 日志。 + +`agc auth token` 会将令牌输出到终端;凭据文件、Service Account 私钥和所有访问令牌都应保存在本机或 CI secret store 中。 ## 命令发现与请求参数 diff --git a/pkg/agcapi/identity.go b/pkg/agcapi/identity.go new file mode 100644 index 0000000..b70a4d2 --- /dev/null +++ b/pkg/agcapi/identity.go @@ -0,0 +1,55 @@ +package agcapi + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// TeamListResponse is returned by Huawei's OAuth-only team account endpoint. +// Team objects remain maps because Huawei does not publish a stable field schema +// for the Teams type in the endpoint overview. +type TeamListResponse struct { + Ret json.RawMessage `json:"ret,omitempty"` + Teams []map[string]any `json:"teams"` +} + +func UserTeamList(ctx context.Context, client *http.Client, baseURL, oauth2Token string) (TeamListResponse, error) { + if oauth2Token == "" { + return TeamListResponse{}, fmt.Errorf("OAuth access token is required") + } + if client == nil { + client = http.DefaultClient + } + if baseURL == "" { + baseURL = "https://connect-api.cloud.huawei.com" + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(baseURL, "/")+"/api/ups/user-permission-service/v1/user-team-list", nil) + if err != nil { + return TeamListResponse{}, err + } + req.Header.Set("oauth2Token", oauth2Token) + resp, err := client.Do(req) + if err != nil { + return TeamListResponse{}, err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return TeamListResponse{}, err + } + if resp.StatusCode >= 400 { + return TeamListResponse{}, fmt.Errorf("team account endpoint returned HTTP %d", resp.StatusCode) + } + var result TeamListResponse + if err := json.Unmarshal(data, &result); err != nil { + return TeamListResponse{}, fmt.Errorf("decode team account response: %w", err) + } + if result.Teams == nil { + result.Teams = []map[string]any{} + } + return result, nil +} diff --git a/pkg/agcapi/identity_test.go b/pkg/agcapi/identity_test.go new file mode 100644 index 0000000..73ce442 --- /dev/null +++ b/pkg/agcapi/identity_test.go @@ -0,0 +1,55 @@ +package agcapi + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestUserTeamList(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/ups/user-permission-service/v1/user-team-list" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("oauth2Token") != "oauth-token" { + t.Error("missing oauth2Token header") + } + w.Write([]byte(`{"ret":{"code":0},"teams":[{"teamId":"one","teamName":"Example Studio"}]}`)) + })) + defer srv.Close() + + result, err := UserTeamList(context.Background(), srv.Client(), srv.URL, "oauth-token") + if err != nil { + t.Fatal(err) + } + if len(result.Teams) != 1 || result.Teams[0]["teamName"] != "Example Studio" { + t.Fatalf("result = %#v", result) + } +} + +func TestUserTeamListRejectsMissingTokenAndHTTPFailure(t *testing.T) { + if _, err := UserTeamList(context.Background(), nil, "", ""); err == nil { + t.Fatal("expected missing token error") + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "denied", http.StatusForbidden) + })) + defer srv.Close() + _, err := UserTeamList(context.Background(), srv.Client(), srv.URL, "oauth-token") + if err == nil || !strings.Contains(err.Error(), "HTTP 403") { + t.Fatalf("error = %v", err) + } +} + +func TestUserTeamListRejectsInvalidJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{`)) + })) + defer srv.Close() + _, err := UserTeamList(context.Background(), srv.Client(), srv.URL, "oauth-token") + if err == nil || !strings.Contains(err.Error(), "decode team account response") { + t.Fatalf("error = %v", err) + } +}