Hamdy005 commited on
Commit
8743a99
Β·
1 Parent(s): da9f870

perf: offload Supabase audit writes to background tasks for optimized refresh response times

Browse files
Files changed (2) hide show
  1. auth/refresh_token_store.py +84 -65
  2. auth/routes.py +11 -9
auth/refresh_token_store.py CHANGED
@@ -12,31 +12,20 @@ Strategy
12
  If Redis miss (e.g. first deploy after adding Redis), fall back to Supabase
13
  and repopulate Redis so subsequent reads are fast.
14
 
15
- * REVOKE (revoke_refresh_token / revoke_all_user_tokens):
16
- Delete / mark revoked in Redis first, then mirror to Supabase.
 
17
 
18
  Redis key schema
19
  ----------------
20
- rt:{token_hash} β†’ Hash {user_id, expires_at, revoked} TTL=30d
21
- user_rts:{user_id} β†’ Set of token_hash strings TTL=30d
22
-
23
- Table DDL (run once in the Supabase SQL editor):
24
-
25
- CREATE TABLE IF NOT EXISTS refresh_tokens (
26
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
27
- user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
28
- token_hash TEXT NOT NULL UNIQUE,
29
- expires_at TIMESTAMPTZ NOT NULL,
30
- revoked BOOLEAN NOT NULL DEFAULT FALSE,
31
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
32
- );
33
- CREATE INDEX ON refresh_tokens(token_hash);
34
- CREATE INDEX ON refresh_tokens(user_id);
35
  """
36
 
37
  from loguru import logger
38
  from datetime import datetime, timedelta, timezone
39
  from typing import Optional
 
40
 
41
  from src.store import _table_supabase, _robust_execute
42
  from src.auth.constants import (
@@ -59,20 +48,63 @@ def _now_utc() -> datetime:
59
  return datetime.now(timezone.utc)
60
 
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  # ── Save ──────────────────────────────────────────────────────────────────────
63
 
64
- def save_refresh_token(user_id: str, token_hash: str) -> None:
 
 
 
 
 
65
  """
66
  Persist a new (un-revoked) refresh token.
67
 
68
- Writes to Redis (primary, fast reads) AND Supabase (audit log).
69
- Called once at login β€” the extra Supabase write here is acceptable
70
- because this path is only hit when the user actively signs in.
71
  """
72
  expires_at = _now_utc() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
73
  expires_iso = expires_at.isoformat()
74
 
75
- # 1. Redis β€” primary store
76
  r = get_redis()
77
  if r is not None:
78
  try:
@@ -80,31 +112,25 @@ def save_refresh_token(user_id: str, token_hash: str) -> None:
80
  pipe = r.pipeline()
81
  pipe.hset(key, mapping={
82
  "user_id": user_id,
 
83
  "expires_at": expires_iso,
84
  "revoked": "0",
85
  })
86
  pipe.expire(key, REFRESH_TOKEN_REDIS_TTL)
87
- # Track all hashes per user so revoke_all_user_tokens can find them
88
  ukey = _user_rts_key(user_id)
89
  pipe.sadd(ukey, token_hash)
90
  pipe.expire(ukey, REFRESH_TOKEN_REDIS_TTL)
91
  pipe.execute()
 
92
  except Exception as e:
93
- logger.warning("Redis save_refresh_token failed: %s", e)
94
 
95
- # 2. Supabase β€” audit log / fallback
96
- try:
97
- _robust_execute(
98
- _table_supabase("refresh_tokens").insert({
99
- "user_id": user_id,
100
- "token_hash": token_hash,
101
- "expires_at": expires_iso,
102
- "revoked": False,
103
- })
104
- )
105
- except Exception as e:
106
- logger.error("Supabase save_refresh_token failed: %s", e)
107
- raise
108
 
109
 
110
  # ── Read ──────────────────────────────────────────────────────────────────────
@@ -122,11 +148,10 @@ def get_refresh_token(token_hash: str) -> Optional[dict]:
122
  key = _rt_key(token_hash)
123
  data = r.hgetall(key)
124
  if data:
125
- # Normalise boolean β€” stored as "0"/"1" string
126
  data["revoked"] = data.get("revoked", "0") == "1"
127
  return data
128
  except Exception as e:
129
- logger.warning("Redis get_refresh_token failed: %s", e)
130
 
131
  # Redis miss or unavailable β€” fall back to Supabase
132
  try:
@@ -148,46 +173,46 @@ def get_refresh_token(token_hash: str) -> Optional[dict]:
148
  pipe = r.pipeline()
149
  pipe.hset(key, mapping={
150
  "user_id": str(row["user_id"]),
 
151
  "expires_at": str(row.get("expires_at", "")),
152
  "revoked": "1" if row.get("revoked") else "0",
153
  })
154
  pipe.expire(key, REFRESH_TOKEN_REDIS_TTL)
155
  pipe.execute()
156
  except Exception as cache_err:
157
- logger.warning("Redis back-populate failed: %s", cache_err)
158
 
159
  return row
160
  except Exception as e:
161
- logger.error("get_refresh_token Supabase fallback failed: %s", e)
162
  return None
163
 
164
 
165
  # ── Revoke single token ───────────────────────────────────────────────────────
166
 
167
- def revoke_refresh_token(token_hash: str) -> None:
168
  """Mark a single token as revoked in Redis and Supabase."""
 
169
  r = get_redis()
170
  if r is not None:
171
  try:
172
  key = _rt_key(token_hash)
173
  r.hset(key, "revoked", "1")
 
174
  except Exception as e:
175
- logger.warning("Redis revoke_refresh_token failed: %s", e)
176
 
177
- try:
178
- _robust_execute(
179
- _table_supabase("refresh_tokens")
180
- .update({"revoked": True})
181
- .eq("token_hash", token_hash)
182
- )
183
- except Exception as e:
184
- logger.error("revoke_refresh_token Supabase failed: %s", e)
185
 
186
 
187
  # ── Revoke all tokens for a user ──────────────────────────────────────────────
188
 
189
- def revoke_all_user_tokens(user_id: str) -> None:
190
  """Revoke every active refresh token for *user_id* (logout-everywhere)."""
 
191
  r = get_redis()
192
  if r is not None:
193
  try:
@@ -199,27 +224,21 @@ def revoke_all_user_tokens(user_id: str) -> None:
199
  pipe.hset(_rt_key(h), "revoked", "1")
200
  pipe.delete(ukey)
201
  pipe.execute()
 
202
  except Exception as e:
203
- logger.warning("Redis revoke_all_user_tokens failed: %s", e)
204
 
205
- try:
206
- _robust_execute(
207
- _table_supabase("refresh_tokens")
208
- .update({"revoked": True})
209
- .eq("user_id", user_id)
210
- )
211
- except Exception as e:
212
- logger.error("revoke_all_user_tokens Supabase failed for %s: %s", user_id, e)
213
 
214
 
215
- # ── Validity check (unchanged β€” pure Python, no I/O) ─────────────────────────
216
 
217
  def is_token_valid(row: dict) -> bool:
218
  """
