Help us shape the future of Go! Take the 2026 Go Developer Survey

goclilogin

package module
v0.2.1 Latest Latest
Warning

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

Go to latest
Published: Aug 31, 2026 License: MIT Imports: 16 Imported by: 0

README

goclilogin

OIDC device-grant login for Go command-line tools. Tokens live in the OS keychain, and the refresh is serialized across every process on the machine.

cfg := goclilogin.Config{
    Issuer:         "https://auth.example.com",
    ClientID:       goclilogin.ClientID("myapp"), // myapp-cli-<hostname>
    KeyringService: "myapp-cli",
}
store := goclilogin.NewTokenStore(cfg)

// Log in, once, interactively.
token, err := goclilogin.Login(ctx, cfg, func(p goclilogin.DevicePrompt) {
    goclilogin.WriteInstructions(os.Stderr, cfg.ClientID, p)
    _ = browser.OpenURL(p.BrowserURL())
})
if err == nil {
    backend, err := store.Save(cfg.ClientID, token)
    // backend says where it went. Report a fall back to the file; a host with
    // no keyring stores the token in plaintext and the user should know.
}

// Then, on every later invocation.
source, err := goclilogin.TokenSource(ctx, cfg, store)
client := oauth2.NewClient(ctx, source)   // injects and renews the bearer token

What this is, and what it is not

The OAuth protocol is golang.org/x/oauth2's. The device authorization grant, the authorization_pending polling with its slow_down backoff, the refresh exchange, and the token model all come from there.

What this package adds is the lifecycle around it: provider discovery, persistence in the OS keychain, and a refresh that survives several processes running at once. That last one is the reason the package exists.

It is not a general OIDC client. There is no ID token verification, no back-channel logout, no DPoP. A CLI whose API verifies tokens server-side against the provider's JWKS does not need those, and one that does need them wants zitadel/oidc instead.

Why the refresh is serialized

Providers following RFC 9700 rotate the refresh token on every use, and revoke the whole authorization grant when a consumed one is presented again. The RFC is explicit that the server cannot tell an attacker from a racing client, so it revokes and accepts the cost of one re-login.

Two processes refreshing from the same stored token therefore cost an interactive login rather than a retry:

  process A ──┐                      A wins:  RT1 → RT2, saved
  process B ──┼── all hold RT1 ────► B, C:    replay RT1
  process C ──┘                               ↓
                                     provider revokes the grant
                                              ↓
                                     RT2 is dead too → log in again

Several processes at once is ordinary rather than exceptional. A scheduler that shells out to the same CLI for each of its checks produces it every run.

TokenSource holds an exclusive flock for the duration of a refresh and re-reads the store once it has the lock, so a process that waited uses the winner's token instead of replaying its own. A mutex cannot cover this — the contention is between processes, not goroutines.

TestTokenSource_WithoutTheLockTheGrantIsRevoked pins that claim: it runs the same eight processes with the lock absent and asserts the grant is revoked. If that test stops failing to revoke, the locked test has stopped proving anything.

Why the device grant

A loopback redirect needs a browser that can reach a listener on the machine running the CLI, which is false over SSH. The device grant moves approval to any browser on any device and leaves the CLI polling, so logging in on a remote box does not mean physically going to it.

Telling a soft expiry from a revoked grant

A stored token says what the machine holds, not what the provider will honor. A status command reading only the local expiry will call a dead grant healthy and promise a refresh that never happens.

VerifySession asks for a token the way a resource call does and reports SessionLive, SessionRejected, or SessionUnverified. A live access token answers with no network call; an expired one performs the refresh the next call would have performed anyway, so the answer costs nothing that was not already due. An unreachable provider proves nothing, which is why it is its own state rather than being folded into either of the others.

IsSessionRejected is the same question asked of an arbitrary error. A refresh happens inside the transport of whichever request triggered it, so a refusal reaches a command as that request's error — carrying a URL and a raw OAuth description, and naming nothing the user can do about it.

Configuration

Field Required Default
Issuer yes —
ClientID yes —
KeyringService yes —
Scopes no openid, profile, offline_access
StateDir no StateDir(KeyringService)

