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

goclilogin

package module
v0.6.0 Latest Latest
Warning

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

Go to latest
Published: Oct 23, 2026 License: MIT Imports: 23 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. The same tools run by a service, with no person to approve a login, use the client-credentials grant instead.

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. This package adds the lifecycle around it: the config file saying where the CLI is pointed, 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 a library with no main package.

Install

go get github.com/datapointchris/goclilogin@latest   # a tagged module; there is no binary

Starting from scratch

A product CLI called myapp wires three commands through the package. A person runs the first one once. Every other command runs the second. The third proves the login took.

// `myapp auth login <url>`
deployment, err := goclilogin.DeploymentForLogin(ctx, "myapp", url, clientIDFlag) // fills only what config.toml lacks
goclilogin.WriteDeployment(os.Stderr, deployment) // each value and its source, before the login server hears anything

cfg := goclilogin.Config{
    Issuer:         deployment.Issuer.Value,
    ClientID:       deployment.ClientID.Value,
    KeyringService: "myapp-cli", // namespaces this product's keychain entries
}
store := goclilogin.NewTokenStore(cfg)

ctx, cancel := context.WithTimeout(ctx, 15*time.Minute) // the poll ends on the provider's answer or this deadline
defer cancel()
token, err := goclilogin.Login(ctx, cfg, func(p goclilogin.DevicePrompt) {
    goclilogin.WriteInstructions(os.Stderr, cfg.ClientID, p) // code and URL on stderr, so stdout stays pipeable
    _ = browser.OpenURL(p.BrowserURL())                      // optional; failing to open one is normal over SSH
})
backend, err := store.Save(cfg.ClientID, token) // BackendFile means no keyring: tell the user
err = deployment.Save()                        // only after the grant succeeds; adds missing keys, rewrites none

// every later command
deployment, err = goclilogin.LoadDeployment(ctx, "myapp", apiFlag) // the file, with --api outranking it
// ...cfg and store built from deployment as above
source, err := goclilogin.TokenSource(ctx, cfg, store) // ErrNotLoggedIn where nothing is stored
client := oauth2.NewClient(ctx, source)                // injects and renews the bearer token
// requests go to deployment.API.Value

// `myapp auth status`
state, _ := goclilogin.VerifySession(ctx, cfg, store) // SessionLive: the provider still honors the grant

The first run needs three things from outside the CLI:

  • The provider advertises a device_authorization_endpoint in its OIDC discovery document. Login refuses before asking for a code where it does not.
  • The client id is registered at the provider as a public client with no secret. It goes in the request body.
  • The API serves RFC 9728 protected-resource metadata naming its login server. A config.toml that already sets issuer skips this.

DeploymentForLogin refuses a config file reached through a symlink, before the grant starts. Deployment.Save waits for the grant because a login refused for an unregistered client should leave nothing behind for every later command to read.

Which function answers which question

Ask Function Reads
Where is this CLI pointed LoadDeployment, WriteDeployment the config file, and an --api's RFC 9728 metadata where it differs from the file's
What does a first login fill in DeploymentForLogin the config file, then the API's RFC 9728 metadata for a missing issuer
How does a person log in Login the issuer's OIDC discovery document, then the device grant
Where did the token go TokenStore.Save, TokenStore.Load the OS keychain, then the mode-600 file in StateDir
How do requests get a token TokenSource the store, then on a refresh the refresh lock and the discovery document
Does the provider still honor the session VerifySession the same as TokenSource
Was this request's error a refused grant IsSessionRejected, ClassifySession the error's chain
What status did the API answer its metadata request with MetadataStatusError the error's chain
How does a service with no person authenticate ClientCredentialsTokenSource the discovery document and the token endpoint
How do I test my commands without the keychain NewTestTokenStore an in-memory map

Each request any of these makes goes through the *http.Client the context carries under oauth2.HTTPClient, where the caller set one. Otherwise it goes through a client that gives up after 30 seconds. A provider that accepts the connection and never answers then fails the command rather than holding it, and ClassifySession reads that failure as SessionUnverified. The bound is per request, so a device login still waits for approval as long as its context allows.

LoadDeployment — one file says where the CLI is pointed, and only login writes it

