auth

package
v1.8.9 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 23, 2026 License: Apache-2.0 Imports: 18 Imported by: 0

Documentation

Overview

Package auth owns token storage for the kupe CLI. Tokens never live in the main config file — they're stored in the OS keyring (Keychain, Secret Service, Credential Manager) or, as a fallback on systems without a keyring, in a separate credentials.yaml file with mode 0600.

See docs/auth.md for the full design.

Index

Constants

View Source
const Service = "cloud.kupe.cli"

Service is the keyring service key used for every context. Account keys are the context name. Domain-reversed to keep the namespace collision-safe against other tools that might use a generic "kupe" service key — e.g., a hypothetical future `kupe-operator` sharing the host keyring.

Variables

View Source
var ErrKeyringTooSmall = errors.New("keyring rejected value as too large")

ErrKeyringTooSmall is returned when the OS keyring rejects the value as too large. macOS Keychain caps a single generic-password item at ~3000 bytes (service + user + password combined), which OIDC token sets can exceed when the JWT carries verbose custom claims. The Manager treats this the same as ErrKeyringUnavailable and falls back to plaintext, so users on macOS aren't dead-ended on a fresh OIDC login.

View Source
var ErrKeyringUnavailable = errors.New("keyring unavailable")

ErrKeyringUnavailable is returned by the keyring backend when the OS doesn't expose a working secrets API — e.g., a headless Linux box without libsecret, or a Secret Service whose D-Bus session bus can't be reached. Callers can fall back to plaintext or surface the error depending on KUPE_STORAGE policy.

Classification note: zalando/go-keyring only returns its typed ErrUnsupportedPlatform from the no-op fallback provider compiled on genuinely unsupported OSes. On Linux the Secret Service provider is always compiled in and surfaces *raw* D-Bus errors (session-bus dial failure, org.freedesktop.secrets not provided) when no secret service is running. So realKeyring treats any keyring error that is not ErrNotFound and not the size-rejection sentinel as "keyring unavailable", which is what makes the documented keyring→plaintext fallback fire on headless Linux/WSL/CI.

View Source
var ErrNotFound = errors.New("token not found")

ErrNotFound is returned by Storage.Get when no token is stored for the given context. Distinct from ErrKeyringUnavailable which signals the whole backend is missing.

View Source
var ErrRefreshFailed = errors.New("OIDC refresh token rejected")

