From 63a9485209ec291402337ea0aa7d089960ffc78f Mon Sep 17 00:00:00 2001
From: lilly
Date: Tue, 19 May 2026 19:28:12 +0200
Subject: [PATCH] api: restructure user authentication modelling to support
other user backends
---
api/src/dooris_api/__init__.py | 25 ++++++++++---
api/src/dooris_api/app.py | 22 ++++-------
api/src/dooris_api/deps.py | 67 +++++++++++++++++++++++++---------
api/src/dooris_api/models.py | 43 ++++++++++++----------
4 files changed, 99 insertions(+), 58 deletions(-)
diff --git a/api/src/dooris_api/__init__.py b/api/src/dooris_api/__init__.py
index 139ee17..618ce97 100644
--- a/api/src/dooris_api/__init__.py
+++ b/api/src/dooris_api/__init__.py
@@ -1,5 +1,4 @@
import os
-import sys
import logging
from contextvars import ContextVar
from argparse import ArgumentParser, Namespace
@@ -51,7 +50,9 @@ def main():
argp.add_argument(
"--ccujack-url",
required=False,
- default=os.environ.get("DOORIS_CCUJACK_URL", "https://hmdooris-ccu.ccchh.net:2122"),
+ default=os.environ.get(
+ "DOORIS_CCUJACK_URL", "https://hmdooris-ccu.ccchh.net:2122"
+ ),
help="The URL under which a CCUJACK instance is hosted that actually operates the locks",
)
argp.add_argument(
@@ -70,12 +71,21 @@ def main():
"--ccujack-password",
required="DOORIS_CCUJACK_PASSWORD" not in os.environ,
default=os.environ.get("DOORIS_CCUJACK_PASSWORD", None),
- help="The password used to authenticate against the CCUJACK"
+ help="The password used to authenticate against the CCUJACK",
+ )
+ argp.add_argument(
+ "--static-api-tokens",
+ required=False,
+ nargs=1,
+ action="append",
+ default=[],
)
args = argp.parse_args()
# setup logging
- logging.basicConfig(level=logging.DEBUG, format="[%(levelname)s] %(filename)s: %(message)s")
+ logging.basicConfig(
+ level=logging.DEBUG, format="[%(levelname)s] %(filename)s: %(message)s"
+ )
# setup app
app_config.set(args)
@@ -84,8 +94,11 @@ def main():
if args.serve_static:
from fastapi.staticfiles import StaticFiles
- app.mount("/", StaticFiles(directory=args.serve_static, html=True), name="static")
-
+
+ app.mount(
+ "/", StaticFiles(directory=args.serve_static, html=True), name="static"
+ )
+
# start webserver
config = uvicorn.Config(app, port=8000, log_level="debug")
server = uvicorn.Server(config)
diff --git a/api/src/dooris_api/app.py b/api/src/dooris_api/app.py
index f862d61..0a33704 100644
--- a/api/src/dooris_api/app.py
+++ b/api/src/dooris_api/app.py
@@ -58,17 +58,9 @@ app.add_exception_handler(
"/api/user-info/",
name="get-user-info",
tags=["auth"],
- responses={status.HTTP_401_UNAUTHORIZED: {"model": models.HttpProblemDetail}},
)
-async def get_user_info(
- req: Request, current_user: deps.CurrentUser
-) -> models.UserStatus:
- return models.UserStatus(
- is_authorized=current_user.may_operate_locks,
- guaranteed_session_until=datetime.fromtimestamp(current_user.id_token.exp, UTC),
- username=current_user.id_token.preferred_username,
- ccchh_roles=current_user.ccchh_roles,
- )
+async def get_user_info(req: Request, current_user: deps.ApiUser) -> models.ApiUser:
+ return current_user
@app.get("/auth/login", tags=["auth"], response_class=RedirectResponse, status_code=302)
@@ -143,7 +135,7 @@ async def login_callback(
auth_start_time = datetime.fromtimestamp(
float(req.cookies["auth_start_time"]), UTC
)
- deps.persist_auth_state(
+ deps.persist_oidc_auth_state(
oidc_client, resp, auth_result, auth_start_time, req.cookies["auth_nonce"]
)
logger.debug("successfully authenticated user")
@@ -165,9 +157,9 @@ async def login_callback(
"/auth/logout", tags=["auth"], response_class=RedirectResponse, status_code=302
)
async def logout(
- resp: Response, oidc_client: deps.OpenidClient, current_user: deps.CurrentUser
+ resp: Response, oidc_client: deps.OpenidClient, current_user: deps.AuthenticatedUser
) -> str:
- deps.clear_auth_state(resp)
+ deps.clear_oidc_auth_state(resp)
return oidc_client.initiate_logout(
RpInitiatedLogoutRequest(
id_token_hint=current_user.raw_id_token,
@@ -244,7 +236,7 @@ async def watch_locks(ccujack: deps.CCUJackClient) -> AsyncIterable[List[models.
while True:
yield await list_locks(ccujack)
await ccujack.data_updated.wait()
- await asyncio.sleep(0.1) # debounce multiple mqtt parameter updates
+ await asyncio.sleep(0.1) # debounce multiple mqtt parameter updates
@app.patch(
@@ -261,7 +253,7 @@ async def operate_lock(
lock_id: str,
requested_op: models.LockOperation,
ccujack: deps.CCUJackClient,
- current_user: deps.CurrentUser,
+ current_user: deps.AuthenticatedUser,
) -> None:
if not current_user.may_operate_locks:
raise exceptions.HttpProblemException.forbidden_to_operate(req.url)
diff --git a/api/src/dooris_api/deps.py b/api/src/dooris_api/deps.py
index 7c5e26b..c611b5d 100644
--- a/api/src/dooris_api/deps.py
+++ b/api/src/dooris_api/deps.py
@@ -1,4 +1,4 @@
-from typing import Annotated, Optional
+from typing import Annotated, Optional, Tuple
import logging
from datetime import datetime, UTC, timedelta
from fastapi import Request, Depends, Response
@@ -20,9 +20,9 @@ async def get_oidc_client(req: Request) -> OpenidClient:
OpenidClient = Annotated[OpenidClient, Depends(get_oidc_client)]
-async def get_current_user(
+async def get_logged_in_oidc_user(
req: Request, resp: Response, oidc_client: OpenidClient
-) -> Optional[models.CurrentUser]:
+) -> Optional[models.ApiUser]:
# easiest case: we still have an access token (which is the most fleeting component)
# everything else should still be valid so we can just use it
if all(i in req.cookies for i in ("access_token", "id_token")):
@@ -30,11 +30,10 @@ async def get_current_user(
"user is fully authenticated, returning current user from existing id_token"
)
id_token = oidc_client.decode_id_token(
- req.cookies["id_token"], nonce=req.cookies.get("auth_nonce", None),
- )
- return models.CurrentUser(
- id_token=id_token, raw_id_token=req.cookies["id_token"]
+ req.cookies["id_token"],
+ nonce=req.cookies.get("auth_nonce", None),
)
+ return models.ApiUser.from_id_token(id_token, req.cookies["id_token"])
# if we have a refresh token, try to get new tokens
elif all(i in req.cookies for i in ("refresh_token",)):
@@ -45,24 +44,26 @@ async def get_current_user(
token_resp = oidc_client.exchange_refresh_token(req.cookies["refresh_token"])
if isinstance(token_resp, TokenSuccessResponse):
logger.debug("successfully got new tokens from refresh token")
- persist_auth_state(oidc_client, resp, token_resp, auth_start_time, None)
+ persist_oidc_auth_state(
+ oidc_client, resp, token_resp, auth_start_time, None
+ )
# return the newly gotten info
id_token = oidc_client.decode_id_token(token_resp.id_token)
- return models.CurrentUser(
- id_token=id_token, raw_id_token=token_resp.id_token
- )
+ return models.ApiUser.from_id_token(id_token, token_resp.id_token)
else:
- logger.debug("failed to exchange refresh token for new access token: %s", token_resp)
+ logger.debug(
+ "failed to exchange refresh token for new access token: %s", token_resp
+ )
# otherwise we can't meaningfully recover any user information or the user is simply not authenticated
else:
- logger.debug("no currently authenticated user")
+ logger.debug("no currently authenticated oidc user")
- raise exceptions.HttpProblemException.unauthorized(req.url)
+ return None
-def persist_auth_state(
+def persist_oidc_auth_state(
oidc_client: OpenidClient,
resp: Response,
tokens: TokenSuccessResponse,
@@ -118,7 +119,7 @@ def persist_auth_state(
)
-def clear_auth_state(resp: Response):
+def clear_oidc_auth_state(resp: Response):
resp.set_cookie("access_token", "", max_age=0)
resp.set_cookie("refresh_token", "", max_age=0)
resp.set_cookie("id_token", "", max_age=0)
@@ -128,7 +129,39 @@ def clear_auth_state(resp: Response):
resp.set_cookie("auth_start_time", "", max_age=0)
-CurrentUser = Annotated[Optional[models.CurrentUser], Depends(get_current_user)]
+async def get_api_user(
+ req: Request, resp: Response, oidc_client: OpenidClient
+) -> models.ApiUser:
+ oidc_user = await get_logged_in_oidc_user(req, resp, oidc_client)
+ # TODO: Implement API user based on static tokens
+ if oidc_user is not None:
+ return oidc_user
+ else:
+ return models.ApiUser(
+ is_anonymous=True,
+ is_ccchh_user=False,
+ is_token_user=False,
+ may_operate_locks=False,
+ username="anonymous",
+ guaranteed_session_until=None,
+ raw_id_token=None,
+ )
+
+
+ApiUser = Annotated[models.ApiUser, Depends(get_api_user)]
+
+
+async def get_authenticated_user(
+ req: Request, resp: Response, oidc_client: OpenidClient
+) -> models.ApiUser:
+ user = await get_api_user(req, resp, oidc_client)
+ if user.is_anonymous:
+ raise exceptions.HttpProblemException.unauthorized(req.url)
+ else:
+ return user
+
+
+AuthenticatedUser = Annotated[models.ApiUser, Depends(get_authenticated_user)]
def get_ccujack(req: Request) -> CCUJackClient:
diff --git a/api/src/dooris_api/models.py b/api/src/dooris_api/models.py
index d1574c4..bfb741c 100644
--- a/api/src/dooris_api/models.py
+++ b/api/src/dooris_api/models.py
@@ -1,6 +1,6 @@
-from typing import Optional, Literal, List
-from datetime import datetime
-from pydantic import BaseModel, HttpUrl
+from typing import Optional, Literal, Self
+from datetime import datetime, UTC
+from pydantic import BaseModel, HttpUrl, Field
from enum import Enum
from simple_openid_connect.data import IdToken
@@ -27,24 +27,27 @@ class HttpProblemDetail(BaseModel):
instance: Optional[HttpUrl]
-class CurrentUser(BaseModel):
- id_token: IdToken
- raw_id_token: str
-
- @property
- def ccchh_roles(self) -> List[str]:
- return getattr(self.id_token, "ccchh-roles", [])
-
- @property
- def may_operate_locks(self) -> bool:
- return "intern@" in self.ccchh_roles
-
-
-class UserStatus(BaseModel):
- is_authorized: bool
- guaranteed_session_until: datetime
+class ApiUser(BaseModel):
+ is_anonymous: bool
+ is_ccchh_user: bool
+ is_token_user: bool
+ may_operate_locks: bool
username: str
- ccchh_roles: List[str]
+ guaranteed_session_until: Optional[datetime]
+
+ raw_id_token: Optional[str] = Field(exclude=True)
+
+ @classmethod
+ def from_id_token(cls, id_token: IdToken, raw_id_token: str) -> Self:
+ return cls(
+ is_anonymous=False,
+ is_ccchh_user=True,
+ is_token_user=False,
+ may_operate_locks="intern@" in getattr(id_token, "ccchh-roles", []),
+ username=id_token.preferred_username,
+ guaranteed_session_until=datetime.fromtimestamp(id_token.exp, UTC),
+ raw_id_token=raw_id_token,
+ )
class LockStatus(BaseModel):