fastapi-startkit-auth 0.7.0__tar.gz → 0.8.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.
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/CHANGELOG.md +44 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/PKG-INFO +1 -1
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/pyproject.toml +1 -1
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/__init__.py +13 -19
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/apitokens/manager.py +1 -75
- fastapi_startkit_auth-0.8.0/src/fastapi_startkit_auth/concurrency.py +22 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/config/__init__.py +2 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/config/auth.py +9 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/dependencies.py +45 -0
- fastapi_startkit_auth-0.8.0/src/fastapi_startkit_auth/facade.py +95 -0
- fastapi_startkit_auth-0.8.0/src/fastapi_startkit_auth/grants/__init__.py +13 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/grants/authorization_code.py +3 -94
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/grants/client_credentials.py +1 -19
- fastapi_startkit_auth-0.8.0/src/fastapi_startkit_auth/grants/password.py +56 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/grants/refresh.py +6 -39
- fastapi_startkit_auth-0.8.0/src/fastapi_startkit_auth/guards/__init__.py +13 -0
- fastapi_startkit_auth-0.8.0/src/fastapi_startkit_auth/guards/chain.py +77 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/guards/guard.py +19 -38
- fastapi_startkit_auth-0.8.0/src/fastapi_startkit_auth/guards/session.py +79 -0
- fastapi_startkit_auth-0.8.0/src/fastapi_startkit_auth/guards/token.py +63 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/manager.py +87 -89
- fastapi_startkit_auth-0.8.0/src/fastapi_startkit_auth/passwords/__init__.py +2 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/passwords/broker.py +1 -62
- fastapi_startkit_auth-0.8.0/src/fastapi_startkit_auth/providers/base.py +51 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/providers/fields.py +1 -8
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/providers/memory.py +12 -10
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/providers/model.py +3 -64
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/tokens/service.py +1 -174
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_api_tokens.py +75 -75
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_async_auth.py +44 -55
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_async_refresh_race.py +2 -2
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_async_safety.py +90 -97
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_client_scopes.py +9 -9
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_facade_api.py +12 -12
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_grants.py +35 -35
- fastapi_startkit_auth-0.8.0/tests/test_guard_dependency.py +298 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_manager.py +15 -15
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_model_provider.py +8 -8
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_model_provider_postgres.py +3 -23
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_multi_provider.py +20 -22
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_oauth21.py +56 -55
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_oauth_hardening.py +48 -48
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_password_reset.py +25 -25
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_resource_binding.py +78 -78
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_security_fixes.py +4 -4
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_session_auth.py +31 -31
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_spa_csrf.py +3 -3
- fastapi_startkit_auth-0.8.0/tests/test_token_service.py +114 -0
- fastapi_startkit_auth-0.8.0/tests/test_user_provider.py +76 -0
- fastapi_startkit_auth-0.7.0/src/fastapi_startkit_auth/concurrency.py +0 -48
- fastapi_startkit_auth-0.7.0/src/fastapi_startkit_auth/facade.py +0 -200
- fastapi_startkit_auth-0.7.0/src/fastapi_startkit_auth/grants/__init__.py +0 -17
- fastapi_startkit_auth-0.7.0/src/fastapi_startkit_auth/grants/password.py +0 -96
- fastapi_startkit_auth-0.7.0/src/fastapi_startkit_auth/guards/__init__.py +0 -14
- fastapi_startkit_auth-0.7.0/src/fastapi_startkit_auth/guards/session.py +0 -149
- fastapi_startkit_auth-0.7.0/src/fastapi_startkit_auth/guards/token.py +0 -112
- fastapi_startkit_auth-0.7.0/src/fastapi_startkit_auth/passwords/__init__.py +0 -4
- fastapi_startkit_auth-0.7.0/src/fastapi_startkit_auth/providers/base.py +0 -118
- fastapi_startkit_auth-0.7.0/tests/test_sync_regression.py +0 -227
- fastapi_startkit_auth-0.7.0/tests/test_token_service.py +0 -114
- fastapi_startkit_auth-0.7.0/tests/test_user_provider.py +0 -76
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/LICENSE.md +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/README.md +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/apitokens/__init__.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/apitokens/facade.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/apitokens/models.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/apitokens/orm.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/apitokens/repository.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/clients/__init__.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/clients/credentials.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/clients/models.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/clients/orm.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/clients/repository.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/commands/__init__.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/commands/oauth2_client.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/exceptions.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/grants/pkce.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/middleware/__init__.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/middleware/context.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/middleware/csrf.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/middleware/session.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/orm.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/passwords/repository.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/policy.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/provider.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/providers/__init__.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/publishable/cors.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/publishable/migrations/2026_09_30_000001_create_sessions_table.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/publishable/migrations/2026_09_30_000002_create_personal_api_tokens_table.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/publishable/migrations/2026_09_30_000003_create_oauth_access_tokens_table.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/publishable/migrations/2026_09_30_000004_create_oauth_refresh_tokens_table.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/publishable/migrations/2026_09_30_000005_create_oauth_auth_codes_table.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/publishable/migrations/2026_10_02_000001_add_resource_to_oauth_tables.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/publishable/migrations/2026_10_04_000001_create_oauth_clients_table.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/publishable/migrations/2026_10_04_000002_add_family_id_to_oauth_refresh_tokens_table.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/publishable/migrations/2026_10_05_000001_add_scopes_to_oauth_clients_table.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/py.typed +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/request_context.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/routes.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/routes_password.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/routes_spa.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/scoped_method.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/security/__init__.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/security/hashing.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/security/jwt.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/sessions/__init__.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/sessions/facade.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/sessions/models.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/sessions/orm.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/sessions/state.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/sessions/store.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/tokens/__init__.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/tokens/models.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/tokens/orm.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/tokens/repository.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/conftest.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_async_stores.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_class_config.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_clients.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_guard_registry.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_hashing.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_integration.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_jwt.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_oauth2_client_command.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_oauth_config_aliases.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_reset_throttle_enumeration.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_session_store.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_startkit_provider.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_store_names.py +0 -0
- {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_token_repository.py +0 -0
|
@@ -11,6 +11,26 @@ Release notes are also published on
|
|
|
11
11
|
|
|
12
12
|
Each item lists what changed and what to do.
|
|
13
13
|
|
|
14
|
+
- **The package is async-only; every sync class is removed.** The sync
|
|
15
|
+
`Auth`, `SessionGuard`, `PassportGuard`, `TokenGuard`, `ChainGuard`,
|
|
16
|
+
`TokenService`, `ApiTokenManager`, `PasswordBroker`, `PasswordGrant`,
|
|
17
|
+
`ClientCredentialsGrant`, `AuthorizationCodeGrant`, `RefreshTokenGrant`,
|
|
18
|
+
`ModelUserProvider` and `UserProvider` are gone, and each `Async*` class now
|
|
19
|
+
carries the plain name (`AsyncAuth` is `Auth`, `AsyncTokenService` is
|
|
20
|
+
`TokenService`, and so on). `user_is_active_async`, `find_user_async`,
|
|
21
|
+
`active_user_async` and `AuthManager.owner_provider_async` lose their
|
|
22
|
+
`_async` suffix; `AuthManager.owner_provider` is now a coroutine.
|
|
23
|
+
`AsyncMisconfiguration`, `ensure_sync`, `is_async_provider`,
|
|
24
|
+
`guard_is_async`, `is_async` and `has_async_methods` are removed, and the
|
|
25
|
+
`"async_model"` provider driver is dropped in favour of `"model"`;
|
|
26
|
+
configuring it raises a `ValueError` naming the replacement. The `"model"`
|
|
27
|
+
driver accepts sync and async models: a sync model's `find`,
|
|
28
|
+
`where(...).first()` and `save()` run in the threadpool.
|
|
29
|
+
`InMemoryUserProvider` methods are coroutines. User-supplied stores,
|
|
30
|
+
listeners and `is_active` hooks may still be sync; they run in the
|
|
31
|
+
threadpool. *Migrate:* import the plain names, `await` every `Auth`, guard,
|
|
32
|
+
provider, broker, grant and token-service call, and change
|
|
33
|
+
`driver="async_model"` to `driver="model"`.
|
|
14
34
|
- **Feature providers replace `Application` and `AuthServiceProvider`.**
|
|
15
35
|
Register `AuthProvider` first, then the features your guards use:
|
|
16
36
|
`AuthSessionProvider(SessionConfig)`, `AuthOAuth2Provider(OAuth2Config)` and
|
|
@@ -90,6 +110,18 @@ Each item lists what changed and what to do.
|
|
|
90
110
|
`revoke_token_chain`, and `store_refresh_token` takes `family_id`.
|
|
91
111
|
- Error responses carry `Cache-Control: no-store`; `invalid_client` challenges
|
|
92
112
|
with `WWW-Authenticate: Basic`.
|
|
113
|
+
- **Class-level `Auth.guard(name)` is a FastAPI dependency.**
|
|
114
|
+
`Auth.guard(name)` / `AsyncAuth.guard(name)` on the class now returns a
|
|
115
|
+
`GuardDependency`: `user = Depends(Auth.guard("api"))` resolves the user
|
|
116
|
+
through that guard and raises 401 otherwise, and equal names compare equal,
|
|
117
|
+
so `app.dependency_overrides[Auth.guard("api")]` works. Inside a request,
|
|
118
|
+
attribute access (`provider`, `user_from_token`, `authenticate`, ...) still
|
|
119
|
+
reaches the named guard, but the value is no longer the guard object itself,
|
|
120
|
+
so identity and `isinstance` checks fail, and outside a request attributes
|
|
121
|
+
raise `AttributeError`. On an instance (`auth.guard(name)` from
|
|
122
|
+
`Depends(Auth.scoped)`) it still returns the guard. *Migrate:* where you need
|
|
123
|
+
the guard object, use `auth.guard(name)` from `Depends(Auth.scoped)` or
|
|
124
|
+
`manager.guard(name)`.
|
|
93
125
|
|
|
94
126
|
### Migrations
|
|
95
127
|
|
|
@@ -104,6 +136,18 @@ using the ORM stores:
|
|
|
104
136
|
|
|
105
137
|
### Added
|
|
106
138
|
|
|
139
|
+
- `ChainGuard(guards=[...])` (`{"driver": "chain"}`): authenticates through the
|
|
140
|
+
first member guard that accepts the request, e.g. a bearer token backed by
|
|
141
|
+
the browser session. A token the request presents is final for every member
|
|
142
|
+
type (oauth2 bearer, or an API token in `Authorization` or a custom header):
|
|
143
|
+
when the member that reads it rejects it, the chain fails instead of trying
|
|
144
|
+
the session. `user_from_token` skips session members.
|
|
145
|
+
- `OAuth2Guard(first_party_only=True)` refuses access tokens issued to an OAuth
|
|
146
|
+
client (`client_id` set), so a first-party API accepts only the
|
|
147
|
+
application's own login and personal tokens.
|
|
148
|
+
- `AuthManager.on_authenticated(listener)`: `listener(request, user, guard)`
|
|
149
|
+
runs whenever a guard dependency resolves a user, before the route handler,
|
|
150
|
+
for activity tracking or request-scoped state.
|
|
107
151
|
- Refresh token families with reuse detection: replaying a rotated refresh token
|
|
108
152
|
revokes the whole family, whoever presents it: the owning client, another
|
|
109
153
|
authenticated client, or no client at all for unbound password-grant tokens.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-startkit-auth
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Laravel-style authentication for FastAPI: OAuth2/JWT authorization server, cookie/session guard, SPA + CSRF protection, and Sanctum-style API tokens.
|
|
5
5
|
Keywords: fastapi,oauth2,jwt,auth,passport,sanctum,authentication,authorization,security,tokens,session,cookie,csrf,spa,starter-kit
|
|
6
6
|
Author: Bedram Tamang
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-startkit-auth"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.0"
|
|
4
4
|
description = "Laravel-style authentication for FastAPI: OAuth2/JWT authorization server, cookie/session guard, SPA + CSRF protection, and Sanctum-style API tokens."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
{fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/__init__.py
RENAMED
|
@@ -8,10 +8,9 @@ Passport while staying idiomatic FastAPI:
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
from .apitokens.facade import ApiToken
|
|
11
|
-
from .apitokens.manager import ApiTokenManager,
|
|
11
|
+
from .apitokens.manager import ApiTokenManager, NewApiToken
|
|
12
12
|
from .apitokens.models import ApiTokenRecord
|
|
13
13
|
from .apitokens.repository import ApiTokenRepository, InMemoryApiTokenRepository
|
|
14
|
-
from .concurrency import AsyncMisconfiguration
|
|
15
14
|
from .clients.models import Client
|
|
16
15
|
from .clients.repository import InMemoryClientRepository
|
|
17
16
|
from .config import (
|
|
@@ -25,6 +24,7 @@ from .config import (
|
|
|
25
24
|
SessionConfig,
|
|
26
25
|
)
|
|
27
26
|
from .dependencies import (
|
|
27
|
+
GuardDependency,
|
|
28
28
|
auth,
|
|
29
29
|
current_user,
|
|
30
30
|
optional_user,
|
|
@@ -61,24 +61,25 @@ from .exceptions import (
|
|
|
61
61
|
UnsupportedResponseTypeException,
|
|
62
62
|
)
|
|
63
63
|
from .policy import GrantPolicy
|
|
64
|
-
from .facade import
|
|
65
|
-
from .guards.
|
|
66
|
-
from .guards.
|
|
67
|
-
from .guards.
|
|
64
|
+
from .facade import Auth
|
|
65
|
+
from .guards.chain import ChainGuard
|
|
66
|
+
from .guards.guard import PassportGuard
|
|
67
|
+
from .guards.session import SessionGuard
|
|
68
|
+
from .guards.token import TokenGuard
|
|
68
69
|
from .manager import AuthManager, FeatureNotRegistered
|
|
69
70
|
from .middleware.context import AuthMiddleware
|
|
70
71
|
from .middleware.csrf import CsrfMiddleware
|
|
71
72
|
from .middleware.session import SessionMiddleware
|
|
72
|
-
from .passwords.broker import
|
|
73
|
+
from .passwords.broker import PasswordBroker
|
|
73
74
|
from .provider import AuthApiTokenProvider, AuthOAuth2Provider, AuthProvider, AuthSessionProvider
|
|
74
75
|
from .providers.memory import InMemoryUserProvider
|
|
75
|
-
from .providers.model import
|
|
76
|
+
from .providers.model import ModelUserProvider
|
|
76
77
|
from .sessions.facade import Session
|
|
77
78
|
from .sessions.models import SessionRecord
|
|
78
79
|
from .sessions.store import InMemorySessionStore, SessionStore
|
|
79
|
-
from .tokens.service import
|
|
80
|
+
from .tokens.service import TokenService
|
|
80
81
|
|
|
81
|
-
__version__ = "0.
|
|
82
|
+
__version__ = "0.8.0"
|
|
82
83
|
|
|
83
84
|
__all__ = (
|
|
84
85
|
"AuthConfig",
|
|
@@ -108,11 +109,11 @@ __all__ = (
|
|
|
108
109
|
"UnsupportedGrantTypeException",
|
|
109
110
|
"UnsupportedResponseTypeException",
|
|
110
111
|
"current_user",
|
|
112
|
+
"GuardDependency",
|
|
111
113
|
"optional_user",
|
|
112
114
|
"require_scopes",
|
|
113
115
|
"require_abilities",
|
|
114
116
|
"AuthException",
|
|
115
|
-
"AsyncMisconfiguration",
|
|
116
117
|
"InvalidGrantException",
|
|
117
118
|
"InvalidClientException",
|
|
118
119
|
"InvalidCredentialsException",
|
|
@@ -149,19 +150,12 @@ __all__ = (
|
|
|
149
150
|
"ApiTokenRecord",
|
|
150
151
|
"ApiTokenRepository",
|
|
151
152
|
"InMemoryApiTokenRepository",
|
|
152
|
-
"AsyncAuth",
|
|
153
153
|
"PassportGuard",
|
|
154
|
-
"
|
|
155
|
-
"AsyncSessionGuard",
|
|
156
|
-
"AsyncTokenGuard",
|
|
157
|
-
"AsyncApiTokenManager",
|
|
154
|
+
"ChainGuard",
|
|
158
155
|
"PasswordBroker",
|
|
159
|
-
"AsyncPasswordBroker",
|
|
160
156
|
"InMemoryUserProvider",
|
|
161
157
|
"ModelUserProvider",
|
|
162
|
-
"AsyncModelUserProvider",
|
|
163
158
|
"TokenService",
|
|
164
|
-
"AsyncTokenService",
|
|
165
159
|
)
|
|
166
160
|
|
|
167
161
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import secrets
|
|
4
|
-
import threading
|
|
5
4
|
import time
|
|
6
5
|
from dataclasses import dataclass
|
|
7
6
|
from typing import Any
|
|
@@ -9,7 +8,7 @@ from typing import Any
|
|
|
9
8
|
from ..concurrency import call
|
|
10
9
|
from ..exceptions import InvalidTokenException
|
|
11
10
|
from .models import ApiTokenRecord
|
|
12
|
-
from .repository import
|
|
11
|
+
from .repository import generate_token_secret, hash_token_secret
|
|
13
12
|
|
|
14
13
|
# Every verification failure raises this exact message so responses (and their
|
|
15
14
|
# timing, via the dummy compare below) never reveal whether the id exists.
|
|
@@ -69,79 +68,6 @@ class NewApiToken:
|
|
|
69
68
|
|
|
70
69
|
|
|
71
70
|
class ApiTokenManager:
|
|
72
|
-
"""Issues, verifies, revokes, and lists opaque API tokens.
|
|
73
|
-
|
|
74
|
-
Exposed as ``AuthManager.api_tokens``; apps wire their own management
|
|
75
|
-
routes around it (the package ships none, per the app-owns-routes
|
|
76
|
-
decision).
|
|
77
|
-
"""
|
|
78
|
-
|
|
79
|
-
def __init__(
|
|
80
|
-
self,
|
|
81
|
-
repository: ApiTokenRepository,
|
|
82
|
-
default_ttl: float | None = None,
|
|
83
|
-
purge_interval: float = 300,
|
|
84
|
-
) -> None:
|
|
85
|
-
self.repository = repository
|
|
86
|
-
self._default_ttl = default_ttl
|
|
87
|
-
self._purge_interval = purge_interval
|
|
88
|
-
self._purge_lock = threading.Lock()
|
|
89
|
-
self._last_purge = time.time()
|
|
90
|
-
|
|
91
|
-
def _maybe_purge(self) -> None:
|
|
92
|
-
"""Drop expired records periodically so stores stay bounded without a
|
|
93
|
-
scheduler (mirrors the purge-on-issue pattern of ``TokenService``)."""
|
|
94
|
-
now = time.time()
|
|
95
|
-
with self._purge_lock:
|
|
96
|
-
if now - self._last_purge < self._purge_interval:
|
|
97
|
-
return
|
|
98
|
-
self._last_purge = now
|
|
99
|
-
self.repository.purge_expired()
|
|
100
|
-
|
|
101
|
-
def create(
|
|
102
|
-
self,
|
|
103
|
-
user_id: Any,
|
|
104
|
-
name: str | None = None,
|
|
105
|
-
abilities: list[str] | None = None,
|
|
106
|
-
expires_at: float | None = None,
|
|
107
|
-
) -> NewApiToken:
|
|
108
|
-
"""Issue a token for ``user_id``; the plaintext is returned exactly once.
|
|
109
|
-
|
|
110
|
-
``expires_at`` overrides the configured default ttl; ``None`` with no
|
|
111
|
-
configured ttl means the token never expires. ``abilities`` default to
|
|
112
|
-
the unrestricted ``["*"]``.
|
|
113
|
-
"""
|
|
114
|
-
self._maybe_purge()
|
|
115
|
-
secret = generate_token_secret()
|
|
116
|
-
record = self.repository.create(
|
|
117
|
-
**_creation_fields(user_id, secret, name, abilities, expires_at, self._default_ttl)
|
|
118
|
-
)
|
|
119
|
-
return NewApiToken(record=record, plain_text=f"{record.id}|{secret}")
|
|
120
|
-
|
|
121
|
-
def verify(self, token: str) -> ApiTokenRecord:
|
|
122
|
-
"""Resolve ``"{id}|{secret}"`` to its live record or raise 401.
|
|
123
|
-
|
|
124
|
-
Lookup is by id (O(1), no scan); the secret comparison is constant-time
|
|
125
|
-
against the stored hash — or against a dummy hash when the id is
|
|
126
|
-
unknown, so the miss path costs the same as a mismatch.
|
|
127
|
-
"""
|
|
128
|
-
token_id, secret = _split_token(token)
|
|
129
|
-
record = self.repository.find(token_id) if token_id else None
|
|
130
|
-
_check_secret(record, secret)
|
|
131
|
-
self.repository.touch(record.id)
|
|
132
|
-
return record
|
|
133
|
-
|
|
134
|
-
def revoke(self, token_id: str) -> bool:
|
|
135
|
-
return self.repository.revoke(token_id)
|
|
136
|
-
|
|
137
|
-
def revoke_all(self, user_id: Any) -> int:
|
|
138
|
-
return self.repository.revoke_all_for_user(user_id)
|
|
139
|
-
|
|
140
|
-
def tokens_for(self, user_id: Any) -> list[ApiTokenRecord]:
|
|
141
|
-
return self.repository.list_for_user(user_id)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
class AsyncApiTokenManager:
|
|
145
71
|
def __init__(self, repository: Any, default_ttl: float | None = None, purge_interval: float = 300) -> None:
|
|
146
72
|
self.repository = repository
|
|
147
73
|
self._default_ttl = default_ttl
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
from starlette.concurrency import run_in_threadpool
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def resolve(value: Any) -> Any:
|
|
10
|
+
if inspect.isawaitable(value):
|
|
11
|
+
return await value
|
|
12
|
+
return value
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def call(function: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
|
16
|
+
if is_coroutine_callable(function):
|
|
17
|
+
return await function(*args, **kwargs)
|
|
18
|
+
return await resolve(await run_in_threadpool(function, *args, **kwargs))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_coroutine_callable(obj: Any) -> bool:
|
|
22
|
+
return inspect.iscoroutinefunction(obj) or inspect.iscoroutinefunction(getattr(obj, "__call__", None))
|
|
@@ -4,6 +4,7 @@ from .auth import (
|
|
|
4
4
|
ApiTokenConfig,
|
|
5
5
|
ApiTokenGuard,
|
|
6
6
|
AuthConfig,
|
|
7
|
+
ChainGuard,
|
|
7
8
|
OAuth2Config,
|
|
8
9
|
OAuth2Guard,
|
|
9
10
|
OAuthClientConfig,
|
|
@@ -29,6 +30,7 @@ __all__ = (
|
|
|
29
30
|
"SessionGuard",
|
|
30
31
|
"OAuth2Guard",
|
|
31
32
|
"ApiTokenGuard",
|
|
33
|
+
"ChainGuard",
|
|
32
34
|
"DEFAULT_GRANT_TYPES",
|
|
33
35
|
"DEPRECATED_OAUTH2_ATTRIBUTES",
|
|
34
36
|
"as_config_class",
|
{fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/config/auth.py
RENAMED
|
@@ -28,6 +28,7 @@ class SessionGuard:
|
|
|
28
28
|
class OAuth2Guard:
|
|
29
29
|
provider: str = "users"
|
|
30
30
|
audience: str | None = None
|
|
31
|
+
first_party_only: bool = False
|
|
31
32
|
driver: Literal["oauth2"] = "oauth2"
|
|
32
33
|
|
|
33
34
|
|
|
@@ -37,6 +38,14 @@ class ApiTokenGuard:
|
|
|
37
38
|
driver: Literal["token"] = "token"
|
|
38
39
|
|
|
39
40
|
|
|
41
|
+
@dataclass
|
|
42
|
+
class ChainGuard:
|
|
43
|
+
"""Authenticates through the first of ``guards`` (guard names) that accepts the request."""
|
|
44
|
+
|
|
45
|
+
guards: list[str] = field(default_factory=list)
|
|
46
|
+
driver: Literal["chain"] = "chain"
|
|
47
|
+
|
|
48
|
+
|
|
40
49
|
@dataclass
|
|
41
50
|
class SessionConfig:
|
|
42
51
|
"""Cookie sessions, enabled by ``AuthSessionProvider``.
|
|
@@ -85,6 +85,51 @@ async def optional_user(
|
|
|
85
85
|
return None
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
class GuardDependency:
|
|
89
|
+
"""FastAPI dependency resolving the request's user through a named guard.
|
|
90
|
+
|
|
91
|
+
``user = Depends(Auth.guard("api"))`` authenticates with whatever the
|
|
92
|
+
guard's driver reads (bearer token, session cookie, API token, a chain of
|
|
93
|
+
them) and raises 401 when it cannot, or when the credential has no user.
|
|
94
|
+
A resolved user is announced to ``AuthManager.on_authenticated`` listeners.
|
|
95
|
+
Equal names compare equal, so ``app.dependency_overrides`` can key on
|
|
96
|
+
``Auth.guard("api")``. Inside a request, other attributes (``provider``,
|
|
97
|
+
``user_from_token``, ``authenticate``...) are read from the named guard, so
|
|
98
|
+
code that used class-level ``Auth.guard(name)`` as the guard keeps working.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, name: str | None = None) -> None:
|
|
102
|
+
self.name = name
|
|
103
|
+
|
|
104
|
+
async def __call__(self, request: Request, token: str | None = Depends(_bearer)) -> Any:
|
|
105
|
+
manager = get_auth_manager(request)
|
|
106
|
+
context = await resolve_context(request, manager, self.name)
|
|
107
|
+
if context.user is None:
|
|
108
|
+
raise InvalidTokenException("This token is not associated with a user.")
|
|
109
|
+
await manager.authenticated(request, context.user, self.name)
|
|
110
|
+
return context.user
|
|
111
|
+
|
|
112
|
+
def __getattr__(self, attribute: str) -> Any:
|
|
113
|
+
if attribute.startswith("__") or attribute == "name":
|
|
114
|
+
raise AttributeError(attribute)
|
|
115
|
+
context = _auth_request.get()
|
|
116
|
+
if context is None or not context.active:
|
|
117
|
+
raise AttributeError(
|
|
118
|
+
f"{type(self).__name__} has no attribute {attribute!r} outside a request; "
|
|
119
|
+
"use the AuthManager's guard() there."
|
|
120
|
+
)
|
|
121
|
+
return getattr(context.manager.guard(self.name), attribute)
|
|
122
|
+
|
|
123
|
+
def __eq__(self, other: object) -> bool:
|
|
124
|
+
return isinstance(other, GuardDependency) and other.name == self.name
|
|
125
|
+
|
|
126
|
+
def __hash__(self) -> int:
|
|
127
|
+
return hash((GuardDependency, self.name))
|
|
128
|
+
|
|
129
|
+
def __repr__(self) -> str:
|
|
130
|
+
return f"GuardDependency({self.name!r})"
|
|
131
|
+
|
|
132
|
+
|
|
88
133
|
def require_scopes(*scopes: str, mode: str = "all") -> Callable[..., AuthContext]:
|
|
89
134
|
"""Dependency factory enforcing token abilities (Passport-style scopes).
|
|
90
135
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, Request
|
|
6
|
+
|
|
7
|
+
from .dependencies import GuardDependency, get_auth_manager, resolve_context
|
|
8
|
+
from .concurrency import call
|
|
9
|
+
from .exceptions import AuthException
|
|
10
|
+
from .guards.session import SessionGuard
|
|
11
|
+
from .manager import AuthManager
|
|
12
|
+
from .providers.base import user_is_active
|
|
13
|
+
from .scoped_method import ScopedMethod
|
|
14
|
+
from .sessions.models import SessionRecord
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GuardAccessor:
|
|
18
|
+
"""``Auth.guard(name)`` is a FastAPI dependency; ``auth.guard(name)`` on an instance is the guard itself."""
|
|
19
|
+
|
|
20
|
+
def __get__(self, instance: Any, owner: type) -> Any:
|
|
21
|
+
if instance is None:
|
|
22
|
+
return GuardDependency
|
|
23
|
+
return instance._manager.guard
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Auth:
|
|
27
|
+
def __init__(self, manager: AuthManager, request: Request) -> None:
|
|
28
|
+
self._manager = manager
|
|
29
|
+
self._request = request
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def scoped(cls, request: Request, manager: AuthManager = Depends(get_auth_manager)) -> "Auth":
|
|
33
|
+
return cls(manager, request)
|
|
34
|
+
|
|
35
|
+
guard = GuardAccessor()
|
|
36
|
+
|
|
37
|
+
@ScopedMethod
|
|
38
|
+
async def login(self, user_or_id: Any, guard: str | None = None) -> SessionRecord:
|
|
39
|
+
session_guard = self._session_guard(guard)
|
|
40
|
+
user = await self._resolve_user(user_or_id, session_guard)
|
|
41
|
+
return await call(session_guard.login, self._request, user)
|
|
42
|
+
|
|
43
|
+
@ScopedMethod
|
|
44
|
+
async def attempt(self, credentials: dict[str, Any], guard: str | None = None) -> bool:
|
|
45
|
+
session_guard = self._session_guard(guard)
|
|
46
|
+
user = await self.validate(credentials, session_guard.name)
|
|
47
|
+
if user is None:
|
|
48
|
+
return False
|
|
49
|
+
await call(session_guard.login, self._request, user)
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
@ScopedMethod
|
|
53
|
+
async def validate(self, credentials: dict[str, Any], guard: str | None = None) -> Any | None:
|
|
54
|
+
provider = self.guard(guard).provider
|
|
55
|
+
user = await call(provider.retrieve_by_credentials, credentials)
|
|
56
|
+
if user is None:
|
|
57
|
+
dummy = getattr(provider, "dummy_verify", None)
|
|
58
|
+
if dummy is not None:
|
|
59
|
+
await call(dummy)
|
|
60
|
+
return None
|
|
61
|
+
valid = await call(provider.validate_credentials, user, credentials)
|
|
62
|
+
return user if valid and await user_is_active(provider, user) else None
|
|
63
|
+
|
|
64
|
+
@ScopedMethod
|
|
65
|
+
async def logout(self, guard: str | None = None) -> None:
|
|
66
|
+
await call(self._session_guard(guard).logout, self._request)
|
|
67
|
+
|
|
68
|
+
@ScopedMethod
|
|
69
|
+
async def user(self, guard: str | None = None) -> Any | None:
|
|
70
|
+
try:
|
|
71
|
+
return (await resolve_context(self._request, self._manager, guard)).user
|
|
72
|
+
except AuthException:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
@ScopedMethod
|
|
76
|
+
async def id(self, guard: str | None = None) -> Any | None:
|
|
77
|
+
user = await self.user(guard)
|
|
78
|
+
if user is None:
|
|
79
|
+
return None
|
|
80
|
+
return self.guard(guard).provider.get_identifier(user)
|
|
81
|
+
|
|
82
|
+
@ScopedMethod
|
|
83
|
+
async def check(self, guard: str | None = None) -> bool:
|
|
84
|
+
return await self.user(guard) is not None
|
|
85
|
+
|
|
86
|
+
def _session_guard(self, name: str | None) -> SessionGuard:
|
|
87
|
+
return self._manager.session_guard(name)
|
|
88
|
+
|
|
89
|
+
async def _resolve_user(self, user_or_id: Any, session_guard: Any) -> Any:
|
|
90
|
+
if not isinstance(user_or_id, (int, str)):
|
|
91
|
+
return user_or_id
|
|
92
|
+
user = await call(session_guard.provider.retrieve_by_id, user_or_id)
|
|
93
|
+
if user is None:
|
|
94
|
+
raise ValueError(f"No user with id {user_or_id!r} in guard {session_guard.name!r}'s provider.")
|
|
95
|
+
return user
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .authorization_code import AuthorizationCodeGrant
|
|
2
|
+
from .client_credentials import ClientCredentialsGrant
|
|
3
|
+
from .password import PasswordGrant
|
|
4
|
+
from .pkce import verify_pkce
|
|
5
|
+
from .refresh import RefreshTokenGrant
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"PasswordGrant",
|
|
9
|
+
"ClientCredentialsGrant",
|
|
10
|
+
"RefreshTokenGrant",
|
|
11
|
+
"AuthorizationCodeGrant",
|
|
12
|
+
"verify_pkce",
|
|
13
|
+
]
|
|
@@ -8,10 +8,10 @@ from ..clients.models import Client
|
|
|
8
8
|
from ..concurrency import call
|
|
9
9
|
from ..exceptions import InvalidClientException, InvalidGrantException
|
|
10
10
|
from ..policy import GrantPolicy, ensure_client_may, ensure_same_resource, ensure_valid_verifier
|
|
11
|
-
from ..tokens.service import IssuedToken
|
|
11
|
+
from ..tokens.service import IssuedToken
|
|
12
12
|
from ..tokens.models import AuthorizationCode
|
|
13
13
|
from .pkce import verify_pkce
|
|
14
|
-
from .refresh import OwnerProviderResolver, ensure_owner_active,
|
|
14
|
+
from .refresh import OwnerProviderResolver, ensure_owner_active, owner_resolver
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
def _validate_request(
|
|
@@ -59,97 +59,6 @@ def _validate_exchange(
|
|
|
59
59
|
|
|
60
60
|
|
|
61
61
|
class AuthorizationCodeGrant:
|
|
62
|
-
"""Authorization-code grant with PKCE (RFC 6749 §4.1 + RFC 7636).
|
|
63
|
-
|
|
64
|
-
PKCE is mandatory for public (secretless) clients and for any exchange where
|
|
65
|
-
the client did not authenticate — both at code issuance (a public client
|
|
66
|
-
cannot obtain a code without a ``code_challenge``) and at exchange (the
|
|
67
|
-
verifier must match). Confidential clients that authenticate may omit PKCE,
|
|
68
|
-
but any challenge they do supply is still verified.
|
|
69
|
-
|
|
70
|
-
``issue_code`` is called after the resource owner approves a request;
|
|
71
|
-
``handle`` exchanges the resulting single-use code for tokens.
|
|
72
|
-
"""
|
|
73
|
-
|
|
74
|
-
def __init__(
|
|
75
|
-
self,
|
|
76
|
-
token_service: TokenService,
|
|
77
|
-
code_ttl: int = 600,
|
|
78
|
-
user_provider: Any = None,
|
|
79
|
-
owner_provider: OwnerProviderResolver | None = None,
|
|
80
|
-
policy: GrantPolicy | None = None,
|
|
81
|
-
) -> None:
|
|
82
|
-
self._tokens = token_service
|
|
83
|
-
self._code_ttl = code_ttl
|
|
84
|
-
self._policy = policy or GrantPolicy()
|
|
85
|
-
self._owner_provider = owner_resolver(user_provider, owner_provider)
|
|
86
|
-
|
|
87
|
-
def validate_request(
|
|
88
|
-
self,
|
|
89
|
-
*,
|
|
90
|
-
client: Client,
|
|
91
|
-
redirect_uri: str | None,
|
|
92
|
-
scopes: list[str],
|
|
93
|
-
code_challenge: str | None,
|
|
94
|
-
code_challenge_method: str | None,
|
|
95
|
-
resource: str | None = None,
|
|
96
|
-
) -> None:
|
|
97
|
-
"""Reject an authorization request before the user is asked to approve it."""
|
|
98
|
-
_validate_request(
|
|
99
|
-
self._policy, client, redirect_uri, scopes, code_challenge, code_challenge_method, resource
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
def issue_code(
|
|
103
|
-
self,
|
|
104
|
-
*,
|
|
105
|
-
client: Client,
|
|
106
|
-
user_id: Any,
|
|
107
|
-
scopes: list[str],
|
|
108
|
-
redirect_uri: str | None,
|
|
109
|
-
code_challenge: str | None,
|
|
110
|
-
code_challenge_method: str | None,
|
|
111
|
-
resource: str | None = None,
|
|
112
|
-
) -> str:
|
|
113
|
-
_validate_request(
|
|
114
|
-
self._policy, client, redirect_uri, scopes, code_challenge, code_challenge_method, resource
|
|
115
|
-
)
|
|
116
|
-
code = secrets.token_urlsafe(40)
|
|
117
|
-
self._tokens.repository.store_auth_code(
|
|
118
|
-
code=code,
|
|
119
|
-
client_id=client.id,
|
|
120
|
-
user_id=user_id,
|
|
121
|
-
scopes=scopes,
|
|
122
|
-
redirect_uri=redirect_uri,
|
|
123
|
-
code_challenge=code_challenge,
|
|
124
|
-
code_challenge_method=code_challenge_method,
|
|
125
|
-
expires_at=time.time() + self._code_ttl,
|
|
126
|
-
resource=resource,
|
|
127
|
-
)
|
|
128
|
-
return code
|
|
129
|
-
|
|
130
|
-
def handle(
|
|
131
|
-
self,
|
|
132
|
-
*,
|
|
133
|
-
client: Client,
|
|
134
|
-
code: str,
|
|
135
|
-
redirect_uri: str | None,
|
|
136
|
-
code_verifier: str | None,
|
|
137
|
-
client_authenticated: bool = False,
|
|
138
|
-
resource: str | None = None,
|
|
139
|
-
) -> IssuedToken:
|
|
140
|
-
record = self._tokens.repository.pull_auth_code(code)
|
|
141
|
-
_validate_exchange(record, client, redirect_uri, code_verifier, client_authenticated, resource)
|
|
142
|
-
ensure_owner_active(self._owner_provider(client.id), record.user_id)
|
|
143
|
-
return self._tokens.issue(
|
|
144
|
-
user_id=record.user_id,
|
|
145
|
-
client_id=client.id,
|
|
146
|
-
scopes=record.scopes,
|
|
147
|
-
with_refresh=True,
|
|
148
|
-
audience=record.resource,
|
|
149
|
-
)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
class AsyncAuthorizationCodeGrant:
|
|
153
62
|
def __init__(
|
|
154
63
|
self,
|
|
155
64
|
token_service: Any,
|
|
@@ -219,7 +128,7 @@ class AsyncAuthorizationCodeGrant:
|
|
|
219
128
|
) -> IssuedToken:
|
|
220
129
|
record = await call(self._tokens.repository.pull_auth_code, code)
|
|
221
130
|
_validate_exchange(record, client, redirect_uri, code_verifier, client_authenticated, resource)
|
|
222
|
-
await
|
|
131
|
+
await ensure_owner_active(await call(self._owner_provider, client.id), record.user_id)
|
|
223
132
|
return await call(
|
|
224
133
|
self._tokens.issue,
|
|
225
134
|
user_id=record.user_id,
|
|
@@ -6,7 +6,7 @@ from ..clients.models import Client
|
|
|
6
6
|
from ..concurrency import call
|
|
7
7
|
from ..exceptions import UnauthorizedClientException
|
|
8
8
|
from ..policy import GrantPolicy, ensure_client_may
|
|
9
|
-
from ..tokens.service import IssuedToken
|
|
9
|
+
from ..tokens.service import IssuedToken
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def _check(policy: GrantPolicy, client: Client, scopes: list[str], resource: str | None) -> None:
|
|
@@ -20,24 +20,6 @@ def _check(policy: GrantPolicy, client: Client, scopes: list[str], resource: str
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
class ClientCredentialsGrant:
|
|
23
|
-
"""Machine-to-machine grant (RFC 6749 §4.4) for confidential clients. No user, no refresh token."""
|
|
24
|
-
|
|
25
|
-
def __init__(self, token_service: TokenService, policy: GrantPolicy | None = None) -> None:
|
|
26
|
-
self._tokens = token_service
|
|
27
|
-
self._policy = policy or GrantPolicy()
|
|
28
|
-
|
|
29
|
-
def handle(self, *, client: Client, scopes: list[str], resource: str | None = None) -> IssuedToken:
|
|
30
|
-
_check(self._policy, client, scopes, resource)
|
|
31
|
-
return self._tokens.issue(
|
|
32
|
-
user_id=None,
|
|
33
|
-
client_id=client.id,
|
|
34
|
-
scopes=scopes,
|
|
35
|
-
with_refresh=False,
|
|
36
|
-
audience=resource,
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
class AsyncClientCredentialsGrant:
|
|
41
23
|
def __init__(self, token_service: Any, policy: GrantPolicy | None = None) -> None:
|
|
42
24
|
self._tokens = token_service
|
|
43
25
|
self._policy = policy or GrantPolicy()
|