Documentation
¶
Overview ¶
Package auth owns the CLI's credentials: how they are obtained, where they are kept, and when they are replaced.
The platform issues two tokens and this package handles both:
account token from Keycloak, says who you are, renewed with a refresh token project token from IAM, says who you are and where, obtained by exchange
Project tokens are not renewable by design. One asserts that you are still a member of that project, and membership changes; renewal would extend the claim without re-proving it.
Index ¶
- Constants
- Variables
- func OpenBrowser(target string)
- type Credentials
- type DeviceCode
- type Manager
- func (m *Manager) Invalidate(kind spec.Credential) error
- func (m *Manager) InvalidateProject() error
- func (m *Manager) Login(ctx context.Context, notify func(target string), abort <-chan struct{}) (*Credentials, error)
- func (m *Manager) LoginWithDeviceCode(ctx context.Context) (*DeviceCode, func() (*Credentials, error), error)
- func (m *Manager) LoginWithRefreshToken(ctx context.Context, refreshToken string) (*Credentials, error)
- func (m *Manager) Logout(ctx context.Context) error
- func (m *Manager) Status() (*Status, error)
- func (m *Manager) Token(ctx context.Context, kind spec.Credential) (string, error)
- func (m *Manager) UseExchange(base, path string)
- type Status
- type StorageMode
- type Store
Constants ¶
const EnvToken = "LEAFLOW_TOKEN"
EnvToken carries a project token straight from the environment. It bypasses storage, renewal and exchange, because CI has no browser and cannot run an interactive login.
const ExchangeOperation = "exchange-project-token"
ExchangeOperation is the contract's id for minting a project token.
Variables ¶
var ( ErrNeedProject = errors.New("no project selected") // ErrAccountTokenInCI is separate because retrying cannot help: the value in // LEAFLOW_TOKEN is a project token and the operation needs an account one. ErrAccountTokenInCI = errors.New( "this command needs an account token (register, list projects, exchange), which " + EnvToken + " cannot provide; run leaflow login") ErrNotAMember = errors.New("cannot obtain a token for that project; you may not be a member") ErrExchangeFailed = errors.New("project token exchange failed") // ErrLoginAborted means the caller gave up on the browser flow, not that // anything failed. ErrLoginAborted = errors.New("browser sign-in abandoned") ErrExchangeUnknown = errors.New( "the bundled contract does not declare " + ExchangeOperation + ", so a project token cannot be obtained") ErrTokenRejected = errors.New("the realm rejected that refresh token; it may be expired, revoked, or from another realm") // ErrCannotRetry says a refused token is not one this manager can replace, // so the transport reports the refusal rather than trying again. ErrCannotRetry = errors.New("this credential cannot be renewed automatically") )
var ( ErrDiscovery = errors.New("cannot reach the login service") // ErrNoDeviceFlow names the thing an operator has to change, because // otherwise this reads like a bug in the CLI. ErrNoDeviceFlow = errors.New( "this realm has no device authorization grant; enable OAuth 2.0 Device Authorization Grant on the client") ErrUnknownClient = errors.New("the login service does not recognise this client; it must be a public client with the device flow enabled") ErrLoginTimeout = errors.New("login timed out, run leaflow login again") ErrLoginDenied = errors.New("login was denied") ErrDeviceCodeExpired = errors.New("device code expired, run leaflow login again") )
var ( ErrAuthorizationDenied = errors.New("authorization was denied") // ErrStateMismatch means the callback did not belong to this login attempt. // Continuing anyway is how an authorization code from another origin gets // accepted, so it is fatal rather than a retry. ErrStateMismatch = errors.New("callback state did not match") )
var ( // ErrNoCredentials means this context has never logged in, or its refresh // token is gone. Callers turn it into "run leaflow login" rather than // surfacing a keyring failure. ErrNoCredentials = errors.New("not logged in") ErrCredentialsUnwritable = errors.New("cannot store credentials") ErrCredentialsUnreadable = errors.New("cannot read credentials") ErrNoKeychain = errors.New("no usable system keychain") )
var StorageModes = []string{ string(StorageAuto), string(StorageKeychain), string(StorageFile), }
Functions ¶
func OpenBrowser ¶
func OpenBrowser(target string)
OpenBrowser is best effort and reports nothing: the URL is already on screen, and a "could not open browser" error would read as though login had failed.
Types ¶
type Credentials ¶
type Credentials struct {
AccountToken string `json:"account_token,omitempty"`
AccountExpires time.Time `json:"account_expires"`
RefreshToken string `json:"refresh_token,omitempty"`
Account string `json:"account,omitempty"`
// Offline records that the refresh token is an offline token, which is not
// tied to an SSO session and so does not expire when that one does.
Offline bool `json:"offline,omitempty"`
// Renaming these makes an entry written by an older version read as having
// no access token. That is a cheap thing to be wrong about: the refresh
// token and the account token are untouched, so the next call exchanges for
// a fresh one and nobody is asked to sign in again.
AccessToken string `json:"access_token,omitempty"`
AccessTokenExpires time.Time `json:"access_token_expires"`
// ProjectID records which project the cached token belongs to. Switching
// projects without clearing it would not fail — it would succeed against the
// wrong project, which is worse than any error.
ProjectID string `json:"project_id,omitempty"`
}
Credentials is one context's full credential set. Keeping them per context matters because working against production and a local stack at once is normal, and mixing the two only ever shows up as a 401.
type DeviceCode ¶
type Manager ¶
type Manager struct {
// contains filtered or unexported fields
}
Manager is the single entry point for "give me a usable token". Commands ask for one and get renewal, exchange, or a clear "log in" without each having to remember to check.
func (*Manager) Invalidate ¶ added in v0.4.0
func (m *Manager) Invalidate(kind spec.Credential) error
InvalidateProject drops the cached project token. Switching projects without it means the next command runs against the previous project and succeeds. Invalidate satisfies the transport's Credentials interface.
Only the project token is ever dropped. An account token comes from the realm and a refused one means the sign-in itself has to happen again, which no retry can do; throwing it away would only turn "log in again" into "log in again, and you are also logged out now".
func (*Manager) InvalidateProject ¶
func (*Manager) Login ¶
func (m *Manager) Login(ctx context.Context, notify func(target string), abort <-chan struct{}) (*Credentials, error)
Login signs in through the browser using authorization code with PKCE.
A public client has no secret to prove it is itself, so PKCE supplies one per attempt: the verifier stays in this process and only its SHA-256 hash is sent with the request, which makes an intercepted code unusable. The realm requires S256 for this client, so a downgrade to `plain` is refused.
The redirect is a loopback listener on a port the OS picks, per RFC 8252. abort, when closed, gives up on the browser flow and returns ErrLoginAborted. The caller offers it when a keypress should switch to the device flow instead.
func (*Manager) LoginWithDeviceCode ¶
func (m *Manager) LoginWithDeviceCode(ctx context.Context) (*DeviceCode, func() (*Credentials, error), error)
LoginWithDeviceCode signs in without a browser on this machine.
Needed wherever a loopback redirect cannot arrive: over ssh, inside a container, on a headless host. The two halves of the flow may happen on different machines, which is the whole point of RFC 8628.
PKCE applies here too. It is not part of RFC 8628, but Keycloak implements the extension and this client requires it, so the verifier is carried through to redemption exactly as in the browser flow.
func (*Manager) LoginWithRefreshToken ¶
func (m *Manager) LoginWithRefreshToken(ctx context.Context, refreshToken string) (*Credentials, error)
LoginWithRefreshToken signs in from a refresh token read elsewhere, for CI.
It redeems the token immediately rather than storing it as given: that proves it is valid and belongs to this realm, so a bad value fails at login instead of at the first real command, where it would look like an outage.
A refresh token is what CI wants because it is the only credential this CLI can renew on its own. A project token expires in minutes, which is what LEAFLOW_TOKEN is for.
func (*Manager) Status ¶
Status reads state without renewing or exchanging: a command for inspecting state should not change it.
func (*Manager) UseExchange ¶ added in v0.2.4
UseExchange tells the manager where the exchange lives. The CLI wires this from the loaded contract at startup.
type Status ¶
type Status struct {
LoggedIn bool
Account string
AccountExpires time.Time
Project string
AccessTokenExpires time.Time
FromEnv bool
Storage string
// Offline reports whether the stored credential outlives the SSO session.
// Worth surfacing: it is the difference between signing in twice a day and
// signing in once a month.
Offline bool
}
type StorageMode ¶
type StorageMode string
StorageMode says where credentials are kept.
The system keychain is the default on all three platforms, reached through the OS API directly — macOS Keychain, Windows Credential Manager, and the freedesktop Secret Service on Linux — so nothing extra has to be installed.
It is not always available: CI containers, machines reached over ssh, and Linux without a session bus have no keychain. StorageFile is for those, and choosing it explicitly is better than discovering the fallback by accident.
const ( // StorageAuto uses the keychain and falls back to a file. StorageAuto StorageMode = "auto" // StorageKeychain refuses to fall back. Worth setting where a credential // silently landing in a file would be a problem worth failing over. StorageKeychain StorageMode = "keychain" // StorageFile always writes a 0600 file and never touches the keychain, // which also avoids the OS prompting for access on every run. StorageFile StorageMode = "file" )
func ParseStorageMode ¶
func ParseStorageMode(value string) (StorageMode, error)
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
func (*Store) Clear ¶
Clear removes both copies regardless of mode. Logging out has to be complete, including a file left behind by an earlier run under a different setting.
func (*Store) Load ¶
func (s *Store) Load() (*Credentials, error)
func (*Store) Save ¶
func (s *Store) Save(creds *Credentials) error