oidcauth-py 0.1.0__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.
- oidcauth_py-0.1.0/PKG-INFO +531 -0
- oidcauth_py-0.1.0/README.md +506 -0
- oidcauth_py-0.1.0/pyproject.toml +95 -0
- oidcauth_py-0.1.0/pyproject.toml.orig +86 -0
- oidcauth_py-0.1.0/src/oidcauth/__init__.py +133 -0
- oidcauth_py-0.1.0/src/oidcauth/client.py +901 -0
- oidcauth_py-0.1.0/src/oidcauth/config.py +178 -0
- oidcauth_py-0.1.0/src/oidcauth/errors.py +122 -0
- oidcauth_py-0.1.0/src/oidcauth/fastapi.py +230 -0
- oidcauth_py-0.1.0/src/oidcauth/flow.py +175 -0
- oidcauth_py-0.1.0/src/oidcauth/helpers.py +101 -0
- oidcauth_py-0.1.0/src/oidcauth/identity.py +237 -0
- oidcauth_py-0.1.0/src/oidcauth/py.typed +0 -0
- oidcauth_py-0.1.0/src/oidcauth/scopes.py +73 -0
- oidcauth_py-0.1.0/src/oidcauth/testing.py +512 -0
- oidcauth_py-0.1.0/src/oidcauth/zitadel/__init__.py +74 -0
- oidcauth_py-0.1.0/src/oidcauth/zitadel/client.py +439 -0
- oidcauth_py-0.1.0/src/oidcauth/zitadel/errors.py +93 -0
- oidcauth_py-0.1.0/src/oidcauth/zitadel/fastapi.py +182 -0
- oidcauth_py-0.1.0/src/oidcauth/zitadel/login.py +193 -0
- oidcauth_py-0.1.0/src/oidcauth/zitadel/testing.py +400 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oidcauth-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OpenID Connect login for Python web apps: authorization code + PKCE, encrypted flow cookie, ID token verification, refresh, revocation, RP-initiated and back-channel logout, a FastAPI/Starlette adapter, a fake provider for tests, and Zitadel's headless identity provider login.
|
|
5
|
+
Keywords: oidc,openid-connect,oauth2,pkce,zitadel,fastapi,sso
|
|
6
|
+
Author: Do Quoc Vuong
|
|
7
|
+
Author-email: Do Quoc Vuong <vuongtlt13@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Framework :: FastAPI
|
|
13
|
+
Classifier: Topic :: Security
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Dist: cryptography>=42.0.0
|
|
16
|
+
Requires-Dist: httpx>=0.27.0
|
|
17
|
+
Requires-Dist: joserfc>=1.0.0
|
|
18
|
+
Requires-Dist: starlette>=0.37.0 ; extra == 'fastapi'
|
|
19
|
+
Requires-Python: >=3.10, <4.0
|
|
20
|
+
Project-URL: Homepage, https://github.com/vuongtlt13/oidcauth-py
|
|
21
|
+
Project-URL: Issues, https://github.com/vuongtlt13/oidcauth-py/issues
|
|
22
|
+
Project-URL: License, https://opensource.org/licenses/MIT
|
|
23
|
+
Provides-Extra: fastapi
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# oidcauth-py
|
|
27
|
+
|
|
28
|
+
`oidcauth` is a thin OpenID Connect relying party for browser login in Python
|
|
29
|
+
(async) services. It adds **OIDC login next to local (username/password)
|
|
30
|
+
login** without changing how your app keeps users signed in.
|
|
31
|
+
|
|
32
|
+
It is the Python sibling of the Go library
|
|
33
|
+
[`github.com/vuongtlt13/oidcauth`](https://github.com/vuongtlt13/oidcauth),
|
|
34
|
+
with the same feature set and the same environment contract in the backend
|
|
35
|
+
templates. It provides:
|
|
36
|
+
|
|
37
|
+
- Authorization Code flow with **PKCE (S256)**, `state` and `nonce`.
|
|
38
|
+
- An encrypted, short-lived **flow cookie** that holds state, nonce, the PKCE
|
|
39
|
+
verifier and the return path. It uses AES-256-GCM, is bound to `state`,
|
|
40
|
+
and is set per login, so several tabs can log in at once.
|
|
41
|
+
- **ID token verification**: signature checked against the JWKS, plus `iss`,
|
|
42
|
+
`aud`, `azp`, `exp`, `iat`, `nonce` and `at_hash`. Userinfo claims are then
|
|
43
|
+
merged in. `sub`/`iss` and other protected claims are never overridden by
|
|
44
|
+
userinfo.
|
|
45
|
+
- A normalized **`Identity`**: issuer, subject, email, `email_verified`,
|
|
46
|
+
profile fields, roles (Zitadel project roles out of the box) and raw claims.
|
|
47
|
+
- `safe_return_to`, which prevents open redirects.
|
|
48
|
+
- A single-use **`TicketStore`** (`MemoryTicketStore`, `RedisTicketStore`), so
|
|
49
|
+
the callback can hand a session token to an SPA without putting it in a URL.
|
|
50
|
+
- **Scopes and standard claims**: `openid`, `profile`, `email`, `phone`,
|
|
51
|
+
`address` and `offline_access`, with every standard claim mapped. The
|
|
52
|
+
scopes the provider actually granted are reported in `Tokens.scopes`.
|
|
53
|
+
- All authorization request parameters: `prompt`, `login_hint`,
|
|
54
|
+
`id_token_hint`, `max_age` (with an `auth_time` check), `acr_values` (with
|
|
55
|
+
an optional `acr` check), `ui_locales`, `claims_locales`, `display`,
|
|
56
|
+
`response_mode` (`query` / `form_post`), `claims` and custom parameters.
|
|
57
|
+
- **Refresh** (`refresh_token` grant), **revocation** (RFC 7009) and
|
|
58
|
+
**userinfo**.
|
|
59
|
+
- RP-initiated logout (`logout_url`) and **back-channel logout** token
|
|
60
|
+
verification (`verify_logout_token`).
|
|
61
|
+
- Client authentication: `client_secret_basic`, `client_secret_post`,
|
|
62
|
+
`private_key_jwt` and `none` (public client + PKCE).
|
|
63
|
+
- A **FastAPI / Starlette adapter** (`oidcauth.fastapi`) and an in-process
|
|
64
|
+
**fake provider** (`oidcauth.testing`) for end-to-end tests.
|
|
65
|
+
|
|
66
|
+
The core modules (`oidcauth`, `oidcauth.fastapi`, `oidcauth.testing`) work
|
|
67
|
+
with any OpenID Connect provider: Zitadel, Keycloak, Google, Microsoft Entra
|
|
68
|
+
ID, Auth0, Authentik, and others. The only Zitadel-aware default is the roles
|
|
69
|
+
claim, and `Config.role_extractor` overrides it. The optional
|
|
70
|
+
`oidcauth.zitadel` package (see
|
|
71
|
+
[Zitadel identity provider login](#zitadel-identity-provider-login-without-zitadels-pages))
|
|
72
|
+
is Zitadel-only.
|
|
73
|
+
|
|
74
|
+
`oidcauth` deliberately does **not** do the following. Each application
|
|
75
|
+
decides them:
|
|
76
|
+
|
|
77
|
+
- Create, link or reject local users. The app receives an `Identity` and does
|
|
78
|
+
whatever its policy says.
|
|
79
|
+
- Issue sessions or JWTs. Keep using your existing token and middleware.
|
|
80
|
+
|
|
81
|
+
### Design notes
|
|
82
|
+
|
|
83
|
+
- **Async only.** Every call that may reach the provider is a coroutine
|
|
84
|
+
(`await client.start(...)`, `await client.finish(...)`, ...). There is no
|
|
85
|
+
sync API.
|
|
86
|
+
- Implemented directly on [httpx](https://www.python-httpx.org/) (async),
|
|
87
|
+
[joserfc](https://jose.authlib.org/) and
|
|
88
|
+
[cryptography](https://cryptography.io/) rather than Authlib: Authlib 1.9
|
|
89
|
+
deprecates its httpx integration and `authlib.jose`.
|
|
90
|
+
- Python >= 3.10. Fully typed (`py.typed`, mypy strict).
|
|
91
|
+
|
|
92
|
+
## Install
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pip install oidcauth-py # core
|
|
96
|
+
pip install "oidcauth-py[fastapi]" # + FastAPI / Starlette adapter
|
|
97
|
+
# or
|
|
98
|
+
uv add "oidcauth-py[fastapi]"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The distribution is `oidcauth-py`; the import name is `oidcauth`. The
|
|
102
|
+
`fastapi` extra only pulls in Starlette; `oidcauth.fastapi` works with plain
|
|
103
|
+
Starlette as well. `RedisTicketStore` takes any `redis.asyncio.Redis`
|
|
104
|
+
compatible client, which you install yourself.
|
|
105
|
+
|
|
106
|
+
## Documentation
|
|
107
|
+
|
|
108
|
+
Step-by-step guides (Vietnamese) are in [`docs/`](https://github.com/vuongtlt13/oidcauth-py/blob/master/docs/README.md):
|
|
109
|
+
|
|
110
|
+
- [Setup](https://github.com/vuongtlt13/oidcauth-py/blob/master/docs/setup.md): wiring, `Config` ↔ environment variables,
|
|
111
|
+
`TicketStore`, cookies, local development, production checklist.
|
|
112
|
+
- [Zitadel](https://github.com/vuongtlt13/oidcauth-py/blob/master/docs/zitadel/README.md): web application (secret, Private Key
|
|
113
|
+
JWT, PKCE), roles and organizations, Google through Zitadel with or without
|
|
114
|
+
Zitadel's pages, logout and account linking, troubleshooting.
|
|
115
|
+
- [Other providers](https://github.com/vuongtlt13/oidcauth-py/blob/master/docs/providers.md): Google direct, Keycloak, Microsoft
|
|
116
|
+
Entra ID, Auth0.
|
|
117
|
+
|
|
118
|
+
## Usage with FastAPI
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
import os
|
|
122
|
+
from datetime import timedelta
|
|
123
|
+
from urllib.parse import urlencode
|
|
124
|
+
|
|
125
|
+
from fastapi import FastAPI, Request
|
|
126
|
+
from fastapi.responses import RedirectResponse, Response
|
|
127
|
+
|
|
128
|
+
from oidcauth import (
|
|
129
|
+
Client,
|
|
130
|
+
Config,
|
|
131
|
+
LoginOptions,
|
|
132
|
+
MemoryTicketStore,
|
|
133
|
+
Result,
|
|
134
|
+
safe_return_to,
|
|
135
|
+
)
|
|
136
|
+
from oidcauth.fastapi import OIDCHandler
|
|
137
|
+
|
|
138
|
+
client = Client(
|
|
139
|
+
Config(
|
|
140
|
+
issuer="https://id.example.com",
|
|
141
|
+
client_id=os.environ["AUTH_OIDC_CLIENT_ID"],
|
|
142
|
+
client_secret=os.environ["AUTH_OIDC_CLIENT_SECRET"],
|
|
143
|
+
redirect_url="https://app.example.com/api/v1/auth/oidc/callback",
|
|
144
|
+
cookie_secret=os.environ["AUTH_OIDC_COOKIE_SECRET"], # >= 32 chars
|
|
145
|
+
cookie_path="/api/v1/auth/oidc",
|
|
146
|
+
)
|
|
147
|
+
) # raises oidcauth.ConfigError listing every config problem
|
|
148
|
+
|
|
149
|
+
tickets = MemoryTicketStore() # RedisTicketStore (GETDEL) with replicas
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def on_success(request: Request, res: Result) -> Response:
|
|
153
|
+
# App policy: link by (iss, sub), else match a local user by verified email.
|
|
154
|
+
user = await users.from_oidc(res.identity)
|
|
155
|
+
if user is None:
|
|
156
|
+
return RedirectResponse("/auth/login?error=not_allowed", 302)
|
|
157
|
+
token = sessions.issue(user)
|
|
158
|
+
ticket = await tickets.put(token, timedelta(minutes=1))
|
|
159
|
+
query = urlencode({"ticket": ticket, "redirect": safe_return_to(res.return_to, "/")})
|
|
160
|
+
return RedirectResponse("/auth/callback?" + query, 302)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def on_error(request: Request, err: Exception) -> Response:
|
|
164
|
+
return RedirectResponse("/auth/login?error=oidc_failed", 302)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
h = OIDCHandler(client, on_success=on_success, on_error=on_error)
|
|
168
|
+
|
|
169
|
+
app = FastAPI()
|
|
170
|
+
app.add_api_route("/api/v1/auth/oidc/login", h.login, methods=["GET"]) # ?redirect=/path
|
|
171
|
+
# Register this as redirect URI. Add "POST" with response_mode=form_post.
|
|
172
|
+
app.add_api_route("/api/v1/auth/oidc/callback", h.callback, methods=["GET"])
|
|
173
|
+
# The SPA then POSTs the ticket to an endpoint that calls tickets.take.
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`login_options(request)` returns the `LoginOptions` of one login (extra
|
|
177
|
+
scopes, prompt, ...). Its `return_to` wins; only when it is empty does
|
|
178
|
+
`return_to` come from the `?redirect=` query parameter (`return_to_param`).
|
|
179
|
+
Use that to sanitize the client value, or to set a server-side marker such as
|
|
180
|
+
`"link:<user id>"` for an account-linking flow, which `on_success` reads back
|
|
181
|
+
from `res.return_to`:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
def link_options(request: Request) -> LoginOptions:
|
|
185
|
+
user = current_user(request) # the app's own session
|
|
186
|
+
return LoginOptions(return_to="link:" + str(user.id), prompt=["select_account"])
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`oidcauth.fastapi.start_options(request, login_options, return_to_param)`
|
|
190
|
+
applies the same rule for custom handlers; `ZitadelIDPHandler` uses it too.
|
|
191
|
+
|
|
192
|
+
`on_success`, `on_error`, `login_options` and the back-channel `on_logout` may
|
|
193
|
+
be plain functions or coroutines. Without `on_error`, the handler answers 401
|
|
194
|
+
(`login failed`) for user errors and 502 (`identity provider unavailable`)
|
|
195
|
+
otherwise (`oidcauth.fastapi.default_error_handler`). The callback always
|
|
196
|
+
clears the flow cookie and sets `Cache-Control: no-store`. Form bodies
|
|
197
|
+
(`form_post` callbacks, back-channel logout) are parsed without
|
|
198
|
+
`python-multipart`.
|
|
199
|
+
|
|
200
|
+
The adapter is optional. `Client.start` and `Client.finish` work with any
|
|
201
|
+
framework: set the returned `Cookie`, redirect, then pass the callback
|
|
202
|
+
parameters and the cookie value back in, and always clear the cookie:
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
auth_url, cookie = await client.start(return_to="/jobs")
|
|
206
|
+
# set cookie.name / value / path / max_age / secure / http_only / same_site, redirect
|
|
207
|
+
|
|
208
|
+
state = params.get("state", "")
|
|
209
|
+
cookie_value = request_cookies.get(client.cookie_name_for(state))
|
|
210
|
+
try:
|
|
211
|
+
res = await client.finish(params, cookie_value)
|
|
212
|
+
finally:
|
|
213
|
+
clear = client.clear_cookie(state) # set it on the response, whatever the outcome
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Close the HTTP client the library created on shutdown with
|
|
217
|
+
`await client.aclose()` (for example in the FastAPI lifespan). Pass your own
|
|
218
|
+
`httpx.AsyncClient` as `Config.http_client` for proxies, timeouts or a custom
|
|
219
|
+
CA.
|
|
220
|
+
|
|
221
|
+
## Errors
|
|
222
|
+
|
|
223
|
+
Every exception the core raises derives from `oidcauth.OIDCAuthError`:
|
|
224
|
+
|
|
225
|
+
| Exception | Meaning |
|
|
226
|
+
|---|---|
|
|
227
|
+
| `ConfigError` (also a `ValueError`) | invalid `Config`; `.errors` lists every problem |
|
|
228
|
+
| `InvalidStateError` | flow cookie missing, forged, foreign or expired; unknown or used ticket in the Zitadel handler |
|
|
229
|
+
| `MissingCodeError` | callback without `code` |
|
|
230
|
+
| `ProviderError` | the provider answered with `error=` (`.code`, `.description`), e.g. `access_denied` |
|
|
231
|
+
| `TokenError` | token request refused or token verification failed (`.op`, `.cause`) |
|
|
232
|
+
| `ProviderUnreachableError` | network error or timeout talking to the provider |
|
|
233
|
+
| `DiscoveryError` | discovery document or JWKS could not be loaded, or `issuer` mismatch |
|
|
234
|
+
| `UserInfoError` | userinfo request of a login failed |
|
|
235
|
+
| `InvalidLogoutTokenError` | back-channel logout token failed verification (`.reason`) |
|
|
236
|
+
| `NotSupportedError` | the provider lacks the endpoint (`end_session_endpoint`, `revocation_endpoint`, `userinfo_endpoint`) |
|
|
237
|
+
| `TicketNotFoundError` | ticket unknown, expired or already taken |
|
|
238
|
+
|
|
239
|
+
`oidcauth.is_user_error(err)` is true for `InvalidStateError`,
|
|
240
|
+
`MissingCodeError`, `ProviderError` and `TokenError`: answer those as a
|
|
241
|
+
refused login (401 / `oidc_failed`) and everything else as the provider being
|
|
242
|
+
unavailable (502 / `idp_unavailable`).
|
|
243
|
+
|
|
244
|
+
## Standards coverage
|
|
245
|
+
|
|
246
|
+
| Specification | Supported |
|
|
247
|
+
|---|---|
|
|
248
|
+
| OpenID Connect Core 1.0, Authorization Code flow | yes. Implicit and hybrid flows are intentionally not supported |
|
|
249
|
+
| RFC 7636 PKCE | S256, always on |
|
|
250
|
+
| OIDC Core §3.1.2.1 request parameters | `scope`, `prompt`, `login_hint`, `id_token_hint`, `max_age`, `acr_values`, `ui_locales`, `claims_locales`, `display`, `response_mode`, `claims` |
|
|
251
|
+
| OIDC Core §3.1.3.7 ID token validation | signature (JWKS, algs from discovery), `iss`, `aud`, `azp`, `exp`, `iat`, `nonce`, `at_hash`, plus `auth_time` with `max_age` and `acr` with `require_acr` |
|
|
252
|
+
| OIDC Core §5.1 standard claims, §5.4 scope claims, §5.5 claims request | yes (`ClaimsRequest`, `ClaimRequest`) |
|
|
253
|
+
| OIDC Core §5.3 UserInfo | merged at login (`skip_userinfo` disables it); `sub` must match the ID token |
|
|
254
|
+
| OIDC Core §9 client authentication | `client_secret_basic`, `client_secret_post`, `private_key_jwt`, `none` |
|
|
255
|
+
| OIDC Core §12 refresh tokens | `refresh`; the refreshed ID token is verified |
|
|
256
|
+
| OAuth 2.0 Form Post Response Mode | `LoginOptions(response_mode=RESPONSE_MODE_FORM_POST)` |
|
|
257
|
+
| RFC 9207 `iss` authorization response parameter | checked when present |
|
|
258
|
+
| RFC 7009 token revocation | `revoke` |
|
|
259
|
+
| OIDC Discovery 1.0 | yes; `discovery_url` overrides the URL; `await client.discovery()` returns the metadata |
|
|
260
|
+
| OIDC RP-Initiated Logout 1.0 | `logout_url` (`id_token_hint`, `logout_hint`, `ui_locales`, `state`) |
|
|
261
|
+
| OIDC Back-Channel Logout 1.0 | `verify_logout_token`, `OIDCHandler.backchannel_logout` |
|
|
262
|
+
|
|
263
|
+
## Scopes and claims
|
|
264
|
+
|
|
265
|
+
The default scopes are `openid profile email`. Change them for every login
|
|
266
|
+
with `Config.scopes`, or for one login with `LoginOptions(scopes=...)` /
|
|
267
|
+
`LoginOptions(extra_scopes=...)`:
|
|
268
|
+
|
|
269
|
+
| Scope | `Identity` fields |
|
|
270
|
+
|---|---|
|
|
271
|
+
| `openid` (always sent) | `issuer`, `subject`, `auth_time`, `acr`, `amr`, `session_id` |
|
|
272
|
+
| `profile` | `name`, `given_name`, `family_name`, `middle_name`, `nickname`, `preferred_username`, `profile`, `picture`, `website`, `gender`, `birthdate`, `zoneinfo`, `locale`, `updated_at` |
|
|
273
|
+
| `email` | `email`, `email_verified` |
|
|
274
|
+
| `phone` | `phone_number`, `phone_number_verified` |
|
|
275
|
+
| `address` | `address` (`Address`) |
|
|
276
|
+
| `offline_access` | `tokens.refresh_token` |
|
|
277
|
+
|
|
278
|
+
A provider may grant fewer scopes than requested. Check the result with
|
|
279
|
+
`identity.tokens.has_scope(oidcauth.SCOPE_EMAIL)`. All claims, standard or
|
|
280
|
+
not, are available in `identity.claims`. `identity.has_role(...)` and
|
|
281
|
+
`identity.display_name()` are small helpers.
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
from datetime import timedelta
|
|
285
|
+
|
|
286
|
+
from oidcauth import (
|
|
287
|
+
PROMPT_SELECT_ACCOUNT,
|
|
288
|
+
SCOPE_OFFLINE_ACCESS,
|
|
289
|
+
SCOPE_PHONE,
|
|
290
|
+
LoginOptions,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
auth_url, cookie = await client.start(
|
|
294
|
+
LoginOptions(
|
|
295
|
+
extra_scopes=[SCOPE_PHONE, SCOPE_OFFLINE_ACCESS],
|
|
296
|
+
prompt=[PROMPT_SELECT_ACCOUNT],
|
|
297
|
+
ui_locales=["vi", "en"],
|
|
298
|
+
max_age=timedelta(minutes=15), # re-authenticate if older; auth_time is checked
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
# Keyword arguments work too: await client.start(return_to="/jobs", extra_scopes=[...])
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Custom parameters go in `LoginOptions(params={"hd": "example.com"})`;
|
|
305
|
+
parameters with a dedicated option (`scope`, `prompt`, `state`, ...) raise
|
|
306
|
+
`ValueError` there.
|
|
307
|
+
|
|
308
|
+
## Refresh, revoke, userinfo
|
|
309
|
+
|
|
310
|
+
```python
|
|
311
|
+
from oidcauth import TOKEN_TYPE_HINT_REFRESH
|
|
312
|
+
|
|
313
|
+
res = await client.refresh(identity.tokens.refresh_token) # res.identity is None without an ID token
|
|
314
|
+
await client.revoke(refresh_token, TOKEN_TYPE_HINT_REFRESH)
|
|
315
|
+
claims = await client.userinfo(access_token, identity.subject)
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Most apps do not need these. They are for apps that call provider APIs on
|
|
319
|
+
behalf of the user, or that want to tie their own session lifetime to the
|
|
320
|
+
provider grant.
|
|
321
|
+
|
|
322
|
+
## Logout
|
|
323
|
+
|
|
324
|
+
```python
|
|
325
|
+
from oidcauth import LogoutToken
|
|
326
|
+
|
|
327
|
+
# RP-initiated: redirect the browser here after clearing the app session.
|
|
328
|
+
url = await client.logout_url(id_token, "https://app.example.com/", "")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# Back-channel: register https://app.example.com/api/v1/auth/oidc/backchannel-logout at the provider.
|
|
332
|
+
async def on_logout(request: Request, t: LogoutToken) -> None:
|
|
333
|
+
# End app sessions of t.subject (all) or t.session_id (Identity.session_id at login).
|
|
334
|
+
# Reject a t.jwt_id seen before t.expiry to stop replays.
|
|
335
|
+
await sessions.end_provider(t.subject, t.session_id)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
app.add_api_route(
|
|
339
|
+
"/api/v1/auth/oidc/backchannel-logout",
|
|
340
|
+
h.backchannel_logout(on_logout),
|
|
341
|
+
methods=["POST"],
|
|
342
|
+
)
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
The back-channel endpoint answers 200 on success, 400 for an invalid token,
|
|
346
|
+
502 when the provider keys cannot be fetched and 501 when `on_logout` raises.
|
|
347
|
+
|
|
348
|
+
## Client authentication
|
|
349
|
+
|
|
350
|
+
| Config | Method |
|
|
351
|
+
|---|---|
|
|
352
|
+
| `client_secret` | `client_secret_basic` (falls back to `client_secret_post` when discovery only lists that). Set `token_endpoint_auth_method=AUTH_METHOD_CLIENT_SECRET_POST` to force `client_secret_post` |
|
|
353
|
+
| `client_key` + `client_key_id` (PEM RSA or EC; Zitadel app key JSON `key` / `keyId`) | `private_key_jwt` |
|
|
354
|
+
| neither | `none`: a public client, protected by PKCE |
|
|
355
|
+
|
|
356
|
+
## Linking identities
|
|
357
|
+
|
|
358
|
+
Store `(identity.issuer, identity.subject)` as the unique key of an external
|
|
359
|
+
identity. Match by `email` only when `email_verified` is true, and only for
|
|
360
|
+
providers you trust to verify email.
|
|
361
|
+
|
|
362
|
+
## Zitadel notes
|
|
363
|
+
|
|
364
|
+
- Roles arrive in `urn:zitadel:iam:org:project:roles` once the project has
|
|
365
|
+
**"Return user roles during authentication"** enabled. Turn on **"Only
|
|
366
|
+
authorized users can authenticate"** to block users without a role
|
|
367
|
+
assignment.
|
|
368
|
+
- To pin login to one organization, use
|
|
369
|
+
`LoginOptions(extra_scopes=["urn:zitadel:iam:org:id:<orgId>"])`.
|
|
370
|
+
|
|
371
|
+
## Zitadel identity provider login without Zitadel's pages
|
|
372
|
+
|
|
373
|
+
The `oidcauth.zitadel` and `oidcauth.zitadel.fastapi` modules are optional and
|
|
374
|
+
**Zitadel-only**. With them, a "Sign in with Google" button can log in to
|
|
375
|
+
Zitadel without Zitadel's login UI: the browser only sees Google's page. The
|
|
376
|
+
app plays Zitadel's login UI through the v2 API, the way Login V2 does.
|
|
377
|
+
|
|
378
|
+
```text
|
|
379
|
+
GET /auth/google/login ZitadelIDPHandler.login
|
|
380
|
+
server side: authorize (not followed) → auth request V2_…
|
|
381
|
+
POST /v2/idp_intents → Google URL
|
|
382
|
+
→ Google → Zitadel /idps/callback
|
|
383
|
+
→ GET /auth/google/idp/callback?rq=… ZitadelIDPHandler.callback
|
|
384
|
+
retrieve intent → find / auto-link / auto-create the user
|
|
385
|
+
POST /v2/sessions → POST /v2/oidc/auth_requests/{id}
|
|
386
|
+
→ GET <redirect_url>?code=… OIDCHandler.callback (state, nonce, PKCE)
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
The result is a regular Zitadel login of the same client: same issuer,
|
|
390
|
+
subject, roles and back-channel logout.
|
|
391
|
+
|
|
392
|
+
```python
|
|
393
|
+
from oidcauth import LoginOptions
|
|
394
|
+
from oidcauth import zitadel
|
|
395
|
+
from oidcauth.zitadel.fastapi import ZitadelIDPHandler
|
|
396
|
+
|
|
397
|
+
api = zitadel.Client(client.config.issuer, os.environ["ZITADEL_SERVICE_PAT"])
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def idp_error(request: Request, err: Exception) -> Response:
|
|
401
|
+
if isinstance(err, zitadel.NotLinkedError): # no user, no auto-link/creation
|
|
402
|
+
...
|
|
403
|
+
elif isinstance(err, zitadel.MFARequiredError): # org forces MFA for external logins
|
|
404
|
+
...
|
|
405
|
+
return RedirectResponse("/login?error=...", 302)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
idp = ZitadelIDPHandler(
|
|
409
|
+
client,
|
|
410
|
+
zitadel.IDPLogin(api, google_idp_id),
|
|
411
|
+
tickets=tickets, # the same TicketStore as the login tickets is fine
|
|
412
|
+
callback_url="https://api.example.com/auth/google/idp/callback",
|
|
413
|
+
login_options=lambda request: LoginOptions(
|
|
414
|
+
extra_scopes=["urn:zitadel:iam:org:id:" + org_id]
|
|
415
|
+
),
|
|
416
|
+
on_error=idp_error,
|
|
417
|
+
)
|
|
418
|
+
app.add_api_route("/auth/google/login", idp.login, methods=["GET"])
|
|
419
|
+
app.add_api_route("/auth/google/idp/callback", idp.callback, methods=["GET"])
|
|
420
|
+
# The code still arrives at the client's callback (OIDCHandler), with on_success as usual.
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
- **Service user**: a Zitadel service user with the *instance* role
|
|
424
|
+
`IAM_LOGIN_CLIENT` and a personal access token.
|
|
425
|
+
- **Who can log in** follows the IdP options in Zitadel:
|
|
426
|
+
- an already linked account;
|
|
427
|
+
- *auto-linking* by verified email (exactly one matching user) or by
|
|
428
|
+
username, if the user's org allows external IdPs and offers this IdP for
|
|
429
|
+
linking;
|
|
430
|
+
- *auto-creation* (needs given and family name) into the org of the
|
|
431
|
+
`urn:zitadel:iam:org:id:` scope, or else the default org.
|
|
432
|
+
- **Errors** (`isinstance`; all `LoginError` subclasses carry `.detail`):
|
|
433
|
+
|
|
434
|
+
| Exception | Meaning |
|
|
435
|
+
|---|---|
|
|
436
|
+
| `zitadel.NotLinkedError` | no user found or created |
|
|
437
|
+
| `zitadel.NotAllowedError` | wrong org, or the org does not offer the IdP |
|
|
438
|
+
| `zitadel.AccessDeniedError` | no project grant |
|
|
439
|
+
| `zitadel.UserInactiveError` | user deactivated or locked |
|
|
440
|
+
| `zitadel.MFARequiredError` | org forces MFA for external logins |
|
|
441
|
+
| `zitadel.AuthRequestExpiredError` | auth request expired or already handled |
|
|
442
|
+
| `zitadel.IntentFailedError` | cancelled at the IdP |
|
|
443
|
+
| `oidcauth.InvalidStateError` | unknown or used ticket |
|
|
444
|
+
| `zitadel.APIError` | other API failures (`.status`, `.message`) |
|
|
445
|
+
| `zitadel.ZitadelError` | base class; also raised for network errors |
|
|
446
|
+
|
|
447
|
+
`zitadel.is_user_error` separates refusals (any `LoginError`, or an
|
|
448
|
+
`APIError` 4xx) from an unavailable Zitadel. The handler's default
|
|
449
|
+
`on_error` answers 401 / 502 accordingly.
|
|
450
|
+
- **Why server-side authorize**: with Login V2 required on the instance,
|
|
451
|
+
Zitadel ignores the per-app Login V2 base URI, so the app cannot be
|
|
452
|
+
registered as the login UI.
|
|
453
|
+
- `oidcauth.zitadel.testing.FakeZitadel` is a fake of the v2 API on top of
|
|
454
|
+
`oidcauth.testing.Provider`, for end-to-end tests.
|
|
455
|
+
|
|
456
|
+
## Testing
|
|
457
|
+
|
|
458
|
+
`oidcauth.testing.Provider` serves discovery, JWKS, token, userinfo,
|
|
459
|
+
revocation and end-session endpoints through an `httpx.MockTransport`, with
|
|
460
|
+
RS256-signed ID tokens. No network is needed:
|
|
461
|
+
|
|
462
|
+
```python
|
|
463
|
+
from oidcauth import Client, Config
|
|
464
|
+
from oidcauth.testing import Provider, User
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
async def test_login() -> None:
|
|
468
|
+
op = Provider("client", "secret")
|
|
469
|
+
client = Client(
|
|
470
|
+
Config(
|
|
471
|
+
issuer=op.issuer,
|
|
472
|
+
client_id="client",
|
|
473
|
+
client_secret="secret",
|
|
474
|
+
redirect_url="https://app.example.com/callback",
|
|
475
|
+
cookie_secret="0123456789abcdef0123456789abcdef",
|
|
476
|
+
http_client=op.http_client(),
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
auth_url, cookie = await client.start()
|
|
480
|
+
params = op.authorize(
|
|
481
|
+
auth_url, User(subject="u1", email="u1@example.com", email_verified=True)
|
|
482
|
+
)
|
|
483
|
+
res = await client.finish(params, cookie.value)
|
|
484
|
+
assert res.identity.subject == "u1"
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
`User` also takes `roles` (emitted as Zitadel project roles), `extra`,
|
|
488
|
+
`userinfo_only`, `amr`, `acr`, `auth_time`, phone and address. The provider
|
|
489
|
+
has knobs for negative tests: `op.down = True` (unreachable),
|
|
490
|
+
`op.mutate_id_token`, `op.grant_scopes`, `op.refresh_without_id_token`, and
|
|
491
|
+
`op.logout_token(sub, sid)` signs a back-channel logout token.
|
|
492
|
+
|
|
493
|
+
The Zitadel IdP login is tested with `FakeZitadel`:
|
|
494
|
+
|
|
495
|
+
```python
|
|
496
|
+
from oidcauth import zitadel
|
|
497
|
+
from oidcauth.zitadel.testing import (
|
|
498
|
+
DEFAULT_ORG_ID,
|
|
499
|
+
IDP_ID,
|
|
500
|
+
TOKEN,
|
|
501
|
+
External,
|
|
502
|
+
FakeZitadel,
|
|
503
|
+
User as ZitadelUser,
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
op = Provider("c1", "s1")
|
|
507
|
+
fake = FakeZitadel(op)
|
|
508
|
+
fake.users["u1"] = ZitadelUser(id="u1", org_id=DEFAULT_ORG_ID)
|
|
509
|
+
fake.external = External(id="g-1", linked_to="u1")
|
|
510
|
+
login = zitadel.IDPLogin(zitadel.Client(op.issuer, TOKEN, fake.http_client()), IDP_ID)
|
|
511
|
+
# After ZitadelIDPHandler.login, follow fake.success_redirect() (or
|
|
512
|
+
# fake.failure_redirect("access_denied")) with the browser cookies.
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
See `tests/test_fastapi.py` and `tests/test_zitadel_fastapi.py` for complete
|
|
516
|
+
FastAPI wiring driven through `httpx.ASGITransport`.
|
|
517
|
+
|
|
518
|
+
## Development
|
|
519
|
+
|
|
520
|
+
```bash
|
|
521
|
+
uv sync # create .venv with the dev group
|
|
522
|
+
uv run pytest # tests
|
|
523
|
+
uv run mypy # strict type check of src/oidcauth
|
|
524
|
+
uv run black src tests # format
|
|
525
|
+
uv run isort src tests
|
|
526
|
+
uv run pre-commit install # black, isort, mypy on commit
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
## License
|
|
530
|
+
|
|
531
|
+
MIT
|