Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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`.

Expand Down
44 changes: 22 additions & 22 deletions src/seamless_auth/fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ def orders(user: VerifiedUser = Depends(RequireBearer(rs, require_scope("orders:
__all__ = [
"BearerHTTPException",
"OptionalUser",
"PermissionHTTPException",
"RequireBearer",
"RequireUser",
"add_bearer_error_handler",
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
12 changes: 6 additions & 6 deletions tests/test_fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
45 changes: 36 additions & 9 deletions tests/test_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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 = {
Expand Down Expand Up @@ -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.
Expand Down
Loading