package auth import ( "context" "errors" "fmt" "time" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" ) // SessionsService implements Sessions. type SessionsService struct { *Core } var _ Sessions = (*SessionsService)(nil) // Mint implements Sessions.Mint (SPEC-004 §6): issue a session for an ACTIVE // identity. A pending or revoked identity gets no session. func (s *SessionsService) Mint(ctx context.Context, identityID int64, ip, ua string) (Session, error) { idn, err := s.identityByID(identityID) if err != nil { return Session{}, err } if idn.Status != StatusActive { return Session{}, fmt.Errorf("%w: identity:%d is %s", ErrIdentityNotActive, identityID, idn.Status) } sid, err := newSessionID() if err != nil { return Session{}, err } now := s.clock() sess := Session{ ID: sid, IdentityID: identityID, UserID: idn.UserID, CreatedAt: now, ExpiresAt: now.Add(s.sessionTTL), ClientIP: ip, UserAgent: ua, } if _, err := s.db.Exec( `INSERT INTO sessions (session_id, identity_id, user_id, created_at, expires_at, client_ip, user_agent) VALUES (?, ?, ?, ?, ?, ?, ?)`, sess.ID, sess.IdentityID, sess.UserID, store.FormatTime(sess.CreatedAt), store.FormatTime(sess.ExpiresAt), nullString(ip), nullString(ua), ); err != nil { return Session{}, fmt.Errorf("auth: insert session: %w", err) } s.record(ctx, audit.Event{ ActorUserID: idn.UserID, Action: "session.mint", Subject: fmt.Sprintf("identity:%d", identityID), Detail: "expires_at=" + store.FormatTime(sess.ExpiresAt), ClientIP: ip, }) return sess, nil } // Validate implements Sessions.Validate (A2). An empty id is rejected outright; // a missing, revoked or expired session is an error. func (s *SessionsService) Validate(ctx context.Context, sessionID string) (Session, error) { if sessionID == "" { return Session{}, ErrEmptySessionID } row := s.db.QueryRow(` SELECT session_id, identity_id, user_id, created_at, expires_at, revoked_at, client_ip, user_agent FROM sessions WHERE session_id = ?`, sessionID) sess, err := scanSession(row) if err != nil { return Session{}, err } if sess.RevokedAt != nil { return Session{}, fmt.Errorf("%w: %s", ErrSessionRevoked, sessionID) } if !sess.ExpiresAt.After(s.clock()) { return Session{}, fmt.Errorf("%w: %s", ErrSessionExpired, sessionID) } return sess, nil } // Rotate implements Sessions.Rotate (fixation defence, SPEC-006 §5): the old // session is revoked and a fresh one issued for the same identity. func (s *SessionsService) Rotate(ctx context.Context, sessionID string) (Session, error) { old, err := s.Validate(ctx, sessionID) if err != nil { return Session{}, err } if _, err := s.db.Exec( `UPDATE sessions SET revoked_at = ? WHERE session_id = ? AND revoked_at IS NULL`, store.FormatTime(s.clock()), sessionID, ); err != nil { return Session{}, fmt.Errorf("auth: revoke on rotate: %w", err) } sess, err := s.Mint(ctx, old.IdentityID, old.ClientIP, old.UserAgent) if err != nil { return Session{}, err } s.record(ctx, audit.Event{ ActorUserID: old.UserID, Action: "session.rotate", Subject: fmt.Sprintf("identity:%d", old.IdentityID), Detail: "session rotated on login", ClientIP: old.ClientIP, }) return sess, nil } // Revoke implements Sessions.Revoke (logout). It is idempotent for a session // that is already revoked. func (s *SessionsService) Revoke(ctx context.Context, sessionID string) error { if sessionID == "" { return ErrEmptySessionID } sess, err := s.lookupSession(sessionID) if err != nil { return err } if sess.RevokedAt != nil { return nil } if _, err := s.db.Exec( `UPDATE sessions SET revoked_at = ? WHERE session_id = ?`, store.FormatTime(s.clock()), sessionID, ); err != nil { return fmt.Errorf("auth: revoke session: %w", err) } s.record(ctx, audit.Event{ ActorUserID: sess.UserID, Action: "session.revoke", Subject: fmt.Sprintf("identity:%d", sess.IdentityID), Detail: "logout", ClientIP: sess.ClientIP, }) return nil } // RevokeAll implements Sessions.RevokeAll (D-007): kill every live session of // an identity. func (s *SessionsService) RevokeAll(ctx context.Context, identityID int64) error { res, err := s.db.Exec( `UPDATE sessions SET revoked_at = ? WHERE identity_id = ? AND revoked_at IS NULL`, store.FormatTime(s.clock()), identityID, ) if err != nil { return fmt.Errorf("auth: revoke all: %w", err) } n, _ := res.RowsAffected() if n == 0 { return nil } s.record(ctx, audit.Event{ Action: "session.revoke_all", Subject: fmt.Sprintf("identity:%d", identityID), Detail: fmt.Sprintf("revoked=%d", n), }) return nil } // GC implements Sessions.GC (SPEC-004 §6): delete expired rows only. It never // removes a still-valid session — "never the principal's last valid session". func (s *SessionsService) GC(ctx context.Context, now time.Time) (int, error) { res, err := s.db.Exec( `DELETE FROM sessions WHERE expires_at <= ?`, store.FormatTime(now), ) if err != nil { return 0, fmt.Errorf("auth: gc: %w", err) } n, err := res.RowsAffected() if err != nil { return 0, fmt.Errorf("auth: gc rows affected: %w", err) } return int(n), nil } // lookupSession loads a session by id without validating it (used by Revoke, // which must tolerate a revoked row). func (s *SessionsService) lookupSession(sessionID string) (Session, error) { row := s.db.QueryRow(` SELECT session_id, identity_id, user_id, created_at, expires_at, revoked_at, client_ip, user_agent FROM sessions WHERE session_id = ?`, sessionID) sess, err := scanSession(row) if errors.Is(err, ErrSessionNotFound) { return Session{}, fmt.Errorf("%w: %s", ErrSessionNotFound, sessionID) } return sess, err }