auth

package
v0.0.0-...-ac804c0 Latest Latest
Warning

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

Go to latest
Published: May 11, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

Documentation

Overview

Package auth orchestrates the Hosted Backup login/logout/refresh/recover flows defined in docs/contracts/hosted-backup-contract.md §5–§6.

The crypto operations (Argon2id KDF, DEK wrap/unwrap) live in internal/backup/crypto and are STUBS until PROMPT 3 lands. Until then, any flow that requires deriving keys (login, signup, recover, push, pull) returns a "crypto: not implemented" error and surfaces INTERNAL_ERROR to the user. The orchestration code is real; the cryptographic primitives are not.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func MakeTokenProvider

func MakeTokenProvider(s *SessionStore) client.TokenProvider

MakeTokenProvider returns a client.TokenProvider that draws access and refresh from the supplied SessionStore. Provided here so the wiring in command handlers reads in one place.

Types

type Authenticator

type Authenticator struct {
	// contains filtered or unexported fields
}

Authenticator owns the substrate auth API surface (contract §5–§6) for one issuer + audience pair. It coordinates between the OIDC discovery client (for endpoint URLs and JWKS), the HTTP client (for transport), the SessionStore (for in-memory + keychain persistence), and the crypto package (for KDF / DEK wrap, currently STUBS).

func NewAuthenticator

func NewAuthenticator(issuer Issuer, o *oidc.Client, c *client.Client, s *SessionStore) *Authenticator

NewAuthenticator constructs an Authenticator. The supplied client.Client MUST be configured with a TokenProvider that defers to session — see auth.MakeTokenProvider helper.

func (*Authenticator) CompleteLogin

func (a *Authenticator) CompleteLogin(ctx context.Context, email string, serverPassword []byte) (*CompleteLoginResponse, *envelope.Error)

CompleteLogin performs login step 2: authenticates with the pre-derived serverPassword and stores the resulting tokens.

On success the session is updated and the refresh token is persisted to the keychain. The caller is responsible for unwrapping the DEK with the masterKey it derived in step 1.

func (*Authenticator) Issuer

func (a *Authenticator) Issuer() Issuer

Issuer returns the configured issuer.

func (*Authenticator) Logout

func (a *Authenticator) Logout(ctx context.Context) *envelope.Error

Logout invalidates the refresh token server-side (best-effort) and clears all local state.

func (*Authenticator) Me

Me fetches the current user's profile from the backend. Used by the `backup status` command to report subscription state.

func (*Authenticator) PreHandshake

func (a *Authenticator) PreHandshake(ctx context.Context, email string) (*preHandshakeResponse, *envelope.Error)

PreHandshake performs login step 1: fetches the user salt + kdfParams so the client can derive the same serverPassword and masterKey that were derived at signup.

Errors map to envelope codes the command handler can return verbatim:

  • 404 → ErrNotFound (no such user)
  • other → BACKEND_* / SCHEMA_INCOMPATIBLE etc.

func (*Authenticator) Recover

Recover performs `POST /api/auth/recover` (contract §6 step 3–4). On success returns the recoveryToken to bear on the finalize call, along with the recoveryKey-wrapped DEK so the caller can unwrap it. Tokens are NOT issued at this step; `RecoverFinalize` issues fresh access/refresh tokens after the new passphrase is set.

func (*Authenticator) RecoverFinalize

func (a *Authenticator) RecoverFinalize(ctx context.Context, recoveryToken, email string, body RecoverFinalizeBody) (*CompleteLoginResponse, *envelope.Error)

RecoverFinalize performs `POST /api/auth/recover/finalize` (contract §6 v2.0 step 7–8). The recoveryToken is bearer-borne. The server burns the token on success; replays return RECOVERY_TOKEN_EXPIRED.

`email` is supplied by the caller (it is not echoed in the substrate response) and is stored in the session for display only — userID is the authoritative key for keychain entries.

`SkipAuthRefresh: true` is required: there is no access token in play here, only the recovery bearer. A 401 means the recoveryToken expired or was already consumed; refresh-then-retry would loop without progress.

