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
18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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] \
Expand Down Expand Up @@ -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. |
Expand Down Expand Up @@ -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)
Expand All @@ -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`)

Expand Down
25 changes: 15 additions & 10 deletions cmd/oidc-token/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
33 changes: 33 additions & 0 deletions cmd/oidc-token/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 25 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@
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"

Check failure on line 46 in internal/config/config.go

View workflow job for this annotation

GitHub Actions / lint

G101: Potential hardcoded credentials (gosec)
)

// 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.
Expand Down Expand Up @@ -108,9 +117,10 @@
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,
Expand Down Expand Up @@ -199,6 +209,7 @@
grantTypeStr = string(GrantAuto)
tokenTypeStr = string(TokenTypeAccessToken)
tokenStoreStr = string(cache.BackendAuto)
formatStr = string(OutputFormatToken)
clientAuthStr string
)

Expand All @@ -214,7 +225,8 @@
&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"},
Expand Down Expand Up @@ -275,6 +287,10 @@
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"] {
Expand Down Expand Up @@ -398,6 +414,11 @@
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:
Expand Down
84 changes: 84 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
2 changes: 1 addition & 1 deletion internal/config/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}},
Expand Down
5 changes: 3 additions & 2 deletions internal/output/doc.go
Original file line number Diff line number Diff line change
@@ -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
64 changes: 64 additions & 0 deletions internal/output/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Loading
Loading