oidc-token-cli
A small CLI that fetches an OIDC token and prints it as a bare string —
built as a drop-in exec credential provider for frpc, kubectl,
curl, or CI pipelines.
Nothing is hardcoded to a specific issuer or client: every endpoint is
resolved at runtime from /.well-known/openid-configuration, and the
interactive grant (authcode+PKCE or device-code) is auto-selected based
on what the issuer supports and what the environment can do (browser vs.
attended terminal). Tokens are cached per (issuer, client_id) and
silently refreshed; a browser or device-code prompt is only shown when the
cache is cold.
Contract
- Success: exactly the token bytes on stdout (no trailing newline), exit 0.
- Failure: non-zero exit, message on stderr, empty stdout — never a
partial or placeholder token.
- Public client + PKCE only — no client secret handling.
Install
Download a release binary (darwin/linux, amd64/arm64) from the
releases page.
Usage
oidc-token \
--issuer https://id.example.com/ \
--client-id my-public-client \
[--scope "openid offline_access"] \
[--audience my-api] \
[--grant-type auto|authcode|device-code] \
[--token-type access_token|id_token] \
[--cache-dir DIR] \
[--token-store auto|keychain|file] \
[--redirect PORT] \
[--non-interactive] \
[--all] \
[--logout] \
[--config FILE]
| Flag |
Default |
Description |
--issuer |
(required) |
OIDC issuer URL (HTTPS, except loopback in tests). Discovery's issuer field must match exactly. |
--client-id |
(required) |
OAuth2/OIDC public client ID. |
--scope |
openid offline_access |
Space-separated scopes. |
--audience |
(empty) |
Expected aud claim — required if the relying party checks audience (e.g. frp's auth.oidc.audience). |
--grant-type |
auto |
auto, authcode, or device-code. See Grant selection. |
--token-type |
access_token |
Which field bare mode prints. |
--cache-dir |
$XDG_CACHE_HOME/oidc-token |
Override the token cache directory (used by the file backend and, in auto mode, as the fallback store). |
--token-store |
auto |
auto, keychain, or file. See Cache. |
--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. |
--logout |
false |
Clear the cached entry for --issuer/--client-id and exit; no login or refresh is attempted. |
--config |
(none) |
Optional JSON config file. |
Every flag except --config also has an env var: OIDC_TOKEN_ISSUER,
OIDC_TOKEN_CLIENT_ID, OIDC_TOKEN_SCOPE, OIDC_TOKEN_AUDIENCE,
OIDC_TOKEN_GRANT_TYPE, OIDC_TOKEN_TOKEN_TYPE, OIDC_TOKEN_CACHE_DIR,
OIDC_TOKEN_STORE, OIDC_TOKEN_NON_INTERACTIVE, OIDC_TOKEN_LOGOUT.
Precedence: defaults < env < --config file < explicit flags.
Grant selection
auto picks the best viable grant based on what the issuer advertises and
what the environment supports:
| Grant |
Advertised when |
Viable when |
authorization_code |
default per OIDC Discovery, or explicitly listed |
a browser can be launched |
device-code |
issuer has a device_authorization_endpoint |
a TTY is attached and --non-interactive was not passed |
Authcode is tried first when both are viable. If the client is rejected
for the grant it tried (unauthorized_client/invalid_grant at the
authorization endpoint), it falls back to the other viable grant once —
capped at 2 attempts total, no cycling back. --grant-type=authcode or
=device-code forces one grant and fails fast if it isn't viable, with a
diagnostic describing what the IdP offers and why browser/terminal
viability failed.
Cache
Tokens are cached per (issuer, client_id) profile, addressed by a
SHA-256 hash of the pair so those values don't leak into keys/filenames.
--token-store selects where:
--token-store |
Behavior |
auto (default) |
Try the OS keychain (macOS Keychain, Linux Secret Service over D-Bus) first; fall back to the plaintext file store only when the keychain backend is unavailable (no daemon reachable) — not on a plain cache miss. Logs a one-line notice to stderr the first time it falls back. |
keychain |
OS keychain only, no fallback. Fails fast at startup if no keychain backend is reachable. |
file |
Plaintext JSON file only — this CLI's original behavior, no cgo, works anywhere. |
The file store lives at --cache-dir (default $XDG_CACHE_HOME/oidc-token
or ~/.cache/oidc-token); files are 0600, written atomically, and
refreshes run under an advisory flock so concurrent invocations converge
on one winner instead of each re-authenticating. auto mode reuses that
same flock for cross-process coordination even when the token payload
lives in the keychain; keychain-enforce mode has no cross-process lock
(OS keychains expose no such primitive), so concurrent invocations against
the same profile aren't fully serialized in that mode.
There's no migration between backends: switching an existing profile from
file to auto/keychain on a machine with a working keychain triggers
one fresh login, since a keychain miss isn't treated as "check the file
next." Use --logout to explicitly clear a cached entry (keychain items
can't be removed with rm the way file entries can).
No cgo dependency either way — the keychain backend shells out to
security on macOS and speaks D-Bus directly on Linux.
Bootstrap model
First run (cache empty) opens a browser or prints a device code and
blocks until login completes. Every run after that is served from cache
or silently refreshed. If the cache is cold and no grant is viable
(headless CI with --non-interactive), it fails fast — it will never
print a device-code prompt nobody can see. Warm the cache once,
interactively, before wiring oidc-token into a non-interactive job.
Recipes
frpc (auth.oidc.tokenSource.exec)
frp's client sends the OAuth2 access_token (not id_token) and
trims all whitespace from the exec output, so use --token-type access_token:
# frpc.toml
[auth]
method = "oidc"
[auth.oidc.tokenSource.exec]
command = "oidc-token"
args = [
"--issuer", "https://id.example.com/",
"--client-id", "frpc-client",
"--token-type", "access_token",
"--audience", "frps", # match frps's auth.oidc.audience exactly
"--non-interactive",
]
--non-interactive here just suppresses the device-code prompt in
frpc's log — a cold cache still opens a browser, since frpc's exec
still has a display available even without a TTY. Bootstrap once before
first use to avoid that popup coming from inside frpc's process tree:
oidc-token --issuer https://id.example.com/ --client-id frpc-client \
--token-type access_token --audience frps
kubectl ExecCredential-style (--all)
# ~/.kube/config (users[].user.exec)
exec:
apiVersion: client.authentication.k8s.io/v1
command: oidc-token
args:
- --issuer=https://id.example.com/
- --client-id=my-k8s-client
- --token-type=id_token
- --all
--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.
CI (--non-interactive)
# One-time, interactive, with a TTY:
oidc-token --issuer https://id.example.com/ --client-id ci-client
# In the actual CI job, against the warmed cache:
export OIDC_TOKEN_ISSUER=https://id.example.com/
export OIDC_TOKEN_CLIENT_ID=ci-client
oidc-token --non-interactive
Reuse the same --cache-dir between the bootstrap step and later
non-interactive runs — an ephemeral runner has no warm cache and will
fail fast by design.
Build & release
go build ./...
go vet ./...
go test ./...
Every push to main runs CI; if it's green, svu
derives the next semver from conventional commits and, on a version bump,
goreleaser release --clean builds darwin/linux × amd64/arm64 archives
plus a GitHub release.
License
Apache-2.0. See LICENSE.