@@ -34,13 +34,19 @@ class CurrentUser:
3434
3535 ``api_key_doc`` is set when the request was authenticated via API key
3636 (``Authorization: Bearer spoo_<raw>``). It is ``None`` for JWT auth.
37- Scope checks inspect ``api_key_doc.scopes`` when present.
37+ Scope checks inspect ``api_key_doc.scopes`` when present, otherwise
38+ ``scopes`` (the JWT ``scp`` claim) when not None.
3839 """
3940
4041 user_id : ObjectId
4142 email_verified : bool
4243 api_key_doc : ApiKeyDoc | None = field (default = None )
4344 amr : str = "pwd"
45+ # Scope slugs from the JWT "scp" claim (device-auth app tokens).
46+ # None = unrestricted interactive session; [] would mean "no scopes".
47+ scopes : list [str ] | None = field (default = None )
48+ # Connected-app id from the JWT "app_id" claim (device auth flow).
49+ app_id : str | None = field (default = None )
4450 # Lowercased user email — consumed by FeatureFlagService's ALLOWLIST
4551 # rollout (allowlist_emails). Populated from the "email" claim on the
4652 # JWT path and from the owning UserDoc on the API-key path. None for
@@ -149,6 +155,17 @@ async def get_current_user(
149155 if isinstance (raw_email , str ) and raw_email .strip ()
150156 else None
151157 )
158+ # Device-auth app tokens carry scp + app_id; session tokens carry
159+ # neither. A malformed scp claim fails closed (empty scope list)
160+ # rather than falling back to unrestricted.
161+ raw_scopes = claims .get ("scp" )
162+ scopes : list [str ] | None = None
163+ if raw_scopes is not None :
164+ scopes = (
165+ [s for s in raw_scopes if isinstance (s , str )]
166+ if isinstance (raw_scopes , list )
167+ else []
168+ )
152169 structlog .contextvars .bind_contextvars (user_id = str (user_id ), auth_method = "jwt" )
153170 return CurrentUser (
154171 user_id = user_id ,
@@ -158,6 +175,8 @@ async def get_current_user(
158175 # Not issued yet — the paid-plans launch adds the claim; TIER
159176 # flag rollouts become a pure data change at that point.
160177 tier = claims .get ("plan" ),
178+ scopes = scopes ,
179+ app_id = claims .get ("app_id" ),
161180 )
162181 except Exception :
163182 return None
@@ -184,14 +203,18 @@ async def require_verified_email(
184203async def require_jwt (
185204 user : CurrentUser = Depends (require_auth ),
186205) -> CurrentUser :
187- """Raise 403 if the request was authenticated via API key .
206+ """Raise 403 unless the request comes from an interactive session .
188207
189- Use on endpoints where API key auth must be explicitly prohibited —
190- e.g. key management routes (an API key must not be able to create,
191- list, or delete other API keys) .
208+ Rejects API keys AND scoped device-app tokens (``scp`` claim). Use on
209+ account-security surfaces — profile, app management, device revoke —
210+ where a delegated credential must never act .
192211 """
193212 if user .api_key_doc is not None :
194213 raise ForbiddenError ("API keys cannot be used to manage API keys" )
214+ # Delegation is marked by app_id, not scp: a legacy grant mints an
215+ # app token with app_id but no scp, and it must be barred here too.
216+ if user .app_id is not None :
217+ raise ForbiddenError ("This operation requires an interactive session" )
195218 return user
196219
197220
@@ -204,19 +227,35 @@ async def require_jwt_verified(
204227 return user
205228
206229
207- def check_api_key_scope (user : CurrentUser | None , required_scopes : set [str ]) -> None :
208- """Raise ForbiddenError if an API-key-authenticated user lacks a required scope.
230+ def _granted_scopes (user : CurrentUser ) -> set [str ] | None :
231+ """The scope set a credential holds, or None for unrestricted sessions."""
232+ if user .api_key_doc is not None :
233+ return set (user .api_key_doc .scopes )
234+ if user .scopes is not None :
235+ return set (user .scopes )
236+ return None
237+
209238
210- JWT-authenticated and anonymous requests are not scope-restricted.
239+ def check_credential_scopes (
240+ user : CurrentUser | None , required_scopes : set [str ]
241+ ) -> None :
242+ """Raise ForbiddenError if a scoped credential lacks a required scope.
243+
244+ Fires for API keys (key scopes) and device-app tokens (``scp`` claim);
245+ interactive sessions and anonymous requests are not scope-restricted.
246+ A single match against ``required_scopes`` suffices (OR semantics).
211247 """
212- if (
213- user is not None
214- and user .api_key_doc is not None
215- and not set (user .api_key_doc .scopes ) & required_scopes
216- ):
248+ if user is None :
249+ return
250+ granted = _granted_scopes (user )
251+ if granted is not None and not granted & required_scopes :
217252 raise ForbiddenError ("Insufficient scope for this operation" )
218253
219254
255+ # Back-compat alias — the check now covers app tokens too.
256+ check_api_key_scope = check_credential_scopes
257+
258+
220259# ── Named scope sets ─────────────────────────────────────────────────────────
221260
222261STATS_SCOPES : set [str ] = {
@@ -238,6 +277,9 @@ def check_api_key_scope(user: CurrentUser | None, required_scopes: set[str]) ->
238277 ApiKeyScope .DOMAINS_READ ,
239278 ApiKeyScope .ADMIN_ALL ,
240279}
280+ # Deliberately NOT satisfied by admin:all: API keys can hold admin:all but
281+ # must never manage keys, and app tokens declare keys:manage explicitly.
282+ KEYS_MANAGE_SCOPES : set [str ] = {ApiKeyScope .KEYS_MANAGE }
241283
242284
243285# ── Parameterised scope dependency factories ─────────────────────────────────
@@ -255,7 +297,30 @@ def require_scopes(scopes: set[str]):
255297 """
256298
257299 async def _dep (user : CurrentUser = Depends (require_auth )) -> CurrentUser :
258- check_api_key_scope (user , scopes )
300+ check_credential_scopes (user , scopes )
301+ return user
302+
303+ return _dep
304+
305+
306+ def require_session_or_scopes (scopes : set [str ]):
307+ """Dependency factory: interactive session OR a credential holding *scopes*.
308+
309+ Passes for an interactive session (no API key, no ``app_id``), or for a
310+ delegated credential whose scope set intersects *scopes*. A delegated
311+ credential is identified by ``api_key_doc``/``app_id``, not by ``scp``,
312+ so a legacy app token (``app_id`` but no ``scp``) is treated as delegated
313+ and denied — it predates ``keys:manage`` and never held it. API keys can
314+ never be created with ``keys:manage`` (see ALLOWED_SCOPES), so the
315+ anti-self-propagation guard holds.
316+ """
317+
318+ async def _dep (user : CurrentUser = Depends (require_auth )) -> CurrentUser :
319+ if user .api_key_doc is None and user .app_id is None :
320+ return user # interactive session — unrestricted
321+ granted = _granted_scopes (user )
322+ if not granted or not granted & scopes :
323+ raise ForbiddenError ("Insufficient scope for this operation" )
259324 return user
260325
261326 return _dep
@@ -275,7 +340,7 @@ def optional_scopes(scopes: set[str]):
275340 async def _dep (
276341 user : CurrentUser | None = Depends (get_current_user ),
277342 ) -> CurrentUser | None :
278- check_api_key_scope (user , scopes )
343+ check_credential_scopes (user , scopes )
279344 return user
280345
281346 return _dep
@@ -321,10 +386,19 @@ async def _dep(
321386 return _dep
322387
323388
389+ # ── Named dependency instances ────────────────────────────────────────────────
390+
391+ # Key listing/deletion: interactive session OR a credential holding
392+ # keys:manage. Key *creation* is not here — minting a new credential is a
393+ # first-party act, so create uses JwtVerifiedUser (interactive session only).
394+ require_keys_access = require_session_or_scopes (KEYS_MANAGE_SCOPES )
395+
396+
324397# ── Annotated type aliases — community-standard Depends shortcuts ─────────────
325398
326399AuthUser = Annotated [CurrentUser , Depends (require_auth )]
327400VerifiedUser = Annotated [CurrentUser , Depends (require_verified_email )]
328401OptionalUser = Annotated [CurrentUser | None , Depends (get_current_user )]
329402JwtUser = Annotated [CurrentUser , Depends (require_jwt )]
330403JwtVerifiedUser = Annotated [CurrentUser , Depends (require_jwt_verified )]
404+ KeysAccessUser = Annotated [CurrentUser , Depends (require_keys_access )]
0 commit comments