From 801edc404241fff83665f34e16ecb023ac3047c2 Mon Sep 17 00:00:00 2001
From: lilly
Date: Sun, 3 May 2026 22:09:35 +0200
Subject: [PATCH] properly use OIDC security parameters like nonce and state
---
api/src/dooris_api/app.py | 62 ++++++++++++++++++++++++------------
api/src/dooris_api/models.py | 11 +++++++
2 files changed, 53 insertions(+), 20 deletions(-)
create mode 100644 api/src/dooris_api/models.py
diff --git a/api/src/dooris_api/app.py b/api/src/dooris_api/app.py
index 186574d..a556684 100644
--- a/api/src/dooris_api/app.py
+++ b/api/src/dooris_api/app.py
@@ -1,11 +1,16 @@
-#!/usr/bin/env python3
from typing import Optional
+import logging
+import secrets
from fastapi import FastAPI, Request, Response
from fastapi.responses import RedirectResponse
-from pydantic import BaseModel
from contextlib import asynccontextmanager
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
@@ -19,16 +24,10 @@ async def lifespan(app: FastAPI):
yield
-app = FastAPI(lifespan=lifespan)
-
-
-class UserInfo(BaseModel):
- name: str
-
-
-class UserStatus(BaseModel):
- is_logged_in: bool
- user_info: Optional[UserInfo]
+app = FastAPI(
+ title="Dooris", summary="API for interacting with HomeMatic door locks in CCCHH", docs_url="/api/docs",
+ lifespan=lifespan,
+)
@app.get("/api/user-info/")
@@ -36,20 +35,43 @@ async def get_user_info() -> UserStatus:
return { "bla": "blub" }
-@app.get("/auth/login")
-async def login_init(req: Request):
+@app.get("/auth/login", response_class=RedirectResponse)
+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
- 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")
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
- 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):
- resp.set_cookie("access_token", auth_result.access_token, httponly=True)
- resp.set_cookie("id_token", auth_result.id_token)
+ # 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")
return {"authenticated": True}
else:
- return {"authenticated": False}
+ return {"authenticated": False, "error": auth_result}
diff --git a/api/src/dooris_api/models.py b/api/src/dooris_api/models.py
new file mode 100644
index 0000000..de33fd1
--- /dev/null
+++ b/api/src/dooris_api/models.py
@@ -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]
+