auth

package
v0.2.4 Latest Latest
Warning

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

Go to latest
Published: Aug 20, 2026 License: MIT Imports: 21 Imported by: 0

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

View Source
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.

View Source
const ExchangeOperation = "exchange-project-token"

ExchangeOperation is the contract's id for minting a project token.

Variables

View Source
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")
)
View Source
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")
)
View Source
var (
	ErrLoopbackUnavailable = errors.New("cannot listen on the loopback interface")

	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")
)
View Source
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")
)

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,omitempty"`
	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"`

	ProjectToken   string    `json:"project_token,omitempty"`
	ProjectExpires time.Time `json:"project_expires,omitempty"`

	// 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 DeviceCode struct {
	DeviceCode      string `json:"device_code"`
	UserCode        string `json:"user_code"`
	VerificationURI string `json:"verification_uri"`
	CompleteURI     string `json:"verification_uri_complete"`
	ExpiresIn       int    `json:"expires_in"`
	Interval        int    `json:"interval"`
}

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 NewManager

func NewManager(cfg *config.Config, client *http.Client) (*Manager, error)

func (*Manager) InvalidateProject

func (m *Manager) InvalidateProject() error

InvalidateProject drops the cached project token. Switching projects without it means the next command runs against the previous project and succeeds.

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) Logout

func (m *Manager) Logout(ctx context.Context) error

func (*Manager) Status

func (m *Manager) Status() (*Status, error)

Status reads state without renewing or exchanging: a command for inspecting state should not change it.

func (*Manager) Token

func (m *Manager) Token(ctx context.Context, kind spec.Credential) (string, error)

func (*Manager) UseExchange added in v0.2.4

func (m *Manager) UseExchange(base, path string)

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
	ProjectExpires 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 NewStore

func NewStore(contextName string, mode StorageMode) (*Store, error)

func (*Store) Clear

func (s *Store) Clear() error

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) Describe

func (s *Store) Describe() string

Describe says where credentials actually live, for `leaflow auth status`.

func (*Store) Fallback

func (s *Store) Fallback() string

func (*Store) Load

func (s *Store) Load() (*Credentials, error)

func (*Store) Save

func (s *Store) Save(creds *Credentials) error

Jump to

Keyboard shortcuts

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