1package storage
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "net/netip"
9 "time"
10)
11
12const forwardSessionColumns = `id, user_id, app_domain, created_at, expires_at, revoked, reauthenticated_at, browser, client_id, address, user_agent, last_seen_at`
13
14func (s *Store) CreateForwardSession(ctx context.Context, input CreateForwardSessionInput) (ForwardSession, error) {
15 const operation = "create forward session"
16 if err := s.checkOpen(operation); err != nil {
17 return ForwardSession{}, err
18 }
19 return createForwardSession(ctx, s.sql, input)
20}
21
22func createForwardSession(ctx context.Context, q execer, input CreateForwardSessionInput) (ForwardSession, error) {
23 const operation = "create forward session"
24 if err := requireValue("session id", input.ID); err != nil {
25 return ForwardSession{}, invalid(operation, err)
26 }
27 if err := requireValue("user id", input.UserID); err != nil {
28 return ForwardSession{}, invalid(operation, err)
29 }
30 if err := requireValue("app domain", input.AppDomain); err != nil {
31 return ForwardSession{}, invalid(operation, err)
32 }
33 if input.CreatedAt.IsZero() {
34 return ForwardSession{}, invalid(operation, fmt.Errorf("creation time is zero"))
35 }
36 createdAt := canonicalTime(input.CreatedAt)
37 expiresAt := canonicalTime(input.ExpiresAt)
38 if !expiresAt.After(createdAt) {
39 return ForwardSession{}, invalid(operation, fmt.Errorf("expiry is not after creation"))
40 }
41 if input.Browser != "" && !ValidBrowser(input.Browser) {
42 return ForwardSession{}, invalid(operation, fmt.Errorf("browser marker is not 32 lowercase hex characters"))
43 }
44 if err := validClientColumns(input); err != nil {
45 return ForwardSession{}, invalid(operation, err)
46 }
47 browser := nullString(input.Browser)
48 client, address, userAgent := nullString(input.ClientID), nullAddress(input.Address), nullString(input.UserAgent)
49 if input.Epoch != nil {
50 inserted, err := execCount(ctx, q, operation, `
51INSERT INTO forward_sessions (id, user_id, app_domain, created_at, expires_at, revoked, browser, client_id, address, user_agent, last_seen_at)
52 SELECT ?, id, ?, ?, ?, 0, ?, ?, ?, ?, ? FROM users
53 WHERE id = ? AND disabled = 0 AND credentials_epoch = ?`, input.ID, input.AppDomain,
54 timeToInt(createdAt), timeToInt(expiresAt), browser, client, address, userAgent, timeToInt(createdAt), input.UserID, *input.Epoch)
55 if err != nil {
56 return ForwardSession{}, err
57 }
58 if inserted == 0 {
59 return ForwardSession{}, fmt.Errorf("%s: %w: the user is disabled or its credentials epoch moved", operation, ErrNotFound)
60 }
61 } else if _, err := q.ExecContext(ctx, `
62INSERT INTO forward_sessions (id, user_id, app_domain, created_at, expires_at, revoked, browser, client_id, address, user_agent, last_seen_at)
63 VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`, input.ID, input.UserID, input.AppDomain,
64 timeToInt(createdAt), timeToInt(expiresAt), browser, client, address, userAgent, timeToInt(createdAt)); err != nil {
65 return ForwardSession{}, classify(operation, err)
66 }
67 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
68}
69
70func (s *Store) ForwardSessionByID(ctx context.Context, id string) (ForwardSession, error) {
71 const operation = "get forward session"
72 if err := s.checkOpen(operation); err != nil {
73 return ForwardSession{}, err
74 }
75 session, err := scanForwardSession(s.sql.QueryRowContext(ctx, `SELECT `+forwardSessionColumns+` FROM forward_sessions WHERE id = ?`, id))
76 if errors.Is(err, sql.ErrNoRows) {
77 return ForwardSession{}, fmt.Errorf("%s: %w", operation, ErrNotFound)
78 }
79 if err != nil {
80 return ForwardSession{}, classify(operation, err)
81 }
82 return session, nil
83}
84
85func (s *Store) ForwardSessionsByUser(ctx context.Context, userID string) ([]ForwardSession, error) {
86 return s.listForwardSessions(ctx, "list forward sessions", `SELECT `+forwardSessionColumns+` FROM forward_sessions WHERE user_id = ? ORDER BY created_at DESC, id`, userID)
87}
88
89func (s *Store) LiveForwardSessionsByUser(ctx context.Context, userID string, at time.Time) ([]ForwardSession, error) {
90 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))
91}
92
93func (s *Store) listForwardSessions(ctx context.Context, operation, query string, args ...any) ([]ForwardSession, error) {
94 if err := s.checkOpen(operation); err != nil {
95 return nil, err
96 }
97 rows, err := s.sql.QueryContext(ctx, query, args...)
98 if err != nil {
99 return nil, classify(operation, err)
100 }
101 defer rows.Close()
102 var sessions []ForwardSession
103 for rows.Next() {
104 session, err := scanForwardSession(rows)
105 if err != nil {
106 return nil, classify(operation, err)
107 }
108 sessions = append(sessions, session)
109 }
110 if err := rows.Err(); err != nil {
111 return nil, classify(operation, err)
112 }
113 return sessions, nil
114}
115
116func (s *Store) MarkReauthenticated(ctx context.Context, id string, at time.Time) error {
117 const operation = "mark forward session reauthenticated"
118 if err := s.checkOpen(operation); err != nil {
119 return err
120 }
121 return markReauthenticated(ctx, s.sql, id, at)
122}
123
124func markReauthenticated(ctx context.Context, q execer, id string, at time.Time) error {
125 const operation = "mark forward session reauthenticated"
126 if at.IsZero() {
127 return invalid(operation, fmt.Errorf("reauthentication time is zero"))
128 }
129 stamp := timeToInt(at)
130 result, err := q.ExecContext(ctx, `
131UPDATE forward_sessions SET reauthenticated_at = ?
132WHERE id = ? AND revoked = 0 AND expires_at > ?`, stamp, id, stamp)
133 if err != nil {
134 return classify(operation, err)
135 }
136 changed, err := result.RowsAffected()
137 if err != nil {
138 return classify(operation+" rows affected", err)
139 }
140 if changed > 0 {
141 return nil
142 }
143 exists, err := forwardSessionExists(ctx, q, operation, id)
144 if err != nil {
145 return err
146 }
147 if !exists {
148 return fmt.Errorf("%s: %w", operation, ErrNotFound)
149 }
150 return invalid(operation, fmt.Errorf("session is revoked or expired"))
151}
152
153func (s *Store) RevokeForwardSession(ctx context.Context, id string) error {
154 const operation = "revoke forward session"
155 if err := s.checkOpen(operation); err != nil {
156 return err
157 }
158 _, err := revokeForwardSession(ctx, s.sql, operation, id)
159 return err
160}
161
162func (s *Store) RevokeForwardSessionAudited(ctx context.Context, id string, audit AppendAuditInput) error {
163 return s.RevokeForwardSessionAuditedAt(ctx, id, audit, s.now())
164}
165
166func (s *Store) RevokeForwardSessionAuditedAt(ctx context.Context, id string, audit AppendAuditInput, now time.Time) error {
167 const operation = "revoke forward session"
168 if err := s.checkOpen(operation); err != nil {
169 return err
170 }
171 tx, err := s.sql.BeginTx(ctx, nil)
172 if err != nil {
173 return classify("begin "+operation, err)
174 }
175 defer tx.Rollback()
176 changed, err := revokeForwardSession(ctx, tx, operation, id)
177 if err != nil || !changed {
178 return err
179 }
180 if _, err := appendAudit(ctx, tx, operation+" audit", audit, canonicalTime(now)); err != nil {
181 return err
182 }
183 return classify("commit "+operation, tx.Commit())
184}
185
186func (s *Store) RevokeForwardSessionsByUser(ctx context.Context, userID string) (int64, error) {
187 const operation = "revoke forward sessions by user"
188 if err := s.checkOpen(operation); err != nil {
189 return 0, err
190 }
191 return revokeForwardSessionsByUser(ctx, s.sql, operation, userID, "")
192}
193
194func (s *Store) DeleteExpiredForwardSessions(ctx context.Context, before time.Time) (int64, error) {
195 const operation = "delete expired forward sessions"
196 if err := s.checkOpen(operation); err != nil {
197 return 0, err
198 }
199 result, err := s.sql.ExecContext(ctx, `DELETE FROM forward_sessions WHERE expires_at <= ?`, timeToInt(before))
200 if err != nil {
201 return 0, classify(operation, err)
202 }
203 count, err := result.RowsAffected()
204 if err != nil {
205 return 0, classify(operation+" rows affected", err)
206 }
207 return count, nil
208}
209
210func revokeForwardSession(ctx context.Context, q execer, operation, id string) (bool, error) {
211 result, err := q.ExecContext(ctx, `UPDATE forward_sessions SET revoked = 1 WHERE id = ? AND revoked = 0`, id)
212 if err != nil {
213 return false, classify(operation, err)
214 }
215 changed, err := result.RowsAffected()
216 if err != nil {
217 return false, classify(operation+" rows affected", err)
218 }
219 if changed > 0 {
220 return true, nil
221 }
222 exists, err := forwardSessionExists(ctx, q, operation, id)
223 if err != nil {
224 return false, err
225 }
226 if !exists {
227 return false, fmt.Errorf("%s: %w", operation, ErrNotFound)
228 }
229 return false, nil
230}
231
232func revokeForwardSessionsByUser(ctx context.Context, q execer, operation, userID, keepID string) (int64, error) {
233 result, err := q.ExecContext(ctx, `UPDATE forward_sessions SET revoked = 1 WHERE user_id = ? AND revoked = 0 AND id <> ?`, userID, keepID)
234 if err != nil {
235 return 0, classify(operation, err)
236 }
237 count, err := result.RowsAffected()
238 if err != nil {
239 return 0, classify(operation+" rows affected", err)
240 }
241 return count, nil
242}
243
244func forwardSessionExists(ctx context.Context, q execer, operation, id string) (bool, error) {
245 var one int
246 err := q.QueryRowContext(ctx, `SELECT 1 FROM forward_sessions WHERE id = ?`, id).Scan(&one)
247 if errors.Is(err, sql.ErrNoRows) {
248 return false, nil
249 }
250 if err != nil {
251 return false, classify(operation, err)
252 }
253 return true, nil
254}
255
256func scanForwardSession(row rowScanner) (ForwardSession, error) {
257 var session ForwardSession
258 var createdAt int64
259 var expiresAt int64
260 var revoked int
261 var reauthenticatedAt sql.NullInt64
262 var browser sql.NullString
263 var client, address, userAgent sql.NullString
264 var lastSeenAt sql.NullInt64
265 if err := row.Scan(&session.ID, &session.UserID, &session.AppDomain, &createdAt, &expiresAt, &revoked, &reauthenticatedAt, &browser, &client, &address, &userAgent, &lastSeenAt); err != nil {
266 return ForwardSession{}, err
267 }
268 session.CreatedAt = intToTime(createdAt)
269 session.ExpiresAt = intToTime(expiresAt)
270 session.Revoked = revoked != 0
271 session.Browser = browser.String
272 session.ClientID = client.String
273 session.UserAgent = userAgent.String
274 if address.Valid {
275 parsed, err := netip.ParseAddr(address.String)
276 if err != nil {
277 return ForwardSession{}, err
278 }
279 session.Address = parsed
280 }
281 if lastSeenAt.Valid {
282 value := intToTime(lastSeenAt.Int64)
283 session.LastSeenAt = &value
284 }
285 if reauthenticatedAt.Valid {
286 value := intToTime(reauthenticatedAt.Int64)
287 session.ReauthenticatedAt = &value
288 }
289 return session, nil
290}