ErrRefreshFailed is returned by Refresh when the issuer rejects the refresh token (typically invalid_grant after Authentik's 30-day TTL or after the user revoked their session). Callers should clear the stored token and ask the user to log in again.

Functions

func DefaultCredentialsPath

func DefaultCredentialsPath(configPath string) string

DefaultCredentialsPath returns the credentials-file path that sits next to the main config file. Callers derive this from config.DefaultPath.

func EmailFromIDToken

func EmailFromIDToken(idToken string) string

EmailFromIDToken parses the JWT payload and returns the "email" claim. Returns empty string if the token is malformed or the claim is absent. Signature is NOT verified — kupe-api validates every request server-side against Authentik's JWKS, so the CLI only uses the email claim cosmetically (Context.User, login confirmation message).

func IsOIDCBlob

func IsOIDCBlob(s string) bool

IsOIDCBlob returns true if s looks like a serialised OIDCTokenSet (a JSON object) rather than a raw kupe_... API key. We check the first non-whitespace byte; storage never round-trips arbitrary user input.

func Revoke

func Revoke(ctx context.Context, issuer, clientID, token, hint string) error

Revoke calls the issuer's RFC 7009 revocation_endpoint with the given refresh_token so logout actually invalidates the credential at the IdP (not just locally). Best-effort: returns nil if the IdP doesn't advertise a revocation_endpoint in discovery, or if the token has already been revoked. Network or 5xx errors are returned for the caller to surface as a non-fatal warning — the local credential is still cleared.

hint is the OAuth 2.0 token_type_hint value (typically "refresh_token"). Authentik treats it as advisory.

func SetBrowserOpenerForTest

func SetBrowserOpenerForTest(fn func(string) error) func()

SetBrowserOpenerForTest replaces browserOpener and returns a function that restores the previous value. Test-only; production code never calls this.

Types

type DevicePrompt

type DevicePrompt func(userCode, verificationURI, verificationURIComplete string, expiresIn time.Duration)

DevicePrompt is invoked once when the device authorization response arrives. The caller (typically login.go) formats this into the user-facing message, e.g.:

To finish signing in, visit:
  https://auth.kupe.cloud/device
and enter the code:
  A1B2-C3D4
Waiting for approval (code expires in 10m)…

verificationURIComplete is the IdP's URL with the code already embedded as a query param — preferred for the best-effort browser launch, but the textual prompt should still show the bare URI + code so users on a different device can type them.

expiresIn is da.Expiry - time.Now() rounded to the nearest second; the prompt should surface it because the CLI's polling context terminates at this deadline and a user who AFKs past the window only sees "context deadline exceeded" otherwise.

type Discovery

type Discovery struct {
	Issuer                      string `json:"issuer"`
	AuthorizationEndpoint       string `json:"authorization_endpoint"`
	TokenEndpoint               string `json:"token_endpoint"`
	DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint,omitempty"`
	RevocationEndpoint          string `json:"revocation_endpoint,omitempty"`
	UserinfoEndpoint            string `json:"userinfo_endpoint,omitempty"`
	JWKSURI                     string `json:"jwks_uri,omitempty"`
}

Discovery is a minimal subset of the OIDC discovery document the CLI needs to build the authorize and token URLs. We hit {issuer}/.well-known/openid-configuration rather than hardcoding paths so we stay correct against any compliant IdP — Authentik in particular puts authorize/token at the realm level (/application/o/authorize/), not under the application slug.

func Discover

func Discover(ctx context.Context, issuer string) (*Discovery, error)

Discover fetches the OIDC discovery document at {issuer}/.well-known/openid-configuration and returns the endpoints the CLI uses. The issuer string here is the iss-claim URL (Authentik app URL: {base}/application/o/{slug}/).

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

Manager picks the right Storage for each operation based on (a) the context's existing TokenRef for reads/deletes and (b) the KUPE_STORAGE policy for writes.

Policy:

KUPE_STORAGE=""          → prefer keyring, fall back to plaintext on
                            ErrKeyringUnavailable.
KUPE_STORAGE=keyring     → keyring only; fail hard if unavailable.
KUPE_STORAGE=plaintext   → always plaintext.

func NewManager

func NewManager(plaintextPath string) *Manager

NewManager wires a Manager with the two backends. plaintextPath is the destination for the plaintext fallback (typically from DefaultCredentialsPath).

func (*Manager) DeleteByRef

func (m *Manager) DeleteByRef(ctxName, ref string) error

DeleteByRef removes the token for ctxName using the given TokenRef. Idempotent: missing tokens return nil.

func (*Manager) GetByRef

func (m *Manager) GetByRef(ctxName, ref string) (string, error)

GetByRef looks up the token for ctxName using the given TokenRef, which must match what Set previously wrote into the config's context.

func (*Manager) RefreshLocked added in v1.8.2

func (m *Manager) RefreshLocked(ctx context.Context, ctxName, ref, issuer, clientID string, current OIDCTokenSet) (OIDCTokenSet, error)

RefreshLocked performs a cross-process-safe OIDC refresh for a context.

It serialises refreshes for the context behind an advisory file lock so two concurrent kupe invocations (routine under the kubectl exec-plugin model) can't race the refresh-token rotation. The sequence is:

  1. Acquire the per-context refresh lock.
  2. Re-read the stored token set. If another process already refreshed while we waited for the lock, return that fresh set without refreshing — this is what stops the loser of a race from spending an already-rotated refresh token and getting invalid_grant.
  3. Refresh against the IdP and persist the rotated set.
  4. On invalid_grant, re-read once more and delete the stored credential ONLY if its refresh token still equals the one that failed. If a winning process already stored a freshly-rotated token, we leave it intact instead of clobbering it.

ctxName/ref identify the stored credential; current is the token set the caller read before contending for the lock; issuer/clientID drive the refresh. ctx bounds the network calls.

If the lock can't be acquired (e.g. an exotic filesystem), the refresh still proceeds lock-free — the equality-guarded delete keeps the destructive path safe even without the lock.

func (*Manager) Set

func (m *Manager) Set(ctxName, token string) (ref string, err error)

Set stores the token for context, respecting the KUPE_STORAGE policy. Returns the TokenRef that should be written into the context's config entry.

In the default policy, both an unavailable keyring (no secret service at all) and a keyring that rejects the value as too large fall back to the plaintext file. The size-rejection path matters in practice on macOS where Keychain caps a single item at ~3KB and OIDC token sets with verbose custom claims can exceed that.

func (*Manager) SetByRef added in v1.8.4

func (m *Manager) SetByRef(ctxName, ref, token string) error

SetByRef stores the token for ctxName into the backend named by ref, bypassing the KUPE_STORAGE write policy that Set applies. RefreshLocked uses it so a rotated token always lands in the same backend the config's tokenRef points at. Set picks a backend by policy + availability and discards the ref it chose (the Manager can't update the config), so a backend flip — a keyring that recovered since login, or a blob that outgrew the macOS Keychain item cap and fell back to plaintext — would otherwise strand the fresh credential where no future read looks for it, forcing an bogus re-login (MEDIUM-2). If the named backend rejects the write, the error is returned rather than silently rerouting elsewhere.

