Spaces:
Running
Running
Commit ·
548dfc9
1
Parent(s): 59554a3
feat: add callback api
Browse files- app/api/server.py +8 -2
- app/api/v1/google_oauth.py +54 -0
- app/models/schemas.py +14 -0
app/api/server.py
CHANGED
|
@@ -37,8 +37,14 @@ _PUBLIC_API_PREFIXES = (
|
|
| 37 |
"/api/v1/url-shortener/",
|
| 38 |
)
|
| 39 |
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
# ---------------------------------------------------------------------------
|