pywire-auth 0.1.1__py3-none-any.whl
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.
- pywire_auth/__init__.py +58 -0
- pywire_auth/_protocols.py +67 -0
- pywire_auth/actions.py +163 -0
- pywire_auth/integration.py +132 -0
- pywire_auth/local/__init__.py +20 -0
- pywire_auth/local/idp.py +201 -0
- pywire_auth/local/routes.py +281 -0
- pywire_auth/local/token.py +107 -0
- pywire_auth/middleware.py +123 -0
- pywire_auth/providers/__init__.py +20 -0
- pywire_auth/providers/auth0.py +40 -0
- pywire_auth/providers/base.py +225 -0
- pywire_auth/providers/facebook.py +37 -0
- pywire_auth/providers/generic.py +98 -0
- pywire_auth/providers/github.py +33 -0
- pywire_auth/providers/google.py +44 -0
- pywire_auth/providers/microsoft.py +46 -0
- pywire_auth/routes.py +342 -0
- pywire_auth/stores/__init__.py +16 -0
- pywire_auth/stores/memory.py +58 -0
- pywire_auth/stores/sqlalchemy.py +322 -0
- pywire_auth-0.1.1.dist-info/METADATA +27 -0
- pywire_auth-0.1.1.dist-info/RECORD +24 -0
- pywire_auth-0.1.1.dist-info/WHEEL +4 -0
pywire_auth/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""pywire-auth — OAuth2 / OIDC providers and identity store adapters.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
|
|
5
|
+
- :func:`connect_auth` — single integration entry point
|
|
6
|
+
- :class:`AuthActions` — one-call claim/session mutations (`app.state.auth`)
|
|
7
|
+
- :class:`AuthMiddleware` — ASGI middleware that populates scope['user']
|
|
8
|
+
- :class:`LocalIdP` + :class:`TokenIssuer` — database-backed local provider
|
|
9
|
+
- OIDC providers: :class:`GoogleProvider`, :class:`GitHubProvider`,
|
|
10
|
+
:class:`MicrosoftProvider`, :class:`FacebookProvider`,
|
|
11
|
+
:class:`Auth0Provider`, :class:`GenericOIDCProvider`
|
|
12
|
+
- Store adapters: :class:`MemoryAuthStore`, :class:`SQLAlchemyAuthStore`
|
|
13
|
+
(requires ``pip install pywire-auth[sqlalchemy]``)
|
|
14
|
+
- Structural interfaces: :class:`AuthStore`, :class:`OIDCProvider`,
|
|
15
|
+
:class:`BaseOAuth2Provider`, :class:`BaseOIDCProvider`
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from pywire_auth._protocols import AuthStore, OIDCProvider
|
|
19
|
+
from pywire_auth.actions import AuthActions
|
|
20
|
+
from pywire_auth.integration import connect_auth
|
|
21
|
+
from pywire_auth.middleware import AuthMiddleware
|
|
22
|
+
from pywire_auth.local import LocalIdP, TokenIssuer
|
|
23
|
+
from pywire_auth.providers import (
|
|
24
|
+
Auth0Provider,
|
|
25
|
+
BaseOAuth2Provider,
|
|
26
|
+
BaseOIDCProvider,
|
|
27
|
+
FacebookProvider,
|
|
28
|
+
GenericOIDCProvider,
|
|
29
|
+
GitHubProvider,
|
|
30
|
+
GoogleProvider,
|
|
31
|
+
MicrosoftProvider,
|
|
32
|
+
)
|
|
33
|
+
from pywire_auth.stores import MemoryAuthStore
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
from pywire_auth.stores.sqlalchemy import SQLAlchemyAuthStore
|
|
37
|
+
except ImportError:
|
|
38
|
+
SQLAlchemyAuthStore = None # type: ignore[assignment,misc]
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"Auth0Provider",
|
|
42
|
+
"AuthActions",
|
|
43
|
+
"AuthMiddleware",
|
|
44
|
+
"AuthStore",
|
|
45
|
+
"BaseOAuth2Provider",
|
|
46
|
+
"BaseOIDCProvider",
|
|
47
|
+
"FacebookProvider",
|
|
48
|
+
"GenericOIDCProvider",
|
|
49
|
+
"GitHubProvider",
|
|
50
|
+
"GoogleProvider",
|
|
51
|
+
"LocalIdP",
|
|
52
|
+
"MemoryAuthStore",
|
|
53
|
+
"MicrosoftProvider",
|
|
54
|
+
"SQLAlchemyAuthStore",
|
|
55
|
+
"OIDCProvider",
|
|
56
|
+
"TokenIssuer",
|
|
57
|
+
"connect_auth",
|
|
58
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Structural interfaces for auth providers and stores."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import (
|
|
6
|
+
Any,
|
|
7
|
+
Dict,
|
|
8
|
+
List,
|
|
9
|
+
Optional,
|
|
10
|
+
Protocol,
|
|
11
|
+
Tuple,
|
|
12
|
+
runtime_checkable,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from pywire.auth import Claim, ClaimsPrincipal
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@runtime_checkable
|
|
19
|
+
class AuthStore(Protocol):
|
|
20
|
+
"""Persistent user + identity store.
|
|
21
|
+
|
|
22
|
+
Used by the local IdP and by account-linking flows. Apps using only
|
|
23
|
+
external OIDC providers don't need a store.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
async def get_user(self, user_id: str) -> Optional[Dict[str, Any]]: ...
|
|
27
|
+
|
|
28
|
+
async def find_by_provider(
|
|
29
|
+
self, provider: str, subject: str
|
|
30
|
+
) -> Optional[Dict[str, Any]]: ...
|
|
31
|
+
|
|
32
|
+
async def create_user(self, **fields: Any) -> str: ...
|
|
33
|
+
|
|
34
|
+
async def update_user(self, user_id: str, **fields: Any) -> None: ...
|
|
35
|
+
|
|
36
|
+
async def link_provider(
|
|
37
|
+
self,
|
|
38
|
+
user_id: str,
|
|
39
|
+
provider: str,
|
|
40
|
+
subject: str,
|
|
41
|
+
claims: Optional[Dict[str, Any]] = None,
|
|
42
|
+
) -> None: ...
|
|
43
|
+
|
|
44
|
+
async def get_password_hash(self, user_id: str) -> Optional[str]: ...
|
|
45
|
+
|
|
46
|
+
async def set_password_hash(self, user_id: str, hash: str) -> None: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class OIDCProvider(Protocol):
|
|
50
|
+
"""Structural shape for OAuth2/OIDC login providers."""
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
scopes: List[str]
|
|
54
|
+
|
|
55
|
+
async def authorize_url(
|
|
56
|
+
self, *, redirect_uri: str, state: str, nonce: str
|
|
57
|
+
) -> str: ...
|
|
58
|
+
|
|
59
|
+
async def exchange_code(
|
|
60
|
+
self, *, code: str, redirect_uri: str, state: str, nonce: str
|
|
61
|
+
) -> Tuple[ClaimsPrincipal, Dict[str, Any]]: ...
|
|
62
|
+
|
|
63
|
+
async def refresh(
|
|
64
|
+
self, refresh_token: str
|
|
65
|
+
) -> Optional[Tuple[ClaimsPrincipal, Dict[str, Any]]]: ...
|
|
66
|
+
|
|
67
|
+
def map_claims(self, raw: Dict[str, Any]) -> List[Claim]: ...
|
pywire_auth/actions.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""AuthActions — one-call claim/session mutations for live auth.
|
|
2
|
+
|
|
3
|
+
Writing a new claim to a logged-in user touches three storage layers:
|
|
4
|
+
|
|
5
|
+
1. The ``AuthStore`` — permanent user row; survives logout/login.
|
|
6
|
+
2. The session store — per-login principal snapshot; survives reload.
|
|
7
|
+
3. The ``AuthChannel`` — in-memory fan-out; updates every live tab now.
|
|
8
|
+
|
|
9
|
+
App code shouldn't have to know about any of that. ``AuthActions`` bundles
|
|
10
|
+
all three behind a small surface. ``connect_auth`` constructs one per app
|
|
11
|
+
and stashes it on ``app.state.auth``; pages use it like::
|
|
12
|
+
|
|
13
|
+
await app.state.auth.grant(self.user, self.request, "role", "admin")
|
|
14
|
+
await app.state.auth.revoke_claim(self.user, self.request, "role")
|
|
15
|
+
await app.state.auth.revoke_session(self.user, self.request)
|
|
16
|
+
|
|
17
|
+
Every method runs all three writes in the right order so a hard reload,
|
|
18
|
+
a fresh login, and every concurrent tab stay consistent.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import replace
|
|
24
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
25
|
+
|
|
26
|
+
from pywire.auth import (
|
|
27
|
+
Claim,
|
|
28
|
+
ClaimsPrincipal,
|
|
29
|
+
clear_principal_from_session,
|
|
30
|
+
write_principal_to_session,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _bare_user_id(principal: ClaimsPrincipal) -> str:
|
|
35
|
+
"""Strip the ``<provider>:`` prefix — auth stores key on the bare id."""
|
|
36
|
+
if not principal.user_id:
|
|
37
|
+
return ""
|
|
38
|
+
return principal.user_id.split(":", 1)[-1]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _claims_to_dict(claims: Iterable[Claim]) -> Dict[str, str]:
|
|
42
|
+
"""Collapse the claim list to a dict for ``AuthStore.update_user``.
|
|
43
|
+
|
|
44
|
+
Strips ``sub`` / ``email`` because LocalIdP re-emits them from the
|
|
45
|
+
user row's top-level columns on every :meth:`principal_for_user`.
|
|
46
|
+
Including them in ``record['claims']`` would just cause duplicates.
|
|
47
|
+
"""
|
|
48
|
+
return {c.type: c.value for c in claims if c.type not in ("sub", "email")}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AuthActions:
|
|
52
|
+
"""Bundles AuthStore + session + channel writes for claim/session ops."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, app: Any) -> None:
|
|
55
|
+
self._app = app
|
|
56
|
+
|
|
57
|
+
# --- claim mutations ---
|
|
58
|
+
|
|
59
|
+
async def update_claims(
|
|
60
|
+
self,
|
|
61
|
+
principal: ClaimsPrincipal,
|
|
62
|
+
request: Any,
|
|
63
|
+
claims: List[Claim],
|
|
64
|
+
) -> ClaimsPrincipal:
|
|
65
|
+
"""Replace the principal's claims. Writes through all three layers."""
|
|
66
|
+
new_principal = replace(
|
|
67
|
+
principal,
|
|
68
|
+
is_authenticated=True,
|
|
69
|
+
claims=list(claims),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
store = self._auth_store()
|
|
73
|
+
if store is not None:
|
|
74
|
+
raw_uid = _bare_user_id(principal)
|
|
75
|
+
if raw_uid:
|
|
76
|
+
await store.update_user(raw_uid, claims=_claims_to_dict(claims))
|
|
77
|
+
|
|
78
|
+
await self._write_session(request, new_principal)
|
|
79
|
+
|
|
80
|
+
channel = getattr(self._app, "_auth_channel", None)
|
|
81
|
+
if channel is not None and principal.user_id:
|
|
82
|
+
await channel.update_principal(principal.user_id, principal=new_principal)
|
|
83
|
+
|
|
84
|
+
return new_principal
|
|
85
|
+
|
|
86
|
+
async def grant(
|
|
87
|
+
self,
|
|
88
|
+
principal: ClaimsPrincipal,
|
|
89
|
+
request: Any,
|
|
90
|
+
claim_type: str,
|
|
91
|
+
claim_value: str,
|
|
92
|
+
) -> ClaimsPrincipal:
|
|
93
|
+
"""Add or overwrite a claim, keeping the rest untouched."""
|
|
94
|
+
remaining = [c for c in principal.claims if c.type != claim_type]
|
|
95
|
+
return await self.update_claims(
|
|
96
|
+
principal,
|
|
97
|
+
request,
|
|
98
|
+
remaining + [Claim(type=claim_type, value=claim_value)],
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
async def revoke_claim(
|
|
102
|
+
self,
|
|
103
|
+
principal: ClaimsPrincipal,
|
|
104
|
+
request: Any,
|
|
105
|
+
claim_type: str,
|
|
106
|
+
) -> ClaimsPrincipal:
|
|
107
|
+
"""Drop every claim of the given type. No-op if none exist."""
|
|
108
|
+
filtered = [c for c in principal.claims if c.type != claim_type]
|
|
109
|
+
return await self.update_claims(principal, request, filtered)
|
|
110
|
+
|
|
111
|
+
# --- session lifecycle ---
|
|
112
|
+
|
|
113
|
+
async def revoke_session(
|
|
114
|
+
self,
|
|
115
|
+
principal: ClaimsPrincipal,
|
|
116
|
+
request: Any,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Clear this session's auth + fire a channel-wide revoke.
|
|
119
|
+
|
|
120
|
+
The WS live-auth loop translates the channel event into a
|
|
121
|
+
navigate-away for each connected tab; the current tab's session
|
|
122
|
+
is also cleared so a hard reload lands on the login page via
|
|
123
|
+
the ``!auth`` guard.
|
|
124
|
+
"""
|
|
125
|
+
session_store = getattr(self._app, "session_store", None)
|
|
126
|
+
sid = self._session_id(request)
|
|
127
|
+
if session_store is not None and sid:
|
|
128
|
+
data = await session_store.get(sid) or {}
|
|
129
|
+
clear_principal_from_session(data)
|
|
130
|
+
data.pop("_refresh_token", None)
|
|
131
|
+
await session_store.set(
|
|
132
|
+
sid, data, ttl=getattr(self._app, "session_ttl", 1800)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
channel = getattr(self._app, "_auth_channel", None)
|
|
136
|
+
if channel is not None and principal.user_id:
|
|
137
|
+
await channel.revoke(principal.user_id)
|
|
138
|
+
|
|
139
|
+
# --- helpers ---
|
|
140
|
+
|
|
141
|
+
def _auth_store(self) -> Any:
|
|
142
|
+
state = getattr(getattr(self._app, "app", None), "state", None)
|
|
143
|
+
return getattr(state, "auth_store", None) if state is not None else None
|
|
144
|
+
|
|
145
|
+
async def _write_session(
|
|
146
|
+
self, request: Any, principal: ClaimsPrincipal
|
|
147
|
+
) -> None:
|
|
148
|
+
session_store = getattr(self._app, "session_store", None)
|
|
149
|
+
sid = self._session_id(request)
|
|
150
|
+
if session_store is None or not sid:
|
|
151
|
+
return
|
|
152
|
+
data = await session_store.get(sid) or {}
|
|
153
|
+
write_principal_to_session(data, principal)
|
|
154
|
+
await session_store.set(
|
|
155
|
+
sid, data, ttl=getattr(self._app, "session_ttl", 1800)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def _session_id(request: Any) -> Optional[str]:
|
|
160
|
+
scope = getattr(request, "scope", None)
|
|
161
|
+
if scope is None:
|
|
162
|
+
return None
|
|
163
|
+
return scope.get("pywire_session_id")
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Single integration entry point — ``connect_auth(app, ...)``.
|
|
2
|
+
|
|
3
|
+
Mounts login/callback/logout routes (plus LocalIdP routes when
|
|
4
|
+
``local_idp=`` is passed), auto-installs :class:`SessionMiddleware`
|
|
5
|
+
when missing, installs ``AuthMiddleware``, and wires ``PolicyEngine``
|
|
6
|
+
+ ``AuthChannel`` onto the app. Apps that only use external OIDC
|
|
7
|
+
providers need nothing more.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Awaitable, Callable, Iterable, Optional
|
|
13
|
+
|
|
14
|
+
from pywire.auth import (
|
|
15
|
+
ClaimsPrincipal,
|
|
16
|
+
MemoryAuthChannel,
|
|
17
|
+
PolicyEngine,
|
|
18
|
+
)
|
|
19
|
+
from starlette.requests import Request
|
|
20
|
+
|
|
21
|
+
from pywire_auth.actions import AuthActions
|
|
22
|
+
from pywire_auth.middleware import AuthMiddleware
|
|
23
|
+
from pywire_auth.routes import _RouteContext, build_routes
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def connect_auth(
|
|
27
|
+
app: Any,
|
|
28
|
+
*,
|
|
29
|
+
providers: Iterable[Any] = (),
|
|
30
|
+
local_idp: Optional[Any] = None,
|
|
31
|
+
policy_engine: Optional[PolicyEngine] = None,
|
|
32
|
+
auth_channel: Optional[Any] = None,
|
|
33
|
+
prefix: str = "/auth",
|
|
34
|
+
default_next: str = "/",
|
|
35
|
+
session_ttl: Optional[int] = None,
|
|
36
|
+
secret_key: Optional[str] = None,
|
|
37
|
+
on_login: Optional[Callable[[ClaimsPrincipal, Request], Awaitable[None]]] = None,
|
|
38
|
+
on_logout: Optional[Callable[[ClaimsPrincipal, Request], Awaitable[None]]] = None,
|
|
39
|
+
) -> Any:
|
|
40
|
+
"""Attach auth routes + middleware to a PyWire app.
|
|
41
|
+
|
|
42
|
+
Returns the ``PolicyEngine`` so policies can be registered directly:
|
|
43
|
+
|
|
44
|
+
engine = connect_auth(app, providers=[GoogleProvider(...)])
|
|
45
|
+
engine.add_policy("AdminOnly", requires_claim=("role", "admin"))
|
|
46
|
+
|
|
47
|
+
Pass ``local_idp=LocalIdP(...)`` to mount default password/JWT
|
|
48
|
+
endpoints at ``{prefix}/local/{register,login,token,verify-token,revoke}``.
|
|
49
|
+
"""
|
|
50
|
+
engine = policy_engine or PolicyEngine()
|
|
51
|
+
channel = auth_channel or MemoryAuthChannel()
|
|
52
|
+
|
|
53
|
+
providers_by_name = {p.name: p for p in providers}
|
|
54
|
+
|
|
55
|
+
ctx = _RouteContext(
|
|
56
|
+
providers=providers_by_name,
|
|
57
|
+
session_store=app.session_store,
|
|
58
|
+
session_ttl=session_ttl or getattr(app, "session_ttl", 1800),
|
|
59
|
+
auth_channel=channel,
|
|
60
|
+
default_next=default_next,
|
|
61
|
+
on_login=on_login,
|
|
62
|
+
on_logout=on_logout,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
for route in build_routes(ctx, prefix, local_idp=local_idp):
|
|
66
|
+
app.app.router.routes.insert(0, route)
|
|
67
|
+
|
|
68
|
+
effective_secret = (
|
|
69
|
+
secret_key or getattr(app, "_session_secret", None) or _resolve_secret(app)
|
|
70
|
+
)
|
|
71
|
+
if not effective_secret:
|
|
72
|
+
raise RuntimeError(
|
|
73
|
+
"connect_auth requires secret_key (or PYWIRE_SESSION_SECRET "
|
|
74
|
+
"set on the app) for session cookie verification"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Auto-install SessionMiddleware when missing — interactive-mode PyWire
|
|
78
|
+
# apps skip it by default (WS owns state), but connect_auth's routes
|
|
79
|
+
# and AuthMiddleware both need scope["pywire_session_id"] to exist on
|
|
80
|
+
# HTTP requests.
|
|
81
|
+
_ensure_session_middleware(app, effective_secret)
|
|
82
|
+
|
|
83
|
+
app.add_middleware(
|
|
84
|
+
AuthMiddleware,
|
|
85
|
+
session_store=app.session_store,
|
|
86
|
+
secret_key=effective_secret,
|
|
87
|
+
policy_engine=engine,
|
|
88
|
+
auth_channel=channel,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
app._auth_engine = engine
|
|
92
|
+
app._auth_channel = channel
|
|
93
|
+
app._auth_providers = providers_by_name
|
|
94
|
+
app._auth_local_idp = local_idp
|
|
95
|
+
|
|
96
|
+
# Also expose on the Starlette app's state so pages can access shared
|
|
97
|
+
# auth state via `app.state.X` without importing main/state.
|
|
98
|
+
_app_state = app.app.state
|
|
99
|
+
_app_state.auth_engine = engine
|
|
100
|
+
_app_state.auth_channel = channel
|
|
101
|
+
_app_state.auth_providers = list(providers_by_name.keys())
|
|
102
|
+
_app_state.local_idp = local_idp
|
|
103
|
+
if local_idp is not None:
|
|
104
|
+
_app_state.auth_store = local_idp.store
|
|
105
|
+
# Single entry point for claim/session mutations — bundles
|
|
106
|
+
# auth_store + session + channel writes so app code never reaches
|
|
107
|
+
# into all three directly. See pywire_auth.actions.AuthActions.
|
|
108
|
+
_app_state.auth = AuthActions(app)
|
|
109
|
+
|
|
110
|
+
return engine
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _ensure_session_middleware(app: Any, secret: str) -> None:
|
|
114
|
+
"""Install ``SessionMiddleware`` if it isn't already on the stack."""
|
|
115
|
+
from pywire.runtime.session_middleware import SessionMiddleware
|
|
116
|
+
|
|
117
|
+
installed = getattr(app.app, "user_middleware", []) or []
|
|
118
|
+
if any(getattr(mw, "cls", None) is SessionMiddleware for mw in installed):
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
app.add_middleware(
|
|
122
|
+
SessionMiddleware,
|
|
123
|
+
session_store=app.session_store,
|
|
124
|
+
session_ttl=getattr(app, "session_ttl", 1800),
|
|
125
|
+
secret_key=secret,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _resolve_secret(app: Any) -> Optional[str]:
|
|
130
|
+
from pywire.config import env
|
|
131
|
+
|
|
132
|
+
return env("PYWIRE_SESSION_SECRET")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Local identity provider — DB-backed, OIDC-native.
|
|
2
|
+
|
|
3
|
+
Issues its own signed id_tokens so downstream code treats a
|
|
4
|
+
password-authenticated user identically to an external OIDC login.
|
|
5
|
+
Users handle registration and login UI themselves; this module only
|
|
6
|
+
exposes backend APIs.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
|
|
10
|
+
from pywire_auth import LocalIdP, MemoryAuthStore
|
|
11
|
+
idp = LocalIdP(store=MemoryAuthStore(), secret="<long random>")
|
|
12
|
+
user_id = await idp.create_user(email="a@b.c", password="hunter2")
|
|
13
|
+
principal = await idp.verify_credentials(email="a@b.c", password="hunter2")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from pywire_auth.local.idp import LocalIdP
|
|
17
|
+
from pywire_auth.local.routes import build_local_routes
|
|
18
|
+
from pywire_auth.local.token import TokenIssuer
|
|
19
|
+
|
|
20
|
+
__all__ = ["LocalIdP", "TokenIssuer", "build_local_routes"]
|
pywire_auth/local/idp.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""LocalIdP — DB-backed identity provider with Argon2 password hashing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from argon2 import PasswordHasher
|
|
10
|
+
from argon2.exceptions import VerifyMismatchError
|
|
11
|
+
|
|
12
|
+
from pywire.auth import Claim, ClaimsPrincipal
|
|
13
|
+
from pywire.config import env as _env
|
|
14
|
+
|
|
15
|
+
from pywire_auth._protocols import AuthStore
|
|
16
|
+
from pywire_auth.local.token import TokenIssuer
|
|
17
|
+
from pywire_auth.stores.memory import MemoryAuthStore
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _default_store() -> AuthStore:
|
|
23
|
+
return MemoryAuthStore()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class LocalIdP:
|
|
28
|
+
"""DB-backed identity provider with OIDC-native token issuance.
|
|
29
|
+
|
|
30
|
+
- Registration: :meth:`create_user`
|
|
31
|
+
- Login (password): :meth:`verify_credentials` → ``ClaimsPrincipal``
|
|
32
|
+
- Machine tokens: :meth:`issue_id_token` → signed JWT
|
|
33
|
+
|
|
34
|
+
Defaults tuned for the zero-boilerplate path:
|
|
35
|
+
|
|
36
|
+
- ``store`` defaults to an in-memory :class:`MemoryAuthStore` — good
|
|
37
|
+
for dev, replace with a SQLAlchemy-backed store for production.
|
|
38
|
+
- ``secret`` falls back to the ``LOCAL_IDP_SECRET`` env var when
|
|
39
|
+
empty. If neither is provided, construction raises so the error
|
|
40
|
+
surfaces at boot rather than at first token-issue.
|
|
41
|
+
|
|
42
|
+
The ``audience`` defaults to the issuer for single-app deployments;
|
|
43
|
+
override per :meth:`issue_id_token` for multi-audience setups.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
store: AuthStore = field(default_factory=_default_store)
|
|
47
|
+
# Supply one of: secret (HS256), OR configure a TokenIssuer explicitly.
|
|
48
|
+
# When empty, reads LOCAL_IDP_SECRET from the environment.
|
|
49
|
+
secret: str = ""
|
|
50
|
+
issuer: str = "pywire-auth-local"
|
|
51
|
+
audience: str = ""
|
|
52
|
+
token_issuer: Optional[TokenIssuer] = None
|
|
53
|
+
_hasher: PasswordHasher = field(default_factory=PasswordHasher, repr=False)
|
|
54
|
+
|
|
55
|
+
def __post_init__(self) -> None:
|
|
56
|
+
if self.token_issuer is None:
|
|
57
|
+
secret = self.secret or _env("LOCAL_IDP_SECRET") or ""
|
|
58
|
+
if not secret:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
"LocalIdP requires a signing secret. Pass secret=... "
|
|
61
|
+
"(HS256), set the LOCAL_IDP_SECRET env var, or supply "
|
|
62
|
+
"an explicit token_issuer=TokenIssuer(...)."
|
|
63
|
+
)
|
|
64
|
+
self.secret = secret
|
|
65
|
+
self.token_issuer = TokenIssuer(
|
|
66
|
+
issuer=self.issuer, algorithm="HS256", secret=secret
|
|
67
|
+
)
|
|
68
|
+
else:
|
|
69
|
+
# Ensure the issuer string matches any external token_issuer.
|
|
70
|
+
self.issuer = self.token_issuer.issuer
|
|
71
|
+
if not self.audience:
|
|
72
|
+
self.audience = self.issuer
|
|
73
|
+
|
|
74
|
+
# --- Registration + credential flows ---
|
|
75
|
+
|
|
76
|
+
async def create_user(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
email: str,
|
|
80
|
+
password: str,
|
|
81
|
+
claims: Optional[Dict[str, Any]] = None,
|
|
82
|
+
name: str = "",
|
|
83
|
+
**extra: Any,
|
|
84
|
+
) -> str:
|
|
85
|
+
existing = await self.store.find_by_provider("local", email)
|
|
86
|
+
if existing is not None:
|
|
87
|
+
raise ValueError(f"User with email {email!r} already exists")
|
|
88
|
+
|
|
89
|
+
user_id = await self.store.create_user(
|
|
90
|
+
email=email, name=name, claims=claims or {}, **extra
|
|
91
|
+
)
|
|
92
|
+
pw_hash = self._hasher.hash(password)
|
|
93
|
+
await self.store.set_password_hash(user_id, pw_hash)
|
|
94
|
+
await self.store.link_provider(
|
|
95
|
+
user_id, "local", email, claims=claims or {"email": email}
|
|
96
|
+
)
|
|
97
|
+
return user_id
|
|
98
|
+
|
|
99
|
+
async def verify_credentials(
|
|
100
|
+
self, *, email: str, password: str
|
|
101
|
+
) -> Optional[ClaimsPrincipal]:
|
|
102
|
+
record = await self.store.find_by_provider("local", email)
|
|
103
|
+
if record is None:
|
|
104
|
+
return None
|
|
105
|
+
user_id = record.get("user_id")
|
|
106
|
+
if not user_id:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
pw_hash = await self.store.get_password_hash(user_id)
|
|
110
|
+
if not pw_hash:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
self._hasher.verify(pw_hash, password)
|
|
115
|
+
except VerifyMismatchError:
|
|
116
|
+
return None
|
|
117
|
+
except Exception:
|
|
118
|
+
logger.warning("Unexpected argon2 verify error", exc_info=True)
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
if self._hasher.check_needs_rehash(pw_hash):
|
|
122
|
+
new_hash = self._hasher.hash(password)
|
|
123
|
+
await self.store.set_password_hash(user_id, new_hash)
|
|
124
|
+
|
|
125
|
+
return await self.principal_for_user(user_id)
|
|
126
|
+
|
|
127
|
+
async def change_password(
|
|
128
|
+
self, *, user_id: str, old_password: str, new_password: str
|
|
129
|
+
) -> bool:
|
|
130
|
+
pw_hash = await self.store.get_password_hash(user_id)
|
|
131
|
+
if not pw_hash:
|
|
132
|
+
return False
|
|
133
|
+
try:
|
|
134
|
+
self._hasher.verify(pw_hash, old_password)
|
|
135
|
+
except VerifyMismatchError:
|
|
136
|
+
return False
|
|
137
|
+
await self.store.set_password_hash(user_id, self._hasher.hash(new_password))
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
async def reset_password(self, *, user_id: str, new_password: str) -> None:
|
|
141
|
+
"""Admin-side password reset (skips old-password check)."""
|
|
142
|
+
await self.store.set_password_hash(user_id, self._hasher.hash(new_password))
|
|
143
|
+
|
|
144
|
+
# --- Principal + token flows ---
|
|
145
|
+
|
|
146
|
+
async def principal_for_user(self, user_id: str) -> Optional[ClaimsPrincipal]:
|
|
147
|
+
record = await self.store.get_user(user_id)
|
|
148
|
+
if record is None:
|
|
149
|
+
return None
|
|
150
|
+
return _principal_from_record(record, provider_prefix="local")
|
|
151
|
+
|
|
152
|
+
def issue_id_token(
|
|
153
|
+
self,
|
|
154
|
+
*,
|
|
155
|
+
user_id: str,
|
|
156
|
+
claims: Optional[Dict[str, Any]] = None,
|
|
157
|
+
ttl: Optional[int] = None,
|
|
158
|
+
audience: Optional[str] = None,
|
|
159
|
+
) -> str:
|
|
160
|
+
assert self.token_issuer is not None
|
|
161
|
+
return self.token_issuer.issue(
|
|
162
|
+
subject=user_id,
|
|
163
|
+
audience=audience or self.audience,
|
|
164
|
+
claims=claims,
|
|
165
|
+
ttl=ttl,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def verify_id_token(
|
|
169
|
+
self, token: str, *, audience: Optional[str] = None
|
|
170
|
+
) -> Optional[Dict[str, Any]]:
|
|
171
|
+
assert self.token_issuer is not None
|
|
172
|
+
return self.token_issuer.verify(token, audience=audience or self.audience)
|
|
173
|
+
|
|
174
|
+
async def principal_from_id_token(self, token: str) -> Optional[ClaimsPrincipal]:
|
|
175
|
+
payload = self.verify_id_token(token)
|
|
176
|
+
if payload is None:
|
|
177
|
+
return None
|
|
178
|
+
user_id = str(payload.get("sub") or "")
|
|
179
|
+
if not user_id:
|
|
180
|
+
return None
|
|
181
|
+
return await self.principal_for_user(user_id)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _principal_from_record(
|
|
185
|
+
record: Dict[str, Any], *, provider_prefix: str = "local"
|
|
186
|
+
) -> ClaimsPrincipal:
|
|
187
|
+
user_id = str(record.get("user_id", ""))
|
|
188
|
+
email = record.get("email")
|
|
189
|
+
name = str(record.get("name") or email or "")
|
|
190
|
+
claims: List[Claim] = [Claim(type="sub", value=user_id)]
|
|
191
|
+
if email:
|
|
192
|
+
claims.append(Claim(type="email", value=str(email)))
|
|
193
|
+
for ctype, cvalue in (record.get("claims") or {}).items():
|
|
194
|
+
claims.append(Claim(type=str(ctype), value=str(cvalue)))
|
|
195
|
+
return ClaimsPrincipal(
|
|
196
|
+
is_authenticated=True,
|
|
197
|
+
name=name,
|
|
198
|
+
user_id=f"{provider_prefix}:{user_id}" if user_id else "",
|
|
199
|
+
claims=claims,
|
|
200
|
+
raw=dict(record),
|
|
201
|
+
)
|