diff --git a/.woodpecker/release.yaml b/.woodpecker/release.yaml index 72a86f1..bca7063 100644 --- a/.woodpecker/release.yaml +++ b/.woodpecker/release.yaml @@ -28,7 +28,7 @@ steps: # for the shell instead of substituting them (as pipeline vars) at parse # time. ${CI_COMMIT_TAG} is a real Woodpecker var and stays single-$. - | - for entry in "vcm:./vcm" "vctx:./vctx"; do + for entry in "vcm:./vcm" "vctx:./vctx" "docker-credential-vault:./docker-credential-vault"; do name="$${entry%%:*}"; pkg="$${entry##*:}" for arch in amd64 arm64; do GOOS=linux GOARCH="$$arch" \ @@ -141,7 +141,7 @@ steps: # root; the package step writes the RPM to dist/. Generate a checksums # manifest over everything we attach so downloads can be verified. RPM=$$(ls dist/*.rpm 2>/dev/null | head -1) - ASSETS="vcm-linux-amd64 vcm-linux-arm64 vctx-linux-amd64 vctx-linux-arm64" + ASSETS="vcm-linux-amd64 vcm-linux-arm64 vctx-linux-amd64 vctx-linux-arm64 docker-credential-vault-linux-amd64 docker-credential-vault-linux-arm64" [ -n "$$RPM" ] && ASSETS="$$ASSETS $$RPM" sha256sum $$ASSETS > sha256sums.txt tea releases assets create "${CI_COMMIT_TAG}" $$ASSETS sha256sums.txt \ diff --git a/AGENTS.md b/AGENTS.md index c2492a3..99a28b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ and token cache so one `~/.config/vcm/` configures everything. shared/ # config.go, cache.go, vault.go (+ *_test.go) vcm/main.go # vcm CLI (module main package) vctx/main.go # vctx CLI (module main package) +docker-credential-vault/main.go # Docker credential helper (main package) docs/vcm.md # per-command docs docs/vctx.md go.mod # module git.unkin.net/unkin/vault-tools diff --git a/Makefile b/Makefile index 5865f09..57f4a47 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ # vault-tools: a Go monorepo of Vault token CLIs (vcm + vctx) sharing shared/. # Each tool is a separate main package under its own subfolder. -BINARIES := vcm vctx +BINARIES := vcm vctx docker-credential-vault +COMPLETIONS := vcm vctx DIST := dist VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)" @@ -38,7 +39,7 @@ install: # Generate bash/zsh/fish completions for every binary into dist/completions. completions: build @mkdir -p $(DIST)/completions - @for b in $(BINARIES); do \ + @for b in $(COMPLETIONS); do \ $(DIST)/$$b completion bash > $(DIST)/completions/$$b.bash; \ $(DIST)/$$b completion zsh > $(DIST)/completions/_$$b; \ $(DIST)/$$b completion fish > $(DIST)/completions/$$b.fish; \ diff --git a/README.md b/README.md index b6a015a..17ae041 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ knows about it. | -------------------- | ----------------------------------------------------------------------- | | [`vcm`](docs/vcm.md) | Log in to / renew Vault tokens per context and cache them on disk. | | [`vctx`](docs/vctx.md) | Run the real `vault` CLI against a named context (sets `VAULT_ADDR`/`VAULT_TOKEN`/`VAULT_NAMESPACE`, then execs `vault`). | +| `docker-credential-vault` | Docker credential helper: mints a fresh Vault identity OIDC token as the registry password on every pull/push. | See the per-command docs in [`docs/`](docs/) for full details. @@ -59,12 +60,37 @@ plus its accessor, policies, TTL/expiry and renewable flag. Files are encrypted with a per-boot key in the kernel user keyring; after a reboot the tokens show as locked, so run `vcm login` again. +## Docker credential helper + +`docker-credential-vault` uses a vcm context's cached token to mint +`identity/oidc/token/` on demand, so no registry password is stored. +Add it to `~/.docker/config.json` and remove any `auths` entry for the registry: + +```json +{"credHelpers": {"artifactapi.k8s.syd1.au.unkin.net": "vault"}} +``` + +`artifactapi.k8s.syd1.au.unkin.net` maps to role `artifactapi-writer`, user +`ci`, built in. Add or override registries in `~/.config/vcm/config.yaml`: + +```yaml +docker: + artifactapi.k8s.syd1.au.unkin.net: + context: sydney # optional when only one context is configured + role: artifactapi-writer + username: ci +``` + +With no context it falls back to `VAULT_ADDR` + `VAULT_TOKEN`/`~/.vault-token`. +It never prompts: run `vcm login ` when the token is missing or expired. + ## Layout ``` shared/ # config parsing, token cache, Vault HTTP client (shared lib + tests) vcm/ # vcm CLI (main package) vctx/ # vctx CLI (main package) +docker-credential-vault/ # Docker credential helper (main package) docs/ # per-command documentation packaging/nfpm.yaml# nfpm spec (envsubst-templated) for the RPM (both binaries + completions) scripts/build-rpm.sh diff --git a/docker-credential-vault/main.go b/docker-credential-vault/main.go new file mode 100644 index 0000000..b1a3a74 --- /dev/null +++ b/docker-credential-vault/main.go @@ -0,0 +1,171 @@ +// Command docker-credential-vault is a Docker credential helper that mints a +// fresh Vault identity OIDC token as the registry password on every `get`. +// +// ~/.docker/config.json: {"credHelpers": {"artifactapi.k8s.syd1.au.unkin.net": "vault"}} +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "git.unkin.net/unkin/vault-tools/shared" +) + +var version = "dev" + +// errNotFound is the docker-credential-helpers message docker treats as "no credentials". +const errNotFound = "credentials not found in native keychain" + +const defaultUsername = "ci" + +var defaultRegistries = map[string]shared.DockerRegistry{ + "artifactapi.k8s.syd1.au.unkin.net": {Role: "artifactapi-writer"}, +} + +type credentials struct { + ServerURL string + Username string + Secret string +} + +func main() { + os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) +} + +func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + if len(args) != 1 { + _, _ = fmt.Fprintln(stderr, "usage: docker-credential-vault ") + return 1 + } + switch args[0] { + case "store", "erase": + _, _ = io.Copy(io.Discard, stdin) + return 0 + case "version": + _, _ = fmt.Fprintln(stdout, version) + return 0 + } + cfg, err := shared.Load() + if err != nil { + _, _ = fmt.Fprintln(stdout, err) + return 1 + } + regs := registries(cfg) + switch args[0] { + case "list": + out := map[string]string{} + for host, r := range regs { + out[host] = r.Username + } + _ = json.NewEncoder(stdout).Encode(out) + return 0 + case "get": + in, _ := io.ReadAll(stdin) + serverURL := strings.TrimSpace(string(in)) + reg, ok := regs[hostOf(serverURL)] + if !ok { + _, _ = fmt.Fprintln(stdout, errNotFound) + return 1 + } + secret, err := mint(cfg, reg) + if err != nil { + _, _ = fmt.Fprintln(stdout, err) + return 1 + } + _ = json.NewEncoder(stdout).Encode(credentials{ServerURL: serverURL, Username: reg.Username, Secret: secret}) + return 0 + } + _, _ = fmt.Fprintf(stderr, "unknown command %q\n", args[0]) + return 1 +} + +// registries merges the config's docker entries over the built-in defaults, field by field. +func registries(cfg *shared.Config) map[string]shared.DockerRegistry { + out := map[string]shared.DockerRegistry{} + for h, r := range defaultRegistries { + out[h] = r + } + for h, r := range cfg.Docker { + h = hostOf(h) + d := out[h] + if r.Context != "" { + d.Context = r.Context + } + if r.Role != "" { + d.Role = r.Role + } + if r.Username != "" { + d.Username = r.Username + } + out[h] = d + } + for h, r := range out { + if r.Username == "" { + r.Username = defaultUsername + } + out[h] = r + } + return out +} + +// hostOf reduces a docker server URL ("https://host/v1/", "host") to its host. +func hostOf(s string) string { + if strings.Contains(s, "://") { + if u, err := url.Parse(s); err == nil { + return u.Host + } + } + h, _, _ := strings.Cut(s, "/") + return h +} + +// mint picks a Vault token (the registry's vcm context, the only configured +// context, else VAULT_ADDR + VAULT_TOKEN/~/.vault-token) and mints reg.Role with it. +func mint(cfg *shared.Config, reg shared.DockerRegistry) (string, error) { + if reg.Role == "" { + return "", errors.New("docker-credential-vault: registry has no role configured") + } + ctx := reg.Context + if ctx == "" && len(cfg.Contexts) == 1 { + ctx = cfg.ContextNames()[0] + } + if ctx == "" { + addr, token := os.Getenv("VAULT_ADDR"), os.Getenv("VAULT_TOKEN") + if token == "" { + home, _ := os.UserHomeDir() + b, _ := os.ReadFile(filepath.Join(home, ".vault-token")) + token = strings.TrimSpace(string(b)) + } + if addr == "" || token == "" { + return "", fmt.Errorf("docker-credential-vault: no vault context for this registry; set docker..context in %s and run 'vcm login '", shared.ConfigPath()) + } + return shared.MintOIDCToken(addr, os.Getenv("VAULT_NAMESPACE"), token, reg.Role) + } + rc, err := cfg.Resolve(ctx) + if err != nil { + return "", err + } + tok, err := shared.LoadToken(ctx) + if err != nil { + return "", err + } + if !tok.ExpiresAt.IsZero() && time.Now().After(tok.ExpiresAt) { + return "", fmt.Errorf("cached token for context %q expired (run 'vcm login %s')", ctx, ctx) + } + ns := rc.Namespace + if ns == "" { + ns = tok.Namespace + } + secret, err := shared.MintOIDCToken(rc.Address, ns, tok.Token, reg.Role) + if err != nil { + return "", fmt.Errorf("%w (run 'vcm login %s' if the token is invalid)", err, ctx) + } + return secret, nil +} diff --git a/docker-credential-vault/main_test.go b/docker-credential-vault/main_test.go new file mode 100644 index 0000000..627781c --- /dev/null +++ b/docker-credential-vault/main_test.go @@ -0,0 +1,244 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.unkin.net/unkin/golib/keyring" + "git.unkin.net/unkin/vault-tools/shared" + "golang.org/x/sys/unix" +) + +func TestMain(m *testing.M) { + shared.KeyringDesc = fmt.Sprintf("vault-tools-test:dcv:%d", os.Getpid()) + k, err := keyring.Open(shared.KeyringDesc) + if errors.Is(err, unix.ENOSYS) && os.Getenv("CI") == "" { + fmt.Println("skipping: kernel keyring unavailable:", err) + os.Exit(0) + } + if err != nil { + fmt.Println(err) + os.Exit(1) + } + code := m.Run() + _ = k.Forget() + os.Exit(code) +} + +// stubVault serves identity/oidc/token/ for token "s.good" only. +func stubVault(t *testing.T) (*httptest.Server, *[]string) { + t.Helper() + var calls []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path+" ns="+r.Header.Get("X-Vault-Namespace")) + if r.Header.Get("X-Vault-Token") != "s.good" { + w.WriteHeader(http.StatusForbidden) + _, _ = io.WriteString(w, `{"errors":["permission denied"]}`) + return + } + role := strings.TrimPrefix(r.URL.Path, "/v1/identity/oidc/token/") + _, _ = fmt.Fprintf(w, `{"data":{"token":"jwt-for-%s"}}`, role) + })) + t.Cleanup(srv.Close) + return srv, &calls +} + +func setup(t *testing.T, cfg string, tokens map[string]shared.Token) { + t.Helper() + cfgHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", cfgHome) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + t.Setenv("VAULT_ADDR", "") + t.Setenv("VAULT_TOKEN", "") + t.Setenv("VAULT_NAMESPACE", "") + dir := filepath.Join(cfgHome, "vcm") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + for name, tok := range tokens { + tk := tok + tk.Context = name + if err := shared.SaveToken(&tk); err != nil { + t.Fatal(err) + } + } +} + +func runCmd(args []string, stdin string) (int, string, string) { + var out, errb bytes.Buffer + code := run(args, strings.NewReader(stdin), &out, &errb) + return code, out.String(), errb.String() +} + +func TestGetDefaultRegistry(t *testing.T) { + srv, calls := stubVault(t) + setup(t, "contexts:\n sydney:\n address: "+srv.URL+"\n namespace: team\n", + map[string]shared.Token{"sydney": {Address: srv.URL, Token: "s.good"}}) + + code, out, _ := runCmd([]string{"get"}, "artifactapi.k8s.syd1.au.unkin.net\n") + if code != 0 { + t.Fatalf("get exit %d: %s", code, out) + } + var c credentials + if err := json.Unmarshal([]byte(out), &c); err != nil { + t.Fatalf("decode %q: %v", out, err) + } + want := credentials{ServerURL: "artifactapi.k8s.syd1.au.unkin.net", Username: "ci", Secret: "jwt-for-artifactapi-writer"} + if c != want { + t.Errorf("creds = %+v, want %+v", c, want) + } + if len(*calls) != 1 || (*calls)[0] != "GET /v1/identity/oidc/token/artifactapi-writer ns=team" { + t.Errorf("vault calls = %v", *calls) + } +} + +func TestGetOverrideAndSchemeURL(t *testing.T) { + srv, _ := stubVault(t) + setup(t, `contexts: + a: + address: `+srv.URL+` + b: + address: `+srv.URL+` +docker: + artifactapi.k8s.syd1.au.unkin.net: + context: b + username: pusher + reg.example.net: + context: b + role: other +`, map[string]shared.Token{"a": {Token: "s.bad"}, "b": {Token: "s.good"}}) + + code, out, _ := runCmd([]string{"get"}, "https://artifactapi.k8s.syd1.au.unkin.net/v1/") + if code != 0 || !strings.Contains(out, `"Username":"pusher"`) || !strings.Contains(out, `"Secret":"jwt-for-artifactapi-writer"`) { + t.Errorf("default override get = %d %s", code, out) + } + code, out, _ = runCmd([]string{"get"}, "reg.example.net") + if code != 0 || !strings.Contains(out, `"Username":"ci"`) || !strings.Contains(out, `"Secret":"jwt-for-other"`) { + t.Errorf("extra registry get = %d %s", code, out) + } +} + +func TestGetUnknownRegistry(t *testing.T) { + setup(t, "", nil) + code, out, _ := runCmd([]string{"get"}, "docker.io") + if code == 0 || strings.TrimSpace(out) != errNotFound { + t.Errorf("get unknown = %d %q, want non-zero %q", code, out, errNotFound) + } +} + +func TestGetNoToken(t *testing.T) { + setup(t, "contexts:\n sydney:\n address: https://v\n", nil) + code, out, _ := runCmd([]string{"get"}, "artifactapi.k8s.syd1.au.unkin.net") + if code == 0 || !strings.Contains(out, "vcm login sydney") || strings.Contains(out, "Secret") { + t.Errorf("get without token = %d %q", code, out) + } +} + +func TestGetExpiredToken(t *testing.T) { + srv, calls := stubVault(t) + setup(t, "contexts:\n sydney:\n address: "+srv.URL+"\n", + map[string]shared.Token{"sydney": {Token: "s.good", ExpiresAt: time.Now().Add(-time.Minute)}}) + code, out, _ := runCmd([]string{"get"}, "artifactapi.k8s.syd1.au.unkin.net") + if code == 0 || !strings.Contains(out, "expired") || len(*calls) != 0 { + t.Errorf("get expired = %d %q calls=%v", code, out, *calls) + } +} + +func TestGetVaultDenied(t *testing.T) { + srv, _ := stubVault(t) + setup(t, "contexts:\n sydney:\n address: "+srv.URL+"\n", + map[string]shared.Token{"sydney": {Token: "s.bad"}}) + code, out, _ := runCmd([]string{"get"}, "artifactapi.k8s.syd1.au.unkin.net") + if code == 0 || !strings.Contains(out, "permission denied") || !strings.Contains(out, "vcm login sydney") { + t.Errorf("get denied = %d %q", code, out) + } +} + +func TestGetEnvFallback(t *testing.T) { + srv, _ := stubVault(t) + setup(t, "", nil) + t.Setenv("VAULT_ADDR", srv.URL) + if err := os.WriteFile(filepath.Join(os.Getenv("HOME"), ".vault-token"), []byte("s.good\n"), 0o600); err != nil { + t.Fatal(err) + } + code, out, _ := runCmd([]string{"get"}, "artifactapi.k8s.syd1.au.unkin.net") + if code != 0 || !strings.Contains(out, "jwt-for-artifactapi-writer") { + t.Errorf("~/.vault-token fallback = %d %q", code, out) + } + t.Setenv("VAULT_TOKEN", "s.bad") + if code, _, _ := runCmd([]string{"get"}, "artifactapi.k8s.syd1.au.unkin.net"); code == 0 { + t.Error("VAULT_TOKEN should take precedence over ~/.vault-token") + } +} + +func TestGetNoContextNoEnv(t *testing.T) { + setup(t, "contexts:\n a:\n address: https://a\n b:\n address: https://b\n", nil) + code, out, _ := runCmd([]string{"get"}, "artifactapi.k8s.syd1.au.unkin.net") + if code == 0 || !strings.Contains(out, "docker..context") { + t.Errorf("ambiguous context = %d %q", code, out) + } +} + +func TestList(t *testing.T) { + setup(t, "docker:\n reg.example.net:\n role: r\n username: u\n", nil) + code, out, _ := runCmd([]string{"list"}, "") + var got map[string]string + if err := json.Unmarshal([]byte(out), &got); code != 0 || err != nil { + t.Fatalf("list = %d %q %v", code, out, err) + } + want := map[string]string{"artifactapi.k8s.syd1.au.unkin.net": "ci", "reg.example.net": "u"} + if len(got) != len(want) || got["artifactapi.k8s.syd1.au.unkin.net"] != "ci" || got["reg.example.net"] != "u" { + t.Errorf("list = %v, want %v", got, want) + } +} + +func TestStoreEraseNoop(t *testing.T) { + setup(t, "", nil) + cache := os.Getenv("XDG_CACHE_HOME") + for _, c := range []struct{ cmd, in string }{ + {"store", `{"ServerURL":"artifactapi.k8s.syd1.au.unkin.net","Username":"ci","Secret":"x"}`}, + {"erase", "artifactapi.k8s.syd1.au.unkin.net"}, + } { + if code, out, _ := runCmd([]string{c.cmd}, c.in); code != 0 || out != "" { + t.Errorf("%s = %d %q", c.cmd, code, out) + } + } + if ents, _ := os.ReadDir(cache); len(ents) != 0 { + t.Errorf("store/erase wrote to cache: %v", ents) + } +} + +func TestBadArgs(t *testing.T) { + for _, a := range [][]string{nil, {"bogus"}, {"get", "x"}} { + if code, _, _ := runCmd(a, ""); code == 0 { + t.Errorf("run(%v) exit 0", a) + } + } +} + +func TestHostOf(t *testing.T) { + for in, want := range map[string]string{ + "artifactapi.k8s.syd1.au.unkin.net": "artifactapi.k8s.syd1.au.unkin.net", + "https://artifactapi.k8s.syd1.au.unkin.net/": "artifactapi.k8s.syd1.au.unkin.net", + "https://index.docker.io/v1/": "index.docker.io", + "reg:5000/": "reg:5000", + } { + if got := hostOf(in); got != want { + t.Errorf("hostOf(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml index c77cfa7..6b1e342 100644 --- a/packaging/nfpm.yaml +++ b/packaging/nfpm.yaml @@ -36,6 +36,12 @@ contents: mode: 0755 owner: root group: root + - src: dist/docker-credential-vault + dst: /usr/bin/docker-credential-vault + file_info: + mode: 0755 + owner: root + group: root # Shell completions (generated by scripts/build-rpm.sh before packaging). - src: dist/completions/vcm.bash diff --git a/scripts/build-rpm.sh b/scripts/build-rpm.sh index 5be3a27..f8a6b02 100755 --- a/scripts/build-rpm.sh +++ b/scripts/build-rpm.sh @@ -12,7 +12,8 @@ cd "${ROOT_DIR}" VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}" VERSION="${VERSION#v}" # strip a leading v PACKAGE="vault-tools" -BINARIES=(vcm vctx) +BINARIES=(vcm vctx docker-credential-vault) +COMPLETIONS=(vcm vctx) DIST="dist" for b in "${BINARIES[@]}"; do @@ -26,7 +27,7 @@ done # match the shipped flags/subcommands. COMP_DIR="${DIST}/completions" mkdir -p "${COMP_DIR}" -for b in "${BINARIES[@]}"; do +for b in "${COMPLETIONS[@]}"; do "./${DIST}/${b}" completion bash >"${COMP_DIR}/${b}.bash" "./${DIST}/${b}" completion zsh >"${COMP_DIR}/_${b}" "./${DIST}/${b}" completion fish >"${COMP_DIR}/${b}.fish" @@ -37,7 +38,7 @@ export PACKAGE_VERSION="${VERSION}" export PACKAGE_RELEASE="1" export PACKAGE_ARCH="amd64" export PACKAGE_PLATFORM="linux" -export PACKAGE_DESCRIPTION="CLI tools for managing Vault tokens across multiple vault instances: vcm (per-context login/renew) and vctx (context-aware vault CLI wrapper)" +export PACKAGE_DESCRIPTION="CLI tools for managing Vault tokens across multiple vault instances: vcm (per-context login/renew) and vctx (context-aware vault CLI wrapper) and docker-credential-vault (Docker credential helper)" export PACKAGE_MAINTAINER="Ben Vincent " export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/vault-tools" export PACKAGE_LICENSE="MIT" diff --git a/shared/config.go b/shared/config.go index c35b9df..7eef10e 100644 --- a/shared/config.go +++ b/shared/config.go @@ -44,6 +44,16 @@ type Context struct { OIDCListen string `yaml:"oidc_listen,omitempty"` } +// DockerRegistry configures docker-credential-vault for one registry host. +type DockerRegistry struct { + // Context is the vcm context whose cached token mints the registry token. + Context string `yaml:"context,omitempty"` + // Role is the Vault identity OIDC role (identity/oidc/token/). + Role string `yaml:"role,omitempty"` + // Username is sent to the registry alongside the minted token. + Username string `yaml:"username,omitempty"` +} + // Defaults holds file-level fallbacks applied to every context that does not // set its own value. type Defaults struct { @@ -58,6 +68,8 @@ type Defaults struct { type Config struct { Defaults Defaults `yaml:"defaults"` Contexts map[string]Context `yaml:"contexts"` + // Docker maps registry hosts to the identity OIDC role docker-credential-vault mints. + Docker map[string]DockerRegistry `yaml:"docker,omitempty"` // path records the file this config was loaded from (empty if none found). path string diff --git a/shared/vault.go b/shared/vault.go index 5539cbb..233a8eb 100644 --- a/shared/vault.go +++ b/shared/vault.go @@ -229,3 +229,23 @@ func Renew(rc ResolvedContext, prev *Token) (*Token, error) { } return t, nil } + +// MintOIDCToken mints a Vault identity token for role via identity/oidc/token/. +func MintOIDCToken(address, namespace, token, role string) (string, error) { + data, err := doRequest(http.MethodGet, address, "identity/oidc/token/"+role, namespace, token, nil) + if err != nil { + return "", err + } + var r struct { + Data struct { + Token string `json:"token"` + } `json:"data"` + } + if err := json.Unmarshal(data, &r); err != nil { + return "", fmt.Errorf("decoding identity token response: %w", err) + } + if r.Data.Token == "" { + return "", fmt.Errorf("identity/oidc/token/%s returned no token", role) + } + return r.Data.Token, nil +}