File size: 8,384 Bytes
2170658
 
 
 
 
 
 
 
 
 
 
 
548dfc9
2170658
 
 
 
 
 
a980424
2170658
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a980424
 
 
 
 
 
 
 
2170658
 
 
 
 
a980424
2170658
 
 
 
 
 
 
 
 
 
f3ab5f0
 
 
 
a980424
 
f3ab5f0
2170658
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
548dfc9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2170658
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
from __future__ import annotations

import time
from typing import Optional

from fastapi import APIRouter, Depends, HTTPException, status

from app.core.logger import get_logger
from app.models.schemas import (
    GoogleOAuthAuthUrlRequest,
    GoogleOAuthAuthUrlResponse,
    GoogleOAuthCallbackRequest,
    GoogleOAuthCallbackResponse,
    GoogleOAuthRefreshRequest,
    GoogleOAuthTokenResponse,
    GoogleOAuthVerifyRequest,
    GoogleOAuthVerifyResponse,
)
from app.services.google_oauth_service import GoogleOAuthError, GoogleOAuthService
from app.services.google_scope_map import resolve_scope_list
from app.services.jwt_service import JWTService
from app.config import get_settings

router = APIRouter(prefix="/google/oauth", tags=["Google OAuth"])
_logger = get_logger(__name__)
_settings = get_settings()
_jwt_service = JWTService()


_oauth_service = GoogleOAuthService()


def get_oauth_service() -> GoogleOAuthService:
    return _oauth_service


async def close_oauth_service() -> None:
    await _oauth_service.close()


def _generate_state() -> str:
    result = _jwt_service.generate(
        subject="google-oauth-state",
        secret=_settings.jwt_secret_key,
        expiry_minutes=_settings.google_oauth_state_ttl_minutes,
    )
    return result["token"]


def _verify_state(state: str) -> bool:
    return _jwt_service.validate(
        state, secret=_settings.jwt_secret_key
    )["valid"]


def _http_error(exc: GoogleOAuthError) -> HTTPException:
    return HTTPException(status_code=exc.status_code, detail=exc.message)


@router.post("/auth-url", response_model=GoogleOAuthAuthUrlResponse,
             summary="Generate a Google OAuth authorization URL (Step 1)")
async def create_auth_url(
    body: GoogleOAuthAuthUrlRequest,
    service: GoogleOAuthService = Depends(get_oauth_service),
):
    start = time.perf_counter()

    resolved_scopes, scope_errors, dropped_scopes = resolve_scope_list(body.scopes)
    if scope_errors:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail="; ".join(scope_errors),
        )

    state = _generate_state()
    auth_url = service.build_auth_url(
        client_id=body.client_id,
        redirect_uri=body.redirect_uri,
        state=state,
        scope=" ".join(resolved_scopes),
        prompt=body.prompt,
        access_type=body.access_type,
        login_hint=body.login_hint,
        include_granted_scopes=body.include_granted_scopes,
    )
    _logger.info(
        "Generated Google OAuth auth URL for client '%s...' (%.2fms)",
        body.client_id[:8],
        (time.perf_counter() - start) * 1000,
    )
    return GoogleOAuthAuthUrlResponse(
        success=True,
        auth_url=auth_url,
        state=state,
        resolved_scopes=resolved_scopes,
        dropped_scopes=dropped_scopes,
    )


@router.post("/callback", response_model=GoogleOAuthTokenResponse,
             summary="Exchange the authorization code for tokens (Step 2)")
async def oauth_callback(
    body: GoogleOAuthCallbackRequest,
    service: GoogleOAuthService = Depends(get_oauth_service),
):
    start = time.perf_counter()

    if not _verify_state(body.state):
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Invalid or expired state token. Possible CSRF attack.",
        )

    try:
        tokens = await service.exchange_code(
            client_id=body.client_id,
            client_secret=body.client_secret,
            code=body.code,
            redirect_uri=body.redirect_uri,
        )
    except GoogleOAuthError as exc:
        raise _http_error(exc) from exc

    id_token_raw: Optional[str] = tokens.get("id_token")
    user = None
    if id_token_raw:
        try:
            user = await service.verify_id_token(id_token_raw, body.client_id)
        except GoogleOAuthError as exc:
            raise _http_error(exc) from exc

    _logger.info(
        "Google OAuth callback completed for client '%s...' (%.2fms)",
        body.client_id[:8],
        (time.perf_counter() - start) * 1000,
    )
    return GoogleOAuthTokenResponse(
        success=True,
        access_token=tokens["access_token"],
        expires_in=int(tokens.get("expires_in", 0)),
        refresh_token=tokens.get("refresh_token"),
        id_token=id_token_raw,
        token_type=tokens.get("token_type", "Bearer"),
        scope=tokens.get("scope"),
        user=user,
    )


