simple-module-auth 0.0.36__tar.gz → 0.0.38__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.36 → simple_module_auth-0.0.38}/.gitignore +2 -2
  2. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/PKG-INFO +4 -4
  3. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/README.md +1 -1
  4. simple_module_auth-0.0.38/auth/_route_match.py +71 -0
  5. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/auth/contracts/provider.py +0 -2
  6. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/auth/contracts/schemas.py +68 -7
  7. simple_module_auth-0.0.38/auth/contracts/tokens.py +65 -0
  8. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/auth/deps.py +8 -3
  9. simple_module_auth-0.0.38/auth/locales/de.json +6 -0
  10. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/auth/middleware.py +17 -4
  11. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/package.json +1 -1
  12. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/pyproject.toml +3 -3
  13. simple_module_auth-0.0.38/tests/test_auth_parse_bearer.py +21 -0
  14. simple_module_auth-0.0.38/tests/test_auth_unknown_paths.py +181 -0
  15. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/tests/test_deps.py +15 -0
  16. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/tests/test_user_context.py +24 -0
  17. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/LICENSE +0 -0
  18. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/auth/__init__.py +0 -0
  19. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/auth/contracts/__init__.py +0 -0
  20. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/auth/contracts/resolver.py +0 -0
  21. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/auth/locales/en.json +0 -0
  22. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/auth/locales/es.json +0 -0
  23. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/auth/module.py +0 -0
  24. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/auth/py.typed +0 -0
  25. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/auth/state.py +0 -0
  26. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/tests/test_auth_middleware.py +0 -0
  27. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/tests/test_auth_next_target.py +0 -0
  28. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/tests/test_auth_provider_protocol.py +0 -0
  29. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/tests/test_module.py +0 -0
  30. {simple_module_auth-0.0.36 → simple_module_auth-0.0.38}/tests/test_resolver_registry.py +0 -0
@@ -7,8 +7,6 @@ build/
7
7
  .venv/
8
8
  *.egg
9
9
 
10
- # UV
11
- uv.lock
12
10
 
13
11
  # Node
14
12
  node_modules/
