@@ -34,8 +34,32 @@ def _disabled_flag() -> _Path:
3434from fastapi .responses import FileResponse , HTMLResponse , JSONResponse , RedirectResponse , StreamingResponse
3535from starlette .requests import Request
3636
37+ from authlib .integrations .starlette_client import OAuth as _OAuth
38+
3739from .auth import make_token , verify_token
3840from .config import get_settings
41+
42+ # Lazy-initialized Google OAuth client
43+ _google_oauth : _OAuth | None = None
44+
45+
46+ def _get_google_oauth () -> _OAuth | None :
47+ """Return authlib OAuth client if Google credentials are configured."""
48+ global _google_oauth
49+ if _google_oauth is not None :
50+ return _google_oauth
51+ settings = get_settings ()
52+ if not settings .google_client_id or not settings .google_client_secret :
53+ return None
54+ _google_oauth = _OAuth ()
55+ _google_oauth .register (
56+ name = "google" ,
57+ client_id = settings .google_client_id ,
58+ client_secret = settings .google_client_secret ,
59+ server_metadata_url = "https://accounts.google.com/.well-known/openid-configuration" ,
60+ client_kwargs = {"scope" : "openid email profile" },
61+ )
62+ return _google_oauth
3963from .db import (
4064 Project ,
4165 User ,
@@ -363,6 +387,86 @@ async def auth_logout(request: Request):
363387 return RedirectResponse ("/" )
364388
365389
390+ _GOOGLE_REDIRECT_URI = "https://kaust-ark.github.io/oauth-callback"
391+
392+
393+ @router .get ("/auth/google" )
394+ async def auth_google (request : Request ):
395+ oauth = _get_google_oauth ()
396+ if not oauth :
397+ raise HTTPException (400 , "Google login is not configured on this server." )
398+ return await oauth .google .authorize_redirect (request , _GOOGLE_REDIRECT_URI )
399+
400+
401+ @router .get ("/auth/google/callback" )
402+ async def auth_google_callback (request : Request ):
403+ oauth = _get_google_oauth ()
404+ if not oauth :
405+ raise HTTPException (400 , "Google login is not configured on this server." )
406+ settings = get_settings ()
407+ try :
408+ token = await oauth .google .authorize_access_token (request )
409+ except Exception as exc :
410+ logger .warning (f"Google OAuth error: { exc } " )
411+ return RedirectResponse ("/?google_error=1" )
412+
413+ userinfo = token .get ("userinfo" ) or {}
414+ email = (userinfo .get ("email" ) or "" ).strip ().lower ()
415+ if not email :
416+ return RedirectResponse ("/?google_error=1" )
417+
418+ # Apply same allow-list checks as magic link
419+ denied = False
420+ if settings .allowed_emails :
421+ if email not in settings .allowed_emails :
422+ denied = True
423+ elif settings .email_domains :
424+ if email .split ("@" )[- 1 ] not in settings .email_domains :
425+ denied = True
426+
427+ if denied :
428+ return HTMLResponse (
429+ f"""<!DOCTYPE html>
430+ <html>
431+ <head>
432+ <meta charset="utf-8" />
433+ <title>Access Denied — ARK</title>
434+ <style>
435+ body {{ font-family: sans-serif; display: flex; align-items: center; justify-content: center;
436+ min-height: 100vh; margin: 0; background: #f0fdfa; }}
437+ .card {{ background: #fff; border-radius: 16px; padding: 48px 52px; max-width: 420px;
438+ box-shadow: 0 4px 24px rgba(0,0,0,.08); text-align: center; }}
439+ h2 {{ color: #991b1b; margin-bottom: 12px; }}
440+ p {{ color: #555; line-height: 1.6; }}
441+ a {{ color: #0d9488; }}
442+ .back {{ margin-top: 24px; display: inline-block; color: #0d9488; font-size: .9rem; }}
443+ </style>
444+ </head>
445+ <body>
446+ <div class="card">
447+ <h2>Access Denied</h2>
448+ <p>Your Google account (<strong>{ email } </strong>) is not authorized to access ARK.</p>
449+ <p>To request access, contact<br/>
450+ <a href="mailto:jihao.xin@kaust.edu.sa">jihao.xin@kaust.edu.sa</a></p>
451+ <a class="back" href="/">← Back to login</a>
452+ </div>
453+ </body>
454+ </html>""" ,
455+ status_code = 403 ,
456+ )
457+
458+ with get_session (settings .db_path ) as session :
459+ user = get_or_create_user_by_email (session , email )
460+ request .session ["user_id" ] = user .id
461+ return RedirectResponse ("/" )
462+
463+
464+ @router .get ("/auth/google/enabled" )
465+ async def auth_google_enabled ():
466+ """Frontend polls this to know whether to show Google button."""
467+ return JSONResponse ({"enabled" : _get_google_oauth () is not None })
468+
469+
366470@router .get ("/api/me" )
367471async def api_me (request : Request ):
368472 user = _get_current_user (request )
0 commit comments