55import os
66import secrets
77import requests
8+ from jose import JWTError , jwt
9+ from datetime import datetime , timedelta
10+ from src import config
811
912from src .api .auth .domain import AuthTokenResponse , LoginRequest , SignupRequest
1013from src .api .auth .services import create_account
1114from src .api .dependencies import authenticate_user , create_jwt_token
12- from src .api .dependencies import get_account_from_bearer
15+ from src .api .dependencies import get_account_even_if_inactive
1316
1417from src .database .account .models import Account
1518from src .database .session import get_session
@@ -23,10 +26,13 @@ def issue_token(user: Account) -> AuthTokenResponse:
2326 return AuthTokenResponse (access_token = token )
2427
2528@router .get ("/roles" )
26- def read_current_roles (user = Depends (get_account_from_bearer ), db = Depends (get_session )):
29+ def read_current_roles (user : Account = Depends (get_account_even_if_inactive ), db = Depends (get_session )):
2730 """Returns a list of the current user's roles. Mainly for frontend role-based rendering."""
2831 roles : list [str ] = []
2932
33+ if not user .is_active :
34+ return ["deactivated" ]
35+
3036 if user .client_id is not None :
3137 roles .append ("client" )
3238
@@ -97,45 +103,62 @@ def token(
97103 return issue_token (user )
98104
99105
100- @router .get ("/google" )
101- def google_oauth (request : Request , code : str | None = None , state : str | None = None , db : Session = Depends (get_session )):
102- """
103- OAuth2 Authorization Code flow for Google.
106+ from fastapi .responses import JSONResponse
104107
105- - Calling GET /auth/google with no query params redirects to Google's consent screen.
108+ @router .get ("/google/url" )
109+ def google_oauth_url ():
110+ """
111+ Returns the Google OAuth login URL and sets the OAuth state cookie.
106112 """
107-
108113 client_id = os .getenv ("GCP_CLIENT_ID" )
109114 client_secret = os .getenv ("GCP_CLIENT_SECRET" )
110115 if not client_id or not client_secret :
111116 raise HTTPException (status_code = 500 , detail = "GCP_CLIENT_ID and GCP_CLIENT_SECRET must be configured" )
112117
113- redirect_uri = "https://api.till-failure.us/auth/google"
118+ redirect_uri = os . getenv ( "OAUTH_REDIRECT_URI" , "https://api.till-failure.us/auth/google" )
114119
115- if code is None :
116- oauth_state = secrets . token_urlsafe ( 16 )
117- params = {
118- "client_id" : client_id ,
119- "response_type" : "code" ,
120- "scope" : "openid email profile" ,
121- "redirect_uri " : redirect_uri ,
122- "state " : oauth_state ,
123- "access_type " : "offline " ,
124- "prompt " : "consent" ,
125- }
126- url = "https://accounts.google.com/o/oauth2/v2/auth"
127- qs = "?" + "&" . join ( f" { k } = { requests . utils . requote_uri ( str ( v )) } " for k , v in params . items ())
128- resp = RedirectResponse ( url + qs )
129-
130- # store state in a cookie to verify on callback
131- resp . set_cookie ( "oauth_state" , oauth_state , httponly = True , secure = True , samesite = "lax" )
132- return resp
120+ oauth_state = secrets . token_urlsafe ( 16 )
121+ # create signed state to make flow completely stateless
122+ to_encode = { "state" : oauth_state , "exp" : datetime . utcnow () + timedelta ( minutes = 15 )}
123+ signed_state = jwt . encode ( to_encode , config . JWT_SECRET , algorithm = config . ALGORITHM )
124+
125+ params = {
126+ "client_id " : client_id ,
127+ "response_type " : "code" ,
128+ "scope " : "openid email profile " ,
129+ "redirect_uri " : redirect_uri ,
130+ "state" : signed_state ,
131+ "access_type" : "offline" ,
132+ "prompt" : "consent" ,
133+ }
134+ url = "https://accounts.google.com/o/oauth2/v2/auth"
135+ qs = "?" + "&" . join ( f" { k } = { requests . utils . requote_uri ( str ( v )) } " for k , v in params . items ())
136+
137+ return JSONResponse ({ "url" : url + qs })
133138
134- # Verify state from callback
135- cookie_state = request .cookies .get ("oauth_state" )
136- if cookie_state is None or state is None or cookie_state != state :
139+ @router .get ("/google" )
140+ def google_oauth_callback (request : Request , code : str | None = None , state : str | None = None , db : Session = Depends (get_session )):
141+ """
142+ OAuth2 Authorization Code flow callback for Google.
143+ """
144+ if code is None :
145+ raise HTTPException (status_code = 400 , detail = "Missing code parameter" )
146+ if state is None :
147+ raise HTTPException (status_code = 400 , detail = "Missing state parameter" )
148+
149+ # Verify stateless signed state
150+ try :
151+ jwt .decode (state , config .JWT_SECRET , algorithms = [config .ALGORITHM ])
152+ except JWTError :
137153 raise HTTPException (status_code = 400 , detail = "Invalid OAuth state" )
138154
155+ # Re-declare credentials needed for token exchange
156+ client_id = os .getenv ("GCP_CLIENT_ID" )
157+ client_secret = os .getenv ("GCP_CLIENT_SECRET" )
158+ if not client_id or not client_secret :
159+ raise HTTPException (status_code = 500 , detail = "GCP_CLIENT_ID and GCP_CLIENT_SECRET must be configured" )
160+ redirect_uri = os .getenv ("OAUTH_REDIRECT_URI" , "https://api.till-failure.us/auth/google" )
161+
139162 # Exchange code for tokens
140163 token_url = "https://oauth2.googleapis.com/token"
141164 data = {
@@ -192,22 +215,24 @@ def google_oauth(request: Request, code: str | None = None, state: str | None =
192215 token_resp = issue_token (user )
193216 jwt_token = token_resp .access_token
194217
195- redirect_to = "https://till-failure.us/onboarding"
218+ frontend_url = os .getenv ("FRONTEND_URL" , "https://till-failure.us" )
219+ redirect_to = f"{ frontend_url } /onboarding"
196220 resp = RedirectResponse (redirect_to )
197221
198222 cookie_value = requests .utils .requote_uri (jwt_token )
199223 cookie_args = {
200224 "httponly" : False ,
201225 "secure" : True ,
202226 "samesite" : "none" ,
203- "domain" : ".till-failure.us" ,
204227 "max_age" : 60 * 60 * 24 * 30 , # 30 days
205228 }
229+
230+ cookie_domain = os .getenv ("COOKIE_DOMAIN" , ".till-failure.us" )
231+
232+ if cookie_domain :
233+ cookie_args ["domain" ] = cookie_domain
206234
207235 # Set the readable cookie `jwt` so frontend JS can access it if needed.
208236 resp .set_cookie ("jwt" , cookie_value , ** cookie_args )
209237
210- # cleanup
211- resp .delete_cookie ("oauth_state" )
212-
213238 return resp
0 commit comments