simple-module-auth 0.0.35__tar.gz → 0.0.37__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/.gitignore +1 -0
  2. {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/PKG-INFO +3 -3
  3. simple_module_auth-0.0.37/auth/_route_match.py +71 -0
  4. simple_module_auth-0.0.37/auth/contracts/schemas.py +139 -0
  5. simple_module_auth-0.0.37/auth/contracts/tokens.py +65 -0
  6. {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/auth/deps.py +15 -8
  7. {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/auth/middleware.py +46 -2
  8. {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/pyproject.toml +3 -3
  9. simple_module_auth-0.0.37/tests/test_auth_next_target.py +98 -0
  10. simple_module_auth-0.0.37/tests/test_auth_parse_bearer.py +21 -0
  11. simple_module_auth-0.0.37/tests/test_auth_unknown_paths.py +181 -0
  12. {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/tests/test_deps.py +38 -12
  13. {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/tests/test_user_context.py +68 -2
  14. simple_module_auth-0.0.35/auth/contracts/schemas.py +0 -75
  15. {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/LICENSE +0 -0
  16. {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/README.md +0 -0
  17. {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/auth/__init__.py +0 -0
  18. {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/auth/contracts/__init__.py +0 -0
  19. {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/auth/contracts/provider.py +0 -0
  20. {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/auth/contracts/resolver.py +0 -0
  21. {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/auth/locales/en.json +0 -0
  22. {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/auth/locales/es.json +0 -0
  23. {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/auth/module.py +0 -0
  24. {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/auth/py.typed +0 -0
  25. {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/auth/state.py +0 -0
  26. {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/package.json +0 -0
  27. {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/tests/test_auth_middleware.py +0 -0
  28. {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/tests/test_auth_provider_protocol.py +0 -0
  29. {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/tests/test_module.py +0 -0
  30. {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/tests/test_resolver_registry.py +0 -0
@@ -64,3 +64,4 @@ host/client_app/.playwright-cli/*
64
64
  .superpowers/
65
65
  .qa/
66
66
  .verify/
67
+ /openapi.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_auth
3
- Version: 0.0.35
3
+ Version: 0.0.37
4
4
  Summary: Session-cookie authentication primitives — middleware, login/logout, redirect helpers for simple_module
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -22,8 +22,8 @@ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
24
  Requires-Dist: itsdangerous>=2.2
25
- Requires-Dist: simple-module-core==0.0.35
26
- Requires-Dist: simple-module-db==0.0.35
25
+ Requires-Dist: simple-module-core==0.0.37
26
+ Requires-Dist: simple-module-db==0.0.37
27
27
  Description-Content-Type: text/markdown
28
28
 
29
29
  # simple_module_auth
@@ -0,0 +1,71 @@
1
+ """Does a request resolve to *any* route of the app? (#473)
2
+
3
+ ``AuthMiddleware`` only gates paths the router would actually serve. A path
4
+ nothing matches falls through to the normal 404 instead of bouncing an
5
+ anonymous visitor to the sign-in form (and storing it as their post-login
6
+ target). Every path that does resolve stays gated exactly as before.
7
+
8
+ The probe mirrors Starlette/FastAPI dispatch without running a handler:
9
+
10
+ * any ``Match`` other than ``NONE`` counts — ``PARTIAL`` is an existing path
11
+ under the wrong method (a 405 for a signed-in caller), so it stays gated;
12
+ * the ``redirect_slashes`` toggle counts too — ``/admin/users/`` that only
13
+ resolves by redirecting to ``/admin/users`` is gated like its target;
14
+ * FastAPI's low-priority (frontend) routes are consulted when present.
15
+
16
+ It fails closed: any surprise while matching reports "exists", so the worst
17
+ case is today's behaviour (a redirect), never an ungated route.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ from typing import Any
24
+
25
+ from starlette.routing import Match
26
+ from starlette.types import Scope
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ def _probe(scope: Scope, path: str | None = None) -> dict[str, Any]:
32
+ # A shallow copy without FastAPI's per-request bookkeeping: matching an
33
+ # included router writes into ``scope["fastapi"]``, which must not leak
34
+ # into the real dispatch that follows.
35
+ probe = {k: v for k, v in scope.items() if k != "fastapi"}
36
+ if path is not None:
37
+ probe["path"] = path
38
+ return probe
39
+
40
+
41
+ def _any_match(router: Any, probe: dict[str, Any]) -> bool:
42
+ for route in router.routes:
43
+ match, _ = route.matches(probe)
44
+ if match != Match.NONE:
45
+ return True
46
+ return False
47
+
48
+
49
+ def route_exists(scope: Scope) -> bool:
50
+ """Whether the app's router would serve this request (or redirect it to a route)."""
51
+ try:
52
+ router = scope["app"].router
53
+ if _any_match(router, _probe(scope)):
54
+ return True
55
+ path: str = scope["path"]
56
+ if getattr(router, "redirect_slashes", False) and path != "/":
57
+ toggled = path.rstrip("/") if path.endswith("/") else path + "/"
58
+ if _any_match(router, _probe(scope, toggled)):
59
+ return True
60
+ low_priority = getattr(router, "_match_low_priority", None)
61
+ if low_priority is not None:
62
+ match = low_priority(_probe(scope))[0]
63
+ if match != Match.NONE:
64
+ return True
65
+ except Exception:
66
+ logger.exception("Route probe failed; treating the path as existing")
67
+ return True
68
+ return False
69
+
70
+
71
+ __all__ = ["route_exists"]
@@ -0,0 +1,139 @@
1
+ """Auth data types shared with other modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from auth.contracts.tokens import AUTH_KIND_SESSION
9
+
10
+ if TYPE_CHECKING:
11
+ # Runtime import would be circular: auth -> users -> auth.
12
+ # Only imported for type-hints, never at runtime.
13
+ from users.models import User
14
+
15
+
16
+ @dataclass
17
+ class UserContext:
18
+ """Authenticated user information for downstream handlers."""
19
+
20
+ id: str
21
+ email: str
22
+ name: str
23
+ roles: list[str] = field(default_factory=list)
24
+ tenant_id: str | None = None
25
+ # Permissions the principal carries itself — a token scope or IdP claim a
26
+ # provider maps in. Frozen in the session cookie like ``roles``; anything
27
+ # revocable belongs in a grant source. The resolver drops ``*`` from here.
28
+ permissions: list[str] = field(default_factory=list)
29
+ # Which credential authenticated the request: ``"session"`` for a browser
30
+ # sign-in, ``"bearer"`` for a provider's own bearer token (a ``users``
31
+ # access token, an IdP JWT), ``"pat"`` / ``"service"`` for an
32
+ # ``api_tokens`` credential (see ``auth.contracts.tokens``). Code that must
33
+ # only ever act for a person at a keyboard — minting more credentials —
34
+ # refuses anything but ``"session"``.
35
+ auth_kind: str = AUTH_KIND_SESSION
36
+ # The ceiling a token carries. ``None`` means "no cap" (an ordinary
37
+ # session); a list — even an empty one — is intersected with whatever the
38
+ # principal resolves to, and the admin-role shortcuts are skipped, so an
39
+ # admin's token is worth exactly the scopes it was given.
40
+ scopes: list[str] | None = None
41
+ # A permission the account must *currently* hold for this credential to
42
+ # authenticate at all — ``api_tokens.use`` for a personal access token.
43
+ # Checked on every request against the account's live, tenant-aware
44
+ # permissions; when missing, the request runs as anonymous (401 on gated
45
+ # routes). ``None`` (a session, a service key) requires nothing.
46
+ required_permission: str | None = None
47
+
48
+ @property
49
+ def is_scoped(self) -> bool:
50
+ """Whether a scope cap applies (any token-authenticated principal)."""
51
+ return self.scopes is not None
52
+
53
+ @classmethod
54
+ def from_user(
55
+ cls,
56
+ user: User | Any,
57
+ *,
58
+ auth_kind: str = AUTH_KIND_SESSION,
59
+ scopes: list[str] | None = None,
60
+ ) -> UserContext:
61
+ """Build a UserContext from a users.models.User with eagerly-loaded roles.
62
+
63
+ Duck-typed to avoid importing users.models at runtime — any object
64
+ exposing .id, .email, .full_name, .roles[*].name works. The user row
65
+ carries no tenant: the active tenant is resolved per request from
66
+ ``tenants_membership``, so ``tenant_id`` stays ``None`` here (only a
67
+ generic provider's claim path sets it).
68
+ The caller is responsible for eager-loading roles (selectinload).
69
+ """
70
+ return cls(
71
+ id=str(user.id),
72
+ email=user.email,
73
+ name=user.full_name or user.email,
74
+ roles=[r.name for r in user.roles],
75
+ auth_kind=auth_kind,
76
+ scopes=list(scopes) if scopes is not None else None,
77
+ )
78
+
79
+ def to_session_dict(self) -> dict[str, Any]:
80
+ """Serialize to a JSON-safe dict for the signed session cookie.
81
+
82
+ The inverse of :meth:`from_session_dict`. Adding a new field to
83
+ ``UserContext`` requires updating both methods here — not the
84
+ AuthMiddleware cache helper, which stays schema-agnostic."""
85
+ return {
86
+ "id": self.id,
87
+ "email": self.email,
88
+ "name": self.name,
89
+ "roles": list(self.roles),
90
+ "tenant_id": self.tenant_id,
91
+ "permissions": list(self.permissions),
92
+ "auth_kind": self.auth_kind,
93
+ "scopes": list(self.scopes) if self.scopes is not None else None,
94
+ "required_permission": self.required_permission,
95
+ }
96
+
97
+ @classmethod
98
+ def from_session_dict(cls, payload: Any) -> UserContext | None:
99
+ """Rebuild from :meth:`to_session_dict` output. Returns ``None`` on any
100
+ shape mismatch so callers can fall through to a fresh DB load."""
101
+ if not isinstance(payload, dict):
102
+ return None
103
+ try:
104
+ return cls(
105
+ id=str(payload["id"]),
106
+ email=str(payload["email"]),
107
+ name=str(payload["name"]),
108
+ roles=list(payload.get("roles") or []),
109
+ tenant_id=payload.get("tenant_id"),
110
+ # Absent on cookies written before the field existed.
111
+ permissions=list(payload.get("permissions") or []),
112
+ auth_kind=str(payload.get("auth_kind") or AUTH_KIND_SESSION),
113
+ scopes=_scopes_from(payload.get("scopes")),
114
+ required_permission=payload.get("required_permission") or None,
115
+ )
116
+ except (KeyError, TypeError, ValueError):
117
+ return None
118
+
119
+ def has_role(self, role: str) -> bool:
120
+ return role in self.roles
121
+
122
+ def has_permission(self, permission: str) -> bool:
123
+ """Exact match against :attr:`permissions` only — no roles, no wildcard.
124
+
125
+ Gate routes with ``RequiresPermission``, which also folds in roles and
126
+ grant sources; this is for the principal's own claims alone."""
127
+ return permission in self.permissions
128
+
129
+ def has_any_role(self, roles: list[str]) -> bool:
130
+ return bool(set(self.roles) & set(roles))
131
+
132
+
133
+ def _scopes_from(raw: Any) -> list[str] | None:
134
+ """``None`` stays ``None`` (uncapped); anything else must be a list."""
135
+ if raw is None:
136
+ return None
137
+ if not isinstance(raw, list):
138
+ raise TypeError("scopes must be a list")
139
+ return [str(s) for s in raw]
@@ -0,0 +1,65 @@
1
+ """Credential kinds and API-token prefixes shared by every auth source.
2
+
3
+ Lives in ``auth`` rather than in the ``api_tokens`` module because the auth
4
+ *providers* need it: ``users`` and ``keycloak`` both claim every
5
+ ``Authorization: Bearer`` header, and must step aside for a value that is
6
+ recognisably an ``api_tokens`` credential — otherwise each token request costs
7
+ a wasted lookup against the provider's own token store (or a JWT parse), and
8
+ the provider's ``None`` there is final, so the principal resolver that
9
+ actually owns the token never gets to see it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ AUTH_KIND_SESSION = "session"
15
+ """A browser sign-in: the principal came from the session cookie."""
16
+
17
+ AUTH_KIND_BEARER = "bearer"
18
+ """An auth provider's own ``Authorization: Bearer`` credential — a ``users``
19
+ access token or an identity provider's JWT. Unscoped (it carries its user's
20
+ full rights, as before), but not a person at a keyboard: it may not manage
21
+ API tokens, so a short-lived access token cannot mint a year-long PAT."""
22
+
23
+ AUTH_KIND_PAT = "pat"
24
+ """A personal access token acting for its owner, capped by its scopes."""
25
+
26
+ AUTH_KIND_SERVICE = "service"
27
+ """A service API key: no person behind it, roles and tenant fixed at creation."""
28
+
29
+ PAT_PREFIX = "smpat_"
30
+ SERVICE_KEY_PREFIX = "smsk_"
31
+ API_TOKEN_PREFIXES: tuple[str, ...] = (PAT_PREFIX, SERVICE_KEY_PREFIX)
32
+
33
+
34
+ def parse_bearer(header: str | None) -> str | None:
35
+ """The credential of an ``Authorization: Bearer <credential>`` header, else None.
36
+
37
+ The auth scheme is case-insensitive (RFC 9110 §11.1): clients that build
38
+ the header from ``/auth/token``'s ``token_type`` send ``bearer``, and must
39
+ authenticate exactly like ``Bearer``. Surrounding and separating whitespace
40
+ is tolerated; a header with no credential, or a different scheme, is None.
41
+ """
42
+ if not header:
43
+ return None
44
+ parts = header.split(None, 1)
45
+ if len(parts) != 2 or parts[0].lower() != "bearer":
46
+ return None
47
+ return parts[1].strip() or None
48
+
49
+
50
+ def is_api_token(credential: str) -> bool:
51
+ """Whether a bearer value is an ``api_tokens`` credential (by prefix only)."""
52
+ return credential.startswith(API_TOKEN_PREFIXES)
53
+
54
+
55
+ __all__ = [
56
+ "API_TOKEN_PREFIXES",
57
+ "AUTH_KIND_BEARER",
58
+ "AUTH_KIND_PAT",
59
+ "AUTH_KIND_SERVICE",
60
+ "AUTH_KIND_SESSION",
61
+ "PAT_PREFIX",
62
+ "SERVICE_KEY_PREFIX",
63
+ "is_api_token",
64
+ "parse_bearer",
65
+ ]
@@ -5,7 +5,10 @@ from __future__ import annotations
5
5
  from typing import Annotated
6
6
 
7
7
  from fastapi import Depends, HTTPException, Request
8
+ from simple_module_core.permissions import grants
8
9
  from simple_module_hosting.i18n_deps import TranslatorDep
10
+ from simple_module_hosting.permissions import ensure_resolved_permissions
11
+ from simple_module_hosting.token_guard import GATE_PERMISSION, mark_token_gate
9
12
 
10
13
  from auth.contracts.schemas import UserContext
11
14
 
@@ -40,15 +43,17 @@ def require_permission(*permissions: str):
40
43
  t: TranslatorDep,
41
44
  user: UserContext = Depends(get_current_user),
42
45
  ):
43
- # Admin role bypasses permission checks
44
- if _ADMIN_ROLE in user.roles:
46
+ # Admin role bypasses permission checks — but not for a token: an
47
+ # admin's PAT is worth only the scopes it carries, and the resolved
48
+ # set below already applies that cap.
49
+ if _ADMIN_ROLE in user.roles and user.scopes is None:
45
50
  return
46
51
 
47
- # Get permission registry from app state
48
- perm_registry = request.app.state.sm.permissions
49
- user_perms = perm_registry.get_permissions_for_roles(user.roles)
50
-
51
- if not any(p in user_perms for p in permissions):
52
+ # The same resolution RequiresPermission reads: the registry's role map
53
+ # plus module grant sources. This used to call get_permissions_for_roles
54
+ # without a map, which grants every non-admin role nothing (GH #337).
55
+ held = await ensure_resolved_permissions(request)
56
+ if not any(grants(held, p) for p in permissions):
52
57
  raise HTTPException(
53
58
  status_code=403,
54
59
  detail=t.t(
@@ -57,4 +62,6 @@ def require_permission(*permissions: str):
57
62
  ),
58
63
  )
59
64
 
60
- return Depends(check)
65
+ # A permission gate: API tokens may reach the route, capped at their
66
+ # scopes (``simple_module_hosting.token_guard``).
67
+ return Depends(mark_token_gate(check, GATE_PERMISSION))
@@ -16,6 +16,8 @@ from starlette.requests import Request
16
16
  from starlette.responses import JSONResponse, RedirectResponse
17
17
  from starlette.types import ASGIApp, Receive, Scope, Send
18
18
 
19
+ from auth._route_match import route_exists
20
+
19
21
  logger = logging.getLogger(__name__)
20
22
 
21
23
  _FRAMEWORK_PUBLIC_PREFIXES = (
@@ -23,7 +25,6 @@ _FRAMEWORK_PUBLIC_PREFIXES = (
23
25
  "/static/",
24
26
  "/api/docs",
25
27
  "/api/redoc",
26
- "/openapi.json",
27
28
  "/i18n/",
28
29
  )
29
30
  _FRAMEWORK_PUBLIC_EXACT = ("/",)
@@ -33,6 +34,27 @@ def _query_suffix(request: Request) -> str:
33
34
  return f"?{request.url.query}" if request.url.query else ""
34
35
 
35
36
 
37
+ def _is_navigation(request: Request) -> bool:
38
+ """Whether this request is the page the visitor asked for, not something it loaded.
39
+
40
+ Only a navigation may become the post-login target: the sign-in page's
41
+ own favicon request used to overwrite it, so sign-in landed on
42
+ /favicon.ico (#416). Inertia visits count — a session expiring mid-click
43
+ must still return the user to that page. Without fetch metadata (old
44
+ clients, scripts) keep the previous behaviour.
45
+ """
46
+ headers = request.headers
47
+ if headers.get("x-inertia") == "true":
48
+ return True
49
+ dest = headers.get("sec-fetch-dest")
50
+ if dest is not None:
51
+ return dest == "document"
52
+ mode = headers.get("sec-fetch-mode")
53
+ if mode is not None:
54
+ return mode == "navigate"
55
+ return True
56
+
57
+
36
58
  class AuthMiddleware:
37
59
  """Authenticate requests via the registered AuthProvider.
38
60
 
@@ -63,6 +85,8 @@ class AuthMiddleware:
63
85
  is_public = (
64
86
  any(path.startswith(p) for p in _FRAMEWORK_PUBLIC_PREFIXES)
65
87
  or path in _FRAMEWORK_PUBLIC_EXACT
88
+ # Anonymous exactly when the host serves it (dev, or openapi_public).
89
+ or path == getattr(app_state, "openapi_url", None)
66
90
  )
67
91
  # Module-contributed public routes (register_public_routes hook). Method
68
92
  # -aware, so a GET read route can be exempted without opening sibling
@@ -76,6 +100,11 @@ class AuthMiddleware:
76
100
  prefix_paths, exact_paths = provider.get_public_paths()
77
101
  is_public = any(path.startswith(p) for p in prefix_paths) or path in exact_paths
78
102
 
103
+ # Contract with the host's RateLimitMiddleware (plain scope key, no import
104
+ # between them): whether *this* middleware treats the request as anonymous-
105
+ # allowed, from all three sources above. One source of truth.
106
+ scope.setdefault("state", {})["auth_public"] = is_public
107
+
79
108
  request = Request(scope)
80
109
  user_ctx = await provider.resolve_user(request)
81
110
 
@@ -92,6 +121,15 @@ class AuthMiddleware:
92
121
  if user_ctx is not None:
93
122
  break
94
123
 
124
+ if user_ctx is None and not is_public and not route_exists(scope):
125
+ # Nothing would serve this path (#473): let the app's 404 answer it
126
+ # — the same page a signed-in caller gets — rather than sending the
127
+ # visitor to sign in for a page that does not exist, or keeping it
128
+ # as their post-login target. The request now reaches the app
129
+ # anonymously, so the rate limiter treats it like any public hit.
130
+ scope["state"]["auth_public"] = True
131
+ is_public = True
132
+
95
133
  if user_ctx is None and not is_public:
96
134
  if path.startswith("/api/") or provider.is_bearer_request(request):
97
135
  response = JSONResponse({"detail": "Not authenticated"}, status_code=401)
@@ -103,7 +141,13 @@ class AuthMiddleware:
103
141
  # goes to the provider — session-based providers ignore it,
104
142
  # but a redirect-based one (OIDC) needs it in the auth URL.
105
143
  session = scope.get("session", {})
106
- next_url = safe_next_or_none(request.url.path + _query_suffix(request))
144
+ # Only a navigation is a target: a subresource (the sign-in
145
+ # page's own favicon) would overwrite it (#416).
146
+ next_url = (
147
+ safe_next_or_none(request.url.path + _query_suffix(request))
148
+ if _is_navigation(request)
149
+ else None
150
+ )
107
151
  if next_url is not None:
108
152
  session[SESSION_NEXT_KEY] = next_url
109
153
  response = RedirectResponse(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_auth"
3
- version = "0.0.35"
3
+ version = "0.0.37"
4
4
  description = "Session-cookie authentication primitives — middleware, login/logout, redirect helpers for simple_module"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -22,8 +22,8 @@ classifiers = [
22
22
  ]
23
23
  dependencies = [
24
24
  "itsdangerous>=2.2",
25
- "simple_module_core==0.0.35",
26
- "simple_module_db==0.0.35",
25
+ "simple_module_core==0.0.37",
26
+ "simple_module_db==0.0.37",
27
27
  ]
28
28
 
29
29
  [project.entry-points.simple_module]
@@ -0,0 +1,98 @@
1
+ """Only top-level navigations may become the post-login target (#416)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+ import pytest
7
+ from auth.middleware import AuthMiddleware
8
+ from auth.state import AuthState
9
+ from fastapi import FastAPI, Request
10
+ from simple_module_core.redirect_safety import SESSION_NEXT_KEY
11
+ from starlette.middleware.sessions import SessionMiddleware
12
+ from starlette.responses import JSONResponse
13
+
14
+ SECRET = "test-next-target-secret"
15
+
16
+
17
+ class _StubProvider:
18
+ name = "stub"
19
+
20
+ async def resolve_user(self, request):
21
+ return None
22
+
23
+ def get_login_url(self, request, next_url=None):
24
+ return "/stub/login"
25
+
26
+ def get_logout_url(self, request):
27
+ return "/stub/logout"
28
+
29
+ def get_public_paths(self):
30
+ return (("/stub/login", "/stub/public/"), ())
31
+
32
+ def is_bearer_request(self, request):
33
+ return False
34
+
35
+
36
+ def _build_app(provider) -> FastAPI:
37
+ app = FastAPI()
38
+ app.state.auth = AuthState(auth_provider=provider, principal_resolvers=[])
39
+
40
+ # /static/ is framework-public, so this reads the session without auth.
41
+ async def _next(request: Request):
42
+ return JSONResponse({"next": request.session.get(SESSION_NEXT_KEY)})
43
+
44
+ async def _any(path: str = ""):
45
+ return JSONResponse({})
46
+
47
+ app.add_api_route("/static/next", _next, methods=["GET"])
48
+ app.add_api_route("/{path:path}", _any, methods=["GET"])
49
+ app.add_middleware(AuthMiddleware)
50
+ app.add_middleware(SessionMiddleware, secret_key=SECRET)
51
+ return app
52
+
53
+
54
+ CASES = [
55
+ ({"Sec-Fetch-Dest": "image", "Sec-Fetch-Mode": "no-cors"}, None), # favicon
56
+ ({"Sec-Fetch-Dest": "script", "Sec-Fetch-Mode": "no-cors"}, None),
57
+ ({"Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors"}, None), # fetch()
58
+ ({"Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate"}, "/protected/page"),
59
+ ({"Sec-Fetch-Mode": "navigate"}, "/protected/page"),
60
+ ({"X-Inertia": "true", "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors"}, "/protected/page"),
61
+ ({}, "/protected/page"), # no metadata: previous behaviour
62
+ ]
63
+
64
+
65
+ @pytest.mark.parametrize(("headers", "expected"), CASES)
66
+ async def test_only_navigations_record_the_target(headers, expected):
67
+ seen: list[str | None] = []
68
+
69
+ class _Recording(_StubProvider):
70
+ def get_login_url(self, request, next_url=None):
71
+ seen.append(next_url)
72
+ return "/stub/login"
73
+
74
+ app = _build_app(_Recording())
75
+ async with httpx.AsyncClient(
76
+ transport=httpx.ASGITransport(app=app), base_url="http://test"
77
+ ) as c:
78
+ resp = await c.get("/protected/page", headers=headers)
79
+ recorded = (await c.get("/static/next")).json()["next"]
80
+ assert resp.status_code == 302
81
+ assert resp.headers["location"] == "/stub/login"
82
+ assert seen == [expected]
83
+ assert recorded == expected
84
+
85
+
86
+ async def test_a_subresource_does_not_overwrite_a_recorded_target():
87
+ """The bug: the sign-in page's favicon request replaced the real target."""
88
+ app = _build_app(_StubProvider())
89
+ async with httpx.AsyncClient(
90
+ transport=httpx.ASGITransport(app=app), base_url="http://test"
91
+ ) as c:
92
+ nav = {"Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate"}
93
+ assert (await c.get("/protected/page", headers=nav)).status_code == 302
94
+ icon = {"Sec-Fetch-Dest": "image", "Sec-Fetch-Mode": "no-cors"}
95
+ resp = await c.get("/favicon.ico", headers=icon)
96
+ assert resp.status_code == 302 # the redirect itself is unchanged
97
+ recorded = (await c.get("/static/next")).json()["next"]
98
+ assert recorded == "/protected/page"
@@ -0,0 +1,21 @@
1
+ """``parse_bearer``: the one place an ``Authorization: Bearer`` header is read (GH #533)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+ from auth.contracts.tokens import parse_bearer
7
+
8
+
9
+ @pytest.mark.parametrize(
10
+ "header",
11
+ ["Bearer abc", "bearer abc", "BEARER abc", "BeArEr abc", " Bearer abc ", "bearer\tabc"],
12
+ )
13
+ def test_scheme_is_case_insensitive_and_whitespace_tolerant(header):
14
+ assert parse_bearer(header) == "abc"
15
+
16
+
17
+ @pytest.mark.parametrize(
18
+ "header", [None, "", "Bearer", "Bearer ", "Basic abc", "Bearerabc", "Token abc"]
19
+ )
20
+ def test_anything_else_is_not_a_bearer_credential(header):
21
+ assert parse_bearer(header) is None
@@ -0,0 +1,181 @@
1
+ """AuthMiddleware lets a path no route serves reach the 404 (#473).
2
+
3
+ Only an *unmatched* path is let through anonymously; anything the router would
4
+ serve — including under the wrong method, or only via a trailing-slash
5
+ redirect — stays gated exactly as before.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import httpx
11
+ import pytest
12
+ from auth.contracts.schemas import UserContext
13
+ from auth.middleware import AuthMiddleware
14
+ from auth.state import AuthState
15
+ from fastapi import APIRouter, FastAPI
16
+ from simple_module_core.redirect_safety import SESSION_NEXT_KEY
17
+ from starlette.middleware.sessions import SessionMiddleware
18
+ from starlette.requests import Request
19
+ from starlette.responses import JSONResponse
20
+
21
+ _USER = UserContext(id="u-1", email="a@example.com", name="A", roles=["admin"])
22
+
23
+
24
+ class _Provider:
25
+ name = "stub"
26
+
27
+ def __init__(self, user: UserContext | None = None) -> None:
28
+ self._user = user
29
+
30
+ async def resolve_user(self, request):
31
+ return self._user
32
+
33
+ def get_login_url(self, request, next_url=None):
34
+ return "/stub/login"
35
+
36
+ def get_logout_url(self, request):
37
+ return "/stub/logout"
38
+
39
+ def get_public_paths(self):
40
+ return (("/stub/login",), ())
41
+
42
+ def is_bearer_request(self, request):
43
+ return request.headers.get("authorization", "").startswith("Bearer ")
44
+
45
+
46
+ def _build(user: UserContext | None = None) -> FastAPI:
47
+ app = FastAPI()
48
+ app.state.auth = AuthState(auth_provider=_Provider(user), principal_resolvers=[])
49
+
50
+ async def ok():
51
+ return {"ok": True}
52
+
53
+ async def item(item_id: int):
54
+ return {"id": item_id}
55
+
56
+ async def session_dump(request: Request):
57
+ return JSONResponse(dict(request.session))
58
+
59
+ app.add_api_route("/dashboard", ok, methods=["GET"])
60
+ app.add_api_route("/api/items", ok, methods=["GET"])
61
+ app.add_api_route("/api/items/{item_id:int}", item, methods=["GET"])
62
+ app.add_api_route("/stub/login", session_dump, methods=["GET"])
63
+ admin = APIRouter(prefix="/admin")
64
+ admin.add_api_route("/users", ok, methods=["GET"])
65
+ admin.add_api_route("/roles/", ok, methods=["GET"])
66
+ app.include_router(admin)
67
+
68
+ app.add_middleware(AuthMiddleware)
69
+ app.add_middleware(SessionMiddleware, secret_key="unknown-paths-secret")
70
+ return app
71
+
72
+
73
+ def _client(app: FastAPI) -> httpx.AsyncClient:
74
+ transport = httpx.ASGITransport(app=app)
75
+ return httpx.AsyncClient(transport=transport, base_url="http://test", follow_redirects=False)
76
+
77
+
78
+ async def test_anonymous_unknown_page_is_404_and_stores_no_next():
79
+ async with _client(_build()) as c:
80
+ resp = await c.get("/does-not-exist", headers={"sec-fetch-dest": "document"})
81
+ assert resp.status_code == 404
82
+ session = (await c.get("/stub/login")).json()
83
+ assert SESSION_NEXT_KEY not in session
84
+
85
+
86
+ async def test_anonymous_unknown_api_is_404_json():
87
+ async with _client(_build()) as c:
88
+ resp = await c.get("/api/nope")
89
+ assert resp.status_code == 404
90
+ assert resp.json() == {"detail": "Not Found"}
91
+
92
+
93
+ async def test_anonymous_unknown_api_with_bad_bearer_is_404():
94
+ async with _client(_build()) as c:
95
+ resp = await c.get("/api/nope", headers={"Authorization": "Bearer junk"})
96
+ assert resp.status_code == 404
97
+
98
+
99
+ async def test_unknown_path_is_marked_anonymous_allowed_for_rate_limit():
100
+ seen: dict = {}
101
+ inner = _build()
102
+
103
+ async def spy(scope, receive, send):
104
+ await inner(scope, receive, send)
105
+ seen.update(scope.get("state", {}))
106
+
107
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=spy), base_url="http://t") as c:
108
+ await c.get("/nope")
109
+ assert seen.get("auth_public") is True
110
+
111
+
112
+ async def test_anonymous_existing_private_page_still_redirects_with_next():
113
+ async with _client(_build()) as c:
114
+ resp = await c.get("/dashboard?tab=1", headers={"sec-fetch-dest": "document"})
115
+ assert resp.status_code == 302
116
+ assert resp.headers["location"] == "/stub/login"
117
+ session = (await c.get("/stub/login")).json()
118
+ assert session[SESSION_NEXT_KEY] == "/dashboard?tab=1"
119
+
120
+
121
+ @pytest.mark.parametrize(
122
+ ("method", "path", "status"),
123
+ [
124
+ ("GET", "/api/items", 401),
125
+ ("GET", "/api/items/7", 401),
126
+ ("POST", "/api/items", 401), # wrong method on an existing route (405 when signed in)
127
+ ("DELETE", "/dashboard", 302),
128
+ ("HEAD", "/dashboard", 302),
129
+ ("GET", "/admin/users", 302), # route from an included router
130
+ ("GET", "/admin/users/", 302), # resolves only via the slash redirect
131
+ ("GET", "/admin/roles", 302), # the other direction of the slash redirect
132
+ ("GET", "/api/items/", 401),
133
+ ],
134
+ )
135
+ async def test_existing_routes_stay_gated(method, path, status):
136
+ async with _client(_build()) as c:
137
+ resp = await c.request(method, path)
138
+ assert resp.status_code == status
139
+
140
+
141
+ async def test_converter_mismatch_is_an_unknown_path():
142
+ # ``/api/items/{item_id:int}`` cannot serve ``/api/items/abc`` for anyone.
143
+ async with _client(_build()) as c:
144
+ resp = await c.get("/api/items/abc")
145
+ assert resp.status_code == 404
146
+
147
+
148
+ async def test_signed_in_unknown_path_is_404():
149
+ async with _client(_build(_USER)) as c:
150
+ resp = await c.get("/does-not-exist")
151
+ assert resp.status_code == 404
152
+
153
+
154
+ async def test_probe_failure_fails_closed(monkeypatch):
155
+ import auth._route_match as rm
156
+
157
+ def boom(router, probe):
158
+ raise RuntimeError("router exploded")
159
+
160
+ monkeypatch.setattr(rm, "_any_match", boom)
161
+ async with _client(_build()) as c:
162
+ resp = await c.get("/does-not-exist")
163
+ assert resp.status_code == 302
164
+
165
+
166
+ async def test_full_app_unknown_paths_render_404_for_anonymous(client):
167
+ page = await client.get("/does-not-exist", follow_redirects=False)
168
+ assert page.status_code == 404
169
+ assert "location" not in page.headers
170
+ api = await client.get("/api/does-not-exist", follow_redirects=False)
171
+ assert api.status_code == 404
172
+ assert api.headers["content-type"].startswith("application/json")
173
+
174
+
175
+ async def test_full_app_private_routes_stay_gated_for_anonymous(client):
176
+ page = await client.get("/admin/users", follow_redirects=False)
177
+ assert page.status_code == 302
178
+ assert "/login" in page.headers["location"]
179
+ slash = await client.get("/admin/users/", follow_redirects=False)
180
+ assert slash.status_code == 302
181
+ assert "/login" in slash.headers["location"]
@@ -27,6 +27,14 @@ def _translator() -> Translator:
27
27
  return Translator(registry, locale="en", default_locale="en")
28
28
 
29
29
 
30
+ def _request(app, user: UserContext) -> MagicMock:
31
+ """A request whose state is real, so the permission cache reads as absent."""
32
+ request = MagicMock()
33
+ request.app.state.sm = SimpleNamespace(permissions=app.state.sm.permissions)
34
+ request.state = SimpleNamespace(user=user)
35
+ return request
36
+
37
+
30
38
  class TestGetCurrentUser:
31
39
  async def test_raises_401_when_no_user(self):
32
40
  """get_current_user raises 401 when request.state has no user."""
@@ -53,10 +61,8 @@ class TestRequirePermission:
53
61
  dep = require_permission("products.delete")
54
62
  check_fn = dep.dependency
55
63
 
56
- request = MagicMock()
57
- request.app.state.sm = SimpleNamespace(permissions=app.state.sm.permissions)
58
-
59
64
  user = UserContext(id="u1", email="u@test.com", name="User", roles=["viewer"])
65
+ request = _request(app, user)
60
66
 
61
67
  with pytest.raises(HTTPException) as exc_info:
62
68
  await check_fn(request, _translator(), user)
@@ -67,10 +73,8 @@ class TestRequirePermission:
67
73
  dep = require_permission("products.delete")
68
74
  check_fn = dep.dependency
69
75
 
70
- request = MagicMock()
71
- request.app.state.sm = SimpleNamespace(permissions=app.state.sm.permissions)
72
-
73
76
  admin_user = UserContext(id="a1", email="admin@test.com", name="Admin", roles=["admin"])
77
+ request = _request(app, admin_user)
74
78
  await check_fn(request, _translator(), admin_user)
75
79
 
76
80
 
@@ -80,21 +84,43 @@ class TestRequirePermissionAdvanced:
80
84
  dep = require_permission("products.view", "products.edit")
81
85
  check_fn = dep.dependency
82
86
 
83
- request = MagicMock()
84
- request.app.state.sm = SimpleNamespace(permissions=app.state.sm.permissions)
85
-
86
87
  admin = UserContext(id="a1", email="a@t.com", name="Admin", roles=["admin"])
88
+ request = _request(app, admin)
87
89
  await check_fn(request, _translator(), admin)
88
90
 
89
91
  async def test_non_admin_without_permission_fails(self, app):
90
92
  dep = require_permission("products.delete")
91
93
  check_fn = dep.dependency
92
94
 
93
- request = MagicMock()
94
- request.app.state.sm = SimpleNamespace(permissions=app.state.sm.permissions)
95
-
96
95
  user = UserContext(id="u1", email="u@t.com", name="User", roles=["user"])
96
+ request = _request(app, user)
97
97
  with pytest.raises(HTTPException) as exc_info:
98
98
  await check_fn(request, _translator(), user)
99
99
  assert exc_info.value.status_code == 403
100
100
  assert "products.delete" in str(exc_info.value.detail)
101
+
102
+ async def test_non_admin_role_mapped_in_registry_passes(self, app):
103
+ """A role granted the key via the registry's role map is admitted.
104
+
105
+ Regression for GH #337: the check resolved roles without the role map,
106
+ so every non-admin role held nothing and this was a 403.
107
+ """
108
+ app.state.sm.permissions.map_role("clerk", ["products.delete"])
109
+ dep = require_permission("products.delete")
110
+ user = UserContext(id="u1", email="u@t.com", name="User", roles=["clerk"])
111
+ await dep.dependency(_request(app, user), _translator(), user)
112
+
113
+
114
+ def test_require_permission_is_a_token_gate():
115
+ """``token_guard`` admits API tokens on a route gated by ``require_permission``."""
116
+ from fastapi import FastAPI
117
+ from simple_module_hosting.token_guard import route_admits_tokens
118
+
119
+ app = FastAPI()
120
+
121
+ @app.get("/g", dependencies=[require_permission("x.view")])
122
+ async def gated() -> None:
123
+ return None
124
+
125
+ route = next(r for r in app.router.routes if getattr(r, "path", "") == "/g")
126
+ assert route_admits_tokens(route) is True
@@ -18,14 +18,14 @@ class TestUserContextFromUser:
18
18
  email="charlie@example.com",
19
19
  full_name="Charlie Brown",
20
20
  roles=[role_a, role_b],
21
- tenant_id="tenant-42",
22
21
  )
23
22
  ctx = UserContext.from_user(fake_user)
24
23
  assert ctx.id == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
25
24
  assert ctx.email == "charlie@example.com"
26
25
  assert ctx.name == "Charlie Brown"
27
26
  assert ctx.roles == ["admin", "editor"]
28
- assert ctx.tenant_id == "tenant-42"
27
+ # The user row carries no tenant; the resolver supplies it per request.
28
+ assert ctx.tenant_id is None
29
29
 
30
30
  async def test_from_user_name_fallback_to_email(self):
31
31
  """When full_name is None, ctx.name falls back to the user's email."""
@@ -93,3 +93,69 @@ class TestUserContextDefaults:
93
93
  async def test_roles_default_is_empty_list(self):
94
94
  ctx = UserContext(id="1", email="a@b.com", name="A")
95
95
  assert ctx.roles == []
96
+
97
+ async def test_permissions_default_is_empty_list(self):
98
+ ctx = UserContext(id="1", email="a@b.com", name="A")
99
+ assert ctx.permissions == []
100
+
101
+ async def test_positional_construction_unchanged(self):
102
+ ctx = UserContext("1", "a@b.com", "A", ["admin"], "t1")
103
+ assert ctx.tenant_id == "t1"
104
+ assert ctx.permissions == []
105
+
106
+
107
+ class TestUserContextPermissions:
108
+ async def test_session_round_trip_keeps_permissions(self):
109
+ ctx = UserContext(id="1", email="a@b.com", name="A", permissions=["x.read", "x.write"])
110
+ payload = ctx.to_session_dict()
111
+ assert payload["permissions"] == ["x.read", "x.write"]
112
+ assert UserContext.from_session_dict(payload) == ctx
113
+
114
+ async def test_legacy_cookie_without_permissions_key(self):
115
+ """A cookie written before the field existed still decodes."""
116
+ payload = {"id": "1", "email": "a@b.com", "name": "A", "roles": [], "tenant_id": None}
117
+ ctx = UserContext.from_session_dict(payload)
118
+ assert ctx is not None
119
+ assert ctx.permissions == []
120
+
121
+ async def test_null_permissions_decodes_empty(self):
122
+ payload = {"id": "1", "email": "a@b.com", "name": "A", "permissions": None}
123
+ ctx = UserContext.from_session_dict(payload)
124
+ assert ctx is not None
125
+ assert ctx.permissions == []
126
+
127
+ async def test_from_user_leaves_permissions_empty(self):
128
+ from types import SimpleNamespace
129
+
130
+ user = SimpleNamespace(id=1, email="a@b.com", full_name="A", roles=[])
131
+ assert UserContext.from_user(user).permissions == []
132
+
133
+ async def test_has_permission_exact_match(self):
134
+ ctx = UserContext(id="1", email="a@b.com", name="A", permissions=["x.read"])
135
+ assert ctx.has_permission("x.read") is True
136
+ assert ctx.has_permission("x.write") is False
137
+ assert ctx.has_permission("x") is False
138
+
139
+
140
+ class TestUserContextTokenFields:
141
+ async def test_defaults_are_an_uncapped_session(self):
142
+ ctx = UserContext(id="1", email="a@b.com", name="A")
143
+ assert (ctx.auth_kind, ctx.scopes, ctx.is_scoped) == ("session", None, False)
144
+
145
+ async def test_legacy_cookie_decodes_as_uncapped_session(self):
146
+ """A cookie from before token principals must not become scoped."""
147
+ payload = {"id": "1", "email": "a@b.com", "name": "A", "roles": ["admin"]}
148
+ ctx = UserContext.from_session_dict(payload)
149
+ assert ctx is not None
150
+ assert (ctx.auth_kind, ctx.scopes) == ("session", None)
151
+
152
+ async def test_token_fields_round_trip_and_empty_scopes_stay_capped(self):
153
+ for scopes in (["x.read"], []):
154
+ ctx = UserContext(id="1", email="", name="A", auth_kind="pat", scopes=scopes)
155
+ back = UserContext.from_session_dict(ctx.to_session_dict())
156
+ assert back == ctx
157
+ assert back is not None and back.is_scoped
158
+
159
+ async def test_malformed_scopes_reject_the_payload(self):
160
+ payload = {"id": "1", "email": "a@b.com", "name": "A", "scopes": "x.read"}
161
+ assert UserContext.from_session_dict(payload) is None
@@ -1,75 +0,0 @@
1
- """Auth data types shared with other modules."""
2
-
3
- from __future__ import annotations
4
-
5
- from dataclasses import dataclass, field
6
- from typing import TYPE_CHECKING, Any
7
-
8
- if TYPE_CHECKING:
9
- # Runtime import would be circular: auth -> users -> auth.
10
- # Only imported for type-hints, never at runtime.
11
- from users.models import User
12
-
13
-
14
- @dataclass
15
- class UserContext:
16
- """Authenticated user information for downstream handlers."""
17
-
18
- id: str
19
- email: str
20
- name: str
21
- roles: list[str] = field(default_factory=list)
22
- tenant_id: str | None = None
23
-
24
- @classmethod
25
- def from_user(cls, user: User | Any) -> UserContext:
26
- """Build a UserContext from a users.models.User with eagerly-loaded roles.
27
-
28
- Duck-typed to avoid importing users.models at runtime — any object
29
- exposing .id, .email, .full_name, .roles[*].name, .tenant_id works.
30
- The caller is responsible for eager-loading roles (selectinload).
31
- """
32
- return cls(
33
- id=str(user.id),
34
- email=user.email,
35
- name=user.full_name or user.email,
36
- roles=[r.name for r in user.roles],
37
- tenant_id=user.tenant_id,
38
- )
39
-
40
- def to_session_dict(self) -> dict[str, Any]:
41
- """Serialize to a JSON-safe dict for the signed session cookie.
42
-
43
- The inverse of :meth:`from_session_dict`. Adding a new field to
44
- ``UserContext`` requires updating both methods here — not the
45
- AuthMiddleware cache helper, which stays schema-agnostic."""
46
- return {
47
- "id": self.id,
48
- "email": self.email,
49
- "name": self.name,
50
- "roles": list(self.roles),
51
- "tenant_id": self.tenant_id,
52
- }
53
-
54
- @classmethod
55
- def from_session_dict(cls, payload: Any) -> UserContext | None:
56
- """Rebuild from :meth:`to_session_dict` output. Returns ``None`` on any
57
- shape mismatch so callers can fall through to a fresh DB load."""
58
- if not isinstance(payload, dict):
59
- return None
60
- try:
61
- return cls(
62
- id=str(payload["id"]),
63
- email=str(payload["email"]),
64
- name=str(payload["name"]),
65
- roles=list(payload.get("roles") or []),
66
- tenant_id=payload.get("tenant_id"),
67
- )
68
- except (KeyError, TypeError, ValueError):
69
- return None
70
-
71
- def has_role(self, role: str) -> bool:
72
- return role in self.roles
73
-
74
- def has_any_role(self, roles: list[str]) -> bool:
75
- return bool(set(self.roles) & set(roles))