Documentation
¶
Overview ¶
Package auth provides olares-cli's authentication primitives: JWT expiry extraction, password-based login (/api/firstfactor + /api/secondfactor/totp), refresh-token bootstrap (/api/refresh), and the on-disk token store.
jwt.go intentionally exposes ONLY ExpiresAt(). The CLI does NOT verify JWT signatures (it has no signing key), so all other claims (`username`, `groups`, `mfa`, `jid`, ...) are untrusted and must not leak into UX. The only JWT field treated as a "hint" is `exp`, because faking it can only trigger a self-inflicted 401 from the server. See §7.5 of docs/notes/olares-cli-auth-profile-config.md for the full rationale.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var ErrNoExpClaim = errors.New("jwt has no exp claim")
ErrNoExpClaim is returned by ExpiresAt when the JWT payload has no `exp` field. Callers can treat this as "unknown expiry" and decide their own policy (Phase 1 conservatively treats unknown as "trust the token until the server says otherwise").
ErrRefreshUnauthorized is returned (wrapped) by Refresh when the server rejects the refresh-token grant with HTTP 401/403. This is the only signal callers should treat as "the grant is dead, mark it invalidated and force re-login". Any other error from /api/refresh (transport hiccup, 5xx, malformed body) is treated as transient and surfaced verbatim so the caller can retry.
The refresher in cli/pkg/credential/refresher.go uses errors.Is(err, ErrRefreshUnauthorized) to gate the MarkInvalidated → ErrTokenInvalidated path; do NOT collapse this with the generic error case.
var ErrTOTPRequired = errors.New("two-factor authentication is required: re-run with --totp <code>")
ErrTOTPRequired is returned from Login when the first-factor response reports FA2 is needed but the caller didn't supply a TOTP code. Callers (e.g. `profile login`) can prompt the user and call Login again with TOTP set.
var ErrTokenNotFound = errors.New("token not found")
ErrTokenNotFound is returned when no token is stored for a given olaresId.
Functions ¶
func ExpiresAt ¶
ExpiresAt decodes only the `exp` claim of a JWT (header.payload.signature) and returns it as a time.Time. It does NOT verify the signature. Use the returned value as a client-side hint only; the server remains the source of truth for token validity.
Returns an error if the input doesn't look like a JWT, the payload can't be base64url-decoded, or the JSON is malformed. Tokens with no `exp` claim produce (zero time, ErrNoExpClaim).
func IsExpired ¶
IsExpired returns true if ExpiresAt(token) is non-zero AND in the past relative to now (or within `skew` of now). Tokens with no exp claim or malformed tokens return (false, err).
`skew` is treated as a non-negative leeway; pass 0 for exact comparison.
func PasswordSalt ¶
PasswordSalt is the md5(`<password>@Olares2025`) wire-format the Authelia backend expects on /api/firstfactor and on the bfl /iam/v1alpha1/users/<name>/password reset endpoint. The salt string is a public, account-independent constant — it is NOT a security feature, only a quirk we have to reproduce on every code path that talks to those two endpoints. The TS counterpart is `passwordAddSort` in apps/packages/app/src/utils/BindTerminusBusiness.ts.
Exported so cli/pkg/wizard.ResetPassword can reuse the same implementation instead of carrying its own copy — having two copies invites silent drift the day someone changes the salt server-side.
Types ¶
type LoginRequest ¶
type LoginRequest struct {
AuthURL string
LocalName string
OlaresID string
Password string
TOTP string
NeedTwoFactor bool
AcceptCookie bool
InsecureSkipVerify bool
Timeout time.Duration // zero → 10s default
}
LoginRequest is the input shared by FirstFactor (low-level, equivalent to the TS onFirstFactor primitive) and Login (high-level wrapper around FirstFactor + optional /api/secondfactor/totp, equivalent to the TS loginTerminus flow).
Field semantics mirror the TS web reference 1:1; if you change anything here, also re-read those two TS functions and keep them aligned:
- apps/packages/app/src/utils/account.ts L7-71 (onFirstFactor)
- apps/packages/app/src/utils/BindTerminusBusiness.ts L353-446 (loginTerminus)
AuthURL is the Olares auth base, e.g. "https://auth.alice.olares.com". The CLI POSTs to AuthURL + "/api/firstfactor" and AuthURL + "/api/secondfactor/totp".
LocalName is the bare username (the part before `@` of the olaresId). The web app uses this as `username` in the request body.
OlaresID is the full olaresId string in @ form (e.g. "alice@olares.com") or an unqualified local with implied domain — the same shape `olares.ParseID` accepts. The implementation derives per-service host names via that parsed ID's Local/Domain (e.g. vault.<local>.<domain>/); do NOT pass the terminus-name dot string alone (e.g. "alice.olares.com") or URL derivation will be wrong.
TOTP is optional — supply it when the account has 2FA enabled. Login returns ErrTOTPRequired when 2FA is needed (tok.FA2 || NeedTwoFactor) but TOTP is empty. FirstFactor never reads TOTP.
NeedTwoFactor mirrors the `needTwoFactor` parameter on TS onFirstFactor: when true, swap targetURL from `vault.<name>/server` to `desktop.<name>/`. This is the ONLY thing it does in the Go API.
Authelia's per-URL access policy is what makes `fa2` flip to true in the response — the vault URL maps to a 1FA policy, the desktop URL maps to a 2FA policy. Callers that want the server to honestly tell them whether the account has 2FA enabled (e.g. `profile login`'s initial probe) MUST pass NeedTwoFactor=true so Authelia evaluates the 2FA policy. NeedTwoFactor does NOT participate in Login's escalation gate — Login uses `tok.FA2` from the server only; see Login's doc for why we diverge from TS's `tok.FA2 || needTwoFactor` here.
AcceptCookie mirrors the `acceptCookie` parameter on TS onFirstFactor; it is passed through verbatim into the request body. callers known to follow up with /api/secondfactor/totp pass true (so Authelia sets the session cookie that the second-factor request needs); the activation/signup caller (cli/pkg/wizard.UserBindTerminus) passes false.
type ProfileLister ¶
ProfileLister enumerates the olaresIds the CLI knows about. The keychain backend can't enumerate its own contents (that's a deliberate property of every OS-keychain API: no globbing across accounts), so List() needs an external index. cliconfig.MultiProfileConfig already serves that purpose.
The interface is deliberately tiny so tests can swap it out without pulling in the whole config layer, and so we avoid widening pkg/auth's coupling to cliconfig beyond the single function it actually needs.
type RefreshRequest ¶
type RefreshRequest struct {
AuthURL string
RefreshToken string
AccessToken string // optional, sent verbatim as X-Authorization when set
InsecureSkipVerify bool
Timeout time.Duration
}
RefreshRequest is the input to a single /api/refresh call. AccessToken is optional — the web client passes the (possibly expired) current token in `X-Authorization` and the server tolerates an empty value during bootstrap, so the CLI's `profile import` path leaves it blank.
type StoredToken ¶
type StoredToken struct {
OlaresID string `json:"olaresId"`
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken,omitempty"`
SessionID string `json:"sessionId,omitempty"`
GrantedAt int64 `json:"grantedAt,omitempty"` // unix milliseconds, audit-only
InvalidatedAt int64 `json:"invalidatedAt,omitempty"` // unix milliseconds; 0 = valid
}
StoredToken is the per-olaresId record persisted by the CLI.
Phase 2 backend: the entire StoredToken is JSON-serialized and stored as a single keychain entry (service=keychain.OlaresCliService, account=olaresId). The keychain backend is OS-specific — see cli/internal/keychain — and on every supported OS the value lands encrypted at rest. Phase 1's plaintext ~/.olares-cli/tokens.json is gone.
There is intentionally NO `ExpiresAt` field: AccessToken is a JWT and the only authoritative expiry comes from decoding its `exp` claim via auth.ExpiresAt. Mirroring the server's `expires_in` here would just create a second source of truth that can drift.
RefreshToken is stored verbatim. It is not necessarily a JWT, so we never attempt to decode it.
InvalidatedAt encodes server-side grant invalidation discovered by the client (e.g. /api/refresh returning 401/403). 0 means valid (or expiry has not yet been "discovered"); any value > 0 marks the entire grant (access_token + refresh_token) as unusable, even if the JWT's `exp` is still in the future. Phase 1 only DEFINES this field — no code path writes it. Phase 2's refreshWithLock will write it. The only way to clear it back to 0 is a successful `profile login` / `profile import` (Set() defensively zeroes it).
type Token ¶
type Token struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
SessionID string `json:"session_id,omitempty"`
FA2 bool `json:"fa2,omitempty"`
}
Token mirrors the Authelia /api/firstfactor + /api/secondfactor/totp + /api/refresh response payload shared by Olares.
We keep only the fields the CLI actually persists or inspects. The wire format historically also returns `expires_in`, `expires_at`, `fa2`, etc; the CLI ignores the time fields (auth.ExpiresAt(AccessToken) is the source of truth) but does honor `fa2` to detect when a TOTP step is required.
func FirstFactor ¶
func FirstFactor(ctx context.Context, req LoginRequest) (*Token, error)
FirstFactor performs a single POST /api/firstfactor and returns the raw token. Mirrors apps/packages/app/src/utils/account.ts:onFirstFactor (L7-71) 1:1: it does NOT inspect or act on the response's `fa2` flag — choosing whether to escalate to /api/secondfactor/totp is the caller's job.
Two callers exist today:
- Login (this file) wraps FirstFactor and does the `(tok.FA2 || NeedTwoFactor)` escalation, mirroring TS loginTerminus.
- cli/pkg/wizard.UserBindTerminus uses FirstFactor directly and ignores fa2, mirroring TS userBindTerminus — at signup time there is no MFA seed yet, so the 1st-factor access_token is what the subsequent signup endpoints need.
func Login ¶
func Login(ctx context.Context, req LoginRequest) (*Token, error)
Login executes the full password login flow:
- POST /api/firstfactor with the salted-MD5 password (via FirstFactor).
- If the server reports `tok.FA2`, POST /api/secondfactor/totp with the supplied TOTP code (or return ErrTOTPRequired if none was given).
Mirrors apps/packages/app/src/utils/BindTerminusBusiness.ts:loginTerminus (L353-446), with one deliberate divergence: the gate is `tok.FA2` only, not the TS `tok.FA2 || needTwoFactor`. The TS code OR's in `needTwoFactor` so the web UI can *force* 2FA when it locally knows the user has it but the server hasn't reported it (defensive UI-state pattern). The CLI has no such caller-side knowledge — it can only trust whatever the server says — and gating on the OR would make non-2FA users (who get fa2=false) hit a spurious ErrTOTPRequired the moment a caller passes NeedTwoFactor=true to probe with the desktop targetURL (e.g. `profile login`).
FirstFactor and the optional second-factor POST share a single http.Client (with cookie jar) so the Authelia session cookie set on /api/firstfactor automatically attaches to /api/secondfactor/totp, mirroring `withCredentials: true` in the TS axios instance.
func Refresh ¶
func Refresh(ctx context.Context, req RefreshRequest) (*Token, error)
Refresh exchanges a refresh_token for a new Token via POST /api/refresh.
Phase 1 uses this in two places:
- `profile import` — bootstrap an access_token from a user-supplied refresh_token (no current access_token to pass).
- (Phase 2) Background refresh when the stored access_token is near expiry.
The wire format mirrors apps/packages/app/src/utils/account.ts `refresh_token`: POST `<authURL>/api/refresh` with `{"refreshToken": "..."}`, optionally carrying `X-Authorization: <currentAccessToken>`. Response envelope is `{"status": "OK", "data": Token}` (same shape as /api/firstfactor).
type TokenStore ¶
type TokenStore interface {
Get(olaresID string) (*StoredToken, error)
Set(token StoredToken) error
Delete(olaresID string) error
List() ([]StoredToken, error)
MarkInvalidated(olaresID string, at time.Time) error
}
TokenStore abstracts the per-olaresId secret backend. Phase 2's only production implementation is keychainStore (cli/pkg/auth/token_store_keychain.go); tests can supply their own via NewTokenStoreWith.
MarkInvalidated stamps an existing entry's InvalidatedAt without touching other fields. Returns ErrTokenNotFound if no entry exists for olaresID. Phase 2's refreshWithLock calls this when /api/refresh returns 401/403.
func NewTokenStore ¶
func NewTokenStore() TokenStore
NewTokenStore returns the production TokenStore: keychain.Default() backend + cliconfig-driven profile enumeration.
func NewTokenStoreWith ¶
func NewTokenStoreWith(kc keychain.KeychainAccess, profiles ProfileLister) TokenStore
NewTokenStoreWith is the test seam: pass any KeychainAccess + ProfileLister. Production code should call NewTokenStore.