pywire-auth 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. pywire_auth-0.1.1/.gitignore +81 -0
  2. pywire_auth-0.1.1/CHANGELOG.md +35 -0
  3. pywire_auth-0.1.1/PKG-INFO +27 -0
  4. pywire_auth-0.1.1/README.md +144 -0
  5. pywire_auth-0.1.1/pyproject.toml +54 -0
  6. pywire_auth-0.1.1/src/pywire_auth/__init__.py +58 -0
  7. pywire_auth-0.1.1/src/pywire_auth/_protocols.py +67 -0
  8. pywire_auth-0.1.1/src/pywire_auth/actions.py +163 -0
  9. pywire_auth-0.1.1/src/pywire_auth/integration.py +132 -0
  10. pywire_auth-0.1.1/src/pywire_auth/local/__init__.py +20 -0
  11. pywire_auth-0.1.1/src/pywire_auth/local/idp.py +201 -0
  12. pywire_auth-0.1.1/src/pywire_auth/local/routes.py +281 -0
  13. pywire_auth-0.1.1/src/pywire_auth/local/token.py +107 -0
  14. pywire_auth-0.1.1/src/pywire_auth/middleware.py +123 -0
  15. pywire_auth-0.1.1/src/pywire_auth/providers/__init__.py +20 -0
  16. pywire_auth-0.1.1/src/pywire_auth/providers/auth0.py +40 -0
  17. pywire_auth-0.1.1/src/pywire_auth/providers/base.py +225 -0
  18. pywire_auth-0.1.1/src/pywire_auth/providers/facebook.py +37 -0
  19. pywire_auth-0.1.1/src/pywire_auth/providers/generic.py +98 -0
  20. pywire_auth-0.1.1/src/pywire_auth/providers/github.py +33 -0
  21. pywire_auth-0.1.1/src/pywire_auth/providers/google.py +44 -0
  22. pywire_auth-0.1.1/src/pywire_auth/providers/microsoft.py +46 -0
  23. pywire_auth-0.1.1/src/pywire_auth/routes.py +342 -0
  24. pywire_auth-0.1.1/src/pywire_auth/stores/__init__.py +16 -0
  25. pywire_auth-0.1.1/src/pywire_auth/stores/memory.py +58 -0
  26. pywire_auth-0.1.1/src/pywire_auth/stores/sqlalchemy.py +322 -0
  27. pywire_auth-0.1.1/tests/test_actions.py +184 -0
  28. pywire_auth-0.1.1/tests/test_integration.py +204 -0
  29. pywire_auth-0.1.1/tests/test_local_idp.py +135 -0
  30. pywire_auth-0.1.1/tests/test_local_routes.py +223 -0
  31. pywire_auth-0.1.1/tests/test_memory_store.py +60 -0
  32. pywire_auth-0.1.1/tests/test_middleware.py +190 -0
  33. pywire_auth-0.1.1/tests/test_providers.py +195 -0
  34. pywire_auth-0.1.1/tests/test_routes.py +446 -0
  35. pywire_auth-0.1.1/tests/test_sqlalchemy_store.py +131 -0
