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.
@@ -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"]
@@ -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
+ )