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.
- pywire_auth-0.1.1/.gitignore +81 -0
- pywire_auth-0.1.1/CHANGELOG.md +35 -0
- pywire_auth-0.1.1/PKG-INFO +27 -0
- pywire_auth-0.1.1/README.md +144 -0
- pywire_auth-0.1.1/pyproject.toml +54 -0
- pywire_auth-0.1.1/src/pywire_auth/__init__.py +58 -0
- pywire_auth-0.1.1/src/pywire_auth/_protocols.py +67 -0
- pywire_auth-0.1.1/src/pywire_auth/actions.py +163 -0
- pywire_auth-0.1.1/src/pywire_auth/integration.py +132 -0
- pywire_auth-0.1.1/src/pywire_auth/local/__init__.py +20 -0
- pywire_auth-0.1.1/src/pywire_auth/local/idp.py +201 -0
- pywire_auth-0.1.1/src/pywire_auth/local/routes.py +281 -0
- pywire_auth-0.1.1/src/pywire_auth/local/token.py +107 -0
- pywire_auth-0.1.1/src/pywire_auth/middleware.py +123 -0
- pywire_auth-0.1.1/src/pywire_auth/providers/__init__.py +20 -0
- pywire_auth-0.1.1/src/pywire_auth/providers/auth0.py +40 -0
- pywire_auth-0.1.1/src/pywire_auth/providers/base.py +225 -0
- pywire_auth-0.1.1/src/pywire_auth/providers/facebook.py +37 -0
- pywire_auth-0.1.1/src/pywire_auth/providers/generic.py +98 -0
- pywire_auth-0.1.1/src/pywire_auth/providers/github.py +33 -0
- pywire_auth-0.1.1/src/pywire_auth/providers/google.py +44 -0
- pywire_auth-0.1.1/src/pywire_auth/providers/microsoft.py +46 -0
- pywire_auth-0.1.1/src/pywire_auth/routes.py +342 -0
- pywire_auth-0.1.1/src/pywire_auth/stores/__init__.py +16 -0
- pywire_auth-0.1.1/src/pywire_auth/stores/memory.py +58 -0
- pywire_auth-0.1.1/src/pywire_auth/stores/sqlalchemy.py +322 -0
- pywire_auth-0.1.1/tests/test_actions.py +184 -0
- pywire_auth-0.1.1/tests/test_integration.py +204 -0
- pywire_auth-0.1.1/tests/test_local_idp.py +135 -0
- pywire_auth-0.1.1/tests/test_local_routes.py +223 -0
- pywire_auth-0.1.1/tests/test_memory_store.py +60 -0
- pywire_auth-0.1.1/tests/test_middleware.py +190 -0
- pywire_auth-0.1.1/tests/test_providers.py +195 -0
- pywire_auth-0.1.1/tests/test_routes.py +446 -0
- 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")
|