Skip to content

Commit 171babb

Browse files
authored
Merge pull request #31 from fells-code/feat/first-party-role-guard
feat: first-party role guard, optional user and organization on User
2 parents 08a98d5 + ea1898d commit 171babb

11 files changed

Lines changed: 444 additions & 26 deletions

File tree

‎README.md‎

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,24 @@ The auth server reads the service token from `API_SERVICE_TOKEN` and your app re
6363

6464
`RequireUser` answers 401 unless the request carries a session, and returns the `User`. It
6565
answers 403 for a recovery session (see [Recovery sessions](#recovery-sessions)) unless you pass
66-
`RequireUser(auth, allow_recovery=True)`.
66+
`RequireUser(auth, allow_recovery=True)`. `RequireUser(auth, roles=["admin"])` also requires a role
67+
(see [Roles](#roles)).
68+
69+
`OptionalUser(auth)` is for routes that serve signed-in and anonymous visitors alike. It returns
70+
the `User` exactly when `RequireUser(auth)` would admit the request, and `None` otherwise (no
71+
session, a recovery session, or a cross-site state change on the cookie). It never answers an
72+
error.
73+
74+
```python
75+
from seamless_auth.fastapi import OptionalUser
76+
77+
maybe_user = OptionalUser(auth)
78+
79+
80+
@app.get("/")
81+
def home(user: User | None = Depends(maybe_user)):
82+
return {"signed_in": user is not None}
83+
```
6784

6885
## Django
6986

@@ -93,7 +110,9 @@ def me(request):
93110
```
94111

95112
`@require_auth(allow_recovery=True)` admits a recovery session too (see
96-
[Recovery sessions](#recovery-sessions)).
113+
[Recovery sessions](#recovery-sessions)), and `@require_auth(roles=["admin"])` also requires a role
114+
(see [Roles](#roles)). `user_of(request)` reads the `User` the guard put on the request; call it
115+
only under `require_auth`, as it raises `AttributeError` on an unguarded request.
97116

98117
`SEAMLESS_AUTH` takes the keyword arguments of `Adapter` (below). `deliver` and
99118
`resolve_client_ip` may be dotted paths to callables. `require_auth` works on sync and async
@@ -126,7 +145,38 @@ while cookies are `SameSite=None` and only for methods other than `GET`, `HEAD`
126145
- With no `Sec-Fetch-Site`, an `Origin: null` answers 403, and when `allowed_origins` is set any
127146
other `Origin` must be on it. A request with neither header passes.
128147

129-
A bearer token is never attached by a browser, so it is not checked.
148+
A bearer token is never attached by a browser, so it is not checked. For the same reason the auth
149+
routes skip the check on a bearer-transport request (`x-seamless-auth-transport: bearer`), which
150+
reads no cookies.
151+
152+
`User.organization_id` is the active organization, from the `org_id` claim of the verified access
153+
token, or `None` when the session has none. Like `User.roles`, it never comes from the cookie.
154+
155+
### Roles
156+
157+
`roles` on the guard (`RequireUser(auth, roles=[...])`, `@require_auth(roles=[...])`) admits a
158+
session when any one of the user's roles grants any one listed role, by `role_grants_access` (the
159+
port of `roleGrantsAccess` in `@seamless-auth/types`): `org:*` grants `org` and `org:billing`, an
160+
unscoped `org` grants `org:billing`, and `projects:write` grants `projects:read`. The roles come
161+
from the verified access token. A session granted none answers 403
162+
`{ "error": "insufficient_role" }` (FastAPI: `{ "detail": "insufficient_role" }`), after the
163+
session, recovery and cross-site checks, and the body never names the roles. An empty `roles`
164+
list raises `ValueError` when the guard is built.
165+
166+
```python
167+
@app.get("/api/admin")
168+
def admin(user: User = Depends(RequireUser(auth, roles=["admin"]))):
169+
return {"id": user.id}
170+
```
171+
172+
```python
173+
@require_auth(roles=["admin", "billing:write"])
174+
def billing(request):
175+
return JsonResponse({"org": user_of(request).organization_id})
176+
```
177+
178+
`Adapter.authenticate_request(method, headers, roles=[...])` and `Adapter.authenticate(headers,
179+
roles=[...])` raise `InsufficientRole`; `has_any_role(user, roles)` is the check on its own.
130180

131181
### Recovery sessions
132182

‎src/seamless_auth/__init__.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,15 @@
1313
:mod:`seamless_auth.django`.
1414
"""
1515

16-
from ._adapter import Adapter, CrossSiteRequest, EnrollmentRequired, Unauthenticated, User
16+
from ._adapter import (
17+
Adapter,
18+
CrossSiteRequest,
19+
EnrollmentRequired,
20+
InsufficientRole,
21+
Unauthenticated,
22+
User,
23+
has_any_role,
24+
)
1725
from ._client_ip import TrustedProxies
1826
from ._delivery import Delivery
1927
from ._http import AuthRequest, AuthResponse, Headers, ResponseCookie
@@ -44,6 +52,7 @@
4452
"EnrollmentRequired",
4553
"Guard",
4654
"Headers",
55+
"InsufficientRole",
4756
"Manifest",
4857
"ManifestError",
4958
"ManifestRoute",
@@ -53,6 +62,7 @@
5362
"Unauthenticated",
5463
"User",
5564
"VerifiedUser",
65+
"has_any_role",
5666
"require_role",
5767
"require_scope",
5868
"require_step_up",

‎src/seamless_auth/_adapter.py‎

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from ._jwt import Claims, sign_hs256, str_claim, unverified_claims
3636
from ._manifest import ManifestRoute, ManifestSource, RouteMatch
3737
from ._refresh import Refresher, RefreshOutcome
38+
from ._resource_server import as_names, role_grants_access
3839

3940
if TYPE_CHECKING:
4041
from ._console import Console
@@ -96,6 +97,8 @@ class User:
9697
#: A recovery session: signed in with a recovery code or self-service recovery,
9798
#: and not yet enrolled in a new passkey or authenticator app.
9899
recovery: bool = False
100+
#: The active organization, from the verified access token's ``org_id`` claim.
101+
organization_id: str | None = None
99102
#: The auth API access token, for calling the API on the user's behalf.
100103
token: str = field(default="", repr=False)
101104

@@ -113,6 +116,23 @@ class CrossSiteRequest(Exception):
113116
"""A state-changing request from another site, carrying a session cookie."""
114117

115118

119+
class InsufficientRole(Exception):
120+
"""A session granted none of the roles a guard requires. Answer it 403
121+
``insufficient_role``, without naming the roles."""
122+
123+
124+
def required_roles(roles: Iterable[str] | None) -> tuple[str, ...] | None:
125+
"""Validates a guard's ``roles`` at setup: ``None`` means no role check, and an
126+
empty list is refused because it could only ever deny or be a mistake."""
127+
return None if roles is None else as_names("roles", roles)
128+
129+
130+
def has_any_role(user: User, roles: Iterable[str]) -> bool:
131+
"""Whether any of the user's roles grants any listed role, by
132+
:func:`~seamless_auth.role_grants_access`."""
133+
return any(role_grants_access(g, r) for r in roles for g in user.roles)
134+
135+
116136
@dataclass
117137
class Config:
118138
auth_server_url: str
@@ -306,11 +326,14 @@ def handle(self, request: AuthRequest) -> AuthResponse:
306326
return _json_response(500, {"error": "internal_error"})
307327

308328
def _handle(self, request: AuthRequest) -> AuthResponse:
309-
blocked = self._check_origin(request.method, request.headers)
310-
if blocked is not None:
311-
return self._write(blocked, bearer=False)
312-
313329
bearer = (request.headers.get(TRANSPORT_HEADER) or "").lower() == "bearer"
330+
# Bearer transport reads no cookies, so a cross-site request has nothing of the
331+
# user's to ride on. A custom header also needs a CORS preflight to be sent.
332+
if not bearer:
333+
blocked = self._check_origin(request.method, request.headers)
334+
if blocked is not None:
335+
return self._write(blocked, bearer=False)
336+
314337
forwarded = self._forwarded(request)
315338
method = request.method.upper()
316339
# Matched on segments, as manifest routes are, so /logout/ and //logout are
@@ -832,33 +855,54 @@ def _logout_route(
832855
# The guard.
833856

834857
def authenticate(
835-
self, headers: Headers | Iterable[tuple[str, str]], *, allow_recovery: bool = False
858+
self,
859+
headers: Headers | Iterable[tuple[str, str]],
860+
*,
861+
allow_recovery: bool = False,
862+
roles: Iterable[str] | None = None,
836863
) -> User:
837864
"""Verifies a request's session: the adapter's access cookie, or an auth API
838865
access token in ``Authorization: Bearer`` for clients with no cookie jar.
839866
The cookie wins when present. It does not refresh: silent refresh belongs
840867
to the auth routes, and a bearer client refreshes through
841868
``POST /auth/refresh`` itself. Raises :class:`Unauthenticated`, or its
842869
subclass :class:`EnrollmentRequired` for a recovery session unless
843-
``allow_recovery`` is set."""
844-
return self._authenticate(_as_headers(headers), allow_recovery)[0]
870+
``allow_recovery`` is set. With ``roles``, raises :class:`InsufficientRole`
871+
unless the session is granted any one of them."""
872+
required = required_roles(roles)
873+
user = self._authenticate(_as_headers(headers), allow_recovery)[0]
874+
_check_roles(user, required)
875+
return user
845876

846877
def authenticate_request(
847878
self,
848879
method: str,
849880
headers: Headers | Iterable[tuple[str, str]],
850881
*,
851882
allow_recovery: bool = False,
883+
roles: Iterable[str] | None = None,
852884
) -> User:
853885
""":meth:`authenticate` for a guarded route. A session from the cookie also
854886
gets the auth routes' cross-site check, or a form on another site could act
855887
as the user: raises :class:`CrossSiteRequest` for one."""
888+
required = required_roles(roles)
856889
headers = _as_headers(headers)
857890
user, by_cookie = self._authenticate(headers, allow_recovery)
858891
if by_cookie and self._check_origin(method, headers) is not None:
859892
raise CrossSiteRequest
893+
_check_roles(user, required)
860894
return user
861895

896+
def optional_user(
897+
self, method: str, headers: Headers | Iterable[tuple[str, str]]
898+
) -> User | None:
899+
"""The user exactly when :meth:`authenticate_request` with its defaults would
900+
return one, otherwise ``None``. A recovery session yields ``None``."""
901+
try:
902+
return self.authenticate_request(method, headers)
903+
except (Unauthenticated, CrossSiteRequest):
904+
return None
905+
862906
def _authenticate(self, headers: Headers, allow_recovery: bool) -> tuple[User, bool]:
863907
user, by_cookie = self._session_of(headers)
864908
if user.recovery and not allow_recovery:
@@ -885,6 +929,7 @@ def _session_of(self, headers: Headers) -> tuple[User, bool]:
885929
email=str_claim(cookie, "email") or None,
886930
phone=str_claim(cookie, "phone") or None,
887931
recovery=claims.get("recovery") is True,
932+
organization_id=str_claim(claims, "org_id") or None,
888933
token=token,
889934
),
890935
True,
@@ -952,6 +997,11 @@ def _is_allowed_origin(self, origin: str) -> bool:
952997
_NO_REFRESH_COOKIE = "no refresh cookie"
953998

954999

1000+
def _check_roles(user: User, required: tuple[str, ...] | None) -> None:
1001+
if required is not None and not has_any_role(user, required):
1002+
raise InsufficientRole
1003+
1004+
9551005
def _as_headers(headers: Headers | Iterable[tuple[str, str]]) -> Headers:
9561006
return headers if isinstance(headers, Headers) else Headers(headers)
9571007

@@ -1078,6 +1128,7 @@ def _user_from(claims: Claims, token: str, recovery: bool) -> User:
10781128
email=str_claim(claims, "email") or None,
10791129
phone=str_claim(claims, "phone") or None,
10801130
recovery=recovery,
1131+
organization_id=str_claim(claims, "org_id") or None,
10811132
token=token,
10821133
)
10831134

‎src/seamless_auth/_resource_server.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,13 @@ def role_grants_access(granted_role: str, required_role: str) -> bool:
453453
)
454454

455455

456+
def as_names(what: str, values: Iterable[str]) -> tuple[str, ...]:
457+
"""``values`` as a non-empty tuple of non-empty strings, or a setup error."""
458+
names = _as_tuple(values)
459+
_check_names(what, names)
460+
return names
461+
462+
456463
def _as_tuple(values: Iterable[str]) -> tuple[str, ...]:
457464
if isinstance(values, str):
458465
raise TypeError("seamless-auth: pass a list of values, not a string")

‎src/seamless_auth/django.py‎

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,16 @@
1414
urlpatterns = [path("auth/", include("seamless_auth.django"))]
1515
1616
# views.py
17-
from seamless_auth.django import require_auth
17+
from seamless_auth.django import require_auth, user_of
1818
1919
@require_auth
2020
def me(request):
2121
return JsonResponse({"id": request.seamless_user.id})
2222
23+
@require_auth(roles=["admin"])
24+
def admin(request):
25+
return JsonResponse({"id": user_of(request).id})
26+
2327
``SEAMLESS_AUTH`` takes the keyword arguments of :class:`seamless_auth.Adapter`.
2428
``deliver`` and ``resolve_client_ip`` may be dotted paths to callables.
2529
@@ -48,7 +52,7 @@ def orders(request):
4852
import functools
4953
import inspect
5054
import threading
51-
from collections.abc import Callable
55+
from collections.abc import Callable, Iterable
5256
from typing import Any, Literal, TypeVar, cast, overload
5357
from urllib.parse import quote, unquote
5458

@@ -65,8 +69,10 @@ def orders(request):
6569
Adapter,
6670
CrossSiteRequest,
6771
EnrollmentRequired,
72+
InsufficientRole,
6873
Unauthenticated,
6974
User,
75+
required_roles,
7076
)
7177
from ._http import AuthRequest, AuthResponse, Headers
7278
from ._resource_server import (
@@ -94,6 +100,7 @@ def orders(request):
94100
"require_scope",
95101
"require_step_up",
96102
"urlpatterns",
103+
"user_of",
97104
]
98105

99106
_PATH_SAFE = "/:@!$&'()*+,;=-._~"
@@ -182,27 +189,42 @@ def console_view(request: HttpRequest, path: str = "") -> HttpResponse:
182189
@overload
183190
def require_auth(view: F) -> F: ...
184191
@overload
185-
def require_auth(*, allow_recovery: bool = False) -> Callable[[F], F]: ...
192+
def require_auth(
193+
*, allow_recovery: bool = False, roles: Iterable[str] | None = None
194+
) -> Callable[[F], F]: ...
186195

187196

188-
def require_auth(view: F | None = None, *, allow_recovery: bool = False) -> Any:
197+
def require_auth(
198+
view: F | None = None,
199+
*,
200+
allow_recovery: bool = False,
201+
roles: Iterable[str] | None = None,
202+
) -> Any:
189203
"""Answers 401 unless the request carries a valid session, and puts the
190204
:class:`~seamless_auth.User` on ``request.seamless_user``. A session from the
191205
cookie also gets the auth routes' cross-site check (403 on a state change from
192206
another site). A recovery session (a user who still has to enroll a new passkey
193207
or authenticator app) answers 403 ``enrollment_required`` unless
194-
``@require_auth(allow_recovery=True)``. Works on sync and async views."""
208+
``@require_auth(allow_recovery=True)``. With ``roles``, a session granted none
209+
of them (by :func:`~seamless_auth.role_grants_access`) answers 403
210+
``insufficient_role``. Works on sync and async views."""
211+
required = required_roles(roles)
195212
if view is None:
196-
return lambda inner: _guard(inner, allow_recovery)
197-
return _guard(view, allow_recovery)
213+
return lambda inner: _guard(inner, allow_recovery, required)
214+
return _guard(view, allow_recovery, required)
198215

199216

200-
def _guard(view: F, allow_recovery: bool) -> F:
217+
def _guard(view: F, allow_recovery: bool, roles: tuple[str, ...] | None) -> F:
201218
def check(request: HttpRequest) -> HttpResponse | None:
202219
try:
203220
user = get_adapter().authenticate_request(
204-
request.method or "GET", _headers(request), allow_recovery=allow_recovery
221+
request.method or "GET",
222+
_headers(request),
223+
allow_recovery=allow_recovery,
224+
roles=roles,
205225
)
226+
except InsufficientRole:
227+
return JsonResponse({"error": "insufficient_role"}, status=403)
206228
except EnrollmentRequired:
207229
return JsonResponse({"error": "enrollment_required"}, status=403)
208230
except CrossSiteRequest:
@@ -326,7 +348,9 @@ def _bearer_response(err: BearerError) -> JsonResponse:
326348

327349

328350
def user_of(request: HttpRequest) -> User:
329-
"""The user :func:`require_auth` put on the request."""
351+
"""The user :func:`require_auth` put on the request. Call it only under that
352+
guard: it is not an optional-user lookup and raises ``AttributeError`` on an
353+
unguarded request."""
330354
return cast(User, request.seamless_user) # type: ignore[attr-defined]
331355

332356

0 commit comments

Comments
 (0)