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.
- authpi_idp-0.2.0/.gitignore +49 -0
- authpi_idp-0.2.0/CHANGELOG.md +11 -0
- authpi_idp-0.2.0/PKG-INFO +639 -0
- authpi_idp-0.2.0/README.md +607 -0
- authpi_idp-0.2.0/authpi_idp/__init__.py +64 -0
- authpi_idp-0.2.0/authpi_idp/agent.py +153 -0
- authpi_idp-0.2.0/authpi_idp/base.py +280 -0
- authpi_idp-0.2.0/authpi_idp/client.py +903 -0
- authpi_idp-0.2.0/authpi_idp/errors.py +102 -0
- authpi_idp-0.2.0/authpi_idp/py.typed +0 -0
- authpi_idp-0.2.0/authpi_idp/scopes.py +113 -0
- authpi_idp-0.2.0/authpi_idp/types.py +105 -0
- authpi_idp-0.2.0/pyproject.toml +69 -0
- authpi_idp-0.2.0/tests/__init__.py +1 -0
- authpi_idp-0.2.0/tests/conftest.py +117 -0
- authpi_idp-0.2.0/tests/test_agent.py +442 -0
- authpi_idp-0.2.0/tests/test_client.py +990 -0
- authpi_idp-0.2.0/tests/test_client_credentials.py +371 -0
- authpi_idp-0.2.0/tests/test_edge_cases.py +485 -0
- authpi_idp-0.2.0/tests/test_errors.py +56 -0
- authpi_idp-0.2.0/tests/test_scopes.py +161 -0
- authpi_idp-0.2.0/uv.lock +485 -0
|
@@ -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
|