Skip to content

Commit 468b179

Browse files
authored
Merge pull request #260 from spoo-me/feat/device-auth-pkce-scopes
feat(auth): PKCE and grant-scoped tokens for connected apps
2 parents 8a611c9 + cbb0428 commit 468b179

32 files changed

Lines changed: 1822 additions & 215 deletions

config/apps.yaml

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,16 @@ apps:
8282
verified: true
8383
status: coming_soon
8484
type: device_auth
85+
redirect_uris:
86+
- https://raycast.com/redirect?packageName=Extension
87+
- https://raycast.com/redirect/extension
88+
links:
89+
store: https://www.raycast.com/zingzy/spoo
90+
scopes:
91+
- shorten:create
92+
- urls:read
93+
- urls:manage
94+
- stats:read
8595

8696
spoo-vscode:
8797
name: VS Code Extension
@@ -126,8 +136,11 @@ apps:
126136
- http://127.0.0.1:53682/callback
127137
links:
128138
github: https://github.com/spoo-me/spoo-cli
129-
permissions:
130-
- Access your spoo.me account
131-
- Create and manage your short URLs
132-
- View your analytics
133-
- Manage your API keys
139+
scopes:
140+
- shorten:create
141+
- urls:read
142+
- urls:manage
143+
- stats:read
144+
- domains:read
145+
- domains:manage
146+
- keys:manage