A product CLI needs three values before it can do anything: the API's address, the login server that issues its tokens, and the client this machine logs in as. All three live in $XDG_CONFIG_HOME/<product>/config.toml, or ~/.config/<product>/config.toml where that variable is unset:

api = "https://api.example.com"
issuer = "https://auth.example.com"
client_id = "myapp-cli-laptop"

Any other key in that file is the product's own, and this package leaves it alone.

Every command calls LoadDeployment. An explicit --api outranks the file, and that is the whole precedence. A value the file lacks is ErrNotConfigured, naming <product> auth login <url>. The Deployment returned beside that error still carries the path and whatever the file set, so a status command can show what is missing. No environment variable supplies a value, and no address is compiled in. An address compiled into a binary points every copy of it at one deployment, a stranger's included. An environment variable is a second place a value can come from, and a stale export gives no sign it is there.

An --api that differs from the file's is checked, not trusted. Every request to it carries a token the file's issuer signed. So its RFC 9728 metadata must name that issuer among its authorization servers, or the command is refused before any token is sent. A local development API trusting the same login server passes. One trusting another login server, or serving no metadata, does not. An answer other than 200 is a *MetadataStatusError carrying the status. So a caller can retry an API whose proxy answered 503, and stop at one that answered 404.

None of the three values is derived after login. Every command reads them from the file and caches them nowhere else. So what the file says is where the CLI is pointed, and a status command printing the file has printed the whole answer. The --api check reads metadata only to refuse, and keeps none of it. TokenSource reads the issuer's OIDC discovery document when a refresh needs the token endpoint. A command holding a live token makes no call to the issuer.

A key set to "" is an error naming the file. TOML counts it as defined, so a login filling it in would write a second definition, and no TOML reader accepts that.

DeploymentForLogin fills in only what the file lacks.

  • api is the URL given to login, where the file has none.
  • issuer comes from the API's RFC 9728 protected-resource metadata. The well-known suffix /.well-known/oauth-protected-resource goes between the API's host and its path, as RFC 9728 § 3.1 places it. The document must name exactly one authorization server. It is refused unless its resource is the URL it was fetched for (RFC 9728 § 3.3), so a document served at one address cannot send a login to a provider describing another.
  • client_id is --client-id where given, otherwise ClientID(product).

A value the user types that differs from the file's is refused, naming the file to edit. Login never rewrites a value, because the file may be rendered by a config manager that remains its authority. A file that already holds all three is used as it is, with no request made.

Each value is a Setting carrying its source: the command line, the file, the discovered metadata, or the hostname. WriteDeployment prints each value beside that source, and the config file's path below them. Print it before Login, so the login server is on screen before anything is sent to it. A status command prints the same thing.

Deployment.Save adds only the keys still missing, at the top of the file, and keeps everything already there, comments included. It reads the file again first, so a key written while the login waited for approval is kept rather than duplicated.

A config file reached through a symlink is refused before the grant, whenever Save would write to it. The same holds for a symlinked product directory. A link means a config manager owns the file, and its source may be shared by every machine it manages. A client id written there would become every machine's, so one revocation would log them all out. The refusal offers api and issuer as lines to add to the source. It says the client id belongs wherever the manager writes each machine's own values.

TokenSource — the refresh is serialized across every process on the machine

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. A process that waited then uses the winner's token instead of replaying its own. A mutex cannot cover this, because the contention is between processes rather than goroutines. The lock is a file in StateDir, held on an open descriptor, so a process that dies releases it.

Every lock failure degrades to an unlocked refresh, never to an error. That covers no state directory, an unwritable one, and a holder still wedged after ten seconds. The race costs one login, and blocking on a wedged holder would cost the CLI.

TestTokenSource_WithoutTheLockTheGrantIsRevoked pins the claim. It runs the same concurrent processes with the lock absent and asserts the grant is revoked. If the unlocked run ever stops revoking, the locked test has stopped proving anything.

Login — the device grant, because approval has to work over SSH

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.

Login takes a func(DevicePrompt) and never writes to a stream itself. The caller owns presentation. WriteInstructions is the conventional rendering, offered rather than imposed.