func (*Authenticator) Session

func (a *Authenticator) Session() *SessionStore

Session returns the underlying SessionStore.

func (*Authenticator) Signup

Signup performs `POST /api/auth/signup`. On success the session is updated with the returned tokens and the refresh token is persisted to the keychain. The caller is responsible for caching the unwrapped DEK via SessionStore.StoreDEK separately — Signup does not see plaintext keys.

type Claims

type Claims struct {
	jwt.RegisteredClaims
	SubscriptionStatus string `json:"subscription_status,omitempty"`
}

Claims is the EdDSA-signed access-token payload shape from contract §4.

func Verify

func Verify(ctx context.Context, tokenStr string, resolver JWKSResolver, opts VerifyOptions) (*Claims, error)

Verify parses and validates the supplied access token. The token must be signed with EdDSA, contain the expected `iss` and `aud`, be within its `nbf` and `exp` window (±60s clock skew), and carry a `kid` that resolves to a key in the JWKS.

On signature failure the JWKS cache is invalidated so the next call refreshes from the backend (handles key rotation).

type CompleteLoginResponse

type CompleteLoginResponse struct {
	UserID             string `json:"userId"`
	AccessToken        string `json:"accessToken"`
	RefreshToken       string `json:"refreshToken"`
	WrappedDEK         string `json:"wrappedDEK"`
	SubscriptionStatus string `json:"subscriptionStatus,omitempty"`
}

CompleteLoginResponse matches the contract §5 step-2 response shape.

type Issuer

type Issuer struct {
	URL      string
	Audience string
}

Issuer is the canonical name of the configured backend, surfaced in status output. Stored next to the session so command handlers don't need to plumb it separately.

func IssuerFromOIDC

func IssuerFromOIDC(c *oidc.Client, audience string) Issuer

IssuerFromOIDC builds an Issuer from an oidc.Client.

type JWKSResolver

type JWKSResolver interface {
	JWKS(ctx context.Context) (*oidc.JWKS, error)
	InvalidateJWKS()
}

JWKSResolver fetches the JWK set the verifier uses. Implemented by oidc.Client (real) or a stub in tests.

type MeResponse

type MeResponse struct {
	UserID             string `json:"userId"`
	Email              string `json:"email"`
	SubscriptionStatus string `json:"subscriptionStatus"`
	CreatedAt          string `json:"createdAt"`
}

MeResponse matches the GET /api/account/me payload (contract §7).

type RecoverBody

type RecoverBody struct {
	Email            string `json:"email"`
	RecoveryKeyProof string `json:"recoveryKeyProof"`
}

RecoverBody is the contract §6 POST /api/auth/recover body.

type RecoverFinalizeBody

type RecoverFinalizeBody struct {
	NewServerPassword string           `json:"newServerPassword"`
	NewSalt           string           `json:"newSalt"`
	NewKDFParams      crypto.KDFParams `json:"newKdfParams"`
	NewWrappedDEK     string           `json:"newWrappedDEK"`
}

RecoverFinalizeBody is the contract §6 v2.0 POST /api/auth/recover/finalize body. The recoveryToken is NOT in the body — it is carried as `Authorization: Bearer <recoveryToken>`. Substrate identifies the user from the bearer's `sub` claim, so `email` is no longer needed.

type RecoverResponse

type RecoverResponse struct {
	RecoveryToken         string `json:"recoveryToken"`
	RecoveryKeyWrappedDEK string `json:"recoveryKeyWrappedDEK"`
	TTLSeconds            int    `json:"ttlSeconds"`
}

RecoverResponse mirrors the substrate response per contract §6 v2.0. The recoveryToken is the bearer credential for the subsequent /finalize call; ttlSeconds is advisory — the server is authoritative for expiry — but lets the GUI surface a "you have N minutes" hint.

type SessionStore

type SessionStore struct {
	// contains filtered or unexported fields
}

SessionStore caches the access + refresh tokens for the current process invocation. The refresh token is also persisted to the OS keychain so it survives process restarts.

func NewSessionStore

func NewSessionStore(kc keychain.Keychain) *SessionStore

NewSessionStore constructs a SessionStore backed by the supplied keychain. Pass keychain.NewSystem() in production paths.

func (*SessionStore) AccessToken

func (s *SessionStore) AccessToken(_ context.Context) (string, error)

AccessToken implements client.TokenProvider.

F4: when the cached expiry is non-zero and within accessSkewBuffer of expiring, return "" so the client.go 401-refresh hook fires on the next request instead of sending a doomed bearer. When the expiry is zero ("unknown") we trust the cached token — keeps pre-F4 callers (and the recovery-finalize bearer, which has no parsed exp) working unchanged.

func (*SessionStore) ClearDEK

func (s *SessionStore) ClearDEK() error

ClearDEK removes the DEK keychain entry for the current userId. Idempotent: returns nil if the entry was already absent.

func (*SessionStore) Forget

func (s *SessionStore) Forget() error

Forget clears the in-memory session and removes both the refresh token and the cached DEK from the keychain. Idempotent: returns nil even if no entries were present. If both deletes fail, the first error is returned and the second is silently dropped — local state is what matters and we want callers to be able to retry without surprises.

func (*SessionStore) Hydrate

func (s *SessionStore) Hydrate(userID string) error

Hydrate loads the persisted refresh token for userID from the keychain into the in-memory session. Called by command handlers on startup so subsequent calls have a session to act on. Returns keychain.ErrNotFound if no refresh token is persisted (i.e. the user is signed out).

Also opportunistically loads the persisted access token + expiry from the F4 entry: if present and not yet within accessSkewBuffer of expiry, the in-memory accessToken/accessExpiry fields are populated so the next request can skip the 401-refresh hop. Missing or expired access entries are silently ignored — the refresh path is the fallback.

func (*SessionStore) HydrateFromCurrent

func (s *SessionStore) HydrateFromCurrent() error

HydrateFromCurrent loads the active userID from the fixed current-user pointer and then loads the refresh token for that userID into the in-memory session. Called by the stack factory before returning to a command handler so every command starts hydrated.

Always returns nil so the stack factory does not need to special-case signed-out vs broken-keychain. Two distinct outcomes are encoded:

  • keychain.ErrNotFound at the pointer → signed-out; lastHydrateErr is cleared.
  • any other error at the pointer → keychain access failure; lastHydrateErr is set and `backup status` surfaces it via the KeychainError field. Session stays empty.

A successful pointer read followed by a Hydrate failure is treated as signed-out without recording the error — that case is "stale pointer" (pointer present, refresh entry absent), rare in practice, and self- heals on the next login.

func (*SessionStore) LastHydrateError

func (s *SessionStore) LastHydrateError() error

LastHydrateError returns the most recent non-ErrNotFound error from HydrateFromCurrent's pointer load, or nil if the last hydration was healthy or genuinely signed out. `backup status` reads this to populate the StatusResult.KeychainError field so users can tell "no session" apart from "keychain is broken".

func (*SessionStore) LoadDEK

func (s *SessionStore) LoadDEK() ([]byte, error)

LoadDEK reads the unwrapped DEK from the keychain. Returns keychain.ErrNotFound if no DEK is persisted (e.g. the user logged out or has not signed in since this engine version). The returned slice is a fresh copy the caller may zero.

func (*SessionStore) LoadWrappedDEK

func (s *SessionStore) LoadWrappedDEK() (string, error)

LoadWrappedDEK reads the cached wrappedDEK for the current userId, returning the same base64 string previously passed to StoreWrappedDEK. Returns keychain.ErrNotFound if no entry is present.

func (*SessionStore) Persist

func (s *SessionStore) Persist() error

Persist writes the refresh token to the keychain under the canonical account name for the current userID, and records the userID in the fixed current-user pointer so a subsequent fresh process can find it via HydrateFromCurrent. Called after a successful login, signup, recover-finalize, or refresh. Idempotent.

If writing the refresh token succeeds but writing the current-user pointer fails, the first error is returned and the pointer is left unwritten — the next invocation will report signed-out, which the caller can recover from with `endstate backup login`. We do not roll back the refresh-token write on pointer failure: it is harmless data (encrypted at rest by the OS keychain) and rolling back would risk leaving an inconsistent set of three entries on subsequent logins.

func (*SessionStore) RefreshAccessToken

func (s *SessionStore) RefreshAccessToken(ctx context.Context) (string, error)

RefreshAccessToken implements client.TokenProvider. Invoked by the client wrapper after a 401 to refresh the access token using the cached refresh token. The actual HTTP call lives on the high-level Authenticator (added in subsequent commits) — this default returns the cached value to keep the wiring compilable; an Authenticator implementation provided to a SessionStore via WithRefreshFn replaces it.

func (*SessionStore) SetTokens

func (s *SessionStore) SetTokens(userID, email, access, refresh, subscription string, accessExpiry time.Time)

SetTokens updates the cached access + refresh tokens and the subscription hint. Called after every successful login/refresh response.

func (*SessionStore) SignedIn

func (s *SessionStore) SignedIn() bool

SignedIn reports whether the store currently has a refresh token. A stale or expired access token still counts as signed in — the next request will refresh.

func (*SessionStore) Snapshot

func (s *SessionStore) Snapshot() Snapshot

Snapshot returns a copy of the current session state. Nil-safe for the "not signed in" case; the returned Snapshot has empty fields.

func (*SessionStore) StoreDEK

func (s *SessionStore) StoreDEK(dek []byte) error

StoreDEK persists the unwrapped DEK to the keychain under the canonical account for the current userId. Returns an error if the session has no userId (caller should set tokens first) or the keychain write fails.

The DEK never appears on stdout, in logs, or in error messages. Callers SHOULD zero their local copy after StoreDEK returns; the keychain is the only place the DEK lives long-term.

func (*SessionStore) StoreWrappedDEK

func (s *SessionStore) StoreWrappedDEK(b64 string) error

StoreWrappedDEK persists the masterKey-wrapped DEK (60 bytes, supplied as a base64 string from substrate's signup / login / recover-finalize responses) so subsequent push calls can populate the manifest's `wrappedDEK` field per contract §3 without rederiving the masterKey.

Stored as raw bytes in the keychain; the base64 encoding is restored in LoadWrappedDEK so callers see the same string substrate returned.

func (*SessionStore) WithRefreshFn

func (s *SessionStore) WithRefreshFn(fn refreshFunc) *SessionStore

WithRefreshFn installs the refresh callback. Returns the receiver for chaining.

type SignupBody

type SignupBody struct {
	Email                 string           `json:"email"`
	ServerPassword        string           `json:"serverPassword"`
	Salt                  string           `json:"salt"`
	KDFParams             crypto.KDFParams `json:"kdfParams"`
	WrappedDEK            string           `json:"wrappedDEK"`
	RecoveryKeyVerifier   string           `json:"recoveryKeyVerifier"`
	RecoveryKeyWrappedDEK string           `json:"recoveryKeyWrappedDEK"`
}

SignupBody is the contract §5 POST /api/auth/signup body. All byte-typed fields are encoded as standard base64.

type Snapshot

type Snapshot struct {
	UserID             string
	Email              string
	AccessToken        string
	AccessExpiry       time.Time
	RefreshToken       string
	SubscriptionStatus string
}

Snapshot returns a read-only view of the session state.

type VerifyOptions

type VerifyOptions struct {
	ExpectedIssuer   string
	ExpectedAudience string
	JWKS             *oidc.JWKS
	Now              time.Time
}

VerifyOptions captures the per-call expectations the engine enforces on every access token: the audience the client requires, the issuer the engine is talking to, and the JWKS to verify the signature against.

Jump to

Keyboard shortcuts

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