auth

package
v0.0.0-...-8a1dbc5 Latest Latest
Warning

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

Go to latest
Published: May 15, 2026 License: AGPL-3.0 Imports: 17 Imported by: 0

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

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

View Source
var ErrRefreshUnauthorized = errors.New("refresh token rejected by server")

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.

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

View Source
var ErrTokenNotFound = errors.New("token not found")

ErrTokenNotFound is returned when no token is stored for a given olaresId.

Functions

func ExpiresAt

func ExpiresAt(token string) (time.Time, error)

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

func IsExpired(token string, now time.Time, skew time.Duration) (bool, error)

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

func PasswordSalt(password string) string

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

type ProfileLister interface {
	ListOlaresIDs() ([]string, error)
}

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:

  1. POST /api/firstfactor with the salted-MD5 password (via FirstFactor).
  2. 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:

  1. `profile import` — bootstrap an access_token from a user-supplied refresh_token (no current access_token to pass).
  2. (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.

Jump to

Keyboard shortcuts

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