validops-east-1 commited on
Commit
548dfc9
·
1 Parent(s): 59554a3

feat: add callback api

Browse files
app/api/server.py CHANGED
@@ -37,8 +37,14 @@ _PUBLIC_API_PREFIXES = (
37
  "/api/v1/url-shortener/",
38
  )
39
 
 
 
 
40
 
41
- def _is_public_path(path: str) -> bool:
 
 
 
42
  return path.startswith(_PUBLIC_API_PREFIXES)
43
 
44
 
@@ -217,7 +223,7 @@ def create_application() -> FastAPI:
217
  @app.middleware("http")
218
  async def auth_middleware(request: Request, call_next):
219
  path = request.url.path
220
- if path.startswith("/api/v1/") and not _is_public_path(path):
221
  auth_header = request.headers.get("Authorization", "")
222
  if not auth_header.startswith("Bearer "):
223
  from starlette.responses import JSONResponse
 
37
  "/api/v1/url-shortener/",
38
  )
39
 
40
+ # GET-only paths that must not require authentication: Google redirects the
41
+ # user's browser here after authorization, and a redirect cannot attach the API key.
42
+ _PUBLIC_GET_PATHS = frozenset({"/api/v1/google/oauth/callback"})
43
 
44
+
45
+ def _is_public_path(path: str, method: str = "GET") -> bool:
46
+ if path in _PUBLIC_GET_PATHS and method == "GET":
47
+ return True
48
  return path.startswith(_PUBLIC_API_PREFIXES)
49
 
50
 
 
223
  @app.middleware("http")
224
  async def auth_middleware(request: Request, call_next):
225
  path = request.url.path
226
+ if path.startswith("/api/v1/") and not _is_public_path(path, request.method):
227
  auth_header = request.headers.get("Authorization", "")
228
  if not auth_header.startswith("Bearer "):
229
  from starlette.responses import JSONResponse
app/api/v1/google_oauth.py CHANGED
@@ -10,6 +10,7 @@ from app.models.schemas import (
10
  GoogleOAuthAuthUrlRequest,
11
  GoogleOAuthAuthUrlResponse,
12
  GoogleOAuthCallbackRequest,
 
13
  GoogleOAuthRefreshRequest,
14
  GoogleOAuthTokenResponse,
15
  GoogleOAuthVerifyRequest,
@@ -134,6 +135,59 @@ async def oauth_callback(
134
  )
135
 
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  @router.post("/refresh", response_model=GoogleOAuthTokenResponse,
138
  summary="Refresh an expired access token (Step 3)")
139
  async def refresh_token(
 
10
  GoogleOAuthAuthUrlRequest,
11
  GoogleOAuthAuthUrlResponse,
12
  GoogleOAuthCallbackRequest,
13
+ GoogleOAuthCallbackResponse,
14
  GoogleOAuthRefreshRequest,
15
  GoogleOAuthTokenResponse,
16
  GoogleOAuthVerifyRequest,
 
135
  )
136
 
137
 
138
+ @router.get("/callback", response_model=GoogleOAuthCallbackResponse,
139
+ summary="Capture the data Google returns after the user authorizes (browser redirect)")
140
+ async def oauth_callback_redirect(
141
+ state: Optional[str] = None,
142
+ code: Optional[str] = None,
143
+ error: Optional[str] = None,
144
+ error_description: Optional[str] = None,
145
+ scope: Optional[str] = None,
146
+ authuser: Optional[str] = None,
147
+ prompt: Optional[str] = None,
148
+ ):
149
+ """Handle the browser redirect Google sends to ``redirect_uri``.
150
+
151
+ Captures the data Google returns after authentication — ``code`` on
152
+ success, or ``error``/``error_description`` when access is denied — and
153
+ returns it so the client can exchange the code via ``POST /callback``.
154
+ """
155
+ start = time.perf_counter()
156
+
157
+ if not state:
158
+ raise HTTPException(
159
+ status_code=status.HTTP_400_BAD_REQUEST,
160
+ detail="Missing 'state' parameter in the OAuth callback.",
161
+ )
162
+ if not _verify_state(state):
163
+ raise HTTPException(
164
+ status_code=status.HTTP_403_FORBIDDEN,
165
+ detail="Invalid or expired state token. Possible CSRF attack.",
166
+ )
167
+
168
+ _logger.info(
169
+ "Google OAuth callback captured data for state '%s...' (%.2fms)",
170
+ state[:12],
171
+ (time.perf_counter() - start) * 1000,
172
+ )
173
+ return GoogleOAuthCallbackResponse(
174
+ success=error is None,
175
+ state=state,
176
+ code=code,
177
+ error=error,
178
+ error_description=error_description,
179
+ scope=scope,
180
+ authuser=authuser,
181
+ prompt=prompt,
182
+ message=(
183
+ "Authorization successful. Exchange the code via POST /google/oauth/callback."
184
+ if error is None
185
+ else f"Authorization failed: {error}."
186
+ + (f" {error_description}" if error_description else "")
187
+ ),
188
+ )
189
+
190
+
191
  @router.post("/refresh", response_model=GoogleOAuthTokenResponse,
192
  summary="Refresh an expired access token (Step 3)")
193
  async def refresh_token(
app/models/schemas.py CHANGED
@@ -956,6 +956,20 @@ class GoogleOAuthVerifyResponse(BaseModel):
956
  error: Optional[str] = None
957
 
958
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
959
  # ---------------------------------------------------------------------------
960
  # Google per-API scope catalog (single endpoint to view/set current scopes)
961
  # ---------------------------------------------------------------------------
 
956
  error: Optional[str] = None
957
 
958
 
959
+ class GoogleOAuthCallbackResponse(BaseModel):
960
+ """Response returned by the browser-redirect callback (GET /callback)."""
961
+
962
+ success: bool = Field(..., description="Whether authorization succeeded (no error from Google)")
963
+ state: Optional[str] = Field(None, description="CSRF state token echoed back by Google")
964
+ code: Optional[str] = Field(None, description="Authorization code to exchange via POST /callback")
965
+ error: Optional[str] = Field(None, description="OAuth error code (e.g. access_denied) when the user denied access")
966
+ error_description: Optional[str] = Field(None, description="Human-readable error description from Google")
967
+ scope: Optional[str] = Field(None, description="Space-separated scope granted by the user")
968
+ authuser: Optional[str] = Field(None, description="Google account index echoed by Google")
969
+ prompt: Optional[str] = Field(None, description="Prompt mode echoed by Google")
970
+ message: Optional[str] = Field(None, description="Developer-friendly summary of the callback outcome")
971
+
972
+
973
  # ---------------------------------------------------------------------------
974
  # Google per-API scope catalog (single endpoint to view/set current scopes)
975
  # ---------------------------------------------------------------------------