@router.get("/callback", response_model=GoogleOAuthCallbackResponse,
            summary="Capture the data Google returns after the user authorizes (browser redirect)")
async def oauth_callback_redirect(
    state: Optional[str] = None,
    code: Optional[str] = None,
    error: Optional[str] = None,
    error_description: Optional[str] = None,
    scope: Optional[str] = None,
    authuser: Optional[str] = None,
    prompt: Optional[str] = None,
):
    """Handle the browser redirect Google sends to ``redirect_uri``.

    Captures the data Google returns after authentication — ``code`` on
    success, or ``error``/``error_description`` when access is denied — and
    returns it so the client can exchange the code via ``POST /callback``.
    """
    start = time.perf_counter()

    if not state:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Missing 'state' parameter in the OAuth callback.",
        )
    if not _verify_state(state):
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Invalid or expired state token. Possible CSRF attack.",
        )

    _logger.info(
        "Google OAuth callback captured data for state '%s...' (%.2fms)",
        state[:12],
        (time.perf_counter() - start) * 1000,
    )
    return GoogleOAuthCallbackResponse(
        success=error is None,
        state=state,
        code=code,
        error=error,
        error_description=error_description,
        scope=scope,
        authuser=authuser,
        prompt=prompt,
        message=(
            "Authorization successful. Exchange the code via POST /google/oauth/callback."
            if error is None
            else f"Authorization failed: {error}."
            + (f" {error_description}" if error_description else "")
        ),
    )


@router.post("/refresh", response_model=GoogleOAuthTokenResponse,
             summary="Refresh an expired access token (Step 3)")
async def refresh_token(
    body: GoogleOAuthRefreshRequest,
    service: GoogleOAuthService = Depends(get_oauth_service),
):
    start = time.perf_counter()

    try:
        tokens = await service.refresh_access_token(
            client_id=body.client_id,
            client_secret=body.client_secret,
            refresh_token=body.refresh_token,
        )
    except GoogleOAuthError as exc:
        raise _http_error(exc) from exc

    id_token_raw: Optional[str] = tokens.get("id_token")
    user = None
    if id_token_raw:
        try:
            user = await service.verify_id_token(id_token_raw, body.client_id)
        except GoogleOAuthError as exc:
            raise _http_error(exc) from exc

    _logger.info(
        "Google OAuth token refresh completed for client '%s...' (%.2fms)",
        body.client_id[:8],
        (time.perf_counter() - start) * 1000,
    )
    return GoogleOAuthTokenResponse(
        success=True,
        access_token=tokens["access_token"],
        expires_in=int(tokens.get("expires_in", 0)),
        refresh_token=body.refresh_token,
        id_token=id_token_raw,
        token_type=tokens.get("token_type", "Bearer"),
        scope=tokens.get("scope"),
        user=user,
    )


@router.post("/verify", response_model=GoogleOAuthVerifyResponse,
             summary="Verify a Google ID token and return the user profile")
async def verify_id_token(
    body: GoogleOAuthVerifyRequest,
    service: GoogleOAuthService = Depends(get_oauth_service),
):
    start = time.perf_counter()

    try:
        user = await service.verify_id_token(body.id_token, body.client_id)
    except GoogleOAuthError as exc:
        raise _http_error(exc) from exc

    _logger.info(
        "Google OAuth ID token verified for client '%s...' (%.2fms)",
        body.client_id[:8],
        (time.perf_counter() - start) * 1000,
    )
    return GoogleOAuthVerifyResponse(success=True, valid=True, user=user)