store login toke expiry correctly in cookies

This commit is contained in:
lilly 2026-05-03 22:48:41 +02:00
commit 2a03930b7e
Signed by: lilly
SSH key fingerprint: SHA256:y9T5GFw2A20WVklhetIxG1+kcg/Ce0shnQmbu1LQ37g
3 changed files with 81 additions and 19 deletions

View file

@ -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}

View file

@ -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)]

View file

@ -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):