package storage import ( "context" "database/sql" "errors" "fmt" "net/netip" "time" ) const forwardSessionColumns = `id, user_id, app_domain, created_at, expires_at, revoked, reauthenticated_at, browser, client_id, address, user_agent, last_seen_at` func (s *Store) CreateForwardSession(ctx context.Context, input CreateForwardSessionInput) (ForwardSession, error) { const operation = "create forward session" if err := s.checkOpen(operation); err != nil { return ForwardSession{}, err } return createForwardSession(ctx, s.sql, input) } func createForwardSession(ctx context.Context, q execer, input CreateForwardSessionInput) (ForwardSession, error) { const operation = "create forward session" if err := requireValue("session id", input.ID); err != nil { return ForwardSession{}, invalid(operation, err) } if err := requireValue("user id", input.UserID); err != nil { return ForwardSession{}, invalid(operation, err) } if err := requireValue("app domain", input.AppDomain); err != nil { return ForwardSession{}, invalid(operation, err) } if input.CreatedAt.IsZero() { return ForwardSession{}, invalid(operation, fmt.Errorf("creation time is zero")) } createdAt := canonicalTime(input.CreatedAt) expiresAt := canonicalTime(input.ExpiresAt) if !expiresAt.After(createdAt) { return ForwardSession{}, invalid(operation, fmt.Errorf("expiry is not after creation")) } if input.Browser != "" && !ValidBrowser(input.Browser) { return ForwardSession{}, invalid(operation, fmt.Errorf("browser marker is not 32 lowercase hex characters")) } if err := validClientColumns(input); err != nil { return ForwardSession{}, invalid(operation, err) } browser := nullString(input.Browser) client, address, userAgent := nullString(input.ClientID), nullAddress(input.Address), nullString(input.UserAgent) if input.Epoch != nil { inserted, err := execCount(ctx, q, operation, ` INSERT INTO forward_sessions (id, user_id, app_domain, created_at, expires_at, revoked, browser, client_id, address, user_agent, last_seen_at) SELECT ?, id, ?, ?, ?, 0, ?, ?, ?, ?, ? FROM users WHERE id = ? AND disabled = 0 AND credentials_epoch = ?`, input.ID, input.AppDomain, timeToInt(createdAt), timeToInt(expiresAt), browser, client, address, userAgent, timeToInt(createdAt), input.UserID, *input.Epoch) if err != nil { return ForwardSession{}, err } if inserted == 0 { return ForwardSession{}, fmt.Errorf("%s: %w: the user is disabled or its credentials epoch moved", operation, ErrNotFound) } } else if _, err := q.ExecContext(ctx, ` INSERT INTO forward_sessions (id, user_id, app_domain, created_at, expires_at, revoked, browser, client_id, address, user_agent, last_seen_at) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`, input.ID, input.UserID, input.AppDomain, timeToInt(createdAt), timeToInt(expiresAt), browser, client, address, userAgent, timeToInt(createdAt)); err != nil { return ForwardSession{}, classify(operation, err) } return ForwardSession{ID: input.ID, UserID: input.UserID, AppDomain: input.AppDomain, CreatedAt: createdAt, ExpiresAt: expiresAt, Browser: input.Browser, ClientID: input.ClientID, Address: input.Address, UserAgent: input.UserAgent, LastSeenAt: &createdAt}, nil } func (s *Store) ForwardSessionByID(ctx context.Context, id string) (ForwardSession, error) { const operation = "get forward session" if err := s.checkOpen(operation); err != nil { return ForwardSession{}, err } session, err := scanForwardSession(s.sql.QueryRowContext(ctx, `SELECT `+forwardSessionColumns+` FROM forward_sessions WHERE id = ?`, id)) if errors.Is(err, sql.ErrNoRows) { return ForwardSession{}, fmt.Errorf("%s: %w", operation, ErrNotFound) } if err != nil { return ForwardSession{}, classify(operation, err) } return session, nil } func (s *Store) ForwardSessionsByUser(ctx context.Context, userID string) ([]ForwardSession, error) { return s.listForwardSessions(ctx, "list forward sessions", `SELECT `+forwardSessionColumns+` FROM forward_sessions WHERE user_id = ? ORDER BY created_at DESC, id`, userID) } func (s *Store) LiveForwardSessionsByUser(ctx context.Context, userID string, at time.Time) ([]ForwardSession, error) { return s.listForwardSessions(ctx, "list live forward sessions", `SELECT `+forwardSessionColumns+` FROM forward_sessions WHERE user_id = ? AND revoked = 0 AND expires_at > ? ORDER BY created_at DESC, id`, userID, timeToInt(at)) } func (s *Store) listForwardSessions(ctx context.Context, operation, query string, args ...any) ([]ForwardSession, error) { if err := s.checkOpen(operation); err != nil { return nil, err } rows, err := s.sql.QueryContext(ctx, query, args...) if err != nil { return nil, classify(operation, err) } defer rows.Close() var sessions []ForwardSession for rows.Next() { session, err := scanForwardSession(rows) if err != nil { return nil, classify(operation, err) } sessions = append(sessions, session) } if err := rows.Err(); err != nil { return nil, classify(operation, err) } return sessions, nil } func (s *Store) MarkReauthenticated(ctx context.Context, id string, at time.Time) error { const operation = "mark forward session reauthenticated" if err := s.checkOpen(operation); err != nil { return err } return markReauthenticated(ctx, s.sql, id, at) } func markReauthenticated(ctx context.Context, q execer, id string, at time.Time) error { const operation = "mark forward session reauthenticated" if at.IsZero() { return invalid(operation, fmt.Errorf("reauthentication time is zero")) } stamp := timeToInt(at) result, err := q.ExecContext(ctx, ` UPDATE forward_sessions SET reauthenticated_at = ? WHERE id = ? AND revoked = 0 AND expires_at > ?`, stamp, id, stamp) if err != nil { return classify(operation, err) } changed, err := result.RowsAffected() if err != nil { return classify(operation+" rows affected", err) } if changed > 0 { return nil } exists, err := forwardSessionExists(ctx, q, operation, id) if err != nil { return err } if !exists { return fmt.Errorf("%s: %w", operation, ErrNotFound) } return invalid(operation, fmt.Errorf("session is revoked or expired")) } func (s *Store) RevokeForwardSession(ctx context.Context, id string) error { const operation = "revoke forward session" if err := s.checkOpen(operation); err != nil { return err } _, err := revokeForwardSession(ctx, s.sql, operation, id) return err } func (s *Store) RevokeForwardSessionAudited(ctx context.Context, id string, audit AppendAuditInput) error { return s.RevokeForwardSessionAuditedAt(ctx, id, audit, s.now()) } func (s *Store) RevokeForwardSessionAuditedAt(ctx context.Context, id string, audit AppendAuditInput, now time.Time) error { const operation = "revoke forward session" if err := s.checkOpen(operation); err != nil { return err } tx, err := s.sql.BeginTx(ctx, nil) if err != nil { return classify("begin "+operation, err) } defer tx.Rollback() changed, err := revokeForwardSession(ctx, tx, operation, id) if err != nil || !changed { return err } if _, err := appendAudit(ctx, tx, operation+" audit", audit, canonicalTime(now)); err != nil { return err } return classify("commit "+operation, tx.Commit()) } func (s *Store) RevokeForwardSessionsByUser(ctx context.Context, userID string) (int64, error) { const operation = "revoke forward sessions by user" if err := s.checkOpen(operation); err != nil { return 0, err } return revokeForwardSessionsByUser(ctx, s.sql, operation, userID, "") } func (s *Store) DeleteExpiredForwardSessions(ctx context.Context, before time.Time) (int64, error) { const operation = "delete expired forward sessions" if err := s.checkOpen(operation); err != nil { return 0, err } result, err := s.sql.ExecContext(ctx, `DELETE FROM forward_sessions WHERE expires_at <= ?`, timeToInt(before)) if err != nil { return 0, classify(operation, err) } count, err := result.RowsAffected() if err != nil { return 0, classify(operation+" rows affected", err) } return count, nil } func revokeForwardSession(ctx context.Context, q execer, operation, id string) (bool, error) { result, err := q.ExecContext(ctx, `UPDATE forward_sessions SET revoked = 1 WHERE id = ? AND revoked = 0`, id) if err != nil { return false, classify(operation, err) } changed, err := result.RowsAffected() if err != nil { return false, classify(operation+" rows affected", err) } if changed > 0 { return true, nil } exists, err := forwardSessionExists(ctx, q, operation, id) if err != nil { return false, err } if !exists { return false, fmt.Errorf("%s: %w", operation, ErrNotFound) } return false, nil } func revokeForwardSessionsByUser(ctx context.Context, q execer, operation, userID, keepID string) (int64, error) { result, err := q.ExecContext(ctx, `UPDATE forward_sessions SET revoked = 1 WHERE user_id = ? AND revoked = 0 AND id <> ?`, userID, keepID) if err != nil { return 0, classify(operation, err) } count, err := result.RowsAffected() if err != nil { return 0, classify(operation+" rows affected", err) } return count, nil } func forwardSessionExists(ctx context.Context, q execer, operation, id string) (bool, error) { var one int err := q.QueryRowContext(ctx, `SELECT 1 FROM forward_sessions WHERE id = ?`, id).Scan(&one) if errors.Is(err, sql.ErrNoRows) { return false, nil } if err != nil { return false, classify(operation, err) } return true, nil } func scanForwardSession(row rowScanner) (ForwardSession, error) { var session ForwardSession var createdAt int64 var expiresAt int64 var revoked int var reauthenticatedAt sql.NullInt64 var browser sql.NullString var client, address, userAgent sql.NullString var lastSeenAt sql.NullInt64 if err := row.Scan(&session.ID, &session.UserID, &session.AppDomain, &createdAt, &expiresAt, &revoked, &reauthenticatedAt, &browser, &client, &address, &userAgent, &lastSeenAt); err != nil { return ForwardSession{}, err } session.CreatedAt = intToTime(createdAt) session.ExpiresAt = intToTime(expiresAt) session.Revoked = revoked != 0 session.Browser = browser.String session.ClientID = client.String session.UserAgent = userAgent.String if address.Valid { parsed, err := netip.ParseAddr(address.String) if err != nil { return ForwardSession{}, err } session.Address = parsed } if lastSeenAt.Valid { value := intToTime(lastSeenAt.Int64) session.LastSeenAt = &value } if reauthenticatedAt.Valid { value := intToTime(reauthenticatedAt.Int64) session.ReauthenticatedAt = &value } return session, nil }