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