byoi 0.1.0a1__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.
byoi-0.1.0a1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 to present ndiverge and individual contributors.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
byoi-0.1.0a1/PKG-INFO ADDED
@@ -0,0 +1,504 @@
1
+ Metadata-Version: 2.4
2
+ Name: byoi
3
+ Version: 0.1.0a1
4
+ Summary: Bring-Your-Own-Identity server library for FastAPI applications.
5
+ Keywords: fastapi,authentication,oidc,openid-connect,oauth2,identity,jwt,pkce,sso,single-sign-on,identity-provider,security
6
+ Author: Wietse Sas
7
+ Author-email: Wietse Sas <wietse.sas@ndiverge.eu>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Framework :: FastAPI
19
+ Classifier: Framework :: Pydantic
20
+ Classifier: Framework :: Pydantic :: 2
21
+ Classifier: Topic :: Internet :: WWW/HTTP :: Session
22
+ Classifier: Topic :: Security
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Typing :: Typed
25
+ Requires-Dist: fastapi>=0.128.0,<1.0.0
26
+ Requires-Dist: httpx>=0.28.1,<1.0.0
27
+ Requires-Dist: pydantic>=2.12.5,<3.0.0
28
+ Requires-Dist: python-jose[cryptography]>=3.5.0,<4.0.0
29
+ Requires-Dist: redis>=7.1.0,<8.0.0 ; extra == 'all'
30
+ Requires-Dist: opentelemetry-api>=1.20.0,<2.0.0 ; extra == 'all'
31
+ Requires-Dist: opentelemetry-sdk>=1.20.0,<2.0.0 ; extra == 'all'
32
+ Requires-Dist: redis>=7.1.0,<8.0.0 ; extra == 'redis'
33
+ Requires-Dist: opentelemetry-api>=1.20.0,<2.0.0 ; extra == 'telemetry'
34
+ Requires-Dist: opentelemetry-sdk>=1.20.0,<2.0.0 ; extra == 'telemetry'
35
+ Maintainer: Wietse Sas
36
+ Maintainer-email: Wietse Sas <wietse.sas@ndiverge.eu>
37
+ Requires-Python: >=3.11
38
+ Project-URL: Homepage, https://www.ndiverge.eu/byoi
39
+ Project-URL: Documentation, https://www.ndiverge.eu/byoi/docs
40
+ Project-URL: Repository, https://github.com/ndiverge/byoi
41
+ Project-URL: Issues, https://github.com/ndiverge/byoi/issues
42
+ Project-URL: Changelog, https://www.ndiverge.eu/byoi/release-notes
43
+ Provides-Extra: all
44
+ Provides-Extra: redis
45
+ Provides-Extra: telemetry
46
+ Description-Content-Type: text/markdown
47
+
48
+ # BYOI - Bring Your Own Identity
49
+
50
+ A server library for FastAPI applications that provides OIDC (OpenID Connect) authentication with support for multiple identity providers.
51
+
52
+ ## Features
53
+
54
+ - **PKCE (Proof Key for Code Exchange)** - Prevent authorization code interception attacks
55
+ - **ID Token Validation** - Full JWT validation with JWKS fetching and caching
56
+ - **Identity Linking** - Users can link multiple providers (Google + Microsoft, etc.)
57
+ - **Nonce Validation** - Prevents replay attacks
58
+ - **Proper Error Handling** - Clean error responses throughout
59
+ - **FastAPI Dependencies** - Easy integration with dependency injection
60
+ - **Multiple Client Types** - Support for web, desktop, and mobile applications
61
+ - **Flexible Caching** - In-memory and Redis cache implementations
62
+ - **OpenTelemetry Support** - Optional tracing integration for observability
63
+
64
+ ## Installation
65
+
66
+ ```bash
67
+ pip install byoi
68
+ ```
69
+
70
+ For Redis cache support:
71
+ ```bash
72
+ pip install byoi[redis]
73
+ ```
74
+
75
+ For OpenTelemetry support:
76
+ ```bash
77
+ pip install byoi[telemetry]
78
+ ```
79
+
80
+ For all optional dependencies:
81
+ ```bash
82
+ pip install byoi[all]
83
+ ```
84
+
85
+ ## Quick Start
86
+
87
+ ### 1. Implement the Repository Protocols
88
+
89
+ BYOI requires you to implement three repository protocols for data persistence:
90
+
91
+ ```python
92
+ from datetime import datetime
93
+ from typing import Any
94
+ from byoi import (
95
+ UserProtocol,
96
+ LinkedIdentityProtocol,
97
+ AuthStateProtocol,
98
+ UserRepositoryProtocol,
99
+ LinkedIdentityRepositoryProtocol,
100
+ AuthStateRepositoryProtocol,
101
+ )
102
+
103
+ # Example User implementation
104
+ class User:
105
+ def __init__(self, id: str, email: str | None, is_active: bool = True):
106
+ self.id = id
107
+ self.email = email
108
+ self.is_active = is_active
109
+
110
+ # Example UserRepository implementation
111
+ class UserRepository:
112
+ def __init__(self):
113
+ self._users: dict[str, User] = {}
114
+
115
+ async def get_by_id(self, user_id: str) -> User | None:
116
+ return self._users.get(user_id)
117
+
118
+ async def get_by_email(self, email: str) -> User | None:
119
+ for user in self._users.values():
120
+ if user.email == email:
121
+ return user
122
+ return None
123
+
124
+ async def create(
125
+ self,
126
+ email: str | None = None,
127
+ is_active: bool = True,
128
+ **extra_data: Any,
129
+ ) -> User:
130
+ import uuid
131
+ user = User(id=str(uuid.uuid4()), email=email, is_active=is_active)
132
+ self._users[user.id] = user
133
+ return user
134
+
135
+ async def update(self, user_id: str, **data: Any) -> User | None:
136
+ user = self._users.get(user_id)
137
+ if user:
138
+ for key, value in data.items():
139
+ setattr(user, key, value)
140
+ return user
141
+
142
+ # Implement LinkedIdentityRepository and AuthStateRepository similarly...
143
+ ```
144
+
145
+ ### 2. Configure BYOI
146
+
147
+ ```python
148
+ from fastapi import FastAPI
149
+ from byoi import BYOI, BYOIConfig, OIDCProviderConfig
150
+
151
+ # Create repositories
152
+ user_repo = UserRepository()
153
+ identity_repo = LinkedIdentityRepository()
154
+ auth_state_repo = AuthStateRepository()
155
+
156
+ # Configure BYOI
157
+ config = BYOIConfig(
158
+ user_repository=user_repo,
159
+ identity_repository=identity_repo,
160
+ auth_state_repository=auth_state_repo,
161
+ # cache is optional - defaults to InMemoryCache if not provided
162
+ providers=[
163
+ OIDCProviderConfig(
164
+ name="google",
165
+ display_name="Google",
166
+ issuer="https://accounts.google.com",
167
+ client_id="your-google-client-id",
168
+ client_secret="your-google-client-secret",
169
+ ),
170
+ OIDCProviderConfig(
171
+ name="microsoft",
172
+ display_name="Microsoft",
173
+ issuer="https://login.microsoftonline.com/{tenant}/v2.0",
174
+ client_id="your-microsoft-client-id",
175
+ client_secret="your-microsoft-client-secret",
176
+ ),
177
+ ],
178
+ )
179
+
180
+ # Create BYOI instance
181
+ byoi = BYOI(config)
182
+
183
+ # Create FastAPI app with BYOI lifespan
184
+ app = FastAPI(lifespan=byoi.lifespan)
185
+ ```
186
+
187
+ ### 3. Create Authentication Routes
188
+
189
+ ```python
190
+ from fastapi import FastAPI, HTTPException
191
+ from byoi import (
192
+ AuthServiceDep,
193
+ ProviderManagerDep,
194
+ AuthorizationRequest,
195
+ TokenExchangeRequest,
196
+ ClientType,
197
+ InvalidStateError,
198
+ StateExpiredError,
199
+ TokenExchangeError,
200
+ )
201
+
202
+ @app.get("/auth/providers")
203
+ async def list_providers(provider_manager: ProviderManagerDep):
204
+ """List all configured identity providers."""
205
+ return provider_manager.list_providers()
206
+
207
+
208
+ @app.post("/auth/authorize")
209
+ async def create_authorization_url(
210
+ provider_name: str,
211
+ redirect_uri: str,
212
+ auth_service: AuthServiceDep,
213
+ ):
214
+ """Create an authorization URL for OAuth flow."""
215
+ request = AuthorizationRequest(
216
+ provider_name=provider_name,
217
+ redirect_uri=redirect_uri,
218
+ client_type=ClientType.WEB,
219
+ )
220
+ return await auth_service.create_authorization_url(request)
221
+
222
+
223
+ @app.post("/auth/callback")
224
+ async def handle_callback(
225
+ code: str,
226
+ state: str,
227
+ redirect_uri: str,
228
+ auth_service: AuthServiceDep,
229
+ ):
230
+ """Handle OAuth callback and authenticate user."""
231
+ try:
232
+ request = TokenExchangeRequest(
233
+ code=code,
234
+ state=state,
235
+ redirect_uri=redirect_uri,
236
+ )
237
+ user = await auth_service.authenticate(request)
238
+ # Create your session/JWT here
239
+ return {"user_id": user.user_id, "is_new": user.is_new_user}
240
+ except InvalidStateError:
241
+ raise HTTPException(400, "Invalid state parameter")
242
+ except StateExpiredError:
243
+ raise HTTPException(400, "Authorization request expired")
244
+ except TokenExchangeError as e:
245
+ raise HTTPException(400, f"Authentication failed: {e.message}")
246
+ ```
247
+
248
+ ## Identity Linking
249
+
250
+ Allow users to link multiple identity providers to their account:
251
+
252
+ ```python
253
+ @app.post("/auth/link")
254
+ async def link_identity(
255
+ user_id: str, # From your session/JWT
256
+ code: str,
257
+ state: str,
258
+ redirect_uri: str,
259
+ auth_service: AuthServiceDep,
260
+ ):
261
+ """Link a new identity provider to user's account."""
262
+ from byoi import LinkIdentityRequest, IdentityAlreadyLinkedError
263
+
264
+ try:
265
+ request = LinkIdentityRequest(
266
+ user_id=user_id,
267
+ code=code,
268
+ state=state,
269
+ redirect_uri=redirect_uri,
270
+ )
271
+ return await auth_service.link_identity(request)
272
+ except IdentityAlreadyLinkedError:
273
+ raise HTTPException(400, "This identity is already linked to another account")
274
+
275
+
276
+ @app.get("/auth/identities/{user_id}")
277
+ async def get_user_identities(
278
+ user_id: str,
279
+ auth_service: AuthServiceDep,
280
+ ):
281
+ """Get all linked identities for a user."""
282
+ return await auth_service.get_user_identities(user_id)
283
+
284
+
285
+ @app.delete("/auth/identities/{user_id}/{identity_id}")
286
+ async def unlink_identity(
287
+ user_id: str,
288
+ identity_id: str,
289
+ auth_service: AuthServiceDep,
290
+ ):
291
+ """Unlink an identity from a user's account."""
292
+ from byoi import UnlinkIdentityRequest
293
+
294
+ request = UnlinkIdentityRequest(user_id=user_id, identity_id=identity_id)
295
+ return await auth_service.unlink_identity(request)
296
+ ```
297
+
298
+ ## Caching
299
+
300
+ BYOI supports multiple cache implementations:
301
+
302
+ ### In-Memory Cache (Default)
303
+
304
+ ```python
305
+ from byoi import InMemoryCache
306
+
307
+ cache = InMemoryCache(cleanup_interval_seconds=300)
308
+ ```
309
+
310
+ ### Redis Cache
311
+
312
+ ```python
313
+ from byoi import RedisCache
314
+
315
+ cache = RedisCache(
316
+ url="redis://localhost:6379/0",
317
+ key_prefix="byoi:",
318
+ max_connections=10, # optional
319
+ )
320
+ ```
321
+
322
+ ### Null Cache
323
+
324
+ A no-op cache implementation that doesn't cache anything. Useful for testing or when caching is not desired.
325
+
326
+ ```python
327
+ from byoi import NullCache
328
+
329
+ cache = NullCache()
330
+ ```
331
+
332
+ ### Custom Cache
333
+
334
+ Implement the `CacheProtocol`:
335
+
336
+ ```python
337
+ from byoi import CacheProtocol
338
+
339
+ class MyCache(CacheProtocol):
340
+ async def get(self, key: str) -> Any | None:
341
+ ...
342
+
343
+ async def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None:
344
+ ...
345
+
346
+ async def delete(self, key: str) -> bool:
347
+ ...
348
+
349
+ async def clear(self) -> None:
350
+ ...
351
+
352
+ async def close(self) -> None:
353
+ ...
354
+ ```
355
+
356
+ ## Adding Custom Providers
357
+
358
+ ```python
359
+ from byoi import OIDCProviderConfig
360
+
361
+ # Standard OIDC provider
362
+ okta_config = OIDCProviderConfig(
363
+ name="okta",
364
+ display_name="Okta",
365
+ issuer="https://your-domain.okta.com",
366
+ client_id="your-client-id",
367
+ client_secret="your-client-secret",
368
+ )
369
+
370
+ # Custom provider with overrides
371
+ custom_config = OIDCProviderConfig(
372
+ name="custom",
373
+ display_name="Custom IDP",
374
+ issuer="https://idp.example.com",
375
+ client_id="client-id",
376
+ client_secret="client-secret",
377
+ # Override discovered endpoints if needed
378
+ authorization_endpoint_override="https://idp.example.com/oauth/authorize",
379
+ token_endpoint_override="https://idp.example.com/oauth/token",
380
+ jwks_uri_override="https://idp.example.com/.well-known/jwks.json",
381
+ # Custom scopes
382
+ scopes=["openid", "email", "profile", "custom_scope"],
383
+ # Extra auth parameters
384
+ extra_auth_params={"prompt": "consent"},
385
+ )
386
+
387
+ # Add providers after setup
388
+ byoi.add_provider(custom_config)
389
+ ```
390
+
391
+ ## Error Handling
392
+
393
+ BYOI provides specific exception types for different error scenarios:
394
+
395
+ ```python
396
+ from byoi import (
397
+ BYOIError, # Base exception for all BYOI errors
398
+ ConfigurationError, # Configuration problems
399
+ # Provider errors
400
+ ProviderError, # Base class for provider-related errors
401
+ ProviderNotFoundError, # Unknown provider
402
+ ProviderDiscoveryError, # OIDC discovery failed
403
+ # Authentication errors
404
+ AuthenticationError, # Base class for authentication errors
405
+ InvalidStateError, # Invalid OAuth state
406
+ StateExpiredError, # OAuth state expired
407
+ InvalidCodeError, # Invalid authorization code
408
+ TokenExchangeError, # Token exchange failed
409
+ TokenRefreshError, # Token refresh failed
410
+ # Token validation errors
411
+ TokenValidationError, # ID token validation failed
412
+ InvalidNonceError, # Nonce mismatch
413
+ InvalidIssuerError, # Issuer mismatch
414
+ InvalidAudienceError, # Audience mismatch
415
+ TokenExpiredError, # Token has expired
416
+ JWKSFetchError, # Failed to fetch JWKS
417
+ # Identity errors
418
+ IdentityError, # Base class for identity errors
419
+ IdentityAlreadyLinkedError, # Identity already linked to another account
420
+ IdentityNotFoundError, # Identity not found
421
+ CannotUnlinkLastIdentityError, # Cannot unlink the last identity
422
+ UserNotFoundError, # User not found
423
+ # Cache errors
424
+ CacheError, # Cache operation failed
425
+ )
426
+ ```
427
+
428
+ ## Protocols Reference
429
+
430
+ ### UserProtocol
431
+
432
+ ```python
433
+ class UserProtocol(Protocol):
434
+ @property
435
+ def id(self) -> str: ...
436
+
437
+ @property
438
+ def email(self) -> str | None: ...
439
+
440
+ @property
441
+ def is_active(self) -> bool: ...
442
+ ```
443
+
444
+ ### LinkedIdentityProtocol
445
+
446
+ ```python
447
+ class LinkedIdentityProtocol(Protocol):
448
+ @property
449
+ def id(self) -> str: ...
450
+
451
+ @property
452
+ def user_id(self) -> str: ...
453
+
454
+ @property
455
+ def provider_name(self) -> str: ...
456
+
457
+ @property
458
+ def provider_subject(self) -> str: ...
459
+
460
+ @property
461
+ def email(self) -> str | None: ...
462
+
463
+ @property
464
+ def created_at(self) -> datetime: ...
465
+
466
+ @property
467
+ def last_used_at(self) -> datetime | None: ...
468
+ ```
469
+
470
+ ### AuthStateProtocol
471
+
472
+ ```python
473
+ class AuthStateProtocol(Protocol):
474
+ @property
475
+ def state(self) -> str: ...
476
+
477
+ @property
478
+ def code_verifier(self) -> str: ...
479
+
480
+ @property
481
+ def nonce(self) -> str: ...
482
+
483
+ @property
484
+ def provider_name(self) -> str: ...
485
+
486
+ @property
487
+ def redirect_uri(self) -> str: ...
488
+
489
+ @property
490
+ def created_at(self) -> datetime: ...
491
+
492
+ @property
493
+ def expires_at(self) -> datetime: ...
494
+
495
+ @property
496
+ def client_type(self) -> str: ...
497
+
498
+ @property
499
+ def extra_data(self) -> dict[str, Any]: ...
500
+ ```
501
+
502
+ ## License
503
+
504
+ MIT License - see LICENSE file for details.