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.
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/.gitignore +1 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/PKG-INFO +3 -3
- simple_module_auth-0.0.37/auth/_route_match.py +71 -0
- simple_module_auth-0.0.37/auth/contracts/schemas.py +139 -0
- simple_module_auth-0.0.37/auth/contracts/tokens.py +65 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/auth/deps.py +15 -8
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/auth/middleware.py +46 -2
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/pyproject.toml +3 -3
- simple_module_auth-0.0.37/tests/test_auth_next_target.py +98 -0
- simple_module_auth-0.0.37/tests/test_auth_parse_bearer.py +21 -0
- simple_module_auth-0.0.37/tests/test_auth_unknown_paths.py +181 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/tests/test_deps.py +38 -12
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/tests/test_user_context.py +68 -2
- simple_module_auth-0.0.35/auth/contracts/schemas.py +0 -75
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/LICENSE +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/README.md +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/auth/__init__.py +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/auth/contracts/__init__.py +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/auth/contracts/provider.py +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/auth/contracts/resolver.py +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/auth/locales/en.json +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/auth/locales/es.json +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/auth/module.py +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/auth/py.typed +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/auth/state.py +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/package.json +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/tests/test_auth_middleware.py +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/tests/test_auth_provider_protocol.py +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/tests/test_module.py +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/tests/test_resolver_registry.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_auth
|
|
3
|
-
Version: 0.0.
|
|
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.
|
|
26
|
-
Requires-Dist: simple-module-db==0.0.
|
|
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
|
-
|
|
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
|
-
#
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if not any(p
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
26
|
-
"simple_module_db==0.0.
|
|
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
|
-
|
|
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))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_auth-0.0.35 → simple_module_auth-0.0.37}/tests/test_auth_provider_protocol.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|