A login waiting for approval outlives a rate limit on the token endpoint. The poll is x/oauth2's, which keeps going only on authorization_pending and slow_down. An HTTP 429, or the OAuth error temporarily_unavailable that Authelia sends when it rate-limits the endpoint itself, reaches that loop as slow_down. The interval then grows by five seconds for every later poll (RFC 8628 § 3.5). The poll stops only on the provider's answer or the context ending, so give Login a context with a deadline.

WriteInstructions prints BrowserURL, the verification URL with the code already in it wherever the provider sends one. The bare verification page is not enough on its own: Authelia takes a browser that is already signed in straight past it, and there is then nowhere to type the code. The code is printed as well, to check against the approval screen.

VerifySession — a soft expiry told apart 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 call to the issuer. An expired one performs the refresh the next call would have performed anyway. An unreachable provider proves nothing, so it is its own state rather than being folded into either of the others.

VerifySession also reports SessionUnverified when nothing is stored. A status command calls TokenStore.Load first and branches on ErrNotLoggedIn.

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. That error carries a URL and a raw OAuth description, and names nothing the user can do about it.

IsSessionRejected counts only a refusal the provider states: an OAuth error code, on any answer but a 5xx or a 429. x/oauth2 returns *oauth2.RetrieveError for a proxy's 502 page and for a provider shedding load as well. Neither says anything about the grant, so both are SessionUnverified, and an outage sends nobody to log in again. ClassifySession applies the same reading to a Token() call's result.

TokenStore — the keychain where there is one, a visible fallback where there is not

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. Load 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. Delete revokes nothing at the provider. The machine forgets the token.

The fallback is for a host that may have no keyring, not for a keyring having a bad day. Where one demonstrably exists and still refuses, Save returns an error naming the fix instead of writing plaintext. A macOS login keychain that will not unlock from an SSH session is the common case, and the error names security unlock-keychain. The lock is a condition of the session, but a token written to the file outlives it, because Load prefers the keyring only while the keyring holds something. So the plaintext copy would go on being used long after the reason for it had gone.

Every Mac has a login keychain, which is what makes that attributable: a security failure there refused rather than being absent. A Secret Service error carries no such guarantee, so it stays unattributed and keeps the fallback.

When nothing is stored anywhere and the keyring failed for a reason other than an absent entry, that reason is carried in the error's text. The error still matches ErrNotLoggedIn. "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.

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.

ClientCredentialsTokenSource — a service authenticates as itself, never as a person

A scheduler running the CLI unattended has no person to approve a device login. Borrowing a person's login makes the service act as them. A confidential client using the client-credentials grant (RFC 6749 § 4.4) is the service's own identity instead:

deployment, err := goclilogin.LoadDeployment(ctx, "myapp", apiFlag)
secret := os.Getenv("MYAPP_CLIENT_SECRET") // wherever the caller keeps it
source, err := goclilogin.ClientCredentialsTokenSource(ctx, goclilogin.ServiceClient{
    Issuer:   deployment.Issuer.Value,
    ClientID: deployment.ClientID.Value, // myapp-svc-scheduler, from the config file
    Scopes:   []string{"myapp.items.read"},
}, secret)
client := oauth2.NewClient(ctx, source)

A service's client id comes from its config file like any other client's. The secret never goes in that file, which is not a secret store. ClientCredentialsTokenSource takes the secret as an argument and reads no environment. Where the secret lives, and what the variable is called, is the CLI's decision, as the client id already is.

myapp-svc-scheduler is never what goclilogin.ClientID(product) returns. That function names the public device-grant client for one machine, which holds no secret and cannot use this grant. Registering that id as confidential breaks the device login on that host.

Give the service's client a name your API can tell from a person's, such as a -svc- prefix. The API then admits it as the service, limited to its scopes, and never as a user.

Scopes is sent as given. An empty list is refused before any request. Authelia grants only the scopes a request names, so a request naming none would get a token whose scp is empty. DefaultScopes does not apply. Its openid and offline_access mean nothing to this grant, and Authelia's validator rejects a client-credentials client holding either.

Tokens live in process memory. A new one is requested once the last expires. Nothing is written to the keychain or the state directory, and no lock is taken. The grant issues no refresh token, so there is nothing to store and no rotation for processes to race on.