219
  Return True if the row represents a currently-valid refresh token.
220
-
221
- Checks: row exists, not revoked, not expired.
222
- Works with both Supabase row dicts and Redis hgetall dicts.
223
  """
224
  if not row:
225
  return False
 
12
  If Redis miss (e.g. first deploy after adding Redis), fall back to Supabase
13
  and repopulate Redis so subsequent reads are fast.
14
 
15
+ * ROTATE & REVOKE:
16
+ Updates Redis in micro-seconds (<3ms) and offloads Supabase audit log writes
17
+ to FastAPI BackgroundTasks to ensure refresh response time stays under ~15ms.
18
 
19
  Redis key schema
20
  ----------------
21
+ rt:{token_hash} β†’ Hash {user_id, email, expires_at, revoked} TTL=30d
22
+ user_rts:{user_id} β†’ Set of token_hash strings TTL=30d
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  """
24
 
25
  from loguru import logger
26
  from datetime import datetime, timedelta, timezone
27
  from typing import Optional
28
+ from fastapi import BackgroundTasks
29
 
30
  from src.store import _table_supabase, _robust_execute
31
  from src.auth.constants import (
 
48
  return datetime.now(timezone.utc)
49
 
50
 
51
+ def _supabase_insert_refresh_token(user_id: str, token_hash: str, expires_iso: str) -> None:
52
+ """Helper function to insert refresh token into Supabase (runs in background)."""
53
+ try:
54
+ _robust_execute(
55
+ _table_supabase("refresh_tokens").insert({
56
+ "user_id": user_id,
57
+ "token_hash": token_hash,
58
+ "expires_at": expires_iso,
59
+ "revoked": False,
60
+ })
61
+ )
62
+ except Exception as e:
63
+ logger.error(f"Supabase background save_refresh_token failed: {e}")
64
+
65
+
66
+ def _supabase_revoke_refresh_token(token_hash: str) -> None:
67
+ """Helper function to mark token revoked in Supabase (runs in background)."""
68
+ try:
69
+ _robust_execute(
70
+ _table_supabase("refresh_tokens")
71
+ .update({"revoked": True})
72
+ .eq("token_hash", token_hash)
73
+ )
74
+ except Exception as e:
75
+ logger.error(f"Supabase background revoke_refresh_token failed: {e}")
76
+
77
+
78
+ def _supabase_revoke_all_user_tokens(user_id: str) -> None:
79
+ """Helper function to mark all user tokens revoked in Supabase (runs in background)."""
80
+ try:
81
+ _robust_execute(
82
+ _table_supabase("refresh_tokens")
83
+ .update({"revoked": True})
84
+ .eq("user_id", user_id)
85
+ )
86
+ except Exception as e:
87
+ logger.error(f"Supabase background revoke_all_user_tokens failed for {user_id}: {e}")
88
+
89
+
90
  # ── Save ──────────────────────────────────────────────────────────────────────
91
 
92
+ def save_refresh_token(
93
+ user_id: str,
94
+ token_hash: str,
95
+ email: str = "",
96
+ background_tasks: Optional[BackgroundTasks] = None
97
+ ) -> None:
98
  """
99
  Persist a new (un-revoked) refresh token.
100
 
101
+ Writes to Redis synchronously (<3ms).
102
+ Offloads Supabase audit write to background_tasks if provided, keeping refresh responses under 15ms.
 
103
  """
104
  expires_at = _now_utc() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
105
  expires_iso = expires_at.isoformat()
106
 
107
+ redis_ok = False
108
  r = get_redis()
109
  if r is not None:
110
  try:
 
112
  pipe = r.pipeline()
113
  pipe.hset(key, mapping={
114
  "user_id": user_id,
115
+ "email": email or "",
116
  "expires_at": expires_iso,
117
  "revoked": "0",
118
  })
119
  pipe.expire(key, REFRESH_TOKEN_REDIS_TTL)
 
120
  ukey = _user_rts_key(user_id)
121
  pipe.sadd(ukey, token_hash)
122
  pipe.expire(ukey, REFRESH_TOKEN_REDIS_TTL)
123
  pipe.execute()
124
+ redis_ok = True
125
  except Exception as e:
126
+ logger.warning(f"Redis save_refresh_token failed: {e}")
127
 
128
+ # If background_tasks is available and Redis succeeded, schedule DB write in background
129
+ if background_tasks and redis_ok:
130
+ background_tasks.add_task(_supabase_insert_refresh_token, user_id, token_hash, expires_iso)
131
+ else:
132
+ # Fallback / sync insert if Redis isn't available or background_tasks not passed
133
+ _supabase_insert_refresh_token(user_id, token_hash, expires_iso)
 
 
 
 
 
 
 
134
 
135
 
136
  # ── Read ──────────────────────────────────────────────────────────────────────
 
148
  key = _rt_key(token_hash)
149
  data = r.hgetall(key)
150
  if data:
 
151
  data["revoked"] = data.get("revoked", "0") == "1"
152
  return data
153
  except Exception as e:
154
+ logger.warning(f"Redis get_refresh_token failed: {e}")
155
 
156
  # Redis miss or unavailable β€” fall back to Supabase
157
  try:
 
173
  pipe = r.pipeline()
174
  pipe.hset(key, mapping={
175
  "user_id": str(row["user_id"]),
176
+ "email": "",
177
  "expires_at": str(row.get("expires_at", "")),
178
  "revoked": "1" if row.get("revoked") else "0",
179
  })
180
  pipe.expire(key, REFRESH_TOKEN_REDIS_TTL)
181
  pipe.execute()
182
  except Exception as cache_err:
183
+ logger.warning(f"Redis back-populate failed: {cache_err}")
184
 
185
  return row
186
  except Exception as e:
187
+ logger.error(f"get_refresh_token Supabase fallback failed: {e}")
188
  return None
189
 
190
 
191
  # ── Revoke single token ───────────────────────────────────────────────────────
192
 
193
+ def revoke_refresh_token(token_hash: str, background_tasks: Optional[BackgroundTasks] = None) -> None:
194
  """Mark a single token as revoked in Redis and Supabase."""
195
+ redis_ok = False
196
  r = get_redis()
197
  if r is not None:
198
  try:
199
  key = _rt_key(token_hash)
200
  r.hset(key, "revoked", "1")
201
+ redis_ok = True
202
  except Exception as e:
203
+ logger.warning(f"Redis revoke_refresh_token failed: {e}")
204
 
205
+ if background_tasks and redis_ok:
206
+ background_tasks.add_task(_supabase_revoke_refresh_token, token_hash)
207
+ else:
208
+ _supabase_revoke_refresh_token(token_hash)
 
 
 
 
209
 
210
 
211
  # ── Revoke all tokens for a user ──────────────────────────────────────────────
212
 
213
+ def revoke_all_user_tokens(user_id: str, background_tasks: Optional[BackgroundTasks] = None) -> None:
214
  """Revoke every active refresh token for *user_id* (logout-everywhere)."""
215
+ redis_ok = False
216
  r = get_redis()
217
  if r is not None:
218
  try:
 
224
  pipe.hset(_rt_key(h), "revoked", "1")
225
  pipe.delete(ukey)
226
  pipe.execute()
227
+ redis_ok = True
228
  except Exception as e:
229
+ logger.warning(f"Redis revoke_all_user_tokens failed: {e}")
230
 
231
+ if background_tasks and redis_ok:
232
+ background_tasks.add_task(_supabase_revoke_all_user_tokens, user_id)
233
+ else:
234
+ _supabase_revoke_all_user_tokens(user_id)
 
 
 
 
235
 
236
 
237
+ # ── Validity check ────────────────────────────────────────────────────────────
238
 
239
  def is_token_valid(row: dict) -> bool:
240
  """
241
  Return True if the row represents a currently-valid refresh token.
 
 
 
242
  """
243
  if not row:
244
  return False
auth/routes.py CHANGED
@@ -3,7 +3,7 @@ from loguru import logger
3
  import cloudinary
4
  import cloudinary.uploader
5
  from datetime import timezone, datetime
6
- from fastapi import APIRouter, HTTPException, UploadFile, File, Response, Request
7
  from fastapi import Depends
8
  from typing import Optional
9
 
@@ -401,7 +401,7 @@ async def exchange_session(request: Request, response: Response):
401
  access_token = create_access_token(user_id, email)
402
  raw_refresh = create_refresh_token()
403
  refresh_hash = hash_token(raw_refresh)
404
- save_refresh_token(user_id, refresh_hash)
405
 
406
  _set_refresh_cookie(response, raw_refresh)
407
 
@@ -413,7 +413,7 @@ async def exchange_session(request: Request, response: Response):
413
 
414
 
415
  @router.post("/refresh")
416
- async def refresh_session(request: Request, response: Response):
417
  """
418
  Silently re-issue a new access token using the HttpOnly refresh token cookie.
419
 
@@ -439,16 +439,18 @@ async def refresh_session(request: Request, response: Response):
439
  raise HTTPException(401, "Refresh token is invalid, expired, or revoked")
440
 
441
  user_id = str(row["user_id"])
 
442
 
443
- # Fetch the user's email for the new access token payload
444
- profile = get_user_by_id(user_id)
445
- email = (profile or {}).get("email", "")
 
446
 
447
- # Rotate: revoke old, issue new refresh token
448
- revoke_refresh_token(token_hash)
449
  new_raw_refresh = create_refresh_token()
450
  new_hash = hash_token(new_raw_refresh)
451
- save_refresh_token(user_id, new_hash)
452
 
453
  access_token = create_access_token(user_id, email)
454
 
 
3
  import cloudinary
4
  import cloudinary.uploader
5
  from datetime import timezone, datetime
6
+ from fastapi import APIRouter, HTTPException, UploadFile, File, Response, Request, BackgroundTasks
7
  from fastapi import Depends
8
  from typing import Optional
9
 
 
401
  access_token = create_access_token(user_id, email)
402
  raw_refresh = create_refresh_token()
403
  refresh_hash = hash_token(raw_refresh)
404
+ save_refresh_token(user_id, refresh_hash, email=email)
405
 
406
  _set_refresh_cookie(response, raw_refresh)
407
 
 
413
 
414
 
415
  @router.post("/refresh")
416
+ async def refresh_session(request: Request, response: Response, background_tasks: BackgroundTasks):
417
  """
418
  Silently re-issue a new access token using the HttpOnly refresh token cookie.
419
 
 
439
  raise HTTPException(401, "Refresh token is invalid, expired, or revoked")
440
 
441
  user_id = str(row["user_id"])
442
+ email = row.get("email")
443
 
444
+ # Fall back to DB lookup only if email wasn't cached in Redis
445
+ if not email:
446
+ profile = get_user_by_id(user_id)
447
+ email = (profile or {}).get("email", "")
448
 
449
+ # Rotate: revoke old, issue new refresh token (background audit writes keep response <15ms)
450
+ revoke_refresh_token(token_hash, background_tasks=background_tasks)
451
  new_raw_refresh = create_refresh_token()
452
  new_hash = hash_token(new_raw_refresh)
453
+ save_refresh_token(user_id, new_hash, email=email, background_tasks=background_tasks)
454
 
455
  access_token = create_access_token(user_id, email)
456