StateDir holds the refresh lock and the fallback token file. Both are state this tool writes, so they live together.

ClientID(product) spells the conventional per-machine id — myapp on a host reporting archlinux.trusted gives myapp-cli-archlinux. One client per machine keeps a token revocable on both axes.

offline_access is what yields the refresh token. A provider-specific authorization scope is deliberately not in the default: Authelia's bearer.authz, for one, permits only authorization_code, refresh_token and client_credentials alongside it, which rules out the device grant entirely.

There is not always a keyring

On Linux the keychain is the Secret Service over D-Bus, which WSL, containers and headless hosts do not have. go-keyring fails there before any request is made, with exec: "dbus-launch": executable file not found in $PATH — naming nothing the user can act on. Refusing to store a token would make the CLI unusable on exactly the machines whose only other route is a browser they do not have.

So the store falls back to a mode-600 file in StateDir, and Save returns which backend took it rather than swallowing it:

backend, err := store.Save(cfg.ClientID, token)
if backend == goclilogin.BackendFile {
    fmt.Fprintf(os.Stderr, "no OS keyring here — token saved to %s\n", store.FilePath())
}

A downgrade from keychain to plaintext is reported at the moment it happens. Load prefers the keyring whenever it holds a token, so a host that gains a provider later moves onto it with no re-login, and it reports its backend too so a status command can say which one is in play. Delete clears both, or a file token outlives the logout meant to remove it.

When nothing is stored anywhere and the keyring failed for a reason other than an absent entry, that reason is carried through the error. "Not logged in" and "your keychain is locked" need different fixes, and a bare ErrNotLoggedIn sends the user to re-authenticate against a store that was never the problem.

This mirrors standards/infrastructure.md § "There is not always a keyring, and the downgrade has to be visible", whose canonical source is ~/tools/ifiles/auth — a different token type, the same obligation.

The fallback is a Unix concern in practice. Windows uses wincred, which is always present, and it has no POSIX mode bits — os.Chmod there toggles only the read-only flag. If the fallback ever does run on Windows, the file's protection is whatever ACL the user's profile directory carries, not 0600.

Testing against it

NewTestTokenStore(cfg) returns a store whose keyring is an in-memory map, so a consumer can test its own command wiring without writing to the real keychain. The keychain is shared machine state and a test must not touch it. Point cfg.StateDir at a t.TempDir() so the fallback file lands there too.

Platforms

Linux, macOS and Windows. Locking is gofrs/flock, which uses LockFileEx on Windows and flock elsewhere.

Documentation

Overview

Package goclilogin logs a command-line tool into an OIDC provider and keeps the resulting session usable across every invocation on the machine.

The OAuth protocol itself is golang.org/x/oauth2's: the device authorization grant, the authorization_pending polling with its slow_down backoff, the refresh exchange, and the token model all come from there. What this package adds is the lifecycle around it — provider discovery, persistence in the OS keychain, and a refresh that is safe when several processes run at once.

Why the device grant

A loopback redirect needs a browser that can reach a listener on the machine running the CLI, which is false over SSH. The device grant moves approval to any browser on any device and leaves the CLI polling, so logging in on a remote box does not mean physically going to it.

Why refresh is serialized

Providers that follow RFC 9700 rotate the refresh token on every use and revoke the whole authorization grant when a consumed one is presented again, which they are required to read as a replay they cannot attribute. Two processes refreshing from the same stored token therefore cost the user an interactive login rather than a retry: one rotates, the other presents the token that rotation consumed, and the provider drops the pair.

Several processes running at once is ordinary rather than exceptional — a scheduler that shells out to the same CLI for each of its checks produces it every run. TokenSource holds a machine-wide lock for the duration of a refresh and re-reads the store once it has the lock, so a process that waited uses the winner's token instead of replaying its own. A mutex cannot cover this, because the contention is between processes.

Index

Constants

This section is empty.

Variables

View Source
var DefaultScopes = []string{"openid", "profile", "offline_access"}

DefaultScopes are what a CLI needs from a standard OIDC provider. offline_access yields the refresh token; openid and profile make the access token a standard OIDC one.

