authpi-idp 0.2.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,49 @@
1
+ .DS_Store
2
+ node_modules
3
+ /build
4
+ .svelte-kit
5
+ /package
6
+ .wrangler
7
+ .env
8
+ .env.*
9
+ .dev.vars
10
+ !.env.example
11
+ !.env.e2e.example
12
+ vite.config.js.timestamp-*
13
+ vite.config.ts.timestamp-*
14
+ oracle.sql
15
+ dist
16
+ storybook-static
17
+ target
18
+ filter/
19
+ Cargo.lock
20
+ /sdk/core/
21
+ /sdk/oidc/
22
+ .stoplight
23
+ ratelimiter/
24
+ reproDo/
25
+ __pycache__/
26
+ .pytest_cache/
27
+ notebooks/
28
+ test-adyen/
29
+ stripe-authpi/
30
+ stripe-app/
31
+ secrets.json
32
+ .idea
33
+ .vscode
34
+ *.pem
35
+ *.key
36
+ *.crt
37
+ *.csr
38
+ *.env
39
+ output.txt
40
+ output.yaml
41
+ pnpm-lock.yaml
42
+ worker-configuration.d.ts
43
+
44
+ # Claude Code ephemeral artifacts
45
+ .claude/design-explorations/
46
+ .claude/plans/
47
+ .claude/worktrees/
48
+ .superpowers/
49
+ docs/plans/
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0](https://github.com/arbfay/authpi/compare/authpi-idp-v0.1.0...authpi-idp-v0.2.0) (2026-03-28)
4
+
5
+
6
+ ### Features
7
+
8
+ * SDK publishing pipeline with release-please ([#182](https://github.com/arbfay/authpi/issues/182)) ([b4f7858](https://github.com/arbfay/authpi/commit/b4f785848a973acc4f192ee737dc7c5a668ac73c))
9
+ * **sdk:** align IdP SDKs with updated INTERFACE.md spec ([#180](https://github.com/arbfay/authpi/issues/180)) ([21cc021](https://github.com/arbfay/authpi/commit/21cc021b4326edd586680f286819016322c79d99))
10
+ * **sdk:** Implement Python IdP SDK with TDD ([#91](https://github.com/arbfay/authpi/issues/91)) ([e6b7bff](https://github.com/arbfay/authpi/commit/e6b7bfffe6ffd7e28d3742a8b3589c16116e5c11))
11
+ * **sdk:** Prep work for IdP SDK implementation ([#84](https://github.com/arbfay/authpi/issues/84)) ([4f237e1](https://github.com/arbfay/authpi/commit/4f237e13bed72525aa9548f957ad009171addfd7))
@@ -0,0 +1,639 @@
1
+ Metadata-Version: 2.4
2
+ Name: authpi-idp
3
+ Version: 0.2.0
4
+ Summary: Official Python SDK for AuthPI identity provider
5
+ Project-URL: Homepage, https://authpi.com
6
+ Project-URL: Documentation, https://docs.authpi.com/sdk/python
7
+ Project-URL: Repository, https://github.com/arbfay/authpi
8
+ Author-email: AuthPI <hello@authpi.com>
9
+ License: MIT
10
+ Keywords: authentication,authpi,idp,oauth,oidc
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: httpx>=0.27.0
24
+ Requires-Dist: pydantic>=2.0.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: mypy>=1.13.0; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
28
+ Requires-Dist: pytest-httpx>=0.34.0; extra == 'dev'
29
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
30
+ Requires-Dist: ruff>=0.8.0; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # authpi-idp
34
+
35
+ Official Python SDK for AuthPI identity provider.
36
+
37
+ **Requires Python 3.11+**
38
+
39
+ ## Installation
40
+
41
+ ```bash
42
+ pip install authpi-idp
43
+ # or
44
+ poetry add authpi-idp
45
+ # or
46
+ uv add authpi-idp
47
+ ```
48
+
49
+ ## Quick Start
50
+
51
+ ```python
52
+ from authpi_idp import IdpClient
53
+
54
+ async def main():
55
+ idp = IdpClient(
56
+ issuer_url="https://idp.authpi.com/iss_xxx",
57
+ client_id="cli_xxx",
58
+ client_secret="secret", # omit for public clients (SPAs)
59
+ redirect_uri="https://app.example.com/callback",
60
+ )
61
+
62
+ # 1. Create authorization URL (sync - no await needed)
63
+ auth = idp.create_authorization_url(
64
+ scopes=["openid", "profile", "email"]
65
+ )
66
+
67
+ # 2. Store code_verifier, state, and nonce in session, redirect user to auth.url
68
+ session["oauth"] = {"code_verifier": auth.code_verifier, "state": auth.state, "nonce": auth.nonce}
69
+ # redirect(auth.url)
70
+
71
+ # 3. Handle callback - exchange code for authenticated agent
72
+ agent = await idp.exchange_code(code, auth.code_verifier)
73
+
74
+ # 4. Store tokens for future requests
75
+ session["tokens"] = agent.tokens.model_dump()
76
+
77
+ # 5. Check authorization
78
+ if agent.has_access_in("org_xxx", "write", "projects"):
79
+ # User can write to projects in org_xxx
80
+ pass
81
+ ```
82
+
83
+ ## Session Management
84
+
85
+ The SDK automatically refreshes expired tokens when creating an agent from stored tokens:
86
+
87
+ ```python
88
+ from authpi_idp import IdpClient
89
+
90
+ idp = IdpClient(
91
+ issuer_url="https://idp.authpi.com/iss_xxx",
92
+ client_id="cli_xxx",
93
+ redirect_uri="https://app.example.com/callback",
94
+ )
95
+
96
+ # Load tokens from your session store (dict or TokenSet)
97
+ tokens = session.get("tokens")
98
+
99
+ # create_agent() automatically refreshes if tokens are expired
100
+ agent = await idp.create_agent(
101
+ tokens,
102
+ # Called when tokens are refreshed - persist the new tokens (receives a dict)
103
+ on_refresh=lambda new_tokens: session.update({"tokens": new_tokens}),
104
+ # Called when refresh fails - handle the error
105
+ on_refresh_error=lambda error: print(f"Session expired: {error}"),
106
+ )
107
+ ```
108
+
109
+ ### Configuring Auto-Refresh
110
+
111
+ ```python
112
+ idp = IdpClient(
113
+ issuer_url="https://idp.authpi.com/iss_xxx",
114
+ client_id="cli_xxx",
115
+ redirect_uri="https://app.example.com/callback",
116
+ auto_refresh=True, # Default: True
117
+ refresh_buffer_seconds=60, # Refresh 60s before expiry (default)
118
+ )
119
+
120
+ # Or disable per-call
121
+ agent = await idp.create_agent(tokens, auto_refresh=False)
122
+ ```
123
+
124
+ ### Token Expiration
125
+
126
+ ```python
127
+ # Check expiration
128
+ agent.expires_at # Unix timestamp
129
+ agent.expires_in # Seconds until expiry (negative if expired)
130
+ agent.is_expired() # True if expires within 30 seconds (default clock skew buffer)
131
+ agent.is_expired(60) # True if expires within 60 seconds
132
+ agent.is_expired(0) # True only if actually expired (no buffer)
133
+ ```
134
+
135
+ ## Machine-to-Machine Authentication
136
+
137
+ For server-to-server or background service authentication using the client credentials flow:
138
+
139
+ ```python
140
+ from authpi_idp import IdpClient, PrincipalType
141
+
142
+ idp = IdpClient(
143
+ issuer_url="https://idp.authpi.com/iss_xxx",
144
+ client_id="agt_machine1",
145
+ client_secret="secret",
146
+ # No redirect_uri needed for client_credentials
147
+ )
148
+
149
+ agent = await idp.client_credentials(scopes=["users:read", "users:write"])
150
+
151
+ # Agent uses token-level scopes (no organizations)
152
+ agent.has_access("read", "users") # True
153
+ agent.type # PrincipalType.AGENT
154
+ ```
155
+
156
+ ## Sync Support
157
+
158
+ For non-async contexts, use the sync client:
159
+
160
+ ```python
161
+ from authpi_idp import IdpClientSync
162
+
163
+ idp = IdpClientSync(
164
+ issuer_url="https://idp.authpi.com/iss_xxx",
165
+ client_id="cli_xxx",
166
+ redirect_uri="https://app.example.com/callback",
167
+ )
168
+
169
+ auth = idp.create_authorization_url(scopes=["openid", "profile", "email"])
170
+ agent = idp.exchange_code(code, auth.code_verifier)
171
+ ```
172
+
173
+ ## Authorization
174
+
175
+ The SDK includes an **optional** authorization framework based on scopes. You can use it to make local authorization decisions without additional API calls, or you can ignore it entirely and implement your own authorization logic.
176
+
177
+ The authorization data comes from the `organizations` claim in the ID token, which AuthPI populates based on your organization and membership configuration.
178
+
179
+ ### Using the Built-in Authorization
180
+
181
+ ```python
182
+ # Check access across all organizations
183
+ agent.has_access("read", "users")
184
+
185
+ # Check access in a specific organization
186
+ agent.has_access_in("org_xxx", "write", "projects")
187
+ agent.has_access_in("org_xxx", "delete", "projects.tasks")
188
+
189
+ # Role checks
190
+ agent.is_owner_of("org_xxx") # Has "owner" scope
191
+ agent.is_admin_of("org_xxx") # Has "admin" scope
192
+ agent.is_member_of("org_xxx") # Has any membership
193
+
194
+ # Get scopes for an organization
195
+ scopes = agent.get_scopes_for("org_xxx")
196
+ # ["users:read", "projects:**"]
197
+ ```
198
+
199
+ ### Rolling Your Own Authorization
200
+
201
+ If the built-in scope system doesn't fit your needs, you can access the raw data directly:
202
+
203
+ ```python
204
+ # Access organizations directly
205
+ for org in agent.organizations:
206
+ print(org.id) # "org_xxx"
207
+ print(org.title) # "Admin" or None
208
+ print(org.scopes) # ["users:read", "projects:**"]
209
+ print(org.joined_at) # Unix timestamp
210
+
211
+ # Use agent.id for your own authorization lookups
212
+ permissions = my_permission_service.get_permissions(agent.id)
213
+ ```
214
+
215
+ ### How Scopes Work
216
+
217
+ AuthPI uses a hierarchical scope format: `resource:action`
218
+
219
+ **Basic format:**
220
+ ```
221
+ resource:action
222
+ resource.subresource:action
223
+ ```
224
+
225
+ **Examples:**
226
+ - `users:read` — Can read users
227
+ - `users:write` — Can create/update users
228
+ - `projects.tasks:delete` — Can delete tasks within projects
229
+
230
+ **Wildcards:**
231
+
232
+ | Pattern | Description |
233
+ |---------|-------------|
234
+ | `users:*` | All actions on users (but not sub-resources) |
235
+ | `users:**` | All actions on users AND all sub-resources |
236
+ | `*:read` | Read access to all top-level resources |
237
+ | `*:**` | Full access to everything (super-admin) |
238
+
239
+ **The difference between `*` and `**`:**
240
+
241
+ - `projects:*` grants `projects:read`, `projects:write`, `projects:delete`
242
+ - `projects:*` does NOT grant `projects.tasks:read` (sub-resource)
243
+ - `projects:**` grants all of the above PLUS `projects.tasks:read`, `projects.tasks.comments:write`, etc.
244
+
245
+ **Scope evaluation example:**
246
+
247
+ ```python
248
+ # User has scopes: ["projects:**", "users:read"]
249
+
250
+ # These all return True:
251
+ agent.has_access_in("org_xxx", "read", "projects")
252
+ agent.has_access_in("org_xxx", "write", "projects")
253
+ agent.has_access_in("org_xxx", "delete", "projects.tasks")
254
+ agent.has_access_in("org_xxx", "read", "projects.tasks.comments")
255
+ agent.has_access_in("org_xxx", "read", "users")
256
+
257
+ # These return False:
258
+ agent.has_access_in("org_xxx", "write", "users") # Only has users:read
259
+ agent.has_access_in("org_xxx", "read", "billing") # No billing scope
260
+ ```
261
+
262
+ ### Special Role Scopes
263
+
264
+ Three scopes have special meaning and dedicated helper methods:
265
+
266
+ | Scope | Method | Typical Use |
267
+ |-------|--------|-------------|
268
+ | `owner` | `is_owner_of(org_id)` | Organization billing, deletion, transfer |
269
+ | `admin` | `is_admin_of(org_id)` | Member management, settings |
270
+ | `member` | `is_member_of(org_id)` | Basic membership check |
271
+
272
+ These are checked directly (not via wildcard expansion):
273
+
274
+ ```python
275
+ # User has scopes: ["owner", "admin", "projects:**"]
276
+ agent.is_owner_of("org_xxx") # True - has "owner" scope
277
+ agent.is_admin_of("org_xxx") # True - has "admin" scope
278
+ agent.is_member_of("org_xxx") # True - has any membership
279
+
280
+ # Note: "*:**" does NOT grant owner/admin status
281
+ # These are explicit role assignments, not permissions
282
+ ```
283
+
284
+ ### Scope Utilities
285
+
286
+ For advanced use cases, you can use the scope utilities directly:
287
+
288
+ ```python
289
+ from authpi_idp import has_access, parse_scope
290
+
291
+ # Check if a list of scopes grants access
292
+ scopes = ["users:read", "projects:**"]
293
+ has_access(scopes, "read", "users") # True
294
+ has_access(scopes, "write", "projects.tasks") # True (** is recursive)
295
+ has_access(scopes, "delete", "users") # False
296
+
297
+ # Parse a scope string into its components
298
+ parsed = parse_scope("users.verifiers:write")
299
+ # ParsedScope(resource='users.verifiers', action='write')
300
+
301
+ parsed = parse_scope("projects:**")
302
+ # ParsedScope(resource='projects', action='**')
303
+ ```
304
+
305
+ ## Error Handling
306
+
307
+ The SDK provides specific error types for different failure modes:
308
+
309
+ ```python
310
+ from authpi_idp import (
311
+ OAuthError,
312
+ TokenExpiredError,
313
+ RefreshError,
314
+ TokenParseError,
315
+ SubjectMismatchError,
316
+ ConfigurationError,
317
+ InsufficientScopeError,
318
+ SessionExpiredError,
319
+ UserBlockedError,
320
+ AccountLinkingRequiredError,
321
+ InteractionRequiredError,
322
+ LoginRequiredError,
323
+ ConsentRequiredError,
324
+ )
325
+
326
+ try:
327
+ agent = await idp.create_agent(tokens)
328
+ except TokenExpiredError:
329
+ # Token expired and no refresh token available
330
+ redirect_to_login()
331
+ except RefreshError as error:
332
+ # Refresh request failed (e.g., refresh token revoked)
333
+ print(f"Refresh failed: {error.error_description}")
334
+ print(f"Status: {error.status_code}")
335
+ redirect_to_login()
336
+ except TokenParseError:
337
+ # ID token missing or malformed
338
+ print("Invalid token data")
339
+ except SubjectMismatchError as error:
340
+ # Security: refreshed token belongs to different user
341
+ print(f"Expected {error.expected_sub}, got {error.actual_sub}")
342
+ ```
343
+
344
+ ### Error Hierarchy
345
+
346
+ All OAuth errors extend `OAuthError`:
347
+
348
+ ```python
349
+ class OAuthError(Exception):
350
+ error: str # OAuth error code
351
+ error_description: str | None
352
+
353
+ class TokenExpiredError(OAuthError): ...
354
+ class RefreshError(OAuthError):
355
+ status_code: int | None # HTTP status from token endpoint
356
+
357
+ class TokenParseError(OAuthError): ...
358
+ class SubjectMismatchError(OAuthError):
359
+ expected_sub: str
360
+ actual_sub: str
361
+
362
+ # AuthPI-specific errors
363
+ class InsufficientScopeError(OAuthError): ... # Token lacks required scope
364
+ class SessionExpiredError(OAuthError): ... # Server-side session timed out
365
+ class UserBlockedError(OAuthError): ... # Blocked user attempted auth
366
+ class AccountLinkingRequiredError(OAuthError): ... # OAuth identity needs linking
367
+
368
+ # OIDC authorization endpoint errors
369
+ class InteractionRequiredError(OAuthError): ... # Silent auth failed
370
+ class LoginRequiredError(OAuthError): ... # No active session
371
+ class ConsentRequiredError(OAuthError): ... # User hasn't consented
372
+ ```
373
+
374
+ ## User Info
375
+
376
+ For full profile data beyond the ID token claims:
377
+
378
+ ```python
379
+ userinfo = await idp.get_user_info(agent)
380
+
381
+ userinfo.sub # "usr_xxx"
382
+ userinfo.email # "user@example.com"
383
+ userinfo.name # "John Doe"
384
+ userinfo.picture # "https://..."
385
+ userinfo.organizations # [Organization(...), ...]
386
+ ```
387
+
388
+ ## Logout
389
+
390
+ ```python
391
+ from authpi_idp import LogoutOptions
392
+
393
+ logout_url = idp.create_logout_url(
394
+ LogoutOptions(
395
+ id_token_hint=agent.tokens.id_token,
396
+ post_logout_redirect_uri="https://app.example.com",
397
+ state="logout_state",
398
+ )
399
+ )
400
+
401
+ # redirect(logout_url)
402
+ ```
403
+
404
+ ## Token Revocation
405
+
406
+ ```python
407
+ # Revoke refresh token (recommended on logout)
408
+ await idp.revoke_token(agent.tokens.refresh_token, "refresh_token")
409
+
410
+ # Revoke access token
411
+ await idp.revoke_token(agent.tokens.access_token, "access_token")
412
+ ```
413
+
414
+ ## API Reference
415
+
416
+ ### IdpClient / IdpClientSync
417
+
418
+ ```python
419
+ IdpClient(
420
+ issuer_url: str, # OIDC issuer URL
421
+ client_id: str, # OAuth client ID
422
+ redirect_uri: str | None = None, # Required for auth code flow, optional for client_credentials
423
+ client_secret: str | None = None, # Optional for public clients
424
+ auto_refresh: bool = True, # Auto-refresh expired tokens
425
+ refresh_buffer_seconds: int = 60, # Refresh buffer
426
+ )
427
+ ```
428
+
429
+ **Methods:**
430
+
431
+ | Method | Description |
432
+ |--------|-------------|
433
+ | `create_authorization_url(scopes, state?, nonce?)` | Create OAuth authorization URL with PKCE |
434
+ | `exchange_code(code, code_verifier)` | Exchange authorization code for agent |
435
+ | `create_agent(tokens, *, on_refresh?, on_refresh_error?, auto_refresh?)` | Create agent from stored tokens (auto-refreshes) |
436
+ | `refresh(agent)` | Manually refresh tokens |
437
+ | `get_user_info(agent)` | Fetch full user profile |
438
+ | `create_logout_url(options?)` | Create OIDC logout URL |
439
+ | `client_credentials(scopes?)` | Authenticate via client credentials (M2M) |
440
+ | `revoke_token(token, hint?)` | Revoke a token |
441
+
442
+ ### AuthenticatedAgent
443
+
444
+ ```python
445
+ class AuthenticatedAgent:
446
+ # Identity
447
+ id: str # Subject ID (usr_xxx, tok_xxx, key_xxx, agt_xxx)
448
+ type: PrincipalType # "user" | "personal_token" | "api_key" | "agent"
449
+ email: str | None
450
+ email_verified: bool | None
451
+
452
+ # Tokens (for storage)
453
+ tokens: TokenSet
454
+
455
+ # Organizations (from ID token)
456
+ organizations: list[Organization]
457
+
458
+ # Expiration
459
+ @property
460
+ def expires_at(self) -> int: ... # Unix timestamp
461
+ @property
462
+ def expires_in(self) -> int: ... # Seconds until expiry
463
+ def is_expired(self, buffer: int = 30) -> bool: ...
464
+
465
+ # Authorization
466
+ def has_access(self, action: str, resource: str) -> bool: ...
467
+ def has_access_in(self, org_id: str, action: str, resource: str) -> bool: ...
468
+ def get_scopes_for(self, org_id: str) -> list[str]: ...
469
+ def is_owner_of(self, org_id: str) -> bool: ...
470
+ def is_admin_of(self, org_id: str) -> bool: ...
471
+ def is_member_of(self, org_id: str) -> bool: ...
472
+ ```
473
+
474
+ ## Framework Examples
475
+
476
+ ### FastAPI
477
+
478
+ ```python
479
+ from fastapi import FastAPI, Request, Depends, HTTPException
480
+ from fastapi.responses import RedirectResponse
481
+ from authpi_idp import IdpClient, TokenExpiredError
482
+
483
+ app = FastAPI()
484
+ idp = IdpClient(
485
+ issuer_url="https://idp.authpi.com/iss_xxx",
486
+ client_id="cli_xxx",
487
+ redirect_uri="http://localhost:8000/callback",
488
+ )
489
+
490
+
491
+ @app.get("/login")
492
+ async def login(request: Request):
493
+ auth = idp.create_authorization_url(scopes=["openid", "profile", "email"])
494
+ request.session["code_verifier"] = auth.code_verifier
495
+ request.session["state"] = auth.state
496
+ return RedirectResponse(auth.url)
497
+
498
+
499
+ @app.get("/callback")
500
+ async def callback(request: Request, code: str, state: str):
501
+ if state != request.session.get("state"):
502
+ return {"error": "Invalid state"}
503
+
504
+ agent = await idp.exchange_code(code, request.session["code_verifier"])
505
+ request.session["tokens"] = agent.tokens.model_dump()
506
+ return RedirectResponse("/dashboard")
507
+
508
+
509
+ async def get_agent(request: Request):
510
+ tokens_data = request.session.get("tokens")
511
+ if not tokens_data:
512
+ raise HTTPException(status_code=401)
513
+
514
+ try:
515
+ return await idp.create_agent(
516
+ tokens_data,
517
+ on_refresh=lambda t: request.session.update({"tokens": t}),
518
+ )
519
+ except TokenExpiredError:
520
+ raise HTTPException(status_code=401)
521
+
522
+
523
+ @app.get("/dashboard")
524
+ async def dashboard(agent=Depends(get_agent)):
525
+ if not agent.has_access_in("org_xxx", "read", "dashboard"):
526
+ raise HTTPException(status_code=403)
527
+ return {"user": agent.id, "email": agent.email}
528
+ ```
529
+
530
+ ### Django
531
+
532
+ ```python
533
+ from django.shortcuts import redirect
534
+ from django.http import HttpResponse
535
+ from authpi_idp import IdpClientSync
536
+
537
+ idp = IdpClientSync(
538
+ issuer_url="https://idp.authpi.com/iss_xxx",
539
+ client_id="cli_xxx",
540
+ redirect_uri="http://localhost:8000/callback",
541
+ )
542
+
543
+
544
+ def login(request):
545
+ auth = idp.create_authorization_url(scopes=["openid", "profile", "email"])
546
+ request.session["code_verifier"] = auth.code_verifier
547
+ request.session["state"] = auth.state
548
+ return redirect(auth.url)
549
+
550
+
551
+ def callback(request):
552
+ code = request.GET.get("code")
553
+ state = request.GET.get("state")
554
+
555
+ if state != request.session.get("state"):
556
+ return HttpResponse("Invalid state", status=400)
557
+
558
+ agent = idp.exchange_code(code, request.session["code_verifier"])
559
+ request.session["tokens"] = agent.tokens.model_dump()
560
+ return redirect("/dashboard")
561
+
562
+
563
+ def dashboard(request):
564
+ tokens_data = request.session.get("tokens")
565
+ if not tokens_data:
566
+ return redirect("/login")
567
+
568
+ agent = idp.create_agent(tokens_data)
569
+
570
+ if not agent.has_access_in("org_xxx", "read", "dashboard"):
571
+ return HttpResponse("Forbidden", status=403)
572
+
573
+ return HttpResponse(f"Welcome, {agent.email}")
574
+ ```
575
+
576
+ ### Flask
577
+
578
+ ```python
579
+ from flask import Flask, redirect, session, request
580
+ from authpi_idp import IdpClientSync
581
+
582
+ app = Flask(__name__)
583
+ app.secret_key = "your-secret-key"
584
+
585
+ idp = IdpClientSync(
586
+ issuer_url="https://idp.authpi.com/iss_xxx",
587
+ client_id="cli_xxx",
588
+ redirect_uri="http://localhost:5000/callback",
589
+ )
590
+
591
+
592
+ @app.route("/login")
593
+ def login():
594
+ auth = idp.create_authorization_url(scopes=["openid", "profile", "email"])
595
+ session["code_verifier"] = auth.code_verifier
596
+ session["state"] = auth.state
597
+ return redirect(auth.url)
598
+
599
+
600
+ @app.route("/callback")
601
+ def callback():
602
+ code = request.args.get("code")
603
+ state = request.args.get("state")
604
+
605
+ if state != session.get("state"):
606
+ return "Invalid state", 400
607
+
608
+ agent = idp.exchange_code(code, session["code_verifier"])
609
+ session["tokens"] = agent.tokens.model_dump()
610
+ return redirect("/dashboard")
611
+
612
+
613
+ @app.route("/dashboard")
614
+ def dashboard():
615
+ tokens_data = session.get("tokens")
616
+ if not tokens_data:
617
+ return redirect("/login")
618
+
619
+ agent = idp.create_agent(tokens_data)
620
+ return f"Welcome, {agent.email}"
621
+ ```
622
+
623
+ ## Context Manager Support
624
+
625
+ Both clients support context managers for proper resource cleanup:
626
+
627
+ ```python
628
+ # Async
629
+ async with IdpClient(...) as idp:
630
+ auth = idp.create_authorization_url(scopes=["openid"])
631
+
632
+ # Sync
633
+ with IdpClientSync(...) as idp:
634
+ auth = idp.create_authorization_url(scopes=["openid"])
635
+ ```
636
+
637
+ ## License
638
+
639
+ MIT