The client authenticates with HTTP Basic, which RFC 6749 § 2.3.1 requires every provider to support. The provider answers a wrong secret with a 401 invalid_client and an unheld scope with a 400 invalid_scope. ClassifySession reports both as SessionRejected. What fixes either is the secret or the client's registration, not a login.

Config — one product on one machine, logging in to one deployment

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

Issuer and ClientID are the config file's issuer and client_id, read through LoadDeployment. Tokens are keyed by ClientID within KeyringService, so several products and machines sharing a keychain do not collide.

StateDir holds the refresh lock and the fallback token file. Both are state this tool writes, so they live together. StateDir(name) is $XDG_STATE_HOME/<name>, or ~/.local/state/<name> where that variable is unset. Where no home directory resolves, it is empty. The fallback file then refuses rather than dropping a token somewhere shared, and the refresh runs unlocked.

ClientID(product) spells the conventional per-machine id from the hostname's first label, lowercased: myapp on a host reporting laptop.example.lan gives myapp-cli-laptop. Login writes it to a config file that names no client. 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.

NewTestTokenStore — a consumer's tests never touch the real keychain

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.

LoadDeployment reads $XDG_CONFIG_HOME, so t.Setenv("XDG_CONFIG_HOME", t.TempDir()) gives a test a config file of its own.

Linux, macOS and Windows all build and pass the tests

Locking is gofrs/flock, which uses LockFileEx on Windows and flock on Linux and macOS. Releases are cut only from a commit that builds, vets and passes go test -race on all three.

What it deliberately does not do

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

It ships no command layer and imports no CLI framework. Each consumer's auth commands are its own, built on the functions above.

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 — the config file saying where the CLI is pointed, provider discovery, persistence in the OS keychain, and a refresh that is safe when several processes run at once.

Where the CLI is pointed

A product's config file, $XDG_CONFIG_HOME/<product>/config.toml, holds the API's address, the issuer and the client id. LoadDeployment reads it on every command, with an explicit --api outranking it, and consults no environment variable and no compiled-in address. An --api that differs from the file's must name the file's issuer in its RFC 9728 metadata before a token is sent to it. Login is the only writer. DeploymentForLogin fills in what the file lacks, the issuer from the API's RFC 9728 protected-resource metadata, and Deployment.Save adds those keys without rewriting any the file already holds.

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.

When no person is present

A CLI run unattended, by a scheduler for one, has nobody to approve a device login. ClientCredentialsTokenSource authenticates a ServiceClient as itself instead. A ServiceClient is a confidential client registered for the service alone, so the service holds its own identity rather than a person's. The client-credentials grant issues no refresh token, so ClientCredentialsTokenSource uses neither the keychain nor the lock.

Every request is bounded

Each request this package makes goes through the *http.Client a context carries under oauth2.HTTPClient, where the caller set one. Otherwise it goes through a client that gives up after 30 seconds. A provider that accepts the connection and never answers then fails the command rather than holding it, and the failure classifies as SessionUnverified. A device login still waits for approval as long as its context allows, because the bound is per request.

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 ErrNotConfigured = errors.New("not configured")

ErrNotConfigured reports a config file that does not yet say where the API is, which login server issues its tokens, or which client this machine logs in as. The error wrapping it names the file and the login command that fills it in.

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 ClientCredentialsTokenSource added in v0.4.0

func ClientCredentialsTokenSource(ctx context.Context, client ServiceClient, secret string) (oauth2.TokenSource, error)

ClientCredentialsTokenSource returns a token source for a confidential client that authenticates as itself with the client-credentials grant (RFC 6749 §4.4). It is for a CLI run by a service, such as a scheduler, where no person is present to approve a device login.

The secret is an argument, and this package reads no environment variable or file for it. Where it is kept is the caller's decision, as the client id already is.

Tokens live in process memory. A new one is requested when the last expires. Nothing is persisted and no lock is taken. The grant issues no refresh token, so there is nothing to store and no rotation for processes to race on.