A provider-specific authorization scope is deliberately absent. Authelia's bearer.authz, for one, permits only authorization_code, refresh_token and client_credentials alongside it, which rules out the device grant. A caller that needs such a scope sets Scopes itself and accepts the consequence.

View Source
var ErrNotLoggedIn = errors.New("not logged in")

ErrNotLoggedIn is returned when no token is stored for the client. It is a normal state rather than a fault, so callers branch on it and print a login hint instead of an error.

Functions

func ClientID

func ClientID(product string) string

ClientID spells the conventional per-machine client id for a product: product "icb" on a host reporting "archlinux.trusted" gives "icb-cli-archlinux". A machine whose hostname differs from its logical name overrides the result rather than relying on it.

func IsSessionRejected

func IsSessionRejected(err error) bool

IsSessionRejected reports whether err is the provider refusing a refresh, anywhere in its chain.

It is worth a helper because of where the refusal surfaces. A refresh happens inside the transport of whichever request triggered it, so the failure reaches a command as that request's error — carrying a URL and a raw OAuth description, and naming nothing the user can do. Callers use this to print the one command that changes the situation.

func Login

func Login(ctx context.Context, cfg Config, show func(DevicePrompt)) (*oauth2.Token, error)

Login runs the OAuth 2.0 device authorization grant (RFC 8628). It asks the provider for a device code, hands the prompt to the caller, and polls until the request is approved or the code expires.

The caller owns presentation. show is called once with the code and URL, and must not return an error for a browser it could not open — that is the normal case over SSH, and the code is still displayed. Pass a ctx with a deadline; the device code carries its own expiry but a caller should bound the wait.

func StateDir

func StateDir(name string) string

StateDir resolves a tool's own XDG state directory — $XDG_STATE_HOME/<name>, falling back to ~/.local/state/<name>. State is what the tool writes and the user does not. Returns "" when no home directory resolves, which callers read as "no state directory available" rather than as an error.

func TokenSource

func TokenSource(ctx context.Context, cfg Config, store *TokenStore) (oauth2.TokenSource, error)

TokenSource returns an auto-refreshing, keychain-persisting token source for the logged-in client, or ErrNotLoggedIn if there is no stored token.

Wrap it with oauth2.NewClient to get an *http.Client that injects and renews the bearer token on every request, so resource code never handles tokens.

func WriteInstructions

func WriteInstructions(w io.Writer, clientID string, p DevicePrompt)

WriteInstructions is the conventional rendering of a DevicePrompt, offered so every CLI does not spell it out again. Write it to stderr, so a command that prints a token to stdout stays pipeable.

Types

type Backend

type Backend string

Backend names where a token came from or went. Two stores mean "where is my token" has an answer worth reporting: a login says when it had to fall back, and a status command says which one is in play.

const (
	// BackendKeyring is the OS keychain, which is where a token belongs.
	BackendKeyring Backend = "OS keyring"

	// BackendFile is the mode-600 fallback file, used only where there is no
	// keyring at all.
	BackendFile Backend = "file"
)

type Config

type Config struct {
	// Issuer is the OIDC provider's base URL. Discovery hangs off it.
	Issuer string

	// ClientID identifies this (product × machine) pair to the provider. One
	// client per machine keeps a token revocable on both axes, and ClientID
	// derives the conventional spelling.
	ClientID string

	// KeyringService namespaces this product's entries in the OS keychain.
	// Tokens are keyed within it by ClientID, so several products and machines
	// sharing a keychain do not collide.
	KeyringService string

	// Scopes requested at login. Empty means DefaultScopes.
	Scopes []string

	// StateDir holds this tool's own mutable state: the refresh lock, and the
	// fallback token file used on hosts with no OS keyring. Empty means
	// StateDir(KeyringService).
	//
	// It is never left unset in a way that skips locking, because an unlocked
	// refresh is the failure this package exists to prevent.
	StateDir string
}

Config is one CLI's view of one deployment. Every field except Scopes and LockDir is required; those two have documented defaults.

type DevicePrompt

