properly use OIDC security parameters like nonce and state

This commit is contained in:
lilly 2026-05-03 22:09:35 +02:00
commit 801edc4042
Signed by: lilly
SSH key fingerprint: SHA256:y9T5GFw2A20WVklhetIxG1+kcg/Ce0shnQmbu1LQ37g
2 changed files with 53 additions and 20 deletions

View file

@ -1,11 +1,16 @@
#!/usr/bin/env python3
from typing import Optional from typing import Optional
import logging
import secrets
from fastapi import FastAPI, Request, Response from fastapi import FastAPI, Request, Response
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from simple_openid_connect.client import OpenidClient from simple_openid_connect.client import OpenidClient
from simple_openid_connect.data import TokenSuccessResponse, IdToken from simple_openid_connect.data import TokenSuccessResponse
from dooris_api.models import UserStatus
logger = logging.getLogger(__name__)
@asynccontextmanager @asynccontextmanager
@ -19,16 +24,10 @@ async def lifespan(app: FastAPI):
yield yield
app = FastAPI(lifespan=lifespan) app = FastAPI(
title="Dooris", summary="API for interacting with HomeMatic door locks in CCCHH", docs_url="/api/docs",
lifespan=lifespan,
class UserInfo(BaseModel): )
name: str
class UserStatus(BaseModel):
is_logged_in: bool
user_info: Optional[UserInfo]
@app.get("/api/user-info/") @app.get("/api/user-info/")
@ -36,20 +35,43 @@ async def get_user_info() -> UserStatus:
return { "bla": "blub" } return { "bla": "blub" }
@app.get("/auth/login") @app.get("/auth/login", response_class=RedirectResponse)
async def login_init(req: Request): async def login_init(req: Request, resp: Response, 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")
# 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")
# 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 oidc_client = req.app.extra["oidc_client"] # type: OpenidClient
return RedirectResponse(oidc_client.authorization_code_flow.start_authentication()) resp.set_cookie("auth_nonce", nonce, max_age=60*10, httponly=True, secure=True, samesite="strict")
return oidc_client.authorization_code_flow.start_authentication(state=state, nonce=nonce)
@app.get("/auth/login-callback") @app.get("/auth/login-callback")
async def login_callback(req: Request, resp: Response): async def login_callback(req: Request, resp: Response):
if "auth_state" not in req.cookies or "auth_nonce" not in req.cookies:
raise ValueError("user is currently not authentication or the authentication expired. try again")
oidc_client = req.app.extra["oidc_client"] # type: OpenidClient oidc_client = req.app.extra["oidc_client"] # type: OpenidClient
auth_result = oidc_client.authorization_code_flow.handle_authentication_result(current_url=req.url) auth_result = oidc_client.authorization_code_flow.handle_authentication_result(current_url=req.url, state=req.cookies["auth_state"])
if isinstance(auth_result, TokenSuccessResponse): if isinstance(auth_result, TokenSuccessResponse):
resp.set_cookie("access_token", auth_result.access_token, httponly=True) # extract the ID token once now to validate its authenticity
resp.set_cookie("id_token", auth_result.id_token) _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")
return {"authenticated": True} return {"authenticated": True}
else: else:
return {"authenticated": False} return {"authenticated": False, "error": auth_result}

View file

@ -0,0 +1,11 @@
from typing import Optional
from pydantic import BaseModel
class UserInfo(BaseModel):
name: str
class UserStatus(BaseModel):
is_logged_in: bool
user_info: Optional[UserInfo]