The client authenticates with HTTP Basic, the method RFC 6749 §2.3.1 requires every provider to support. ClassifySession reports a wrong secret or an unheld scope as SessionRejected, and a 5xx or 429 answer as SessionUnverified.

func ClientID

func ClientID(product string) string

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

func ConfigPath added in v0.5.0

func ConfigPath(product string) string

ConfigPath is a product's config file: $XDG_CONFIG_HOME/<product>/config.toml, falling back to ~/.config/<product>/config.toml. It returns "" when no home directory resolves, and every function reading the file then refuses.

func IsSessionRejected

func IsSessionRejected(err error) bool

IsSessionRejected reports whether err, anywhere in its chain, is the provider stating that it refuses to issue a token. x/oauth2 returns a RetrieveError for every non-2xx answer, so the type alone proves nothing. A refusal is an OAuth error code on any answer but a 5xx or a 429. Those two say the server failed or is shedding load, so a code they carry is about the moment rather than the grant. A proxy's 502 page carries no code at all.

A code on a 200 counts too: x/oauth2 accepts one there, and some providers send it.

A refresh happens inside the transport of whichever request triggered it, so a refusal reaches a command as that request's error. That error carries a URL and a raw OAuth description, and names nothing the user can do. Callers use this to tell the user what fixes it: a new login for a device-grant session, or the secret or the client's registration for a client-credentials one.

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. It reads no network: the issuer's discovery document is read on the first refresh, so a command holding a live token works while the issuer is unreachable.

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 WriteDeployment added in v0.5.0

func WriteDeployment(w io.Writer, d Deployment)

WriteDeployment is the conventional rendering of a Deployment: each value, the layer that set it, and the config file's path. A login prints it before the device grant starts, and a status command prints it as the answer to where the CLI is pointed.

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.

The URL printed is BrowserURL, the same one a local browser is handed, so a login approved on another device reaches the same approval screen. The code is printed as well: with the complete URL it is there to check against that screen, and with the bare one it is what the user types.

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 login writes to a file that has none.
	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 StateDir is required; those two have documented defaults. Issuer and ClientID come from the product's config file, through LoadDeployment. A service with no person present authenticates as a ServiceClient instead.

type Deployment added in v0.5.0

type Deployment struct {
	// Path is the config file the values were read from and are saved to.
	Path string `json:"config_file"`

	API      Setting `json:"api"`
	Issuer   Setting `json:"issuer"`
	ClientID Setting `json:"client_id"`
}

Deployment is where a product CLI's API and login server are, and the client this machine logs in to them as. It is read from the product's config file, which holds the keys api, issuer and client_id.

func DeploymentForLogin added in v0.5.0

func DeploymentForLogin(ctx context.Context, product, api, clientID string) (Deployment, error)

DeploymentForLogin resolves the deployment a login authenticates against. It fills in only what the config file lacks:

  • api is the file's, or else api, the URL the user gave login.
  • issuer is the file's, or else the one authorization server the API names in its RFC 9728 metadata. That document's URL puts /.well-known/oauth-protected-resource between the API's host and its path, as RFC 9728 § 3.1 places it. The document is refused unless its resource is the API it was fetched for (RFC 9728 § 3.3).
  • client_id is the file's, or else clientID from --client-id, or else ClientID(product).

A value the user typed that differs from the file's is refused, naming the file to edit, because login never rewrites what the file says. A file reached through a symlink is refused whenever Save would write to it, so the refusal comes before the user approves a grant rather than after. Print the result before the device grant starts, so the login server is on screen before anything is sent to it, and Save it once the grant succeeds.

func LoadDeployment added in v0.5.0

func LoadDeployment(ctx context.Context, product, api string) (Deployment, error)

LoadDeployment reads the deployment every command of a product CLI talks to. api is the --api flag's value, and outranks the file's when it is not empty.

Nothing else is consulted: no environment variable and no compiled-in address. A value the file lacks is ErrNotConfigured, wrapped with the `<product> auth login <url>` command that fills it in. The Deployment returned beside that error still carries Path and whatever the file did set, so a status command can show what is missing.

An api that differs from the file's is checked rather than trusted, because every request to it carries a token the file's issuer signed. Its RFC 9728 metadata must name that issuer among its authorization servers, or the command is refused before any token is sent. Nothing the check reads is kept.