type OIDCTokenSet

type OIDCTokenSet struct {
	AccessToken  string    `json:"access_token"`
	RefreshToken string    `json:"refresh_token"`
	IDToken      string    `json:"-"`
	Expiry       time.Time `json:"expiry"`
}

OIDCTokenSet is what the CLI persists for an OIDC-authenticated context. It serialises as a JSON blob into the same keyring slot the apikey path uses; IsOIDCBlob distinguishes the two on read.

IDToken intentionally has json:"-" — it's only useful at login time (we extract the email claim into Context.User) and persisting it would blow past the macOS Keychain ~3KB per-item limit when the JWT carries the kupe-tenants / kupe-groups custom claims.

func DeviceFlow

func DeviceFlow(ctx context.Context, prompt DevicePrompt, issuer, clientID, scopes string) (OIDCTokenSet, error)

DeviceFlow runs an OAuth 2.0 Device Authorization Grant (RFC 8628) login against the Authentik kupe-cli public client.

The flow is purely HTTP — no localhost listener, no port binding, no redirect — which makes it work identically on a developer laptop, an SSH session, a CI runner, or a remote dev container. The CLI:

  1. POSTs to the issuer's device_authorization_endpoint to get a device_code, user_code, verification URL, and polling interval.
  2. Calls the prompt callback so the caller can echo the user_code + verification URL to the user (typically stderr).
  3. Best-effort opens the user's browser at verification_uri_complete (the URL with the code pre-filled) so the local-laptop happy path is one click. Failure is fine — the prompt has already shown the code and URL.
  4. Polls the token endpoint until the user approves, the code expires, or the user denies. golang.org/x/oauth2 handles the authorization_pending / slow_down RFC 8628 error mapping.

func Refresh

func Refresh(ctx context.Context, issuer, clientID string, current OIDCTokenSet) (OIDCTokenSet, error)

Refresh exchanges a refresh_token for a new token set against the discovered token endpoint. On invalid_grant the function returns ErrRefreshFailed; on transport or other errors it returns the raw error.

func UnmarshalOIDC

func UnmarshalOIDC(s string) (OIDCTokenSet, error)

UnmarshalOIDC parses a stored blob. Returns an error if s isn't a JSON object (callers should IsOIDCBlob first when both shapes are possible).

func (OIDCTokenSet) Marshal

func (t OIDCTokenSet) Marshal() (string, error)

Marshal returns the JSON form stored in the keyring/plaintext file. The keyring/plaintext storage layer needs the token set serialised; both backends mode-protect the data at rest.

func (OIDCTokenSet) Valid

func (t OIDCTokenSet) Valid() bool

Valid reports whether the access token is non-empty and not within the refresh skew of expiry.

type Storage

type Storage interface {
	Get(context string) (string, error)
	Set(context, token string) error
	Delete(context string) error
	// Kind returns a short identifier ("keyring" / "plaintext") used as the
	// TokenRef written into the main config file.
	Kind() string
}

Storage is the abstraction over token-store backends (keyring, plaintext). Tests satisfy it with an in-memory map; production wires in the keyring and/or plaintext implementations.

func NewKeyringStorage

func NewKeyringStorage() Storage

NewKeyringStorage returns a Storage backed by the OS keyring.

func NewPlaintextStorage

func NewPlaintextStorage(path string) Storage

NewPlaintextStorage returns a Storage backed by a plaintext credentials file. Path defaults to ~/.config/kupe/credentials.yaml when configPath is empty (co-located with the main config).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL