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.
@@ -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