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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 才会发送请求
Expand Down
81 changes: 80 additions & 1 deletion cmd/agc/command/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
}

Expand Down
53 changes: 52 additions & 1 deletion cmd/agc/command/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 13 additions & 3 deletions docs/CLI_USAGE.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
16 changes: 13 additions & 3 deletions docs/CLI_USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 绑定项目

Expand Down Expand Up @@ -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 中。

## 命令发现与请求参数

Expand Down
55 changes: 55 additions & 0 deletions pkg/agcapi/identity.go
Original file line number Diff line number Diff line change
@@ -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
}
55 changes: 55 additions & 0 deletions pkg/agcapi/identity_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading