// Package auth owns MyDay's identities and sessions (PROJECT_PLAN.md §6). // // - an IDENTITY is the rotating access record (a GUID + its credentials); // a USER is the stable person. Credentials hang off an identity, never the // user (D-008), and a GUID is never recycled (D-008/AC-2). // - a SESSION is the opaque credential minted after enroll/login; the GUID is // only a username (D-001 — a bookmark is never a login). // // This package also provides the encryption seam for TOTP secrets (A9) — the // key comes from config later; secrets are never stored or logged in plaintext. // // The two service interfaces both name Mint/Rotate/Revoke, so they are separate // Go types (IdentitiesService, SessionsService) sharing one Core. // // Spec: docs/specs/spec-004-identity-sessions.md §3–§6. package auth import ( "context" "crypto/rand" "database/sql" "encoding/base64" "errors" "fmt" "time" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" ) // IdentityStatus is an identity's lifecycle state (SPEC-004 §4). type IdentityStatus string const ( // StatusPendingEnrollment is a freshly minted identity — the invite window // is open but the identity is a bearer until enrolled (A1). StatusPendingEnrollment IdentityStatus = "pending_enrollment" // StatusActive is an enrolled identity; it may authenticate. StatusActive IdentityStatus = "active" // StatusRevoked is a tombstoned identity — its GUID is dead forever // (D-008; a revoked GUID behaves as "not found" to the router). StatusRevoked IdentityStatus = "revoked" ) // Identity is the rotating access record (SPEC-004 §4 `identities`). type Identity struct { ID int64 UserID int64 GUID string Status IdentityStatus CreatedAt time.Time EnrolledAt *time.Time RevokedAt *time.Time } // Session is one server-side session row; the id is the cookie value // (SPEC-004 §4 `sessions`). type Session struct { ID string IdentityID int64 UserID int64 CreatedAt time.Time ExpiresAt time.Time RevokedAt *time.Time ClientIP string UserAgent string } // Identities is the identity service contract (SPEC-004 §5). Per ruling R-1 // every method takes the request context so audit metadata (actor · client IP · // correlation) reaches each audit row (A11). type Identities interface { // Mint creates a new identity for userID with status pending_enrollment and // returns its id and a fresh, unguessable GUID. Mint(ctx context.Context, userID int64) (IdentityID int64, guid string, err error) // Activate moves a pending identity to active (the enrollment completes). Activate(ctx context.Context, id int64) error // Rotate performs recovery (D-007): revoke the user's current identity and // all its sessions, then mint a fresh pending identity — a NEW GUID. Rotate(ctx context.Context, userID int64) (guid string, err error) // Revoke revokes an identity AND every session it minted. Revoke(ctx context.Context, id int64) error // ByGUID resolves a GUID. A missing GUID yields ErrIdentityNotFound. // NOTE: it is context-taking only for symmetry; a revoked (tombstoned) GUID // is still returned so the caller can distinguish and audit it (SPEC-004 §8). ByGUID(ctx context.Context, guid string) (Identity, error) // ByUserID returns the user's current (non-revoked) identity — the one the // door and the /setup wizard follow (SPEC-005 §4: "which step is next?"). // A user with no live identity yields ErrIdentityNotFound. ByUserID(ctx context.Context, userID int64) (Identity, error) // WithinEnrollmentWindow reports whether a pending identity is still inside // its bounded invite window (SPEC-004 §4, A1). SPEC-006 refuses enrollment // outside it. A non-pending identity reports false. WithinEnrollmentWindow(ctx context.Context, id int64) (bool, error) } // Sessions is the session service contract (SPEC-004 §5/§6). Per ruling R-1 // every method takes the request context. type Sessions interface { // Mint issues a session for an ACTIVE identity. Mint(ctx context.Context, identityID int64, ip, ua string) (Session, error) // Validate accepts a session id only if it is non-empty (A2), exists, is // not expired and is not revoked. Validate(ctx context.Context, sessionID string) (Session, error) // Rotate replaces a valid session with a fresh one (fixation defence). Rotate(ctx context.Context, sessionID string) (Session, error) // Revoke kills one session (logout). Revoke(ctx context.Context, sessionID string) error // RevokeAll kills every live session of an identity (D-007). RevokeAll(ctx context.Context, identityID int64) error // GC deletes expired rows (housekeeping) and returns how many it removed. // It NEVER deletes a still-valid session (SPEC-004 §6). GC(ctx context.Context, now time.Time) (int, error) } // Sentinel errors. var ( ErrIdentityNotFound = errors.New("auth: identity not found") ErrIdentityExists = errors.New("auth: the user already has a non-revoked identity") ErrIdentityNotPending = errors.New("auth: identity is not pending enrollment") ErrIdentityNotActive = errors.New("auth: identity is not active") ErrUnknownUser = errors.New("auth: user does not exist") ErrGUIDExhausted = errors.New("auth: could not allocate a unique GUID") ErrEmptySessionID = errors.New("auth: empty session id") // A2 ErrSessionNotFound = errors.New("auth: session not found") ErrSessionExpired = errors.New("auth: session expired") ErrSessionRevoked = errors.New("auth: session revoked") ErrNoSealer = errors.New("auth: no TOTP sealer configured") ErrEmptySecret = errors.New("auth: empty TOTP secret") ErrSecretNotFound = errors.New("auth: TOTP secret not found") ErrInvalidCiphertext = errors.New("auth: invalid ciphertext") ) // Defaults. These are code defaults to be overridden from config later // (D-035 keeps settings in the config file). const ( // DefaultSessionTTL is how long a minted session lives. DefaultSessionTTL = 24 * time.Hour // DefaultEnrollWindow bounds the un-enrolled-GUID bearer window (A1). DefaultEnrollWindow = 72 * time.Hour ) // Core holds the shared dependencies of the identity and session services. type Core struct { db *sql.DB audit audit.Audit sealer Sealer now func() time.Time sessionTTL time.Duration enrollWindow time.Duration } // Option configures a Core. type Option func(*Core) // WithSessionTTL overrides the session lifetime (default DefaultSessionTTL). func WithSessionTTL(d time.Duration) Option { return func(c *Core) { if d > 0 { c.sessionTTL = d } } } // WithEnrollWindow overrides the enrollment window (default DefaultEnrollWindow). func WithEnrollWindow(d time.Duration) Option { return func(c *Core) { if d > 0 { c.enrollWindow = d } } } // WithSealer supplies the TOTP secret encryption seam (A9). func WithSealer(sealer Sealer) Option { return func(c *Core) { c.sealer = sealer } } // WithClock injects a clock (tests). func WithClock(fn func() time.Time) Option { return func(c *Core) { if fn != nil { c.now = fn } } } // New builds the shared Core. aud may be nil (tests) — audit is then skipped. func New(db *sql.DB, aud audit.Audit, opts ...Option) *Core { c := &Core{ db: db, audit: aud, now: time.Now, sessionTTL: DefaultSessionTTL, enrollWindow: DefaultEnrollWindow, } for _, opt := range opts { opt(c) } return c } // Identities returns the identity service over this Core. func (c *Core) Identities() *IdentitiesService { return &IdentitiesService{Core: c} } // Sessions returns the session service over this Core. func (c *Core) Sessions() *SessionsService { return &SessionsService{Core: c} } // clock returns the current time, UTC and truncated to the storage precision, // so comparisons line up exactly with the values written by store.FormatTime. func (c *Core) clock() time.Time { return c.now().UTC().Truncate(time.Second) } func (c *Core) record(ctx context.Context, e audit.Event) { if c.audit != nil { c.audit.Record(ctx, e) } } // userExists reports whether the user record exists. func (c *Core) userExists(userID int64) (bool, error) { var one int err := c.db.QueryRow(`SELECT 1 FROM users WHERE user_id = ?`, userID).Scan(&one) switch { case errors.Is(err, sql.ErrNoRows): return false, nil case err != nil: return false, fmt.Errorf("auth: user lookup: %w", err) } return true, nil } // querier is the subset shared by *sql.DB and *sql.Tx. type querier interface { Exec(query string, args ...any) (sql.Result, error) QueryRow(query string, args ...any) *sql.Row } // newGUID allocates a fresh, unguessable GUID (>=128 bits, D-001): 16 random // bytes, base64url. It rejects reserved workflow names and any GUID already // present (identities are never recycled, D-008/AC-2), and retries on // collision. func newGUID(q querier) (string, error) { for attempt := 0; attempt < 8; attempt++ { var b [16]byte if _, err := rand.Read(b[:]); err != nil { return "", fmt.Errorf("auth: guid entropy: %w", err) } guid := base64.RawURLEncoding.EncodeToString(b[:]) if err := routing.ValidateWorkflowName(guid); err != nil { continue // would collide with a reserved level-1 name } var one int err := q.QueryRow(`SELECT 1 FROM identities WHERE guid = ?`, guid).Scan(&one) switch { case errors.Is(err, sql.ErrNoRows): return guid, nil // free case err != nil: return "", fmt.Errorf("auth: guid lookup: %w", err) } } return "", ErrGUIDExhausted } // newSessionID returns a 256-bit opaque session id, base64url-encoded. func newSessionID() (string, error) { var b [32]byte if _, err := rand.Read(b[:]); err != nil { return "", fmt.Errorf("auth: session entropy: %w", err) } return base64.RawURLEncoding.EncodeToString(b[:]), nil } // rowScanner is satisfied by *sql.Row and *sql.Rows. type rowScanner interface { Scan(dest ...any) error } // scanIdentity reads one identity row. func scanIdentity(row rowScanner) (Identity, error) { var ( idn Identity status string created string enrolled sql.NullString revoked sql.NullString ) err := row.Scan(&idn.ID, &idn.UserID, &idn.GUID, &status, &created, &enrolled, &revoked) if errors.Is(err, sql.ErrNoRows) { return Identity{}, ErrIdentityNotFound } if err != nil { return Identity{}, fmt.Errorf("auth: scan identity: %w", err) } idn.Status = IdentityStatus(status) if idn.CreatedAt, err = store.ParseTime(created); err != nil { return Identity{}, fmt.Errorf("auth: parse identity created_at: %w", err) } if idn.EnrolledAt, err = parseNullTime(enrolled); err != nil { return Identity{}, err } if idn.RevokedAt, err = parseNullTime(revoked); err != nil { return Identity{}, err } return idn, nil } // scanSession reads one session row. func scanSession(row rowScanner) (Session, error) { var ( sess Session created string expires string revoked sql.NullString clientIP sql.NullString userAgent sql.NullString ) err := row.Scan(&sess.ID, &sess.IdentityID, &sess.UserID, &created, &expires, &revoked, &clientIP, &userAgent) if errors.Is(err, sql.ErrNoRows) { return Session{}, ErrSessionNotFound } if err != nil { return Session{}, fmt.Errorf("auth: scan session: %w", err) } if sess.CreatedAt, err = store.ParseTime(created); err != nil { return Session{}, fmt.Errorf("auth: parse session created_at: %w", err) } if sess.ExpiresAt, err = store.ParseTime(expires); err != nil { return Session{}, fmt.Errorf("auth: parse session expires_at: %w", err) } if sess.RevokedAt, err = parseNullTime(revoked); err != nil { return Session{}, err } sess.ClientIP = clientIP.String sess.UserAgent = userAgent.String return sess, nil } func parseNullTime(ns sql.NullString) (*time.Time, error) { if !ns.Valid || ns.String == "" { return nil, nil } t, err := store.ParseTime(ns.String) if err != nil { return nil, fmt.Errorf("auth: parse timestamp: %w", err) } return &t, nil } // identityByID loads one identity by primary key. It lives on Core so both the // identity and session services can use it. func (c *Core) identityByID(id int64) (Identity, error) { row := c.db.QueryRow(` SELECT identity_id, user_id, guid, status, created_at, enrolled_at, revoked_at FROM identities WHERE identity_id = ?`, id) return scanIdentity(row) } // nullString maps "" to NULL so the trail distinguishes "absent" from "empty". func nullString(s string) any { if s == "" { return nil } return s }