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.
Files changed (130) hide show
  1. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/CHANGELOG.md +44 -0
  2. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/PKG-INFO +1 -1
  3. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/pyproject.toml +1 -1
  4. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/__init__.py +13 -19
  5. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/apitokens/manager.py +1 -75
  6. fastapi_startkit_auth-0.8.0/src/fastapi_startkit_auth/concurrency.py +22 -0
  7. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/config/__init__.py +2 -0
  8. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/config/auth.py +9 -0
  9. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/dependencies.py +45 -0
  10. fastapi_startkit_auth-0.8.0/src/fastapi_startkit_auth/facade.py +95 -0
  11. fastapi_startkit_auth-0.8.0/src/fastapi_startkit_auth/grants/__init__.py +13 -0
  12. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/grants/authorization_code.py +3 -94
  13. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/grants/client_credentials.py +1 -19
  14. fastapi_startkit_auth-0.8.0/src/fastapi_startkit_auth/grants/password.py +56 -0
  15. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/grants/refresh.py +6 -39
  16. fastapi_startkit_auth-0.8.0/src/fastapi_startkit_auth/guards/__init__.py +13 -0
  17. fastapi_startkit_auth-0.8.0/src/fastapi_startkit_auth/guards/chain.py +77 -0
  18. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/guards/guard.py +19 -38
  19. fastapi_startkit_auth-0.8.0/src/fastapi_startkit_auth/guards/session.py +79 -0
  20. fastapi_startkit_auth-0.8.0/src/fastapi_startkit_auth/guards/token.py +63 -0
  21. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/manager.py +87 -89
  22. fastapi_startkit_auth-0.8.0/src/fastapi_startkit_auth/passwords/__init__.py +2 -0
  23. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/passwords/broker.py +1 -62
  24. fastapi_startkit_auth-0.8.0/src/fastapi_startkit_auth/providers/base.py +51 -0
  25. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/providers/fields.py +1 -8
  26. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/providers/memory.py +12 -10
  27. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/providers/model.py +3 -64
  28. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/tokens/service.py +1 -174
  29. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_api_tokens.py +75 -75
  30. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_async_auth.py +44 -55
  31. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_async_refresh_race.py +2 -2
  32. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_async_safety.py +90 -97
  33. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_client_scopes.py +9 -9
  34. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_facade_api.py +12 -12
  35. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_grants.py +35 -35
  36. fastapi_startkit_auth-0.8.0/tests/test_guard_dependency.py +298 -0
  37. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_manager.py +15 -15
  38. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_model_provider.py +8 -8
  39. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_model_provider_postgres.py +3 -23
  40. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_multi_provider.py +20 -22
  41. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_oauth21.py +56 -55
  42. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_oauth_hardening.py +48 -48
  43. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_password_reset.py +25 -25
  44. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_resource_binding.py +78 -78
  45. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_security_fixes.py +4 -4
  46. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_session_auth.py +31 -31
  47. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_spa_csrf.py +3 -3
  48. fastapi_startkit_auth-0.8.0/tests/test_token_service.py +114 -0
  49. fastapi_startkit_auth-0.8.0/tests/test_user_provider.py +76 -0
  50. fastapi_startkit_auth-0.7.0/src/fastapi_startkit_auth/concurrency.py +0 -48
  51. fastapi_startkit_auth-0.7.0/src/fastapi_startkit_auth/facade.py +0 -200
  52. fastapi_startkit_auth-0.7.0/src/fastapi_startkit_auth/grants/__init__.py +0 -17
  53. fastapi_startkit_auth-0.7.0/src/fastapi_startkit_auth/grants/password.py +0 -96
  54. fastapi_startkit_auth-0.7.0/src/fastapi_startkit_auth/guards/__init__.py +0 -14
  55. fastapi_startkit_auth-0.7.0/src/fastapi_startkit_auth/guards/session.py +0 -149
  56. fastapi_startkit_auth-0.7.0/src/fastapi_startkit_auth/guards/token.py +0 -112
  57. fastapi_startkit_auth-0.7.0/src/fastapi_startkit_auth/passwords/__init__.py +0 -4
  58. fastapi_startkit_auth-0.7.0/src/fastapi_startkit_auth/providers/base.py +0 -118
  59. fastapi_startkit_auth-0.7.0/tests/test_sync_regression.py +0 -227
  60. fastapi_startkit_auth-0.7.0/tests/test_token_service.py +0 -114
  61. fastapi_startkit_auth-0.7.0/tests/test_user_provider.py +0 -76
  62. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/LICENSE.md +0 -0
  63. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/README.md +0 -0
  64. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/apitokens/__init__.py +0 -0
  65. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/apitokens/facade.py +0 -0
  66. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/apitokens/models.py +0 -0
  67. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/apitokens/orm.py +0 -0
  68. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/apitokens/repository.py +0 -0
  69. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/clients/__init__.py +0 -0
  70. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/clients/credentials.py +0 -0
  71. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/clients/models.py +0 -0
  72. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/clients/orm.py +0 -0
  73. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/clients/repository.py +0 -0
  74. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/commands/__init__.py +0 -0
  75. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/commands/oauth2_client.py +0 -0
  76. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/exceptions.py +0 -0
  77. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/grants/pkce.py +0 -0
  78. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/middleware/__init__.py +0 -0
  79. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/middleware/context.py +0 -0
  80. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/middleware/csrf.py +0 -0
  81. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/middleware/session.py +0 -0
  82. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/orm.py +0 -0
  83. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/passwords/repository.py +0 -0
  84. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/policy.py +0 -0
  85. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/provider.py +0 -0
  86. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/providers/__init__.py +0 -0
  87. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/publishable/cors.py +0 -0
  88. {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
  89. {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
  90. {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
  91. {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
  92. {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
  93. {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
  94. {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
  95. {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
  96. {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
  97. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/py.typed +0 -0
  98. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/request_context.py +0 -0
  99. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/routes.py +0 -0
  100. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/routes_password.py +0 -0
  101. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/routes_spa.py +0 -0
  102. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/scoped_method.py +0 -0
  103. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/security/__init__.py +0 -0
  104. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/security/hashing.py +0 -0
  105. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/security/jwt.py +0 -0
  106. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/sessions/__init__.py +0 -0
  107. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/sessions/facade.py +0 -0
  108. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/sessions/models.py +0 -0
  109. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/sessions/orm.py +0 -0
  110. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/sessions/state.py +0 -0
  111. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/sessions/store.py +0 -0
  112. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/tokens/__init__.py +0 -0
  113. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/tokens/models.py +0 -0
  114. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/tokens/orm.py +0 -0
  115. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/src/fastapi_startkit_auth/tokens/repository.py +0 -0
  116. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/conftest.py +0 -0
  117. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_async_stores.py +0 -0
  118. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_class_config.py +0 -0
  119. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_clients.py +0 -0
  120. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_guard_registry.py +0 -0
  121. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_hashing.py +0 -0
  122. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_integration.py +0 -0
  123. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_jwt.py +0 -0
  124. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_oauth2_client_command.py +0 -0
  125. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_oauth_config_aliases.py +0 -0
  126. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_reset_throttle_enumeration.py +0 -0
  127. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_session_store.py +0 -0
  128. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_startkit_provider.py +0 -0
  129. {fastapi_startkit_auth-0.7.0 → fastapi_startkit_auth-0.8.0}/tests/test_store_names.py +0 -0
  130. {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.7.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.7.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"
@@ -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, AsyncApiTokenManager, NewApiToken
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 AsyncAuth, Auth
65
- from .guards.guard import AsyncPassportGuard, PassportGuard
66
- from .guards.session import AsyncSessionGuard, SessionGuard
67
- from .guards.token import AsyncTokenGuard, TokenGuard
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 AsyncPasswordBroker, PasswordBroker
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 AsyncModelUserProvider, ModelUserProvider
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 AsyncTokenService, TokenService
80
+ from .tokens.service import TokenService
80
81
 
81
- __version__ = "0.7.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
- "AsyncPassportGuard",
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 ApiTokenRepository, generate_token_secret, hash_token_secret
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",
@@ -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, TokenService
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, ensure_owner_active_async, owner_resolver
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 ensure_owner_active_async(await call(self._owner_provider, client.id), record.user_id)
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, TokenService
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()