type DevicePrompt struct {
	// UserCode is what the user enters on the verification page.
	UserCode string

	// VerificationURI is the page to open, without the code embedded. This is
	// the one to show a human, because it is short enough to read off a screen
	// and type on another device.
	VerificationURI string

	// VerificationURIComplete carries the code already embedded, so a browser
	// that opens lands on an approval screen with nothing to type. Providers
	// are not required to send it, so it can be empty.
	VerificationURIComplete string
}

DevicePrompt is what the user has to act on: a code to type and a URL to open.

func (DevicePrompt) BrowserURL

func (p DevicePrompt) BrowserURL() string

BrowserURL is the URL to hand a browser: the complete one where the provider supplied it, the bare one otherwise.

type SessionState

type SessionState string

SessionState is what asking the provider for a usable token established.

const (
	// SessionLive means a usable access token was obtained.
	SessionLive SessionState = "live"

	// SessionRejected means the provider refused the refresh. Only an
	// interactive login fixes it.
	SessionRejected SessionState = "rejected"

	// SessionUnverified means the provider could not be reached to ask, which
	// proves nothing about the grant either way.
	SessionUnverified SessionState = "unverified"
)

func ClassifySession

func ClassifySession(token *oauth2.Token, err error) (SessionState, *oauth2.Token)

ClassifySession reads the outcome of asking for a token. A refusal at the token endpoint is the provider saying the grant is gone; every other error is this machine failing to ask, which must not be reported as though it settled anything.

func VerifySession

func VerifySession(ctx context.Context, cfg Config, store *TokenStore) (SessionState, *oauth2.Token)

VerifySession obtains a token the way a resource call does and reports which state the session is in, along with whatever token it ended up holding.

This is the only thing that separates a soft expiry from a revoked grant. A stored token says what the machine holds, not what the provider will honor, and a status command that reads only the local expiry will call a dead grant healthy. A live access token answers without a network call; an expired one performs the refresh the next call would have performed anyway.

type TokenStore

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

TokenStore persists OAuth tokens, in the OS keychain wherever there is one and in a mode-600 file where there is not.

func NewTestTokenStore

func NewTestTokenStore(cfg Config) *TokenStore

NewTestTokenStore returns a store backed by an in-memory keyring. It exists so a consumer can test its own command wiring without touching the real keychain, which is shared machine state that tests must not write.

func NewTokenStore

func NewTokenStore(cfg Config) *TokenStore

NewTokenStore returns a store for cfg: the keychain under cfg.KeyringService, with the fallback file inside cfg.StateDir.

func (*TokenStore) Delete

func (t *TokenStore) Delete(clientID string) error

Delete forgets the token in both stores rather than in the first one that answers, returning ErrNotLoggedIn when neither held one. A token written to the file before that host had a keyring would otherwise survive the logout meant to remove it.

It does not revoke the grant at the provider — this machine simply forgets it.

func (*TokenStore) FilePath

func (t *TokenStore) FilePath() string

FilePath reports the fallback file, so a caller told the token went there can name it.

func (*TokenStore) Load

func (t *TokenStore) Load(clientID string) (*oauth2.Token, Backend, error)

Load returns the stored token for clientID and where it came from, or ErrNotLoggedIn when neither store holds one. A missing entry is a normal state rather than a fault, so callers branch on it and print a login hint.

The keyring wins whenever it actually holds something, so a host that gains a Secret Service later moves onto it without a re-login.

func (*TokenStore) Save

func (t *TokenStore) Save(clientID string, tok *oauth2.Token) (Backend, error)

Save writes the token for clientID and reports which backend took it.

On Linux the keyring is the Secret Service over D-Bus, and a host without one fails before any request is made — a bare Ubuntu userland answers `exec: "dbus-launch": executable file not found in $PATH`. Refusing to store a token there would make the CLI unusable on exactly the machines that have no browser to fall back to. The token goes to a mode-600 file instead, and the backend is returned rather than swallowed: a downgrade from keychain to plaintext is not something to find out about later.

The id token rides along in a field of its own, because oauth2.Token keeps it in an Extra map that does not survive a JSON round-trip.

Jump to

Keyboard shortcuts

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