|
8 | 8 |
|
9 | 9 | from app.core.limiter import limiter |
10 | 10 | from app.core import audit |
| 11 | +from app.core.config import get_settings |
11 | 12 |
|
12 | 13 | from app.api.routes.auth.cookie_attach import attach_auth_cookies, clear_auth_cookies |
13 | 14 | from app.api.routes.auth.schema import ( |
@@ -80,8 +81,16 @@ async def create_session( |
80 | 81 |
|
81 | 82 | access = create_access_token(uid) |
82 | 83 | raw_refresh = new_refresh_token() |
83 | | - await set_refresh_token_hash(uid, hash_refresh_token(raw_refresh)) |
84 | | - attach_auth_cookies(response, access, raw_refresh) |
| 84 | + settings = get_settings() |
| 85 | + refresh_days = ( |
| 86 | + settings.LONG_LIVED_REFRESH_TOKEN_EXPIRE_DAYS |
| 87 | + if payload.long_lived |
| 88 | + else settings.REFRESH_TOKEN_EXPIRE_DAYS |
| 89 | + ) |
| 90 | + await set_refresh_token_hash( |
| 91 | + uid, hash_refresh_token(raw_refresh), long_lived=payload.long_lived |
| 92 | + ) |
| 93 | + attach_auth_cookies(response, access, raw_refresh, refresh_days=refresh_days) |
85 | 94 |
|
86 | 95 | doc = await get_user_doc(uid) |
87 | 96 | if not doc: |
@@ -132,10 +141,20 @@ async def refresh_session( |
132 | 141 | detail="Invalid refresh token.", |
133 | 142 | ) |
134 | 143 |
|
| 144 | + # Preserve the session's long-lived flag across rotation so desktop |
| 145 | + # sessions keep their 60-day cookie TTL on every refresh. |
| 146 | + user_doc = await get_user_doc(uid) |
| 147 | + long_lived = bool(user_doc.get("refresh_long_lived", False)) if user_doc else False |
| 148 | + settings = get_settings() |
| 149 | + refresh_days = ( |
| 150 | + settings.LONG_LIVED_REFRESH_TOKEN_EXPIRE_DAYS |
| 151 | + if long_lived |
| 152 | + else settings.REFRESH_TOKEN_EXPIRE_DAYS |
| 153 | + ) |
135 | 154 | new_raw = new_refresh_token() |
136 | | - await set_refresh_token_hash(uid, hash_refresh_token(new_raw)) |
| 155 | + await set_refresh_token_hash(uid, hash_refresh_token(new_raw), long_lived=long_lived) |
137 | 156 | access = create_access_token(uid) |
138 | | - attach_auth_cookies(response, access, new_raw) |
| 157 | + attach_auth_cookies(response, access, new_raw, refresh_days=refresh_days) |
139 | 158 | audit.set_action("auth.token_refresh") |
140 | 159 | audit.set_entity("user", uid) |
141 | 160 | audit.set_summary("Refreshed session") |
@@ -300,6 +319,26 @@ async def session_check(_uid: Annotated[str, Depends(get_current_uid)]) -> OkRes |
300 | 319 | return OkResponse(ok=True) |
301 | 320 |
|
302 | 321 |
|
| 322 | +@router.post( |
| 323 | + "/desktop-token", |
| 324 | + summary="Mint a Firebase custom token for the desktop-app sign-in handoff", |
| 325 | +) |
| 326 | +async def desktop_token(uid: Annotated[str, Depends(get_current_uid)]) -> dict: |
| 327 | + # The web session (browser) mints a short-lived Firebase custom token which |
| 328 | + # is handed to the desktop app via the mydevtools:// deep link; the app |
| 329 | + # signs in with it and runs the normal session exchange. |
| 330 | + from app.core.firebase import get_firebase_app |
| 331 | + |
| 332 | + try: |
| 333 | + from firebase_admin import auth as firebase_auth |
| 334 | + except Exception: # pragma: no cover - firebase_admin always present in prod |
| 335 | + raise HTTPException(status_code=503, detail="Firebase unavailable") |
| 336 | + get_firebase_app() |
| 337 | + token_bytes = firebase_auth.create_custom_token(uid) |
| 338 | + token = token_bytes.decode("utf-8") if isinstance(token_bytes, bytes) else token_bytes |
| 339 | + return {"token": token} |
| 340 | + |
| 341 | + |
303 | 342 | # ── Master-password vault ───────────────────────────────────────────────────── |
304 | 343 |
|
305 | 344 |
|
|
0 commit comments