func (Deployment) Save added in v0.5.0

func (d Deployment) Save() error

Save writes every value that did not come from the config file into it, so every later command reads it there. A file that lacks nothing is not touched.

Nothing already in the file is rewritten. The file is read again first, so a key written while the login waited for approval is kept rather than duplicated. The keys still missing go at the top of the file, where TOML reads them as top-level keys whatever tables follow.

A config file reached through a symlink is refused, as DeploymentForLogin refuses it before the grant. A link created while the login waited is the case left for Save.

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. Send a
	// user here only where VerificationURIComplete is empty: some providers,
	// Authelia among them, take a browser that is already signed in straight
	// past this page, and it then has nowhere to type the code.
	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 send a user to, whether a browser is opened on it or it is printed for one on another device: the complete one where the provider supplied it, the bare one otherwise.

type MetadataStatusError added in v0.6.0

type MetadataStatusError struct {
	URL        string
	StatusCode int
	Status     string
}

MetadataStatusError is an API answering its RFC 9728 metadata request with a status other than 200. A caller reads StatusCode to tell an API that is down, such as a 503 from its proxy, from one that serves no metadata at all.

func (*MetadataStatusError) Error added in v0.6.0

func (e *MetadataStatusError) Error() string

type ServiceClient added in v0.4.0

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

	// ClientID is the confidential client registered for the service. It is
	// never what the ClientID function returns. That function names the public
	// device-grant client for one machine, which holds no secret and cannot use
	// this grant.
	ClientID string

	// Scopes are sent as given and must not be empty. Authelia grants only the
	// scopes a request names, so a request naming none would get a token whose
	// scp is empty. DefaultScopes does not apply. Its openid and offline_access
	// mean nothing to this grant, and Authelia's validator rejects a
	// client-credentials client holding either.
	Scopes []string
}

ServiceClient is a confidential client that a service authenticates as, with no person present. Unlike Config it has no keyring service or state directory, because nothing it obtains is stored.

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 stated that it refuses to issue a
	// token. For a device-grant session that is a refused refresh, which only
	// an interactive login fixes. For a client-credentials client the provider
	// refused the client itself, and the secret or the client's registration
	// needs fixing.
	SessionRejected SessionState = "rejected"

	// SessionUnverified means no refusal was stated. The provider could not be
	// reached, a proxy in front of it answered instead, or it answered with a
	// 5xx or a 429. That 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 the provider states is SessionRejected. Every other error is SessionUnverified, because no answer about the grant came back.

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 with no call to the issuer, so it is SessionLive even while the issuer is unreachable. An expired one performs the refresh the next call would have performed anyway, and an unreachable issuer then makes it SessionUnverified.

Nothing stored is SessionUnverified as well, because the return carries no error to tell it apart. A status command calls TokenStore.Load first and branches on ErrNotLoggedIn.

type Setting added in v0.5.0

type Setting struct {
	Value  string `json:"value"`
	Source Source `json:"source,omitempty"`
	From   string `json:"from,omitempty"`
}

Setting is one resolved value and the layer that set it. An empty Value means nothing set it.

type Source added in v0.5.0

type Source string

Source names the layer that set a Setting.

const (
	// SourceCommandLine is a value the user typed: the --api flag, the URL
	// given to login, or login's --client-id.
	SourceCommandLine Source = "command-line"

	// SourceFile is the product's config file. From is its path.
	SourceFile Source = "file"

	// SourceDiscovered is the authorization server the API names in its
	// protected-resource metadata (RFC 9728). From is the URL it was read from.
	SourceDiscovered Source = "discovered"

	// SourceHostname is the per-machine spelling ClientID derives. From is the
	// short hostname it was derived from.
	SourceHostname Source = "hostname"
)

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.

A keyring the host demonstrably has is a different matter, and Save refuses rather than downgrading. A locked macOS keychain is a condition of the session and clears with one command, but a token written to the file outlives it — Load prefers the keyring only while the keyring holds something, so the plaintext copy keeps being used until a re-login moves it back. Trading a transient failure for a durable plaintext secret is not a trade the caller asked for, so the error names the fix instead.

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