dependencies/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from dependencies.auth import (
99
DOMAIN_MANAGE_SCOPES,
1010
DOMAIN_READ_SCOPES,
11+
KEYS_MANAGE_SCOPES,
1112
REPORTS_SCOPES,
1213
SHORTEN_SCOPES,
1314
STATS_SCOPES,
@@ -17,17 +18,21 @@
1718
CurrentUser,
1819
JwtUser,
1920
JwtVerifiedUser,
21+
KeysAccessUser,
2022
OptionalUser,
2123
VerifiedUser,
2224
check_api_key_scope,
25+
check_credential_scopes,
2326
get_current_user,
2427
optional_scopes,
2528
optional_scopes_verified,
2629
require_auth,
2730
require_jwt,
2831
require_jwt_verified,
32+
require_keys_access,
2933
require_scopes,
3034
require_scopes_verified,
35+
require_session_or_scopes,
3136
require_verified_email,
3237
)
3338
from dependencies.infra import (
@@ -94,6 +99,7 @@
9499
__all__ = [
95100
"DOMAIN_MANAGE_SCOPES",
96101
"DOMAIN_READ_SCOPES",
102+
"KEYS_MANAGE_SCOPES",
97103
"REPORTS_SCOPES",
98104
"SHORTEN_SCOPES",
99105
"STATS_SCOPES",
@@ -120,6 +126,7 @@
120126
"JwtConfig",
121127
"JwtUser",
122128
"JwtVerifiedUser",
129+
"KeysAccessUser",
123130
"OAuthProviders",
124131
"OAuthSvc",
125132
"OnboardingCacheDep",
@@ -135,6 +142,7 @@
135142
"VerificationSvc",
136143
"VerifiedUser",
137144
"check_api_key_scope",
145+
"check_credential_scopes",
138146
# services (getters)
139147
"fetch_user_profile",
140148
"get_api_key_service",
@@ -172,7 +180,9 @@
172180
"require_auth",
173181
"require_jwt",
174182
"require_jwt_verified",
183+
"require_keys_access",
175184
"require_scopes",
176185
"require_scopes_verified",
186+
"require_session_or_scopes",
177187
"require_verified_email",
178188
]

dependencies/auth.py

Lines changed: 89 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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(
184203
async 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

222261
STATS_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

326399
AuthUser = Annotated[CurrentUser, Depends(require_auth)]
327400
VerifiedUser = Annotated[CurrentUser, Depends(require_verified_email)]
328401
OptionalUser = Annotated[CurrentUser | None, Depends(get_current_user)]
329402
JwtUser = Annotated[CurrentUser, Depends(require_jwt)]
330403
JwtVerifiedUser = Annotated[CurrentUser, Depends(require_jwt_verified)]
404+
KeysAccessUser = Annotated[CurrentUser, Depends(require_keys_access)]

dependencies/wiring.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,7 @@ def wire_services(app: FastAPI, settings: AppSettings, redis_client) -> None:
298298
user_repo,
299299
token_repo,
300300
token_factory,
301+
app_grant_repo,
301302
app_registry=getattr(app.state, "app_registry", None),
302303
)
303304
app.state.oauth_service = OAuthService(

infrastructure/crypto.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
"""
2-
Cryptographic helpers — password hashing and token hashing.
2+
Cryptographic helpers — password hashing, token hashing, and PKCE.
33
4-
Uses argon2 for passwords (via argon2-cffi) and SHA-256 for token hashing.
4+
Uses argon2 for passwords (via argon2-cffi) and SHA-256 for token hashing
5+
and PKCE code-challenge derivation (RFC 7636).
56
"""
67

78
from __future__ import annotations
89

10+
import base64
911
import hashlib
1012

1113
from argon2 import PasswordHasher
@@ -49,3 +51,16 @@ def hash_token(token: str) -> str:
4951
64-character lowercase hex string.
5052
"""
5153
return hashlib.sha256(token.encode("utf-8")).hexdigest()
54+
55+
56+
def pkce_s256_challenge(code_verifier: str) -> str:
57+
"""Derive the S256 PKCE code challenge for *code_verifier* (RFC 7636 §4.2).
58+
59+
``code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))``
60+
with no ``=`` padding.
61+
62+
Returns:
63+
43-character base64url string.
64+
"""
65+
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
66+
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")

repositories/app_grant_repository.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,26 @@ async def find_all_for_user(self, user_id: ObjectId) -> list[AppGrantDoc]:
5858
)
5959
raise
6060

61-
async def create_or_reactivate(self, user_id: ObjectId, app_id: str) -> AppGrantDoc:
61+
async def find_by_id_for_user(
62+
self, user_id: ObjectId, grant_id: ObjectId
63+
) -> AppGrantDoc | None:
64+
"""Find a grant by document id, scoped to its owner."""
65+
return await self._find_one({"_id": grant_id, "user_id": user_id})
66+
67+
async def create_or_reactivate(
68+
self,
69+
user_id: ObjectId,
70+
app_id: str,
71+
*,
72+
scopes: list[str] | None,
73+
) -> AppGrantDoc:
6274
"""Create a new grant or reactivate a revoked one.
6375
6476
Uses upsert: if a document exists for (user_id, app_id), clears
6577
revoked_at and updates granted_at. Otherwise inserts a new document.
78+
79+
``scopes`` snapshots the registry scopes at approval time; passing
80+
None preserves a legacy (unrestricted) grant shape.
6681
"""
6782
now = datetime.now(timezone.utc)
6883
try:
@@ -72,6 +87,7 @@ async def create_or_reactivate(self, user_id: ObjectId, app_id: str) -> AppGrant
7287
"$set": {
7388
"granted_at": now,
7489
"revoked_at": None,
90+
"scopes": scopes,
7591
},
7692
"$setOnInsert": {
7793
"user_id": user_id,

routes/api_v1/apps.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from middleware.openapi import AUTH_RESPONSES
1515
from middleware.rate_limiter import Limits, limiter
1616
from schemas.dto.responses.app_grant import AppGrantResponse, AppGrantsListResponse
17+
from services.auth.device import effective_scopes_for
1718

1819
router = APIRouter(tags=["Apps"])
1920

@@ -48,6 +49,11 @@ async def list_app_grants(
4849
grants.sort(key=lambda g: g.granted_at, reverse=True)
4950
return AppGrantsListResponse(
5051
items=[
51-
AppGrantResponse.from_grant(g, app_registry.get(g.app_id)) for g in grants
52+
AppGrantResponse.from_grant(
53+
g,
54+
app_registry.get(g.app_id),
55+
effective_scopes_for(g, app_registry.get(g.app_id)),
56+
)
57+
for g in grants
5258
]
5359
)

0 commit comments

Comments
 (0)