@@ -64,3 +62,5 @@ host/client_app/.playwright-cli/*
64
62
  .superpowers/
65
63
  .qa/
66
64
  .verify/
65
+ /openapi.json
66
+ qa-shots/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_auth
3
- Version: 0.0.36
3
+ Version: 0.0.38
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.36
26
- Requires-Dist: simple-module-db==0.0.36
25
+ Requires-Dist: simple-module-core==0.0.38
26
+ Requires-Dist: simple-module-db==0.0.38
27
27
  Description-Content-Type: text/markdown
28
28
 
29
29
  # simple_module_auth
@@ -41,7 +41,7 @@ pip install simple_module_auth
41
41
  ## What it provides
42
42
 
43
43
  - `UserContext` — the request-scoped principal (`id`, `name`, `email`, `roles`).
44
- - `AuthProvider` protocol — the swappable-backend contract (`resolve_user`, `get_login_url`, `get_logout_url`, `get_public_paths`, `is_bearer_request`); exactly one provider module registers an implementation on `app.state.auth.auth_provider`.
44
+ - `AuthProvider` protocol — the swappable-backend contract (`resolve_user`, `get_login_url`, `get_logout_url`, `is_bearer_request`); exactly one provider module registers an implementation on `app.state.auth.auth_provider`.
45
45
  - `PrincipalResolver` chain — async `(Request) -> UserContext | None` callables apps append to `app.state.auth.principal_resolvers` (e.g. PAT/bearer-token or API-key auth), consulted after the session path.
46
46
  - `AuthMiddleware` — delegates to the provider + resolver chain on every request and populates the request principal.
47
47
  - `get_current_user` / `CurrentUser` dependency and the `require_permission(*permissions)` dependency factory.
@@ -13,7 +13,7 @@ pip install simple_module_auth
13
13
  ## What it provides
14
14
 
15
15
  - `UserContext` — the request-scoped principal (`id`, `name`, `email`, `roles`).
16
- - `AuthProvider` protocol — the swappable-backend contract (`resolve_user`, `get_login_url`, `get_logout_url`, `get_public_paths`, `is_bearer_request`); exactly one provider module registers an implementation on `app.state.auth.auth_provider`.
16
+ - `AuthProvider` protocol — the swappable-backend contract (`resolve_user`, `get_login_url`, `get_logout_url`, `is_bearer_request`); exactly one provider module registers an implementation on `app.state.auth.auth_provider`.
17
17
  - `PrincipalResolver` chain — async `(Request) -> UserContext | None` callables apps append to `app.state.auth.principal_resolvers` (e.g. PAT/bearer-token or API-key auth), consulted after the session path.
18
18
  - `AuthMiddleware` — delegates to the provider + resolver chain on every request and populates the request principal.
19
19
  - `get_current_user` / `CurrentUser` dependency and the `require_permission(*permissions)` dependency factory.
@@ -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"]
@@ -26,8 +26,6 @@ class AuthProvider(Protocol):
26
26
 
27
27
  def get_logout_url(self, request: Request) -> str: ...
28
28
 
29
- def get_public_paths(self) -> tuple[tuple[str, ...], tuple[str, ...]]: ...
30
-
31
29
  def is_bearer_request(self, request: Request) -> bool: ...
32
30
 
33
31
 
@@ -2,13 +2,28 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Iterable
5
6
  from dataclasses import dataclass, field
6
- from typing import TYPE_CHECKING, Any
7
+ from typing import Any, Protocol
7
8
 
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
9
+ from auth.contracts.tokens import AUTH_KIND_SESSION
10
+
11
+
12
+ class NamedRole(Protocol):
13
+ name: str
14
+
15
+
16
+ class AccountLike(Protocol):
17
+ """What :meth:`UserContext.from_user` reads from an account row.
18
+
19
+ Structural, so ``auth`` names no provider's model: ``users`` depends on
20
+ ``auth``, never the other way round (GH #501).
21
+ """
22
+
23
+ id: Any
24
+ email: str
25
+ full_name: str | None
26
+ roles: Iterable[NamedRole]
12
27
 
13
28
 
14
29
  @dataclass
@@ -24,10 +39,39 @@ class UserContext:
24
39
  # provider maps in. Frozen in the session cookie like ``roles``; anything
25
40
  # revocable belongs in a grant source. The resolver drops ``*`` from here.
26
41
  permissions: list[str] = field(default_factory=list)
42
+ # Which credential authenticated the request: ``"session"`` for a browser
43
+ # sign-in, ``"bearer"`` for a provider's own bearer token (a ``users``
44
+ # access token, an IdP JWT), ``"pat"`` / ``"service"`` for an
45
+ # ``api_tokens`` credential (see ``auth.contracts.tokens``). Code that must
46
+ # only ever act for a person at a keyboard — minting more credentials —
47
+ # refuses anything but ``"session"``.
48
+ auth_kind: str = AUTH_KIND_SESSION
49
+ # The ceiling a token carries. ``None`` means "no cap" (an ordinary
50
+ # session); a list — even an empty one — is intersected with whatever the
51
+ # principal resolves to, and the admin-role shortcuts are skipped, so an
52
+ # admin's token is worth exactly the scopes it was given.
53
+ scopes: list[str] | None = None
54
+ # A permission the account must *currently* hold for this credential to
55
+ # authenticate at all — ``api_tokens.use`` for a personal access token.
56
+ # Checked on every request against the account's live, tenant-aware
57
+ # permissions; when missing, the request runs as anonymous (401 on gated
58
+ # routes). ``None`` (a session, a service key) requires nothing.
59
+ required_permission: str | None = None
60
+
61
+ @property
62
+ def is_scoped(self) -> bool:
63
+ """Whether a scope cap applies (any token-authenticated principal)."""
64
+ return self.scopes is not None
27
65
 
28
66
  @classmethod
29
- def from_user(cls, user: User | Any) -> UserContext:
30
- """Build a UserContext from a users.models.User with eagerly-loaded roles.
67
+ def from_user(
68
+ cls,
69
+ user: AccountLike,
70
+ *,
71
+ auth_kind: str = AUTH_KIND_SESSION,
72
+ scopes: list[str] | None = None,
73
+ ) -> UserContext:
74
+ """Build a UserContext from an :class:`AccountLike` with eagerly-loaded roles.
31
75
 
32
76
  Duck-typed to avoid importing users.models at runtime — any object
33
77
  exposing .id, .email, .full_name, .roles[*].name works. The user row
@@ -41,6 +85,8 @@ class UserContext:
41
85
  email=user.email,
42
86
  name=user.full_name or user.email,
43
87
  roles=[r.name for r in user.roles],
88
+ auth_kind=auth_kind,
89
+ scopes=list(scopes) if scopes is not None else None,
44
90
  )
45
91
 
46
92
  def to_session_dict(self) -> dict[str, Any]:
@@ -56,6 +102,9 @@ class UserContext:
56
102
  "roles": list(self.roles),
57
103
  "tenant_id": self.tenant_id,
58
104
  "permissions": list(self.permissions),
105
+ "auth_kind": self.auth_kind,
106
+ "scopes": list(self.scopes) if self.scopes is not None else None,
107
+ "required_permission": self.required_permission,
59
108
  }
60
109
 
61
110
  @classmethod
@@ -73,6 +122,9 @@ class UserContext:
73
122
  tenant_id=payload.get("tenant_id"),
74
123
  # Absent on cookies written before the field existed.
75
124
  permissions=list(payload.get("permissions") or []),
125
+ auth_kind=str(payload.get("auth_kind") or AUTH_KIND_SESSION),
126
+ scopes=_scopes_from(payload.get("scopes")),
127
+ required_permission=payload.get("required_permission") or None,
76
128
  )
77
129
  except (KeyError, TypeError, ValueError):
78
130
  return None
@@ -89,3 +141,12 @@ class UserContext:
89
141
 
90
142
  def has_any_role(self, roles: list[str]) -> bool:
91
143
  return bool(set(self.roles) & set(roles))
144
+
145
+
146
+ def _scopes_from(raw: Any) -> list[str] | None:
147
+ """``None`` stays ``None`` (uncapped); anything else must be a list."""
148
+ if raw is None:
149
+ return None
150
+ if not isinstance(raw, list):
151
+ raise TypeError("scopes must be a list")
152
+ 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
+ ]
@@ -8,6 +8,7 @@ from fastapi import Depends, HTTPException, Request
8
8
  from simple_module_core.permissions import grants
9
9
  from simple_module_hosting.i18n_deps import TranslatorDep
10
10
  from simple_module_hosting.permissions import ensure_resolved_permissions
11
+ from simple_module_hosting.token_guard import GATE_PERMISSION, mark_token_gate
11
12
 
12
13
  from auth.contracts.schemas import UserContext
13
14
 
@@ -42,8 +43,10 @@ def require_permission(*permissions: str):
42
43
  t: TranslatorDep,
43
44
  user: UserContext = Depends(get_current_user),
44
45
  ):
45
- # Admin role bypasses permission checks
46
- 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:
47
50
  return
48
51
 
49
52
  # The same resolution RequiresPermission reads: the registry's role map
@@ -59,4 +62,6 @@ def require_permission(*permissions: str):
59
62
  ),
60
63
  )
61
64
 
62
- 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))
@@ -0,0 +1,6 @@
1
+ {
2
+ "errors": {
3
+ "not_authenticated": "Nicht authentifiziert",
4
+ "missing_permission": "Erforderliche Berechtigung fehlt: {permissions}"
5
+ }
6
+ }
@@ -10,12 +10,15 @@ from __future__ import annotations
10
10
 
11
11
  import logging
12
12
 
13
+ from simple_module_core.public_routes import legacy_provider_public_paths
13
14
  from simple_module_core.redirect_safety import SESSION_NEXT_KEY, safe_next_or_none
14
15
  from simple_module_db.listeners import current_user_id
15
16
  from starlette.requests import Request
16
17
  from starlette.responses import JSONResponse, RedirectResponse
17
18
  from starlette.types import ASGIApp, Receive, Scope, Send
18
19
 
20
+ from auth._route_match import route_exists
21
+
19
22
  logger = logging.getLogger(__name__)
20
23
 
21
24
  _FRAMEWORK_PUBLIC_PREFIXES = (
@@ -23,7 +26,6 @@ _FRAMEWORK_PUBLIC_PREFIXES = (
23
26
  "/static/",
24
27
  "/api/docs",
25
28
  "/api/redoc",
26
- "/openapi.json",
27
29
  "/i18n/",
28
30
  )
29
31
  _FRAMEWORK_PUBLIC_EXACT = ("/",)
@@ -84,6 +86,8 @@ class AuthMiddleware:
84
86
  is_public = (
85
87
  any(path.startswith(p) for p in _FRAMEWORK_PUBLIC_PREFIXES)
86
88
  or path in _FRAMEWORK_PUBLIC_EXACT
89
+ # Anonymous exactly when the host serves it (dev, or openapi_public).
90
+ or path == getattr(app_state, "openapi_url", None)
87
91
  )
88
92
  # Module-contributed public routes (register_public_routes hook). Method
89
93
  # -aware, so a GET read route can be exempted without opening sibling
@@ -91,10 +95,10 @@ class AuthMiddleware:
91
95
  if not is_public:
92
96
  public_routes = getattr(app_state, "public_routes", None)
93
97
  is_public = public_routes is not None and public_routes.matches(method, path)
94
- # Legacy provider-declared paths (prefix-only, method-agnostic). Kept for
95
- # back-compat with AuthProvider implementations.
98
+ # Deprecated provider-declared paths (prefix-only, method-agnostic); honoured
99
+ # until 0.1.0 for third-party providers. users/keycloak use the registry.
96
100
  if not is_public:
97
- prefix_paths, exact_paths = provider.get_public_paths()
101
+ prefix_paths, exact_paths = legacy_provider_public_paths(provider)
98
102
  is_public = any(path.startswith(p) for p in prefix_paths) or path in exact_paths
99
103
 
100
104
  # Contract with the host's RateLimitMiddleware (plain scope key, no import
@@ -118,6 +122,15 @@ class AuthMiddleware:
118
122
  if user_ctx is not None:
119
123
  break
120
124
 
125
+ if user_ctx is None and not is_public and not route_exists(scope):
126
+ # Nothing would serve this path (#473): let the app's 404 answer it
127
+ # — the same page a signed-in caller gets — rather than sending the
128
+ # visitor to sign in for a page that does not exist, or keeping it
129
+ # as their post-login target. The request now reaches the app
130
+ # anonymously, so the rate limiter treats it like any public hit.
131
+ scope["state"]["auth_public"] = True
132
+ is_public = True
133
+
121
134
  if user_ctx is None and not is_public:
122
135
  if path.startswith("/api/") or provider.is_bearer_request(request):
123
136
  response = JSONResponse({"detail": "Not authenticated"}, status_code=401)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simple-module-py/auth",
3
- "version": "0.1.0",
3
+ "version": "0.0.38",
4
4
  "private": true,
5
5
  "description": "Frontend assets for the Auth module",
6
6
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_auth"
3
- version = "0.0.36"
3
+ version = "0.0.38"
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.36",
26
- "simple_module_db==0.0.36",
25
+ "simple_module_core==0.0.38",
26
+ "simple_module_db==0.0.38",
27
27
  ]
28
28
 
29
29
  [project.entry-points.simple_module]
@@ -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"]
@@ -109,3 +109,18 @@ class TestRequirePermissionAdvanced:
109
109
  dep = require_permission("products.delete")
110
110
  user = UserContext(id="u1", email="u@t.com", name="User", roles=["clerk"])
111
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
@@ -135,3 +135,27 @@ class TestUserContextPermissions:
135
135
  assert ctx.has_permission("x.read") is True
136
136
  assert ctx.has_permission("x.write") is False
137
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