From 0085482fc74a18f575bb3b0193be1e86f5ecbf0d Mon Sep 17 00:00:00 2001 From: Andre Loreth Date: Mon, 20 Jul 2026 23:45:54 +0200 Subject: [PATCH] feat: add --format selector with real Kubernetes ExecCredential output Replaces the boolean --all toggle with a --format selector (token|json|exec-credential). Adds a genuine Kubernetes ExecCredential envelope honoring --token-type and echoing KUBERNETES_EXEC_INFO apiVersion. Keeps --all as a deprecated alias for --format=json. --- README.md | 18 +++-- cmd/oidc-token/main.go | 25 +++--- cmd/oidc-token/main_test.go | 33 ++++++++ internal/config/config.go | 29 ++++++- internal/config/config_test.go | 84 ++++++++++++++++++++ internal/config/usage.go | 2 +- internal/output/doc.go | 5 +- internal/output/output.go | 64 +++++++++++++++ internal/output/output_test.go | 138 +++++++++++++++++++++++++++++++++ 9 files changed, 375 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 9389198..1e0c5f2 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ oidc-token \ [--token-store auto|keychain|file|none] \ [--redirect PORT] \ [--non-interactive] \ + [--format token|json|exec-credential] \ [--all] \ [--logout] \ [--config FILE] \ @@ -92,7 +93,8 @@ oidc-token \ |---|---|---| | `--redirect` | `0` (ephemeral) | Fixed loopback port for the authcode callback, if your IdP requires an exact redirect URI. | | `--non-interactive` | `false` | Never emit a device-code prompt; authcode+browser is still allowed if a display is available. | -| `--all` | `false` | Print a JSON document instead of a bare token. | +| `--format` | `token` | `token`, `json`, or `exec-credential`. See [kubectl ExecCredential](#kubectl-execcredential---format-exec-credential). | +| `--all` | `false` | Deprecated: alias for `--format=json`. Print a JSON document instead of a bare token. Ignored when `--format` is set explicitly. | | `--logout` | `false` | Clear the cached entry for `--issuer`/`--client-id` and exit; no login or refresh is attempted. | | `--config` | *(none)* | Optional JSON config file. | | `--extra` | *(none)* | Repeatable `key=value` pair forwarded to the token endpoint. In a config file, set as an `"extra"` object. | @@ -298,7 +300,7 @@ oidc-token --issuer https://id.example.com/ --client-id frpc-client \ --token-type access_token --audience frps ``` -### kubectl `ExecCredential`-style (`--all`) +### kubectl `ExecCredential` (`--format exec-credential`) ```yaml # ~/.kube/config (users[].user.exec) @@ -309,12 +311,16 @@ exec: - --issuer=https://id.example.com/ - --client-id=my-k8s-client - --token-type=id_token - - --all + - --format=exec-credential ``` -`--all`'s JSON has `access_token`/`id_token`/`refresh_token`/`expiry` but -no `apiVersion`/`kind`/`status` envelope — wrap it if your consumer needs -the literal k8s schema. +`--format=exec-credential` emits a genuine Kubernetes `ExecCredential` +envelope: `apiVersion`, `kind: ExecCredential`, and a `status.token` field +(plus `status.expirationTimestamp` when the token has a known expiry) — +exactly what `kubectl`'s exec plugin protocol expects, no wrapping needed. +The `apiVersion` echoes the one `kubectl` passes via `$KUBERNETES_EXEC_INFO`, +falling back to `client.authentication.k8s.io/v1` when that env var is +absent or unparsable. ### CI (`--non-interactive`) diff --git a/cmd/oidc-token/main.go b/cmd/oidc-token/main.go index b0ecaf3..a2b9d39 100644 --- a/cmd/oidc-token/main.go +++ b/cmd/oidc-token/main.go @@ -2,9 +2,10 @@ // client, using cached credentials and silent refresh where possible. // // This file is the only place in the program that writes to stdout. On -// success it writes exactly the token bytes (or, with --all, a JSON -// document) and exits 0. On any failure it writes a message to stderr, -// writes nothing to stdout, and exits non-zero. +// success it writes exactly the selected --format's output (bare token +// bytes, a JSON document, or an ExecCredential envelope) and exits 0. On +// any failure it writes a message to stderr, writes nothing to stdout, and +// exits non-zero. package main import ( @@ -113,7 +114,7 @@ func run(args []string, stdout, stderr io.Writer, newSource newSourceFunc, newTo fmt.Fprintln(stderr, "error:", err) return 1 } - return writeResult(stdout, stderr, cfg, result) + return writeResult(stdout, stderr, cfg, result, os.Getenv) } store, err := buildStore(ctx, cfg, stderr) @@ -144,19 +145,23 @@ func run(args []string, stdout, stderr io.Writer, newSource newSourceFunc, newTo return 1 } - return writeResult(stdout, stderr, cfg, result) + return writeResult(stdout, stderr, cfg, result, os.Getenv) } -// writeResult writes result to stdout as either a bare token or, with -// --all, a JSON document. The full output is built in memory first: a +// writeResult writes result to stdout in the format selected by +// cfg.Format: a bare token, a full JSON document, or a Kubernetes +// ExecCredential envelope. The full output is built in memory first: a // write failure partway through must never leave a partial token on // stdout. -func writeResult(stdout, stderr io.Writer, cfg *config.Config, result output.Result) int { +func writeResult(stdout, stderr io.Writer, cfg *config.Config, result output.Result, getenv func(string) string) int { var buf bytes.Buffer var err error - if cfg.All { + switch cfg.Format { + case config.OutputFormatJSON: err = output.WriteAll(&buf, result) - } else { + case config.OutputFormatExecCredential: + err = output.WriteExecCredential(&buf, result, output.TokenType(cfg.TokenType), output.ExecCredentialAPIVersion(getenv)) + default: err = output.WriteBare(&buf, result, output.TokenType(cfg.TokenType)) } if err != nil { diff --git a/cmd/oidc-token/main_test.go b/cmd/oidc-token/main_test.go index 91270de..897034f 100644 --- a/cmd/oidc-token/main_test.go +++ b/cmd/oidc-token/main_test.go @@ -133,6 +133,39 @@ func TestRun_Success_All_ValidJSON(t *testing.T) { } } +func TestRun_Success_ExecCredential_ValidJSON(t *testing.T) { + dir := t.TempDir() + var stdout, stderr bytes.Buffer + + code := run([]string{ + "--issuer=https://issuer.example", "--client-id=cid", "--format=exec-credential", + "--token-store-dir=" + filepath.Join(dir, "cache"), "--token-store=file", + }, &stdout, &stderr, func(cfg *config.Config) runner.TokenSource { + return fakeSource{loginResult: output.Result{AccessToken: "at", IDToken: "it", RefreshToken: "rt"}} + }, failTokenExchange(t)) + + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, stderr.String()) + } + var doc map[string]any + if err := json.Unmarshal(stdout.Bytes(), &doc); err != nil { + t.Fatalf("--format=exec-credential stdout is not valid JSON: %v, raw: %s", err, stdout.String()) + } + if _, ok := doc["apiVersion"]; !ok { + t.Fatalf("expected apiVersion in output, got %v", doc) + } + if doc["kind"] != "ExecCredential" { + t.Fatalf("kind = %v, want ExecCredential", doc["kind"]) + } + status, ok := doc["status"].(map[string]any) + if !ok { + t.Fatalf("status is not an object: %v", doc["status"]) + } + if token, _ := status["token"].(string); token == "" { + t.Fatalf("status.token must be non-empty, got %v", status["token"]) + } +} + func TestRun_LoginFailure_ExitNonZero_EmptyStdout_NonEmptyStderr(t *testing.T) { dir := t.TempDir() var stdout, stderr bytes.Buffer diff --git a/internal/config/config.go b/internal/config/config.go index 0c10b50..9c1e9c4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -37,6 +37,15 @@ const ( TokenTypeIDToken TokenType = "id_token" ) +// OutputFormat selects how the final result is written to stdout. +type OutputFormat string + +const ( + OutputFormatToken OutputFormat = "token" + OutputFormatJSON OutputFormat = "json" + OutputFormatExecCredential OutputFormat = "exec-credential" +) + // ClientAuthMethod selects how the client authenticates itself to the token // endpoint. ClientAuthNone (the default) is a public client: no secret, no // assertion, identical to this tool's original behavior. @@ -108,9 +117,10 @@ type Config struct { TokenStore cache.Backend // auto|keychain|file|none, see cache.Backend RedirectPort int // 0 = ephemeral loopback port (RFC 8252 default) NonInteractive bool - All bool // --all: print full JSON document instead of a bare token - Logout bool // --logout: clear the cached entry and exit, no login/refresh - ExtraFields url.Values // --extra key=value pairs forwarded to the token endpoint + Format OutputFormat // --format: token|json|exec-credential, see OutputFormat + All bool // --all: print full JSON document instead of a bare token (deprecated: use --format=json) + Logout bool // --logout: clear the cached entry and exit, no login/refresh + ExtraFields url.Values // --extra key=value pairs forwarded to the token endpoint // ClientAuthMethod selects how the client authenticates to the token // endpoint. ClientAuthNone means a public client (this tool's original, @@ -199,6 +209,7 @@ func Parse(args []string, stderr io.Writer, env Env, grants []grant.Grant) (*Con grantTypeStr = string(GrantAuto) tokenTypeStr = string(TokenTypeAccessToken) tokenStoreStr = string(cache.BackendAuto) + formatStr = string(OutputFormatToken) clientAuthStr string ) @@ -214,7 +225,8 @@ func Parse(args []string, stderr io.Writer, env Env, grants []grant.Grant) (*Con &flagbinding.StringField{Target: &cfg.TokenStoreDir, FlagName: "token-store-dir", EnvKey: "OIDC_TOKEN_STORE_DIR", JsonKey: "token_store_dir", Usage: "token store directory for the file backend"}, &flagbinding.StringField{Target: &tokenStoreStr, FlagName: "token-store", EnvKey: "OIDC_TOKEN_STORE", JsonKey: "token_store", Usage: "auto|keychain|file|none", Def: string(cache.BackendAuto)}, &flagbinding.BoolField{Target: &cfg.NonInteractive, FlagName: "non-interactive", EnvKey: "OIDC_TOKEN_NON_INTERACTIVE", JsonKey: "non_interactive", Usage: "disable browser and device-code prompts"}, - &flagbinding.BoolField{Target: &cfg.All, FlagName: "all", JsonKey: "all", Usage: "print full JSON token response"}, + &flagbinding.StringField{Target: &formatStr, FlagName: "format", EnvKey: "OIDC_TOKEN_FORMAT", JsonKey: "format", Usage: "token|json|exec-credential", Def: string(OutputFormatToken)}, + &flagbinding.BoolField{Target: &cfg.All, FlagName: "all", JsonKey: "all", Usage: "print full JSON token response (deprecated: use --format=json)"}, &flagbinding.BoolField{Target: &cfg.Logout, FlagName: "logout", EnvKey: "OIDC_TOKEN_LOGOUT", JsonKey: "logout", Usage: "clear cached tokens and exit"}, &flagbinding.StringField{Target: &clientAuthStr, FlagName: "client-auth-method", EnvKey: "OIDC_TOKEN_CLIENT_AUTH_METHOD", JsonKey: "client_auth_method", Usage: "client_secret_basic|client_secret_post|private_key_jwt"}, &flagbinding.StringField{Target: &cfg.ClientSecret, FlagName: "client-secret", EnvKey: "OIDC_TOKEN_CLIENT_SECRET", JsonKey: "client_secret", Usage: "client secret for client_secret_basic or client_secret_post"}, @@ -275,6 +287,10 @@ func Parse(args []string, stderr io.Writer, env Env, grants []grant.Grant) (*Con cfg.TokenType = TokenType(tokenTypeStr) cfg.TokenStore = cache.Backend(tokenStoreStr) cfg.ClientAuthMethod = ClientAuthMethod(clientAuthStr) + cfg.Format = OutputFormat(formatStr) + if !explicit["format"] && cfg.All { + cfg.Format = OutputFormatJSON + } // 5. Special-case overrides not covered by the table. if explicit["extra"] { @@ -398,6 +414,11 @@ func (c *Config) validate(grants []grant.Grant) error { default: return fmt.Errorf("config: invalid --token-type %q (want access_token|id_token)", c.TokenType) } + switch c.Format { + case OutputFormatToken, OutputFormatJSON, OutputFormatExecCredential: + default: + return fmt.Errorf("config: invalid --format %q (want token|json|exec-credential)", c.Format) + } switch c.TokenStore { case cache.BackendAuto, cache.BackendKeychain, cache.BackendFile, cache.BackendNone: default: diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d447c62..5597533 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -163,6 +163,90 @@ func TestParse_InvalidTokenType(t *testing.T) { } } +func TestParse_Format_DefaultsToToken(t *testing.T) { + var stderr bytes.Buffer + cfg, err := Parse([]string{"--issuer=https://issuer.example", "--client-id=cid"}, &stderr, Env{Getenv: noEnv}, testGrants()) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if cfg.Format != OutputFormatToken { + t.Errorf("Format = %q, want %q", cfg.Format, OutputFormatToken) + } +} + +func TestParse_Format_JSONAndExecCredential(t *testing.T) { + var stderr bytes.Buffer + cfg, err := Parse([]string{ + "--issuer=https://issuer.example", "--client-id=cid", "--format=json", + }, &stderr, Env{Getenv: noEnv}, testGrants()) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if cfg.Format != OutputFormatJSON { + t.Errorf("Format = %q, want %q", cfg.Format, OutputFormatJSON) + } + + cfg, err = Parse([]string{ + "--issuer=https://issuer.example", "--client-id=cid", "--format=exec-credential", + }, &stderr, Env{Getenv: noEnv}, testGrants()) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if cfg.Format != OutputFormatExecCredential { + t.Errorf("Format = %q, want %q", cfg.Format, OutputFormatExecCredential) + } +} + +func TestParse_Format_Invalid(t *testing.T) { + var stderr bytes.Buffer + _, err := Parse([]string{ + "--issuer=https://issuer.example", "--client-id=cid", "--format=bogus", + }, &stderr, Env{Getenv: noEnv}, testGrants()) + if err == nil { + t.Fatal("expected error for invalid --format") + } +} + +func TestParse_Format_AllAliasesToJSON(t *testing.T) { + var stderr bytes.Buffer + cfg, err := Parse([]string{ + "--issuer=https://issuer.example", "--client-id=cid", "--all", + }, &stderr, Env{Getenv: noEnv}, testGrants()) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if cfg.Format != OutputFormatJSON { + t.Errorf("Format = %q, want %q (--all alias)", cfg.Format, OutputFormatJSON) + } +} + +func TestParse_Format_ExplicitFormatWinsOverAll(t *testing.T) { + var stderr bytes.Buffer + cfg, err := Parse([]string{ + "--issuer=https://issuer.example", "--client-id=cid", "--all", "--format=token", + }, &stderr, Env{Getenv: noEnv}, testGrants()) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if cfg.Format != OutputFormatToken { + t.Errorf("Format = %q, want %q (explicit --format wins over --all)", cfg.Format, OutputFormatToken) + } +} + +func TestParse_Format_EnvVar(t *testing.T) { + env := envFrom(map[string]string{"OIDC_TOKEN_FORMAT": "exec-credential"}) + var stderr bytes.Buffer + cfg, err := Parse([]string{ + "--issuer=https://issuer.example", "--client-id=cid", + }, &stderr, Env{Getenv: env}, testGrants()) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if cfg.Format != OutputFormatExecCredential { + t.Errorf("Format = %q, want env override %q", cfg.Format, OutputFormatExecCredential) + } +} + func TestParse_TokenStoreDirOverride(t *testing.T) { var stderr bytes.Buffer cfg, err := Parse([]string{ diff --git a/internal/config/usage.go b/internal/config/usage.go index e608edf..a80ca9f 100644 --- a/internal/config/usage.go +++ b/internal/config/usage.go @@ -20,7 +20,7 @@ type flagGroup struct { // silently disappear from --help. var groups = []flagGroup{ {Title: "Common", Flags: []string{"issuer", "client-id", "scope", "audience", "grant-type", "token-type", "config"}}, - {Title: "Output", Flags: []string{"all", "logout", "non-interactive"}}, + {Title: "Output", Flags: []string{"format", "all", "logout", "non-interactive"}}, {Title: "Token Storage", Flags: []string{"token-store", "token-store-dir"}}, {Title: "Advanced - Client Authentication", Flags: []string{"client-auth-method", "client-secret", "client-secret-file", "private-key-file", "private-key-id", "private-key-alg", "client-assertion-audience"}}, {Title: "Advanced - Token Exchange (--grant-type=token-exchange)", Flags: []string{"subject-token", "subject-token-file", "subject-token-type", "subject-token-source", "requested-token-type", "resource"}}, diff --git a/internal/output/doc.go b/internal/output/doc.go index f140fb7..c749087 100644 --- a/internal/output/doc.go +++ b/internal/output/doc.go @@ -1,3 +1,4 @@ -// Package output renders the final token to stdout: bare token bytes, or an -// ExecCredential-style JSON document when requested. +// Package output renders the final token to stdout in one of three modes: +// bare token bytes (token), a JSON document with every available credential +// field (json), or a Kubernetes ExecCredential envelope (exec-credential). package output diff --git a/internal/output/output.go b/internal/output/output.go index aa623a8..8408e3b 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -15,6 +15,10 @@ const ( TokenTypeIDToken TokenType = "id_token" ) +// DefaultExecCredentialAPIVersion is used by WriteExecCredential when no +// apiVersion is supplied. +const DefaultExecCredentialAPIVersion = "client.authentication.k8s.io/v1" + // Result carries whatever credential material a successful run produced. type Result struct { AccessToken string @@ -88,3 +92,63 @@ func WriteAll(w io.Writer, r Result) error { _, err = w.Write(b) return err } + +// WriteExecCredential writes a Kubernetes client.authentication.k8s.io +// ExecCredential document to w, with the selected token as status.token. It +// writes nothing to w on error (the document is built in memory first, then +// written in one call). apiVersion defaults to +// DefaultExecCredentialAPIVersion when empty. +func WriteExecCredential(w io.Writer, r Result, tt TokenType, apiVersion string) error { + token, ok := Select(r, tt) + if !ok { + return fmt.Errorf("output: no %s available", tt) + } + if apiVersion == "" { + apiVersion = DefaultExecCredentialAPIVersion + } + + status := map[string]any{"token": token} + if !r.Expiry.IsZero() { + status["expirationTimestamp"] = r.Expiry.UTC().Format(time.RFC3339) + } + doc := map[string]any{ + "apiVersion": apiVersion, + "kind": "ExecCredential", + "status": status, + } + + b, err := json.Marshal(doc) + if err != nil { + return err + } + _, err = w.Write(b) + return err +} + +// execCredentialEnv is the shape of $KUBERNETES_EXEC_INFO that kubectl sets +// when invoking an exec credential plugin. +type execCredentialEnv struct { + APIVersion string `json:"apiVersion"` +} + +// ExecCredentialAPIVersion resolves the apiVersion for WriteExecCredential +// from $KUBERNETES_EXEC_INFO, falling back to DefaultExecCredentialAPIVersion +// when the env var is unset, unparsable, or lacks an apiVersion field. +// getenv may be nil. +func ExecCredentialAPIVersion(getenv func(string) string) string { + if getenv == nil { + return DefaultExecCredentialAPIVersion + } + raw := getenv("KUBERNETES_EXEC_INFO") + if raw == "" { + return DefaultExecCredentialAPIVersion + } + var info execCredentialEnv + if err := json.Unmarshal([]byte(raw), &info); err != nil { + return DefaultExecCredentialAPIVersion + } + if info.APIVersion == "" { + return DefaultExecCredentialAPIVersion + } + return info.APIVersion +} diff --git a/internal/output/output_test.go b/internal/output/output_test.go index 9e493c3..366aa1d 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -89,6 +89,144 @@ func TestWriteAll_OmitsEmptyFields(t *testing.T) { } } +func TestWriteExecCredential_AccessToken(t *testing.T) { + var buf bytes.Buffer + r := Result{AccessToken: "at", IDToken: "it"} + if err := WriteExecCredential(&buf, r, TokenTypeAccessToken, ""); err != nil { + t.Fatalf("WriteExecCredential: %v", err) + } + var doc map[string]any + if err := json.Unmarshal(buf.Bytes(), &doc); err != nil { + t.Fatalf("output is not valid JSON: %v\nraw: %s", err, buf.String()) + } + if doc["apiVersion"] != DefaultExecCredentialAPIVersion { + t.Fatalf("apiVersion = %v, want %q", doc["apiVersion"], DefaultExecCredentialAPIVersion) + } + if doc["kind"] != "ExecCredential" { + t.Fatalf("kind = %v, want ExecCredential", doc["kind"]) + } + status, ok := doc["status"].(map[string]any) + if !ok { + t.Fatalf("status is not an object: %v", doc["status"]) + } + if status["token"] != "at" { + t.Fatalf("status.token = %v, want %q", status["token"], "at") + } +} + +func TestWriteExecCredential_IDToken(t *testing.T) { + var buf bytes.Buffer + r := Result{AccessToken: "at", IDToken: "it"} + if err := WriteExecCredential(&buf, r, TokenTypeIDToken, ""); err != nil { + t.Fatalf("WriteExecCredential: %v", err) + } + var doc map[string]any + if err := json.Unmarshal(buf.Bytes(), &doc); err != nil { + t.Fatalf("output is not valid JSON: %v\nraw: %s", err, buf.String()) + } + status, ok := doc["status"].(map[string]any) + if !ok { + t.Fatalf("status is not an object: %v", doc["status"]) + } + if status["token"] != "it" { + t.Fatalf("status.token = %v, want %q", status["token"], "it") + } +} + +func TestWriteExecCredential_ExpirationTimestampPresentWhenExpirySet(t *testing.T) { + var buf bytes.Buffer + expiry := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC) + r := Result{AccessToken: "at", Expiry: expiry} + if err := WriteExecCredential(&buf, r, TokenTypeAccessToken, ""); err != nil { + t.Fatalf("WriteExecCredential: %v", err) + } + var doc map[string]any + if err := json.Unmarshal(buf.Bytes(), &doc); err != nil { + t.Fatalf("output is not valid JSON: %v\nraw: %s", err, buf.String()) + } + status := doc["status"].(map[string]any) + if status["expirationTimestamp"] != expiry.Format(time.RFC3339) { + t.Fatalf("status.expirationTimestamp = %v, want %q", status["expirationTimestamp"], expiry.Format(time.RFC3339)) + } +} + +func TestWriteExecCredential_ExpirationTimestampAbsentWhenExpiryZero(t *testing.T) { + var buf bytes.Buffer + r := Result{AccessToken: "at"} + if err := WriteExecCredential(&buf, r, TokenTypeAccessToken, ""); err != nil { + t.Fatalf("WriteExecCredential: %v", err) + } + var doc map[string]any + if err := json.Unmarshal(buf.Bytes(), &doc); err != nil { + t.Fatalf("output is not valid JSON: %v\nraw: %s", err, buf.String()) + } + status := doc["status"].(map[string]any) + if _, ok := status["expirationTimestamp"]; ok { + t.Fatalf("expected expirationTimestamp to be omitted, got %v", status) + } +} + +func TestWriteExecCredential_MissingTokenType_EmptyBufferOnError(t *testing.T) { + var buf bytes.Buffer + r := Result{AccessToken: "abc"} // no id_token + err := WriteExecCredential(&buf, r, TokenTypeIDToken, "") + if err == nil { + t.Fatal("expected error when requested token type is absent") + } + if buf.Len() != 0 { + t.Fatalf("buffer must stay empty on error, got %q", buf.String()) + } +} + +func TestWriteExecCredential_CustomAPIVersion(t *testing.T) { + var buf bytes.Buffer + r := Result{AccessToken: "at"} + const custom = "client.authentication.k8s.io/v1beta1" + if err := WriteExecCredential(&buf, r, TokenTypeAccessToken, custom); err != nil { + t.Fatalf("WriteExecCredential: %v", err) + } + var doc map[string]any + if err := json.Unmarshal(buf.Bytes(), &doc); err != nil { + t.Fatalf("output is not valid JSON: %v\nraw: %s", err, buf.String()) + } + if doc["apiVersion"] != custom { + t.Fatalf("apiVersion = %v, want %q", doc["apiVersion"], custom) + } +} + +func TestExecCredentialAPIVersion_DefaultWhenEmptyOrNil(t *testing.T) { + if got := ExecCredentialAPIVersion(nil); got != DefaultExecCredentialAPIVersion { + t.Fatalf("ExecCredentialAPIVersion(nil) = %q, want %q", got, DefaultExecCredentialAPIVersion) + } + if got := ExecCredentialAPIVersion(func(string) string { return "" }); got != DefaultExecCredentialAPIVersion { + t.Fatalf("ExecCredentialAPIVersion(empty env) = %q, want %q", got, DefaultExecCredentialAPIVersion) + } +} + +func TestExecCredentialAPIVersion_FromKubernetesExecInfo(t *testing.T) { + getenv := func(key string) string { + if key == "KUBERNETES_EXEC_INFO" { + return `{"apiVersion":"client.authentication.k8s.io/v1beta1","kind":"ExecCredential"}` + } + return "" + } + got := ExecCredentialAPIVersion(getenv) + if want := "client.authentication.k8s.io/v1beta1"; got != want { + t.Fatalf("ExecCredentialAPIVersion = %q, want %q", got, want) + } +} + +func TestExecCredentialAPIVersion_DefaultWhenAPIVersionMissingOrMalformed(t *testing.T) { + missing := func(string) string { return `{"kind":"ExecCredential"}` } + if got := ExecCredentialAPIVersion(missing); got != DefaultExecCredentialAPIVersion { + t.Fatalf("ExecCredentialAPIVersion(missing apiVersion) = %q, want %q", got, DefaultExecCredentialAPIVersion) + } + malformed := func(string) string { return `not json` } + if got := ExecCredentialAPIVersion(malformed); got != DefaultExecCredentialAPIVersion { + t.Fatalf("ExecCredentialAPIVersion(malformed) = %q, want %q", got, DefaultExecCredentialAPIVersion) + } +} + func TestSelect(t *testing.T) { r := Result{AccessToken: "at", IDToken: "it"} if tok, ok := Select(r, TokenTypeAccessToken); !ok || tok != "at" {