3535from ._jwt import Claims , sign_hs256 , str_claim , unverified_claims
3636from ._manifest import ManifestRoute , ManifestSource , RouteMatch
3737from ._refresh import Refresher , RefreshOutcome
38+ from ._resource_server import as_names , role_grants_access
3839
3940if 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
117137class 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+
9551005def _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
0 commit comments