From 3a914b827f0e842d7ea2b882156f8a78a1c16501 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 6 Nov 2026 00:30:18 -0500 Subject: [PATCH] fix!: answer FastAPI session guard refusals under error RequireUser answered its 401, 403 and recovery refusals as {"detail": ...}, while Django, the permission guard and every other adapter answer {"error": ...}. All of them now answer {"error": ...}, with the same status codes and the same error strings. exc.detail still holds the error string. PermissionHTTPException, which never shipped in a release, is now GuardHTTPException, since it covers every guard refusal. BREAKING CHANGE: FastAPI RequireUser refusals (unauthenticated, insufficient_role, enrollment_required, cross_site_request_blocked) answer {"error": ""} instead of {"detail": ""}. A client that reads body.detail must read body.error. They also no longer reach an application's own HTTPException handler. To change them, register a handler for GuardHTTPException. --- README.md | 17 ++++++-------- src/seamless_auth/fastapi.py | 44 +++++++++++++++++------------------ tests/test_fastapi.py | 12 +++++----- tests/test_permissions.py | 45 ++++++++++++++++++++++++++++-------- 4 files changed, 71 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 915380c..0b3b1ce 100644 --- a/README.md +++ b/README.md @@ -168,9 +168,8 @@ session when any one of the user's roles grants any one listed role, by `role_gr port of `roleGrantsAccess` in `@seamless-auth/types`): `org:*` grants `org` and `org:billing`, an unscoped `org` grants `org:billing`, and `projects:write` grants `projects:read`. The roles come from the verified access token. A session granted none answers 403 -`{ "error": "insufficient_role" }` (FastAPI: `{ "detail": "insufficient_role" }`), after the -session, recovery and cross-site checks, and the body never names the roles. An empty `roles` -list raises `ValueError` when the guard is built. +`{ "error": "insufficient_role" }`, after the session, recovery and cross-site checks, and the body +never names the roles. An empty `roles` list raises `ValueError` when the guard is built. ```python @app.get("/api/admin") @@ -234,10 +233,9 @@ Matching is exact and case-sensitive, unlike roles: no trimming, no wildcards, n | Token omits its permissions and the live read fails | 503 `{ "error": "permissions_unavailable" }` | | Live read answers 401 | The guard's 401 | -These two bodies are the same on FastAPI and Django, with no setup. A dependency can only raise, so -on FastAPI the guard registers a handler for its `PermissionHTTPException` the first time it refuses -a request. To answer differently, register your own for that class. The guard's 401 and -`enrollment_required` are the same as `RequireUser`'s, so on FastAPI they stay under `detail`. +Every guard refusal has the same `{ "error": ... }` body on FastAPI and Django, with no setup. A +dependency can only raise, so on FastAPI the guard registers a handler for its `GuardHTTPException` +the first time it refuses a request. To answer differently, register your own for that class. A large permission set does not fit in a token, so the API sends `permissions_omitted: true` in place of `permissions`, `org_roles` and `org_permissions`. `User.permissions` is then empty, which @@ -259,9 +257,8 @@ and `Adapter.authenticate` raise `InsufficientPermission` or `PermissionsUnavail A user who signs in with a recovery code, or completes self-service account recovery, gets a recovery session: its access token carries `recovery: true`, and the auth API refuses it on everything but enrolling a new passkey or authenticator app until they do. The guard refuses it -too, with 403 `{ "error": "enrollment_required" }` (FastAPI's `RequireUser` answers `{ "detail": -"enrollment_required" }`, as it does its other errors), so an application never treats a -half-recovered account as signed in. Allow it on the routes that guide the user through +too, with 403 `{ "error": "enrollment_required" }`, so an application never treats a half-recovered +account as signed in. Allow it on the routes that guide the user through enrollment with `allow_recovery=True`, and read `User.recovery` there. `Adapter.authenticate` raises `EnrollmentRequired`, a subclass of `Unauthenticated`. diff --git a/src/seamless_auth/fastapi.py b/src/seamless_auth/fastapi.py index 6b97661..6f7f9d5 100644 --- a/src/seamless_auth/fastapi.py +++ b/src/seamless_auth/fastapi.py @@ -74,7 +74,6 @@ def orders(user: VerifiedUser = Depends(RequireBearer(rs, require_scope("orders: __all__ = [ "BearerHTTPException", "OptionalUser", - "PermissionHTTPException", "RequireBearer", "RequireUser", "add_bearer_error_handler", @@ -128,11 +127,12 @@ class RequireUser: :func:`~seamless_auth.role_grants_access`) answers 403 ``insufficient_role``. With ``permissions``, a session that does not meet the - :class:`~seamless_auth.PermissionRule` answers 403 - ``{"error": "insufficient_permission"}``, and when a token omits its permissions - and the live read fails, 503 ``{"error": "permissions_unavailable"}``. An empty - list allows nobody. ``organization_param`` names the path parameter that must - equal the active organization's id. + :class:`~seamless_auth.PermissionRule` answers 403 ``insufficient_permission``, and + when a token omits its permissions and the live read fails, 503 + ``permissions_unavailable``. An empty list allows nobody. ``organization_param`` + names the path parameter that must equal the active organization's id. + + Every refusal is a :class:`GuardHTTPException`, answered ``{"error": ...}``. A plain function, so FastAPI runs it on its threadpool: the first sight of a key may fetch the auth API's key set. @@ -170,44 +170,44 @@ def __call__(self, request: Request) -> User: route_params=request.path_params, ) except InsufficientPermission: - raise _permission_refusal(request, 403, "insufficient_permission") from None + raise _guard_refusal(request, 403, "insufficient_permission") from None except PermissionsUnavailable: - raise _permission_refusal(request, 503, "permissions_unavailable") from None + raise _guard_refusal(request, 503, "permissions_unavailable") from None except InsufficientRole: - raise HTTPException(status_code=403, detail="insufficient_role") from None + raise _guard_refusal(request, 403, "insufficient_role") from None except EnrollmentRequired: - raise HTTPException(status_code=403, detail="enrollment_required") from None + raise _guard_refusal(request, 403, "enrollment_required") from None except CrossSiteRequest: - raise HTTPException(status_code=403, detail="cross_site_request_blocked") from None + raise _guard_refusal(request, 403, "cross_site_request_blocked") from None except Unauthenticated: - raise HTTPException(status_code=401, detail="unauthenticated") from None + raise _guard_refusal(request, 401, "unauthenticated") from None -class PermissionHTTPException(HTTPException): - """A permission guard's refusal, answered ``{"error": ...}`` as on every other - framework and adapter, not FastAPI's ``{"detail": ...}``. To answer it differently, - register a handler for this class.""" +class GuardHTTPException(HTTPException): + """A guard's refusal, answered ``{"error": ...}`` as on every other framework and + adapter, not FastAPI's ``{"detail": ...}``. To answer it differently, register a + handler for this class.""" def __init__(self, status_code: int, error: str) -> None: super().__init__(status_code, detail=error) self.body = {"error": error} -def _permission_exception_handler(request: Request, exc: Exception) -> Response: - if not isinstance(exc, PermissionHTTPException): +def _guard_exception_handler(request: Request, exc: Exception) -> Response: + if not isinstance(exc, GuardHTTPException): raise exc return JSONResponse(exc.body, status_code=exc.status_code) -def _permission_refusal(request: Request, status: int, error: str) -> PermissionHTTPException: +def _guard_refusal(request: Request, status: int, error: str) -> GuardHTTPException: # A dependency can only raise, and FastAPI's handler for an HTTPException always # wraps the body in "detail". An add_exception_handler call would be a second step # that, once forgotten, changes the response shape, so the guard registers the # handler itself. A handler the application registered wins. handlers: Any = request.scope.get("starlette.exception_handlers") if handlers is not None: - handlers[0].setdefault(PermissionHTTPException, _permission_exception_handler) - return PermissionHTTPException(status, error) + handlers[0].setdefault(GuardHTTPException, _guard_exception_handler) + return GuardHTTPException(status, error) class OptionalUser: @@ -306,7 +306,7 @@ def __call__(self, request: Request) -> VerifiedUser: guard.check_route(user, request.path_params) except BearerError as err: if err.error == "insufficient_permission": - raise _permission_refusal(request, err.status, err.error) from None + raise _guard_refusal(request, err.status, err.error) from None raise BearerHTTPException(err) from None return user diff --git a/tests/test_fastapi.py b/tests/test_fastapi.py index 7ac12cf..91185ed 100644 --- a/tests/test_fastapi.py +++ b/tests/test_fastapi.py @@ -162,7 +162,7 @@ def test_the_guard_refuses_a_recovery_session_unless_allowed(api: FakeApi) -> No r = client.get("/api/me", headers=recovery) assert r.status_code == 403 - assert r.json() == {"detail": "enrollment_required"} + assert r.json() == {"error": "enrollment_required"} r = client.get("/api/enroll", headers=recovery) assert (r.status_code, r.json()) == (200, {"id": "u1", "recovery": True}) assert client.get("/api/enroll").status_code == 401 @@ -200,13 +200,13 @@ def test_the_role_guard(api: FakeApi) -> None: client = app_for(api.adapter()) r = client.get("/api/admin") - assert (r.status_code, r.json()) == (401, {"detail": "unauthenticated"}) + assert (r.status_code, r.json()) == (401, {"error": "unauthenticated"}) r = client.get("/api/admin", headers=bearer_header(roles=["user"])) - assert (r.status_code, r.json()) == (403, {"detail": "insufficient_role"}) + assert (r.status_code, r.json()) == (403, {"error": "insufficient_role"}) r = client.get( "/api/admin", headers=bearer_header(roles=["admin", "secret-role"], recovery=True) ) - assert (r.status_code, r.json()) == (403, {"detail": "enrollment_required"}) + assert (r.status_code, r.json()) == (403, {"error": "enrollment_required"}) for roles in (["admin"], ["billing:*"], ["billing"]): r = client.get("/api/admin", headers=bearer_header(roles=roles)) assert (r.status_code, r.json()) == (200, {"id": "u1"}), roles @@ -215,12 +215,12 @@ def test_the_role_guard(api: FakeApi) -> None: "seamless-access", {"sub": "u1", "token": api_token("u1", "access"), "roles": ["admin"]} ) r = client.get("/api/admin", headers={"cookie": cookie}) - assert (r.status_code, r.json()) == (403, {"detail": "insufficient_role"}) + assert (r.status_code, r.json()) == (403, {"error": "insufficient_role"}) admin_cookie = signed_cookie( "seamless-access", {"sub": "u1", "token": api_token("u1", "access", roles=["admin"])} ) r = client.post("/api/admin", headers={"cookie": admin_cookie, "sec-fetch-site": "cross-site"}) - assert (r.status_code, r.json()) == (403, {"detail": "cross_site_request_blocked"}) + assert (r.status_code, r.json()) == (403, {"error": "cross_site_request_blocked"}) def test_the_role_guard_refuses_no_roles_at_setup(api: FakeApi) -> None: diff --git a/tests/test_permissions.py b/tests/test_permissions.py index ae7596f..09eea1c 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -63,7 +63,7 @@ ) from seamless_auth.django import _reset_adapter, _reset_resource_server, require_auth from seamless_auth.fastapi import ( - PermissionHTTPException, + GuardHTTPException, RequireBearer, RequireUser, add_bearer_error_handler, @@ -97,8 +97,8 @@ def handler(user: User = Depends(guard)) -> dict[str, str]: # noqa: B008 @dataclass class Stack: - """One framework's routes, and the bodies that framework gives the existing - refusals, which the permission guard leaves as they are.""" + """One framework's routes, and the bodies its guard gives the refusals that are + not about permissions.""" get: Callable[..., Any] prefix: str @@ -115,7 +115,7 @@ def stack(request: pytest.FixtureRequest, api: FakeApi) -> Iterator[Stack]: if request.param == "fastapi": client = TestClient(fastapi_app(api.adapter()), base_url="https://testserver") yield Stack( - client.get, "sync", {"detail": "unauthenticated"}, {"detail": "enrollment_required"} + client.get, "sync", {"error": "unauthenticated"}, {"error": "enrollment_required"} ) return options = { @@ -741,20 +741,47 @@ def test_fastapi_an_application_handler_wins(api: FakeApi) -> None: def mine(request: Request, exc: Exception) -> Response: return JSONResponse({"mine": True}, status_code=418) - app.add_exception_handler(PermissionHTTPException, mine) + app.add_exception_handler(GuardHTTPException, mine) client = _one_route(api, RequireUser(api.adapter(), permissions=["reports:read"]), app) r = client.get("/x", headers=bearer()) assert (r.status_code, r.json()) == (418, {"mine": True}) -def test_fastapi_the_session_role_guard_still_answers_under_detail(api: FakeApi) -> None: - """Records the role guard's existing difference from every other guard: its body is - ``{"detail": ...}``. The permission guard does not share it. See the README, Roles.""" +def test_fastapi_the_session_role_guard_answers_under_error(api: FakeApi) -> None: client = _one_route(api, RequireUser(api.adapter(), roles=["admin"])) r = client.get("/x", headers=bearer()) - assert (r.status_code, r.json()) == (403, {"detail": "insufficient_role"}) + assert (r.status_code, r.json()) == (403, {"error": "insufficient_role"}) + + +def test_fastapi_a_mounted_app_gets_the_error_body_from_every_refusal(api: FakeApi) -> None: + inner = FastAPI() + _one_route(api, RequireUser(api.adapter(), roles=["admin"]), inner) + outer = FastAPI() + outer.mount("/inner", inner) + client = TestClient(outer) + + r = client.get("/inner/x") + assert (r.status_code, r.json()) == (401, {"error": "unauthenticated"}) + r = client.get("/inner/x", headers=bearer()) + assert (r.status_code, r.json()) == (403, {"error": "insufficient_role"}) + r = client.get("/inner/x", headers=bearer(recovery=True)) + assert (r.status_code, r.json()) == (403, {"error": "enrollment_required"}) + + +def test_fastapi_an_application_handler_wins_for_every_refusal(api: FakeApi) -> None: + app = FastAPI() + + def mine(request: Request, exc: Exception) -> Response: + assert isinstance(exc, GuardHTTPException) + return JSONResponse({"mine": exc.detail}, status_code=418) + + app.add_exception_handler(GuardHTTPException, mine) + client = _one_route(api, RequireUser(api.adapter(), roles=["admin"]), app) + + assert client.get("/x").json() == {"mine": "unauthenticated"} + assert client.get("/x", headers=bearer()).json() == {"mine": "insufficient_role"} # Verify-only.