From 2a03930b7e9a506c6781d3819b8f5af381c289e7 Mon Sep 17 00:00:00 2001
From: lilly
Date: Sun, 3 May 2026 22:48:41 +0200
Subject: [PATCH] store login toke expiry correctly in cookies
---
api/src/dooris_api/app.py | 67 ++++++++++++++++++++++++++----------
api/src/dooris_api/deps.py | 20 +++++++++++
api/src/dooris_api/models.py | 13 ++++++-
3 files changed, 81 insertions(+), 19 deletions(-)
create mode 100644 api/src/dooris_api/deps.py
diff --git a/api/src/dooris_api/app.py b/api/src/dooris_api/app.py
index a556684..8678eee 100644
--- a/api/src/dooris_api/app.py
+++ b/api/src/dooris_api/app.py
@@ -1,13 +1,16 @@
from typing import Optional
import logging
import secrets
+import math
+from datetime import datetime, UTC, timedelta
from fastapi import FastAPI, Request, Response
from fastapi.responses import RedirectResponse
from contextlib import asynccontextmanager
from simple_openid_connect.client import OpenidClient
from simple_openid_connect.data import TokenSuccessResponse
-from dooris_api.models import UserStatus
+from dooris_api import deps
+from dooris_api.models import UserStatus, UserInfo
logger = logging.getLogger(__name__)
@@ -31,46 +34,74 @@ app = FastAPI(
@app.get("/api/user-info/")
-async def get_user_info() -> UserStatus:
- return { "bla": "blub" }
+async def get_user_info(req: Request, current_user: deps.CurrentUser) -> UserStatus:
+ if current_user is None:
+ return UserStatus(is_logged_in=False, user_info=None)
+ else:
+ return UserStatus(
+ is_logged_in=True,
+ user_info=UserInfo(
+ username=current_user.id_token.preferred_username,
+ )
+ )
-@app.get("/auth/login", response_class=RedirectResponse)
-async def login_init(req: Request, resp: Response, next: Optional[str] = "") -> str:
+@app.get("/auth/login", response_class=RedirectResponse, status_code=302)
+async def login_init(req: Request, resp: Response, oidc_client: deps.OpenidClient, next: Optional[str] = "") -> str:
# save the ?next url for later redirection if the user requested that
if next:
- resp.set_cookie("auth_next", next, max_age=60 * 10, httponly=True, secure=True, samesite="strict")
+ resp.set_cookie("auth_next", next, max_age=60 * 10, httponly=True, secure=True)
# save the login state into a cookie to prevent CSRF attacks
# ref: https://simple-openid-connect.readthedocs.io/en/stable/nonce_and_state.html
state = secrets.token_urlsafe(32)
- resp.set_cookie("auth_state", state, max_age=60 * 10, httponly=True, secure=True, samesite="strict")
+ resp.set_cookie("auth_state", state, max_age=60 * 10, httponly=True, secure=True)
+ resp.set_cookie("auth_start_time", datetime.now(UTC).timestamp(), max_age=60 * 10, httponly=True, secure=True)
# prevent replay attacks by generating and specifying a nonce
# ref: https://simple-openid-connect.readthedocs.io/en/stable/nonce_and_state.html
nonce = secrets.token_urlsafe(32)
# assemble the response
- oidc_client = req.app.extra["oidc_client"] # type: OpenidClient
- resp.set_cookie("auth_nonce", nonce, max_age=60*10, httponly=True, secure=True, samesite="strict")
+ resp.set_cookie("auth_nonce", nonce, max_age=60 * 10, httponly=True, secure=True)
return oidc_client.authorization_code_flow.start_authentication(state=state, nonce=nonce)
-
@app.get("/auth/login-callback")
-async def login_callback(req: Request, resp: Response):
- if "auth_state" not in req.cookies or "auth_nonce" not in req.cookies:
+async def login_callback(req: Request, resp: Response, oidc_client: deps.OpenidClient):
+ # check that the user is currently in an authenticating state
+ # these cookies are set by the login_init() view
+ if "auth_state" not in req.cookies or "auth_nonce" not in req.cookies or "auth_start_time" not in req.cookies:
raise ValueError("user is currently not authentication or the authentication expired. try again")
+
+ # ensure cookies are always cleared in the response
+ resp.set_cookie("auth_state", "", max_age=0)
+ resp.set_cookie("auth_start_time", "", max_age=0)
- oidc_client = req.app.extra["oidc_client"] # type: OpenidClient
+ # perform the actual authentication via OIDC token exchange
auth_result = oidc_client.authorization_code_flow.handle_authentication_result(current_url=req.url, state=req.cookies["auth_state"])
+
+ # save the authentication result for later reuse
if isinstance(auth_result, TokenSuccessResponse):
- # extract the ID token once now to validate its authenticity
- _id_token = oidc_client.decode_id_token(auth_result.id_token, nonce=req.cookies["auth_nonce"])
-
- resp.set_cookie("access_token", auth_result.access_token, httponly=True, secure=True, samesite="strict")
- resp.set_cookie("id_token", auth_result.id_token, httponly=False, secure=True, samesite="strict")
+ now = datetime.now(UTC)
+ auth_start_time = datetime.fromtimestamp(float(req.cookies["auth_start_time"]), UTC)
+
+ # extract the ID token now to validate its authenticity and properly set the cookie lifetime
+ id_token = oidc_client.decode_id_token(auth_result.id_token, nonce=req.cookies["auth_nonce"])
+
+ # calculate how long each token is valid
+ at_max_age = auth_start_time - now + timedelta(seconds=auth_result.expires_in)
+ rt_max_age = auth_start_time - now + timedelta(seconds=auth_result.refresh_expires_in)
+ id_max_age = datetime.fromtimestamp(id_token.exp, UTC) - now
+
+ # update cookies
+ resp.set_cookie("access_token", auth_result.access_token, max_age=int(at_max_age.total_seconds()), httponly=True, secure=True)
+ resp.set_cookie("refresh_token", auth_result.refresh_token, max_age=int(rt_max_age.total_seconds()), httponly=True, secure=True)
+ resp.set_cookie("id_token", auth_result.id_token, max_age=int(id_max_age.total_seconds()), httponly=True, secure=True)
+ resp.set_cookie("auth_nonce", req.cookies["auth_nonce"], max_age=int(id_max_age.total_seconds()), httponly=True, secure=True)
+
+ # redirect the user to the page they wanted to visit
return {"authenticated": True}
else:
return {"authenticated": False, "error": auth_result}
diff --git a/api/src/dooris_api/deps.py b/api/src/dooris_api/deps.py
new file mode 100644
index 0000000..1d8dccf
--- /dev/null
+++ b/api/src/dooris_api/deps.py
@@ -0,0 +1,20 @@
+from typing import Annotated, Optional
+from fastapi import Request, Depends
+from simple_openid_connect.client import OpenidClient
+
+from dooris_api import models
+
+
+async def get_oidc_client(req: Request) -> OpenidClient:
+ return req.app.extra["oidc_client"]
+
+
+OpenidClient = Annotated[OpenidClient, Depends(get_oidc_client)]
+
+
+async def get_current_user(req: Request, oidc_client: OpenidClient) -> Optional[models.CurrentUser]:
+ return None
+
+
+CurrentUser = Annotated[Optional[models.CurrentUser], Depends(get_current_user)]
+
diff --git a/api/src/dooris_api/models.py b/api/src/dooris_api/models.py
index de33fd1..72791f7 100644
--- a/api/src/dooris_api/models.py
+++ b/api/src/dooris_api/models.py
@@ -1,8 +1,19 @@
from typing import Optional
+from datetime import datetime
from pydantic import BaseModel
+from simple_openid_connect.data import IdToken
+
+
+class CurrentUser(BaseModel):
+ access_token: str
+ access_token_expiry: datetime
+ refresh_token: Optional[str]
+ refresh_token_expiry: Optional[datetime]
+ id_token: IdToken
+
class UserInfo(BaseModel):
- name: str
+ username: str
class UserStatus(BaseModel):