@@ -0,0 +1,81 @@
1
+ # ─────────────────────── Python ──────────────────────
2
+ .venv/
3
+ venv/
4
+ env/
5
+ ENV/
6
+ __pycache__/
7
+ *.pyc
8
+ *.py[cod]
9
+ *$py.class
10
+ *.egg-info/
11
+ dist/
12
+ build/
13
+ .coverage
14
+ .pytest_cache/
15
+ .mypy_cache/
16
+ .ruff_cache/
17
+ .tox/
18
+ **/_version.py
19
+
20
+ # ─────────────────────── Rust ────────────────────────
21
+ target/
22
+
23
+ # ─────────────────────── Node ────────────────────────
24
+ node_modules/
25
+ coverage/
26
+ *.log
27
+ .astro/
28
+
29
+ # ─────────────────────── IDE ─────────────────────────
30
+ .vscode/
31
+ .idea/
32
+ *.swp
33
+ *.swo
34
+
35
+ # ─────────────────────── OS ──────────────────────────
36
+ .DS_Store
37
+ Thumbs.db
38
+
39
+ # ─────────────────────── Env ─────────────────────────
40
+ .env
41
+ .env.*
42
+ !.env.example
43
+
44
+ # ─────────────────────── pywire package ──────────────
45
+ .pywire/
46
+ scratch/
47
+ packages/pywire/src/pywire/*.so
48
+ packages/pywire/src/pywire/*.pyd
49
+ packages/pywire/src/pywire/client/dist/
50
+ # Un-ignore static dir — maturin must include it in WASM wheels
51
+ !packages/pywire/src/pywire/static/
52
+ !packages/pywire/src/pywire/static/**
53
+ # Re-ignore built JS bundles (generated by pnpm build)
54
+ packages/pywire/src/pywire/static/pywire.*.min.js
55
+ packages/pywire/emsdk/
56
+ emsdk/
57
+ packages/pywire/.pyodide-*
58
+
59
+ # ─────────────────────── vscode-pywire ───────────────
60
+ packages/vscode-pywire/out/
61
+ packages/vscode-pywire/bundled/
62
+ *.vsix
63
+
64
+ # ─────────────────────── tree-sitter-pywire ──────────
65
+ packages/tree-sitter-pywire/Cargo.lock
66
+ packages/tree-sitter-pywire/*.wasm
67
+
68
+ # ─────────────────────── Docs ────────────────────────
69
+ docs/public/sw.js
70
+ docs/public/pywire-worker.js
71
+
72
+ # ─────────────────────── Certs ───────────────────────
73
+ localhost.pem
74
+ localhost-key.pem
75
+
76
+ # ─────────────────────── Other ───────────────────────
77
+ chatbot.db
78
+ .gemini/
79
+ .claude/worktrees/
80
+ gha-creds-*.json
81
+ .agent/settings.local.json
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ ## [0.1.1](https://github.com/pywire/pywire/compare/pywire-auth-v0.1.0...pywire-auth-v0.1.1) (2026-04-20)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **pywire-auth:** public-API docstring + add PyPI publish job ([620aa66](https://github.com/pywire/pywire/commit/620aa66523c9429e20948df94729fcba693fbcbc))
9
+
10
+ ## [0.1.0](https://github.com/pywire/pywire/compare/pywire-auth-v0.1.0...pywire-auth-v0.1.0) (2026-04-20)
11
+
12
+
13
+ ### Features
14
+
15
+ * **pywire-auth:** initial release — OIDC + LocalIdP + policies ([6a5af64](https://github.com/pywire/pywire/commit/6a5af6412a992dcfcaa062e6d26356bf0f087ce1))
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **prettier-plugin-pywire:** bump for consistency ([33fc161](https://github.com/pywire/pywire/commit/33fc1613c5a70dfc4e8b00d1da3d99f2dc849925))
21
+ * **pywire-language-server:** bump for release ([88aa449](https://github.com/pywire/pywire/commit/88aa449a1a55ece6145f1f21bc7b1ca807e394ca))
22
+ * **pywire-language-server:** fix PyPI publish by using explicit build output dir ([8dec973](https://github.com/pywire/pywire/commit/8dec97382276ce0f139cc5abd89b0bd54806bc7d))
23
+ * **pywire-language-server:** trigger release PR update ([77491d0](https://github.com/pywire/pywire/commit/77491d02b5f51957b83b1ae3a320cbab53fb56a8))
24
+ * **tree-sitter-pywire:** bump for consistency ([42b40bd](https://github.com/pywire/pywire/commit/42b40bd3ec2711445865c772573b52774857bbc0))
25
+ * **vscode-pywire:** fix CI publish with --no-dependencies flag ([1ef1d72](https://github.com/pywire/pywire/commit/1ef1d72018f4c02bf29a923dd33e4f96bdf813ac))
26
+
27
+ ## Changelog
28
+
29
+ All notable changes to `pywire-auth` are documented here. This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and uses [release-please](https://github.com/googleapis/release-please) for automated releases.
30
+
31
+ Initial release: batteries-included authentication — OAuth2/OIDC providers (Google, GitHub, Microsoft, Facebook, Auth0, generic OIDC), a local identity provider with Argon2 password hashing, a `SQLAlchemyAuthStore` for cross-restart persistence, policy engine with claim-based guards, `AuthActions` bundling store/session/channel mutations into one call, and a live auth channel that pushes claim changes to logged-in tabs without reload.
32
+
33
+ ## Unreleased
34
+
35
+ _Release PR tracked by release-please._
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: pywire-auth
3
+ Version: 0.1.1
4
+ Summary: Authentication providers and identity store adapters for PyWire
5
+ Author-email: Reece Holmdahl <reece@pywire.dev>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: argon2-cffi>=23.1
9
+ Requires-Dist: authlib>=1.3
10
+ Requires-Dist: httpx>=0.25
11
+ Requires-Dist: pywire
12
+ Requires-Dist: starlette>=0.31
13
+ Provides-Extra: apple
14
+ Provides-Extra: auth0
15
+ Provides-Extra: dev
16
+ Requires-Dist: httpx>=0.25; extra == 'dev'
17
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
18
+ Requires-Dist: pytest>=7.4; extra == 'dev'
19
+ Provides-Extra: facebook
20
+ Provides-Extra: github
21
+ Provides-Extra: google
22
+ Provides-Extra: microsoft
23
+ Provides-Extra: redis
24
+ Requires-Dist: redis>=5; extra == 'redis'
25
+ Provides-Extra: sqlalchemy
26
+ Requires-Dist: aiosqlite>=0.19; extra == 'sqlalchemy'
27
+ Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'sqlalchemy'
@@ -0,0 +1,144 @@
1
+ # pywire-auth
2
+
3
+ Authentication for [PyWire](https://pywire.dev). OAuth2 / OIDC providers, a local identity provider with Argon2 password hashing, session-backed principals, policies, and a live-update channel so logged-in tabs react to claim changes without a reload.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pip install pywire-auth
9
+ ```
10
+
11
+ Optional extras:
12
+
13
+ - `pywire-auth[sqlalchemy]` — persistent `SQLAlchemyAuthStore` for the local IdP (SQLite / Postgres / MySQL / any async SQLA driver; ships `aiosqlite` for the default SQLite URL)
14
+ - `pywire-auth[redis]` — cross-worker `RedisAuthChannel` (coming)
15
+
16
+ Providers ship as config-only extras; the HTTP layer is `httpx` + `authlib` which are always installed:
17
+
18
+ - `pywire-auth[google]` `pywire-auth[github]` `pywire-auth[microsoft]` `pywire-auth[facebook]` `pywire-auth[auth0]` — declarative today, provider-specific deps land here later without a compat break
19
+
20
+ ## Quick start
21
+
22
+ ```python
23
+ # src/main.py
24
+ import os
25
+ from pathlib import Path
26
+ from pywire import PyWire
27
+ from pywire_auth import (
28
+ GoogleProvider,
29
+ GitHubProvider,
30
+ LocalIdP,
31
+ SQLAlchemyAuthStore,
32
+ connect_auth,
33
+ )
34
+
35
+ app = PyWire(pages_dir=str(Path(__file__).parent / "pages"))
36
+
37
+ # Local password auth, persisted to SQLite by default. Override via
38
+ # LOCAL_AUTH_DB env var for Postgres / etc.
39
+ store = SQLAlchemyAuthStore(
40
+ os.environ.get("LOCAL_AUTH_DB", "sqlite+aiosqlite:///./local-auth.db")
41
+ )
42
+
43
+ providers = []
44
+ if os.environ.get("GOOGLE_CLIENT_ID"):
45
+ providers.append(GoogleProvider(
46
+ client_id=os.environ["GOOGLE_CLIENT_ID"],
47
+ client_secret=os.environ["GOOGLE_CLIENT_SECRET"],
48
+ ))
49
+ if os.environ.get("GITHUB_CLIENT_ID"):
50
+ providers.append(GitHubProvider(
51
+ client_id=os.environ["GITHUB_CLIENT_ID"],
52
+ client_secret=os.environ["GITHUB_CLIENT_SECRET"],
53
+ ))
54
+
55
+ engine = connect_auth(
56
+ app,
57
+ providers=providers,
58
+ local_idp=LocalIdP(store=store), # reads LOCAL_IDP_SECRET from env
59
+ )
60
+ engine.add_policy("AdminOnly", requires_claim=("role", "admin"))
61
+ ```
62
+
63
+ `connect_auth` mounts:
64
+
65
+ - `GET /auth/{provider}/login` + `GET /auth/{provider}/callback` — one pair per OIDC provider in the list
66
+ - `POST /auth/local/{register,login,token,verify-token,revoke}` — only when `local_idp=...` is passed
67
+ - `GET /auth/logout` — clears session + fires an `AuthChannel.revoke`
68
+
69
+ and exposes the `AuthActions` helper at `app.state.auth` for one-call claim mutations.
70
+
71
+ ## Protecting pages
72
+
73
+ Page-level (hard redirect):
74
+
75
+ ```wire
76
+ !auth {"policy": "AdminOnly"}
77
+
78
+ <h1>Admin dashboard</h1>
79
+ ```
80
+
81
+ Region-level (renders an "allowed" or "denied" branch in place):
82
+
83
+ ```wire
84
+ {$auth policy="AdminOnly"}
85
+ <p>Admin-only content</p>
86
+ {$else}
87
+ <p>Contact an administrator.</p>
88
+ {/auth}
89
+ ```
90
+
91
+ Both check the principal against the named policy or the inline `claims=[...]` list. Policies fail closed — unknown policies, missing engine, and user-code exceptions all deny.
92
+
93
+ ## LocalIdP persistence
94
+
95
+ In-memory default is fine for unit tests. For any real dev loop, wire a SQLite file:
96
+
97
+ ```python
98
+ from pywire_auth import LocalIdP, SQLAlchemyAuthStore
99
+
100
+ store = SQLAlchemyAuthStore("sqlite+aiosqlite:///./local-auth.db")
101
+ idp = LocalIdP(store=store) # secret from LOCAL_IDP_SECRET env
102
+ ```
103
+
104
+ Schema (three tables — `pywire_auth_users`, `pywire_auth_credentials`, `pywire_auth_provider_links`) auto-initializes on first query. For Postgres, `postgresql+asyncpg://user:pw@host/db`; for MySQL, `mysql+aiomysql://...`. The `metadata` object is importable for Alembic:
105
+
106
+ ```python
107
+ from pywire_auth.stores.sqlalchemy import metadata
108
+ ```
109
+
110
+ See the [Local IdP setup guide](https://pywire.dev/guides/authentication/local-idp/) for first-time setup + production tips.
111
+
112
+ ## Live auth
113
+
114
+ `AuthActions` writes claim changes to all three layers in one call:
115
+
116
+ ```python
117
+ # In a page handler
118
+ await app.state.auth.grant(self.user, self.request, "role", "admin")
119
+ await app.state.auth.revoke_claim(self.user, self.request, "role")
120
+ await app.state.auth.revoke_session(self.user, self.request)
121
+ ```
122
+
123
+ Store → session → `AuthChannel` in that order: the change persists across logins, survives a hard reload, and re-renders every connected tab for that user without a page refresh.
124
+
125
+ ## Providers
126
+
127
+ | Provider | Shape | Notes |
128
+ |---|---|---|
129
+ | `GoogleProvider` | OIDC | Fixed endpoints; returns `sub`, `email`, `email_verified`, `name`, `picture`, optional `hd` |
130
+ | `GitHubProvider` | OAuth2 | No id_token; `sub` derived from `id`, `login` returned |
131
+ | `MicrosoftProvider` | OIDC | `tenant="common"` default; per-tenant via GUID/domain |
132
+ | `FacebookProvider` | OAuth2 | v18.0 endpoints; unwraps nested `picture.data.url` |
133
+ | `Auth0Provider` | OIDC | Pass `domain=<tenant>.auth0.com`; extends `GenericOIDCProvider` |
134
+ | `GenericOIDCProvider` | OIDC | Any `/.well-known/openid-configuration`; works for Keycloak / Okta / Cognito / Zitadel / Logto |
135
+
136
+ All providers accept `client_id` + `client_secret` and expose `authorize_url` / `exchange_code` / `refresh` / `map_claims`.
137
+
138
+ ## API reference
139
+
140
+ Full reference at [pywire.dev/reference/auth-api](https://pywire.dev/reference/auth-api/).
141
+
142
+ ## License
143
+
144
+ MIT
@@ -0,0 +1,54 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pywire-auth"
7
+ version = "0.1.1"
8
+ description = "Authentication providers and identity store adapters for PyWire"
9
+ authors = [
10
+ { name = "Reece Holmdahl", email = "reece@pywire.dev" },
11
+ ]
12
+ requires-python = ">=3.11"
13
+ license = "MIT"
14
+ dependencies = [
15
+ "pywire",
16
+ "starlette>=0.31",
17
+ "authlib>=1.3",
18
+ "httpx>=0.25",
19
+ "argon2-cffi>=23.1",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ # Each OIDC provider is a config-only extra today — the network layer
24
+ # is authlib + httpx (already required). Extras exist so users can
25
+ # declare intent and so providers can pick up provider-specific deps
26
+ # in the future without a compat break.
27
+ google = []
28
+ github = []
29
+ microsoft = []
30
+ facebook = []
31
+ apple = []
32
+ auth0 = []
33
+
34
+ # Persistent user store — pulls SQLAlchemy when an app needs account
35
+ # linking or the local IdP. aiosqlite covers dev / single-worker apps;
36
+ # Postgres / MySQL users swap in their own async driver.
37
+ sqlalchemy = [
38
+ "sqlalchemy[asyncio]>=2.0",
39
+ "aiosqlite>=0.19",
40
+ ]
41
+
42
+ # Cross-worker realtime updates.
43
+ redis = [
44
+ "redis>=5",
45
+ ]
46
+
47
+ dev = [
48
+ "pytest>=7.4",
49
+ "pytest-asyncio>=0.21",
50
+ "httpx>=0.25",
51
+ ]
52
+
53
+ [tool.hatch.build.targets.wheel]
54
+ packages = ["src/pywire_auth"]
@@ -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]: ...
@@ -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")