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.
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.
Index ¶
- Variables
- func ClientCredentialsTokenSource(ctx context.Context, client ServiceClient, secret string) (oauth2.TokenSource, error)
- func ClientID(product string) string
- func IsSessionRejected(err error) bool
- func Login(ctx context.Context, cfg Config, show func(DevicePrompt)) (*oauth2.Token, error)
- func StateDir(name string) string
- func TokenSource(ctx context.Context, cfg Config, store *TokenStore) (oauth2.TokenSource, error)
- func WriteInstructions(w io.Writer, clientID string, p DevicePrompt)
- type Backend
- type Config
- type DevicePrompt
- type ServiceClient
- type SessionState
- type TokenStore
Constants ¶
This section is empty.
Variables ¶
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.
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 because where it comes from is the caller's decision, as the client id already is. This package reads it from nowhere.
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 ¶
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 ¶
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.
RFC 6749 §5.2 puts a refusal on a 400 or 401. x/oauth2 also accepts one on a 200, which some providers send. So the status is checked for failure rather than matched against those two.
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 ¶
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 ¶
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.
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.
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 StateDir is required; those two have documented defaults. A service with no person present authenticates as a ServiceClient instead.
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 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 it is the // client's own request refused. The secret or the client's registration is // what needs fixing there. 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 ¶
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 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 ¶
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 ¶
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.