55import secrets
66import requests
77from urllib .parse import urlencode , urlparse
8- from datetime import datetime
8+ from datetime import datetime , timezone , timedelta
99from typing import Tuple , Optional , Dict , Any
1010from app .utils .db import get_db_connection
1111from app .utils .logger import get_logger
@@ -26,9 +26,121 @@ def get_oauth_service():
2626
2727class OAuthService :
2828 """OAuth service for Google and GitHub authentication"""
29-
29+
30+ # Class-level cache: only create OAuth state table once per process
31+ _state_schema_ensured : bool = False
32+
3033 def __init__ (self ):
3134 self ._load_config ()
35+
36+ def _oauth_state_ttl_minutes (self ) -> int :
37+ try :
38+ return max (5 , min (120 , int (float (os .getenv ("OAUTH_STATE_TTL_MINUTES" , "20" ) or 20 ))))
39+ except Exception :
40+ return 20
41+
42+ def _ensure_oauth_state_schema (self , cur ) -> None :
43+ """Create qd_oauth_states table if missing.
44+
45+ OAuth state MUST be shared across Gunicorn workers / replicas; an
46+ in-memory dict breaks multi-worker deployments (authorize hits worker
47+ A, callback lands on worker B → Invalid state).
48+ """
49+ if OAuthService ._state_schema_ensured :
50+ return
51+ try :
52+ cur .execute (
53+ """
54+ CREATE TABLE IF NOT EXISTS qd_oauth_states (
55+ state VARCHAR(128) PRIMARY KEY,
56+ provider VARCHAR(20) NOT NULL,
57+ redirect TEXT,
58+ created_at TIMESTAMP DEFAULT NOW(),
59+ expires_at TIMESTAMP NOT NULL
60+ )
61+ """
62+ )
63+ cur .execute (
64+ "CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON qd_oauth_states(expires_at)"
65+ )
66+ OAuthService ._state_schema_ensured = True
67+ except Exception as e :
68+ logger .debug (f"_ensure_oauth_state_schema: { e } " )
69+
70+ def _oauth_state_save (self , state : str , provider : str , redirect : Optional [str ]) -> None :
71+ # Store naive UTC for TIMESTAMP WITHOUT TIME ZONE columns.
72+ exp = (
73+ datetime .now (timezone .utc ) + timedelta (minutes = self ._oauth_state_ttl_minutes ())
74+ ).replace (tzinfo = None )
75+ red = (redirect or "" ).strip () if redirect else ""
76+ with get_db_connection () as db :
77+ cur = db .cursor ()
78+ self ._ensure_oauth_state_schema (cur )
79+ try :
80+ cur .execute ("DELETE FROM qd_oauth_states WHERE expires_at < NOW()" )
81+ except Exception :
82+ pass
83+ # IMPORTANT: include RETURNING state explicitly. Otherwise the
84+ # PostgresCursor wrapper would auto-append RETURNING id, and this
85+ # table has no "id" column → INSERT fails with UndefinedColumn.
86+ cur .execute (
87+ """
88+ INSERT INTO qd_oauth_states (state, provider, redirect, expires_at)
89+ VALUES (?, ?, ?, ?)
90+ ON CONFLICT (state) DO UPDATE
91+ SET provider = EXCLUDED.provider,
92+ redirect = EXCLUDED.redirect,
93+ expires_at = EXCLUDED.expires_at
94+ RETURNING state
95+ """ ,
96+ (state , provider , red , exp ),
97+ )
98+ try :
99+ cur .fetchone ()
100+ except Exception :
101+ pass
102+ db .commit ()
103+ cur .close ()
104+
105+ def _oauth_state_peek_redirect (self , state : str ) -> str :
106+ if not state :
107+ return ""
108+ try :
109+ with get_db_connection () as db :
110+ cur = db .cursor ()
111+ self ._ensure_oauth_state_schema (cur )
112+ cur .execute (
113+ "SELECT redirect FROM qd_oauth_states WHERE state = ? AND expires_at > NOW()" ,
114+ (state ,),
115+ )
116+ row = cur .fetchone ()
117+ cur .close ()
118+ if not row :
119+ return ""
120+ return (row .get ("redirect" ) or "" ).strip ()
121+ except Exception as e :
122+ logger .warning (f"OAuth state peek failed: { e } " )
123+ return ""
124+
125+ def _oauth_state_consume (self , state : str , provider : str ) -> bool :
126+ """Delete and validate state in one statement; True iff a row was removed."""
127+ if not state :
128+ return False
129+ try :
130+ with get_db_connection () as db :
131+ cur = db .cursor ()
132+ self ._ensure_oauth_state_schema (cur )
133+ cur .execute (
134+ "DELETE FROM qd_oauth_states WHERE state = ? AND provider = ? AND expires_at > NOW()" ,
135+ (state , provider ),
136+ )
137+ n = int (getattr (cur , "rowcount" , 0 ) or 0 )
138+ db .commit ()
139+ cur .close ()
140+ return n > 0
141+ except Exception as e :
142+ logger .error (f"OAuth state consume failed: { e } " , exc_info = True )
143+ return False
32144
33145 def _load_config (self ):
34146 """Load OAuth configuration from environment variables"""
@@ -57,9 +169,6 @@ def _load_config(self):
57169 if origin :
58170 self .allowed_redirect_origins .add (origin )
59171
60- # State storage (in-memory for simplicity, could use Redis in production)
61- self ._states = {}
62-
63172 @staticmethod
64173 def _normalize_origin (url : str ) -> str :
65174 """Return scheme://host[:port] for a URL, empty string if invalid."""
@@ -82,10 +191,7 @@ def is_redirect_allowed(self, redirect_url: str) -> bool:
82191
83192 def peek_state_redirect (self , state : str ) -> str :
84193 """Read the redirect URL associated with a pending OAuth state (no deletion)."""
85- if not state :
86- return ''
87- entry = self ._states .get (state ) or {}
88- return entry .get ('redirect' ) or ''
194+ return self ._oauth_state_peek_redirect (state )
89195
90196 # =========================================================================
91197 # Google OAuth
@@ -106,11 +212,15 @@ def get_google_auth_url(self, state: str = None, redirect_url: str = None) -> Tu
106212 return '' , ''
107213
108214 state = state or secrets .token_urlsafe (32 )
109- state_data = { 'provider' : 'google' , 'created_at' : datetime . now ()}
215+ red = ""
110216 if redirect_url and self .is_redirect_allowed (redirect_url ):
111- state_data ['redirect' ] = redirect_url
112- self ._states [state ] = state_data
113-
217+ red = redirect_url .strip ()
218+ try :
219+ self ._oauth_state_save (state , "google" , red or None )
220+ except Exception as e :
221+ logger .error (f"Failed to persist Google OAuth state: { e } " )
222+ return '' , ''
223+
114224 params = {
115225 'client_id' : self .google_client_id ,
116226 'redirect_uri' : self .google_redirect_uri ,
@@ -135,12 +245,9 @@ def handle_google_callback(self, code: str, state: str) -> Tuple[bool, Dict[str,
135245 Returns:
136246 (success, user_info_or_error)
137247 """
138- # Validate state
139- if state not in self ._states or self ._states [state ].get ('provider' ) != 'google' :
248+ if not self ._oauth_state_consume (state , "google" ):
140249 return False , {'error' : 'Invalid state parameter' }
141-
142- del self ._states [state ]
143-
250+
144251 try :
145252 # Exchange code for tokens
146253 token_response = requests .post (
@@ -204,11 +311,15 @@ def get_github_auth_url(self, state: str = None, redirect_url: str = None) -> Tu
204311 return '' , ''
205312
206313 state = state or secrets .token_urlsafe (32 )
207- state_data = { 'provider' : 'github' , 'created_at' : datetime . now ()}
314+ red = ""
208315 if redirect_url and self .is_redirect_allowed (redirect_url ):
209- state_data ['redirect' ] = redirect_url
210- self ._states [state ] = state_data
211-
316+ red = redirect_url .strip ()
317+ try :
318+ self ._oauth_state_save (state , "github" , red or None )
319+ except Exception as e :
320+ logger .error (f"Failed to persist GitHub OAuth state: { e } " )
321+ return '' , ''
322+
212323 params = {
213324 'client_id' : self .github_client_id ,
214325 'redirect_uri' : self .github_redirect_uri ,
@@ -230,12 +341,9 @@ def handle_github_callback(self, code: str, state: str) -> Tuple[bool, Dict[str,
230341 Returns:
231342 (success, user_info_or_error)
232343 """
233- # Validate state
234- if state not in self ._states or self ._states [state ].get ('provider' ) != 'github' :
344+ if not self ._oauth_state_consume (state , "github" ):
235345 return False , {'error' : 'Invalid state parameter' }
236-
237- del self ._states [state ]
238-
346+
239347 try :
240348 # Exchange code for token
241349 token_response = requests .post (
@@ -591,12 +699,16 @@ def unlink_oauth(self, user_id: int, provider: str) -> Tuple[bool, str]:
591699 # =========================================================================
592700
593701 def cleanup_expired_states (self , max_age_minutes : int = 10 ):
594- """Clean up expired OAuth states"""
595- cutoff = datetime .now ()
596- from datetime import timedelta
597- cutoff = cutoff - timedelta (minutes = max_age_minutes )
598-
599- expired = [k for k , v in self ._states .items ()
600- if v .get ('created_at' , datetime .now ()) < cutoff ]
601- for k in expired :
602- del self ._states [k ]
702+ """Delete expired OAuth state rows from the DB.
703+ `max_age_minutes` is kept for backward-compat but is ignored — expiry
704+ is driven by qd_oauth_states.expires_at which is set on insert.
705+ """
706+ try :
707+ with get_db_connection () as db :
708+ cur = db .cursor ()
709+ self ._ensure_oauth_state_schema (cur )
710+ cur .execute ("DELETE FROM qd_oauth_states WHERE expires_at < NOW()" )
711+ db .commit ()
712+ cur .close ()
713+ except Exception as e :
714+ logger .debug (f"cleanup_expired_states: { e } " )
0 commit comments