lenapi 0.1.0__tar.gz → 0.1.1__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.
- {lenapi-0.1.0 → lenapi-0.1.1}/PKG-INFO +11 -10
- {lenapi-0.1.0 → lenapi-0.1.1}/README.md +10 -9
- lenapi-0.1.1/lenapi/authentication.py +218 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/cli.py +70 -92
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/security.py +9 -2
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi.egg-info/PKG-INFO +11 -10
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi.egg-info/SOURCES.txt +1 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/pyproject.toml +1 -1
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/__init__.py +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/application.py +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/authorization.py +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/background.py +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/database.py +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/dependencies.py +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/logging.py +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/middleware.py +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/model.py +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/models.py +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/mongodb.py +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/openapi.py +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/parameters.py +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/payments.py +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/request.py +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/response.py +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/security_store.py +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/service.py +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi.egg-info/dependency_links.txt +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi.egg-info/entry_points.txt +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi.egg-info/requires.txt +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/lenapi.egg-info/top_level.txt +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/setup.cfg +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/tests/test_media_upload.py +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/tests/test_mongodb.py +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/tests/test_platform_services.py +0 -0
- {lenapi-0.1.0 → lenapi-0.1.1}/tests/test_throttle.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lenapi
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: A small, typed ASGI web framework
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -214,13 +214,13 @@ The repository root contains the lenApi library. Create an application scaffold
|
|
|
214
214
|
|
|
215
215
|
```bash
|
|
216
216
|
python -m pip install -e '.[server]'
|
|
217
|
-
|
|
217
|
+
lenora startproject my_api
|
|
218
218
|
cd my_api
|
|
219
219
|
python lenora.py migration
|
|
220
220
|
python lenora.py runserver
|
|
221
221
|
```
|
|
222
222
|
|
|
223
|
-
The server listens on `127.0.0.1:8009` by default. The generated application contains `app/models.py`, `
|
|
223
|
+
Use the installed `lenora` command to create the first project; `python lenora.py ...` commands are available after entering the generated project. The server listens on `127.0.0.1:8009` by default. The generated application contains `app/models.py`, `app/authentication.py`, `settings.py`, `main.py`, a `tests/` package, a project-local CLI launcher, `alembic.ini`, and a `migrations/` folder. The source tree keeps working applications under `app/`; they are separate from the installable `lenapi` package.
|
|
224
224
|
|
|
225
225
|
`settings.py` centralizes the API title/version, docs and OpenAPI URLs, host, port, database URL, token settings, and shared security storage. `app = LenApi()` loads these project settings automatically, including the title displayed by Swagger. Each generated project gets a unique high-entropy `SECRET_KEY`; `TOKEN` uses it to sign tokens and configures token lifetime and issuer. Keep the key private. Changing it invalidates tokens signed with the old key. Override the host, port, or SQLite URL with `LENAPI_HOST`, `LENAPI_PORT`, and `LENAPI_DATABASE_URL` environment variables.
|
|
226
226
|
|
|
@@ -235,7 +235,7 @@ python -m pip install -e '.[test]'
|
|
|
235
235
|
python -m pytest
|
|
236
236
|
```
|
|
237
237
|
|
|
238
|
-
The `generate_test`, `run_test`, and `run_test_output` commands are application commands. Run them from an application directory, where `main.py` and `settings.py` live. `
|
|
238
|
+
The `test`, `generate_test`, `run_test`, and `run_test_output` commands are application commands. Run them from an application directory, where `main.py` and `settings.py` live. `test` generates endpoint checks temporarily, runs them, and writes a CSV report under `outputs/`. `run_test_output` also writes a CSV report.
|
|
239
239
|
|
|
240
240
|
For the included Student API example, run those commands from `app/student_api`:
|
|
241
241
|
|
|
@@ -243,6 +243,7 @@ For the included Student API example, run those commands from `app/student_api`:
|
|
|
243
243
|
cd app/student_api
|
|
244
244
|
python lenora.py generate_test
|
|
245
245
|
python lenora.py run_test
|
|
246
|
+
python lenora.py test
|
|
246
247
|
```
|
|
247
248
|
|
|
248
249
|
Request bodies are limited to 10 MiB by default, and lenApi allows up to 30 seconds to receive the body. Configure `MAX_REQUEST_BODY_SIZE` (bytes) and `REQUEST_BODY_TIMEOUT_SECONDS` in `settings.py`; set either to `None` to disable that limit. Oversized bodies receive HTTP 413, and bodies that exceed the receive timeout receive HTTP 408. Direct `Application(...)` instances accept the equivalent `max_request_body_size` and `request_body_timeout` arguments.
|
|
@@ -424,20 +425,20 @@ BASIC_AUTH_FIELD = "email" # username, email, phone, or another User field
|
|
|
424
425
|
|
|
425
426
|
The HTTP Basic username slot is treated as the configured identifier, so a client can send `auth=(email, password)` or `auth=(phone, password)`. For a custom identifier, add that field to `User` and migrate the database; then set `BASIC_AUTH_FIELD` to the model field name.
|
|
426
427
|
|
|
427
|
-
The generated `main.py` uses
|
|
428
|
+
The generated `main.py` uses the selected setting to protect `/auth/profile`; Swagger shows the selected scheme. JWT mode also registers `/auth/token` and `/auth/refresh` automatically. Initialize the selected method through `authentication_type`:
|
|
428
429
|
|
|
429
430
|
```python
|
|
430
|
-
from lenapi import Depends
|
|
431
|
+
from lenapi import Depends
|
|
432
|
+
from lenapi.authentication import authentication_type
|
|
433
|
+
from app import models
|
|
431
434
|
import settings
|
|
432
435
|
|
|
433
|
-
|
|
436
|
+
auth_type = settings.AUTHENTICATION # "jwt", "api_key", "basic_login", "session", etc.
|
|
437
|
+
auth = authentication_type(auth_type, settings=settings, user_model=models.User, app=app)
|
|
434
438
|
|
|
435
439
|
@app.get("/protected")
|
|
436
440
|
def protected(user=Depends(auth)):
|
|
437
441
|
return {"authenticated": True}
|
|
438
|
-
|
|
439
|
-
if settings.AUTHENTICATION == "jwt":
|
|
440
|
-
app.include_router(auth.router(authenticate_user))
|
|
441
442
|
```
|
|
442
443
|
|
|
443
444
|
The primary authentication setting chooses one method for the scaffold's default protected route: `jwt`, `api_key`, `basic_login`, `opaque_bearer`, or `session`. Session cookies, HMAC, and OIDC can also be attached as additional callable dependencies on selected routes. This lets an app use API keys generally and require a session cookie on a browser route, or require both on one route:
|
|
@@ -180,13 +180,13 @@ The repository root contains the lenApi library. Create an application scaffold
|
|
|
180
180
|
|
|
181
181
|
```bash
|
|
182
182
|
python -m pip install -e '.[server]'
|
|
183
|
-
|
|
183
|
+
lenora startproject my_api
|
|
184
184
|
cd my_api
|
|
185
185
|
python lenora.py migration
|
|
186
186
|
python lenora.py runserver
|
|
187
187
|
```
|
|
188
188
|
|
|
189
|
-
The server listens on `127.0.0.1:8009` by default. The generated application contains `app/models.py`, `
|
|
189
|
+
Use the installed `lenora` command to create the first project; `python lenora.py ...` commands are available after entering the generated project. The server listens on `127.0.0.1:8009` by default. The generated application contains `app/models.py`, `app/authentication.py`, `settings.py`, `main.py`, a `tests/` package, a project-local CLI launcher, `alembic.ini`, and a `migrations/` folder. The source tree keeps working applications under `app/`; they are separate from the installable `lenapi` package.
|
|
190
190
|
|
|
191
191
|
`settings.py` centralizes the API title/version, docs and OpenAPI URLs, host, port, database URL, token settings, and shared security storage. `app = LenApi()` loads these project settings automatically, including the title displayed by Swagger. Each generated project gets a unique high-entropy `SECRET_KEY`; `TOKEN` uses it to sign tokens and configures token lifetime and issuer. Keep the key private. Changing it invalidates tokens signed with the old key. Override the host, port, or SQLite URL with `LENAPI_HOST`, `LENAPI_PORT`, and `LENAPI_DATABASE_URL` environment variables.
|
|
192
192
|
|
|
@@ -201,7 +201,7 @@ python -m pip install -e '.[test]'
|
|
|
201
201
|
python -m pytest
|
|
202
202
|
```
|
|
203
203
|
|
|
204
|
-
The `generate_test`, `run_test`, and `run_test_output` commands are application commands. Run them from an application directory, where `main.py` and `settings.py` live. `
|
|
204
|
+
The `test`, `generate_test`, `run_test`, and `run_test_output` commands are application commands. Run them from an application directory, where `main.py` and `settings.py` live. `test` generates endpoint checks temporarily, runs them, and writes a CSV report under `outputs/`. `run_test_output` also writes a CSV report.
|
|
205
205
|
|
|
206
206
|
For the included Student API example, run those commands from `app/student_api`:
|
|
207
207
|
|
|
@@ -209,6 +209,7 @@ For the included Student API example, run those commands from `app/student_api`:
|
|
|
209
209
|
cd app/student_api
|
|
210
210
|
python lenora.py generate_test
|
|
211
211
|
python lenora.py run_test
|
|
212
|
+
python lenora.py test
|
|
212
213
|
```
|
|
213
214
|
|
|
214
215
|
Request bodies are limited to 10 MiB by default, and lenApi allows up to 30 seconds to receive the body. Configure `MAX_REQUEST_BODY_SIZE` (bytes) and `REQUEST_BODY_TIMEOUT_SECONDS` in `settings.py`; set either to `None` to disable that limit. Oversized bodies receive HTTP 413, and bodies that exceed the receive timeout receive HTTP 408. Direct `Application(...)` instances accept the equivalent `max_request_body_size` and `request_body_timeout` arguments.
|
|
@@ -390,20 +391,20 @@ BASIC_AUTH_FIELD = "email" # username, email, phone, or another User field
|
|
|
390
391
|
|
|
391
392
|
The HTTP Basic username slot is treated as the configured identifier, so a client can send `auth=(email, password)` or `auth=(phone, password)`. For a custom identifier, add that field to `User` and migrate the database; then set `BASIC_AUTH_FIELD` to the model field name.
|
|
392
393
|
|
|
393
|
-
The generated `main.py` uses
|
|
394
|
+
The generated `main.py` uses the selected setting to protect `/auth/profile`; Swagger shows the selected scheme. JWT mode also registers `/auth/token` and `/auth/refresh` automatically. Initialize the selected method through `authentication_type`:
|
|
394
395
|
|
|
395
396
|
```python
|
|
396
|
-
from lenapi import Depends
|
|
397
|
+
from lenapi import Depends
|
|
398
|
+
from lenapi.authentication import authentication_type
|
|
399
|
+
from app import models
|
|
397
400
|
import settings
|
|
398
401
|
|
|
399
|
-
|
|
402
|
+
auth_type = settings.AUTHENTICATION # "jwt", "api_key", "basic_login", "session", etc.
|
|
403
|
+
auth = authentication_type(auth_type, settings=settings, user_model=models.User, app=app)
|
|
400
404
|
|
|
401
405
|
@app.get("/protected")
|
|
402
406
|
def protected(user=Depends(auth)):
|
|
403
407
|
return {"authenticated": True}
|
|
404
|
-
|
|
405
|
-
if settings.AUTHENTICATION == "jwt":
|
|
406
|
-
app.include_router(auth.router(authenticate_user))
|
|
407
408
|
```
|
|
408
409
|
|
|
409
410
|
The primary authentication setting chooses one method for the scaffold's default protected route: `jwt`, `api_key`, `basic_login`, `opaque_bearer`, or `session`. Session cookies, HMAC, and OIDC can also be attached as additional callable dependencies on selected routes. This lets an app use API keys generally and require a session cookie on a browser route, or require both on one route:
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Application-level authentication helpers for lenApi projects.
|
|
2
|
+
|
|
3
|
+
Use these factories to connect lenApi's authentication dependencies to a user
|
|
4
|
+
model, rather than putting credential lookup and password handling in
|
|
5
|
+
``main.py``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
9
|
+
|
|
10
|
+
from .security import (
|
|
11
|
+
APIKeyAuth,
|
|
12
|
+
BasicAuth,
|
|
13
|
+
HMACAuth,
|
|
14
|
+
JWTAuth,
|
|
15
|
+
OIDCAuth,
|
|
16
|
+
OpaqueBearerAuth,
|
|
17
|
+
SessionAuth,
|
|
18
|
+
WebhookAuth,
|
|
19
|
+
check_password,
|
|
20
|
+
hash_api_key,
|
|
21
|
+
make_api_key,
|
|
22
|
+
make_password,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UserRegistration(BaseModel):
|
|
27
|
+
"""Default request schema for registering a project user."""
|
|
28
|
+
|
|
29
|
+
username: str = Field(min_length=1, max_length=120)
|
|
30
|
+
email: str = Field(pattern=r"^[^@\s]+@[^@\s]+\.[^@\s]+$", max_length=254)
|
|
31
|
+
first_name: str = Field(default="", max_length=150)
|
|
32
|
+
last_name: str = Field(default="", max_length=150)
|
|
33
|
+
password: str = Field(min_length=8, max_length=128)
|
|
34
|
+
confirm_password: str = Field(min_length=8, max_length=128)
|
|
35
|
+
|
|
36
|
+
@model_validator(mode="after")
|
|
37
|
+
def passwords_must_match(self):
|
|
38
|
+
if self.password != self.confirm_password:
|
|
39
|
+
raise ValueError("password and confirm_password must match")
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class UserPublic(BaseModel):
|
|
44
|
+
"""Default public representation returned after user registration."""
|
|
45
|
+
|
|
46
|
+
model_config = ConfigDict(from_attributes=True)
|
|
47
|
+
|
|
48
|
+
id: int
|
|
49
|
+
username: str
|
|
50
|
+
email: str | None
|
|
51
|
+
first_name: str
|
|
52
|
+
last_name: str
|
|
53
|
+
api_key: str | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _user_authenticator(user_model, identifier_field="username", password_field="password"):
|
|
57
|
+
columns = user_model.__table__.columns
|
|
58
|
+
if identifier_field not in columns:
|
|
59
|
+
raise ValueError(f"Authentication identifier '{identifier_field}' is not a field on {user_model.__name__}")
|
|
60
|
+
if password_field not in columns:
|
|
61
|
+
raise ValueError(f"Password field '{password_field}' is not a field on {user_model.__name__}")
|
|
62
|
+
|
|
63
|
+
def authenticate(identifier, password):
|
|
64
|
+
user = user_model.get(**{identifier_field: identifier}).get()
|
|
65
|
+
if user is not None and check_password(password, getattr(user, password_field)):
|
|
66
|
+
return str(user.id)
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
return authenticate
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def basic(user_model, *, identifier_field="username", password_field="password"):
|
|
73
|
+
"""Create database-backed HTTP Basic authentication for a user model."""
|
|
74
|
+
return BasicAuth(_user_authenticator(user_model, identifier_field, password_field))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def api_key(user_model, *, field="api_key", header_name="x-api-key"):
|
|
78
|
+
"""Create database-backed API key authentication for a user model.
|
|
79
|
+
|
|
80
|
+
Raw API keys are hashed before lookup. Store only ``hash_api_key(key)`` in
|
|
81
|
+
the configured model field.
|
|
82
|
+
"""
|
|
83
|
+
if field not in user_model.__table__.columns:
|
|
84
|
+
raise ValueError(f"API key field '{field}' is not a field on {user_model.__name__}")
|
|
85
|
+
|
|
86
|
+
def authenticate(raw_key):
|
|
87
|
+
if not raw_key:
|
|
88
|
+
return None
|
|
89
|
+
user = user_model.get(**{field: hash_api_key(raw_key)}).get()
|
|
90
|
+
return {"user_id": str(user.id), "auth_method": "api_key"} if user is not None else None
|
|
91
|
+
|
|
92
|
+
return APIKeyAuth(header_name=header_name, authenticate=authenticate)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def jwt_auth(settings, user_model=None, *, identifier_field="username", password_field="password"):
|
|
96
|
+
"""Create JWT authentication, optionally backed by a project user model.
|
|
97
|
+
|
|
98
|
+
Use ``auth.router(authenticate)`` to mount token and refresh endpoints.
|
|
99
|
+
"""
|
|
100
|
+
token = settings.TOKEN
|
|
101
|
+
auth = JWTAuth(
|
|
102
|
+
token["SECRET_KEY"],
|
|
103
|
+
token_lifetime_seconds=token.get("LIFETIME_SECONDS", 3600),
|
|
104
|
+
refresh_token_lifetime_seconds=token.get("REFRESH_LIFETIME_SECONDS", 60 * 60 * 24 * 30),
|
|
105
|
+
issuer=token.get("ISSUER"),
|
|
106
|
+
authenticate=(
|
|
107
|
+
_user_authenticator(user_model, identifier_field, password_field)
|
|
108
|
+
if user_model is not None else None
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
return auth
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def session(*, store=None, lookup=None, cookie_name="lenapi_session"):
|
|
115
|
+
"""Create session authentication for use as a route dependency."""
|
|
116
|
+
return SessionAuth(lookup, store=store if lookup is None else None, cookie_name=cookie_name)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def opaque_bearer(*, store=None, lookup=None):
|
|
120
|
+
"""Create server-checked opaque bearer token authentication."""
|
|
121
|
+
return OpaqueBearerAuth(lookup, store=store if lookup is None else None)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def hmac(secret, *, tolerance_seconds=300, nonce_store=None):
|
|
125
|
+
"""Create optional HMAC request authentication."""
|
|
126
|
+
return HMACAuth(secret, tolerance_seconds=tolerance_seconds, nonce_store=nonce_store)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def oidc(lookup):
|
|
130
|
+
"""Create OIDC authentication backed by a token lookup callback."""
|
|
131
|
+
return OIDCAuth(lookup)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def webhook(secret, *, tolerance_seconds=300, nonce_store=None):
|
|
135
|
+
"""Create webhook signature authentication."""
|
|
136
|
+
return WebhookAuth(secret, tolerance_seconds=tolerance_seconds, nonce_store=nonce_store)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def configure(settings, user_model, *, security_store=None):
|
|
140
|
+
"""Build the primary authenticator selected by ``settings.AUTHENTICATION``."""
|
|
141
|
+
mode = getattr(settings, "AUTHENTICATION", "jwt").lower()
|
|
142
|
+
identifier_field = getattr(settings, "BASIC_AUTH_FIELD", "username")
|
|
143
|
+
if mode == "basic_login":
|
|
144
|
+
return basic(user_model, identifier_field=identifier_field)
|
|
145
|
+
if mode == "api_key":
|
|
146
|
+
return api_key(user_model)
|
|
147
|
+
if mode == "jwt":
|
|
148
|
+
return jwt_auth(settings, user_model)
|
|
149
|
+
if mode == "session":
|
|
150
|
+
options = getattr(settings, "SESSION", {})
|
|
151
|
+
return session(store=security_store, cookie_name=options.get("COOKIE_NAME", "lenapi_session"))
|
|
152
|
+
if mode == "opaque_bearer":
|
|
153
|
+
return opaque_bearer(store=security_store)
|
|
154
|
+
if mode == "hmac":
|
|
155
|
+
secret = getattr(settings, "HMAC_SECRET", None)
|
|
156
|
+
if not secret:
|
|
157
|
+
raise ValueError("hmac authentication requires HMAC_SECRET")
|
|
158
|
+
return hmac(secret, tolerance_seconds=getattr(settings, "HMAC_TOLERANCE_SECONDS", 300))
|
|
159
|
+
if mode in {"oidc", "oauth2_oidc"}:
|
|
160
|
+
lookup = getattr(settings, "OIDC_TOKEN_LOOKUP", None)
|
|
161
|
+
if lookup is None:
|
|
162
|
+
raise ValueError("oidc authentication requires OIDC_TOKEN_LOOKUP")
|
|
163
|
+
return oidc(lookup)
|
|
164
|
+
raise ValueError(f"Unknown AUTHENTICATION mode: {mode}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def authentication_type(auth_type, *, settings, user_model, app):
|
|
168
|
+
"""Configure an application's selected authentication method in one call.
|
|
169
|
+
|
|
170
|
+
``auth_type`` may be ``"jwt"``, ``"api_key"``, ``"basic_login``,
|
|
171
|
+
``"session"``, ``"opaque_bearer"``, ``"hmac"``, or ``"oidc"``. The
|
|
172
|
+
returned object is the dependency to use with ``Depends``. JWT token and
|
|
173
|
+
refresh routes are added to ``app`` automatically.
|
|
174
|
+
"""
|
|
175
|
+
from types import SimpleNamespace
|
|
176
|
+
|
|
177
|
+
mode = str(auth_type).lower()
|
|
178
|
+
selected_settings = SimpleNamespace(**{
|
|
179
|
+
key: value for key, value in vars(settings).items() if not key.startswith("__")
|
|
180
|
+
})
|
|
181
|
+
selected_settings.AUTHENTICATION = mode
|
|
182
|
+
authenticator = configure(
|
|
183
|
+
selected_settings,
|
|
184
|
+
user_model,
|
|
185
|
+
security_store=getattr(app, "security_store", None),
|
|
186
|
+
)
|
|
187
|
+
if mode == "jwt":
|
|
188
|
+
app.include_router(authenticator.router())
|
|
189
|
+
return authenticator
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def register_user(user_model, data, *, authentication="jwt"):
|
|
193
|
+
"""Create a user, hash its password, and return a one-time API key if used."""
|
|
194
|
+
raw_key = make_api_key() if authentication == "api_key" else None
|
|
195
|
+
user = user_model.create(
|
|
196
|
+
username=data.username,
|
|
197
|
+
email=data.email,
|
|
198
|
+
first_name=data.first_name,
|
|
199
|
+
last_name=data.last_name,
|
|
200
|
+
password=make_password(data.password),
|
|
201
|
+
api_key=hash_api_key(raw_key) if raw_key is not None else None,
|
|
202
|
+
)
|
|
203
|
+
return {
|
|
204
|
+
"id": user.id,
|
|
205
|
+
"username": user.username,
|
|
206
|
+
"email": user.email,
|
|
207
|
+
"first_name": user.first_name,
|
|
208
|
+
"last_name": user.last_name,
|
|
209
|
+
"api_key": raw_key,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
__all__ = [
|
|
214
|
+
"UserRegistration", "UserPublic", "basic", "api_key", "jwt_auth",
|
|
215
|
+
"session", "opaque_bearer", "hmac", "oidc", "webhook", "configure",
|
|
216
|
+
"authentication_type",
|
|
217
|
+
"register_user",
|
|
218
|
+
]
|
|
@@ -64,82 +64,31 @@ TOKEN = {
|
|
|
64
64
|
"ISSUER": API_TITLE,
|
|
65
65
|
}
|
|
66
66
|
''',
|
|
67
|
-
"main.py": '''"""Application
|
|
67
|
+
"main.py": '''"""Application routes."""
|
|
68
68
|
|
|
69
|
-
from lenapi import Depends, LenApi, check_password, create_authenticator, hash_api_key, make_api_key, make_password
|
|
70
69
|
from app import models # Import models so migrations can discover them.
|
|
71
|
-
import
|
|
72
|
-
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
73
|
-
|
|
74
|
-
app = LenApi()
|
|
75
|
-
|
|
76
|
-
if settings.AUTHENTICATION == "basic_login":
|
|
77
|
-
identifier_field = getattr(settings, "BASIC_AUTH_FIELD", "username")
|
|
78
|
-
if identifier_field not in models.User.__table__.columns:
|
|
79
|
-
raise ValueError(f"BASIC_AUTH_FIELD '{identifier_field}' is not a field on User")
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def authenticate_user(username, password):
|
|
83
|
-
user = models.User.get(username=username).get()
|
|
84
|
-
if user is not None and check_password(password, user.password):
|
|
85
|
-
return str(user.id)
|
|
86
|
-
return None
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def authenticate_api_key(api_key):
|
|
90
|
-
if not api_key:
|
|
91
|
-
return None
|
|
92
|
-
user = models.User.get(api_key=hash_api_key(api_key)).get()
|
|
93
|
-
return {"user_id": str(user.id), "auth_method": "api_key"} if user is not None else None
|
|
70
|
+
from app.authentication import app
|
|
94
71
|
|
|
95
72
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
app.include_router(authenticator.router(authenticate_user))
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
class UserRegistration(BaseModel):
|
|
105
|
-
username: str = Field(min_length=1, max_length=120)
|
|
106
|
-
email: str = Field(pattern=r"^[^@\s]+@[^@\s]+\.[^@\s]+$", max_length=254)
|
|
107
|
-
first_name: str = Field(default="", max_length=150)
|
|
108
|
-
last_name: str = Field(default="", max_length=150)
|
|
109
|
-
password: str = Field(min_length=8, max_length=128)
|
|
110
|
-
confirm_password: str = Field(min_length=8, max_length=128)
|
|
111
|
-
|
|
112
|
-
@model_validator(mode="after")
|
|
113
|
-
def passwords_must_match(self):
|
|
114
|
-
if self.password != self.confirm_password:
|
|
115
|
-
raise ValueError("password and confirm_password must match")
|
|
116
|
-
return self
|
|
117
|
-
|
|
73
|
+
@app.get("/")
|
|
74
|
+
def home():
|
|
75
|
+
return {"message": "lenApp is running"}
|
|
76
|
+
''',
|
|
77
|
+
"app/authentication.py": '''"""Project authentication setup and built-in auth endpoints."""
|
|
118
78
|
|
|
119
|
-
|
|
120
|
-
|
|
79
|
+
from lenapi import Depends, LenApi
|
|
80
|
+
from lenapi.authentication import UserPublic, UserRegistration, authentication_type, register_user
|
|
81
|
+
from app import models
|
|
82
|
+
import settings
|
|
121
83
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
first_name: str
|
|
126
|
-
last_name: str
|
|
127
|
-
api_key: str | None = None
|
|
84
|
+
app = LenApi()
|
|
85
|
+
auth_type = settings.AUTHENTICATION # e.g. "jwt", "api_key", "basic_login", or "session"
|
|
86
|
+
authenticator = authentication_type(auth_type, settings=settings, user_model=models.User, app=app)
|
|
128
87
|
|
|
129
88
|
|
|
130
89
|
@app.post("/auth/register", response_model=UserPublic)
|
|
131
|
-
def
|
|
132
|
-
|
|
133
|
-
user = models.User.create(
|
|
134
|
-
username=data.username,
|
|
135
|
-
email=data.email,
|
|
136
|
-
first_name=data.first_name,
|
|
137
|
-
last_name=data.last_name,
|
|
138
|
-
password=make_password(data.password),
|
|
139
|
-
api_key=hash_api_key(api_key) if api_key is not None else None,
|
|
140
|
-
)
|
|
141
|
-
return {"id": user.id, "username": user.username, "email": user.email,
|
|
142
|
-
"first_name": user.first_name, "last_name": user.last_name, "api_key": api_key}
|
|
90
|
+
def create_user(data: UserRegistration):
|
|
91
|
+
return register_user(models.User, data, authentication=auth_type)
|
|
143
92
|
|
|
144
93
|
|
|
145
94
|
@app.get("/auth/profile")
|
|
@@ -147,11 +96,6 @@ def auth_profile(identity=Depends(authenticator)):
|
|
|
147
96
|
if isinstance(identity, dict) and "sub" in identity:
|
|
148
97
|
return {"user_id": identity["sub"]}
|
|
149
98
|
return {"identity": identity}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
@app.get("/")
|
|
153
|
-
def home():
|
|
154
|
-
return {"message": "lenApp is running"}
|
|
155
99
|
''',
|
|
156
100
|
"app/__init__.py": '''"""Application package."""
|
|
157
101
|
''',
|
|
@@ -328,18 +272,12 @@ python lenora.py migration
|
|
|
328
272
|
python lenora.py runserver
|
|
329
273
|
```
|
|
330
274
|
|
|
331
|
-
The development server uses port 8009. Open `/docs/` for Swagger UI. Run `python lenora.py
|
|
275
|
+
The development server uses port 8009. Open `/docs/` for Swagger UI. Run `python lenora.py test` to generate endpoint checks, run the tests, and write a CSV report.
|
|
332
276
|
''',
|
|
333
|
-
"
|
|
334
|
-
|
|
335
|
-
from main import app
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
def test_root_route_is_registered():
|
|
339
|
-
assert any(route.path == "/" and "GET" in route.methods for route in app.routes)
|
|
277
|
+
"tests/__init__.py": '''"""Project test package. Run its tests with ``python lenora.py test``."""
|
|
340
278
|
''',
|
|
341
279
|
"pytest.ini": '''[pytest]
|
|
342
|
-
python_files =
|
|
280
|
+
python_files = test_*.py *_test.py
|
|
343
281
|
''',
|
|
344
282
|
}
|
|
345
283
|
|
|
@@ -391,6 +329,15 @@ def _main() -> None:
|
|
|
391
329
|
help="CSV report path (default: outputs/lenapi_pytest_results.csv)",
|
|
392
330
|
)
|
|
393
331
|
run_test_output.add_argument("paths", nargs="*", help="optional pytest files or directories")
|
|
332
|
+
test_command = commands.add_parser(
|
|
333
|
+
"test", help="generate endpoint checks, run tests, and write a CSV report"
|
|
334
|
+
)
|
|
335
|
+
test_command.add_argument("--app", default=DEFAULT_APP, help=f"ASGI app import string (default: {DEFAULT_APP})")
|
|
336
|
+
test_command.add_argument(
|
|
337
|
+
"--output", default="outputs/lenapi_test_results.csv",
|
|
338
|
+
help="CSV report path (default: outputs/lenapi_test_results.csv)",
|
|
339
|
+
)
|
|
340
|
+
test_command.add_argument("paths", nargs="*", help="optional pytest files or directories")
|
|
394
341
|
|
|
395
342
|
args = parser.parse_args()
|
|
396
343
|
|
|
@@ -427,7 +374,7 @@ def _main() -> None:
|
|
|
427
374
|
print(f"Next: cd {args.name} && python lenora.py migration && python lenora.py runserver")
|
|
428
375
|
return
|
|
429
376
|
|
|
430
|
-
if args.command in {"generate_test", "run_test", "run_test_output"}:
|
|
377
|
+
if args.command in {"generate_test", "run_test", "run_test_output", "test"}:
|
|
431
378
|
module_name, separator, attribute_path = args.app.partition(":")
|
|
432
379
|
if not separator or not module_name:
|
|
433
380
|
parser.error("--app must use the form package.module:app")
|
|
@@ -443,10 +390,15 @@ def _main() -> None:
|
|
|
443
390
|
for method in route.methods
|
|
444
391
|
)
|
|
445
392
|
websocket_endpoints = sorted(route.path for route in app.websocket_routes)
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
393
|
+
generated_test_temp = None
|
|
394
|
+
if args.command in {"generate_test", "test"}:
|
|
395
|
+
if args.command == "generate_test":
|
|
396
|
+
test_dir = Path.cwd() / "tests"
|
|
397
|
+
test_dir.mkdir(parents=True, exist_ok=True)
|
|
398
|
+
destination = test_dir / "test_generated_endpoints.py"
|
|
399
|
+
else:
|
|
400
|
+
generated_test_temp = tempfile.TemporaryDirectory(prefix="lenapi-generated-tests-")
|
|
401
|
+
destination = Path(generated_test_temp.name) / "test_generated_endpoints.py"
|
|
450
402
|
websocket_test_source = (
|
|
451
403
|
'''@pytest.mark.parametrize("path", WEBSOCKET_ENDPOINTS)
|
|
452
404
|
def test_websocket_endpoint_is_registered(path):
|
|
@@ -455,7 +407,7 @@ def test_websocket_endpoint_is_registered(path):
|
|
|
455
407
|
'''
|
|
456
408
|
if websocket_endpoints else ""
|
|
457
409
|
)
|
|
458
|
-
generated_source = f'''"""Generated endpoint registration and OpenAPI coverage.
|
|
410
|
+
generated_source = f'''"""Generated endpoint registration and OpenAPI coverage."""
|
|
459
411
|
|
|
460
412
|
from importlib import import_module
|
|
461
413
|
|
|
@@ -486,13 +438,16 @@ def test_http_endpoint_is_registered_and_documented(method, path):
|
|
|
486
438
|
{websocket_test_source}
|
|
487
439
|
'''
|
|
488
440
|
destination.write_text(generated_source, encoding="utf-8")
|
|
489
|
-
print(f"Generated {
|
|
441
|
+
print(f"Generated endpoint checks: {len(http_endpoints) + len(websocket_endpoints)}")
|
|
442
|
+
if args.command == "generate_test":
|
|
443
|
+
print(f"Generated {destination}")
|
|
490
444
|
print(f"HTTP endpoints covered: {len(http_endpoints)}")
|
|
491
445
|
for method, path in http_endpoints:
|
|
492
446
|
print(f" {method.upper():7} {path}")
|
|
493
447
|
for path in websocket_endpoints:
|
|
494
448
|
print(f" WEBSOCKET {path}")
|
|
495
|
-
|
|
449
|
+
if args.command == "generate_test":
|
|
450
|
+
return
|
|
496
451
|
|
|
497
452
|
print("Endpoints covered by generated route tests:")
|
|
498
453
|
for method, path in http_endpoints:
|
|
@@ -501,7 +456,17 @@ def test_http_endpoint_is_registered_and_documented(method, path):
|
|
|
501
456
|
print(f" WEBSOCKET {path}")
|
|
502
457
|
sys.stdout.flush()
|
|
503
458
|
|
|
504
|
-
if args.
|
|
459
|
+
if args.command == "test":
|
|
460
|
+
pytest_args = [*args.paths]
|
|
461
|
+
if not pytest_args and (Path.cwd() / "tests").is_dir():
|
|
462
|
+
pytest_args.append("tests")
|
|
463
|
+
pytest_args.append(str(destination))
|
|
464
|
+
project_dir = Path.cwd().resolve()
|
|
465
|
+
pytest_args = [
|
|
466
|
+
str((project_dir / path).resolve()) if not Path(path).is_absolute() else path
|
|
467
|
+
for path in pytest_args
|
|
468
|
+
]
|
|
469
|
+
elif args.paths:
|
|
505
470
|
pytest_args = [*args.paths]
|
|
506
471
|
else:
|
|
507
472
|
pytest_args = []
|
|
@@ -512,11 +477,22 @@ def test_http_endpoint_is_registered_and_documented(method, path):
|
|
|
512
477
|
if not pytest_args:
|
|
513
478
|
pytest_args = ["tests"]
|
|
514
479
|
command = [sys.executable, "-m", "pytest", "-q"]
|
|
515
|
-
if args.command
|
|
480
|
+
if args.command in {"run_test_output", "test"}:
|
|
516
481
|
with tempfile.TemporaryDirectory(prefix="lenapi-pytest-") as temp_dir:
|
|
517
482
|
junit_path = Path(temp_dir) / "results.xml"
|
|
483
|
+
run_options = {}
|
|
484
|
+
if args.command == "test":
|
|
485
|
+
project_dir = Path.cwd().resolve()
|
|
486
|
+
framework_root = str(Path(__file__).resolve().parents[1])
|
|
487
|
+
environment = os.environ.copy()
|
|
488
|
+
search_paths = [framework_root, str(project_dir)]
|
|
489
|
+
if environment.get("PYTHONPATH"):
|
|
490
|
+
search_paths.append(environment["PYTHONPATH"])
|
|
491
|
+
environment["PYTHONPATH"] = os.pathsep.join(search_paths)
|
|
492
|
+
run_options = {"cwd": project_dir.parent, "env": environment}
|
|
518
493
|
result = subprocess.run(
|
|
519
|
-
[*command, "--junitxml", str(junit_path), *pytest_args], check=False
|
|
494
|
+
[*command, "--junitxml", str(junit_path), *pytest_args], check=False,
|
|
495
|
+
**run_options,
|
|
520
496
|
)
|
|
521
497
|
suites = ET.parse(junit_path).getroot()
|
|
522
498
|
cases = suites.findall(".//testcase")
|
|
@@ -559,6 +535,8 @@ def test_http_endpoint_is_registered_and_documented(method, path):
|
|
|
559
535
|
print(f"CSV report written to {report_path}")
|
|
560
536
|
else:
|
|
561
537
|
result = subprocess.run([*command, *pytest_args], check=False)
|
|
538
|
+
if generated_test_temp is not None:
|
|
539
|
+
generated_test_temp.cleanup()
|
|
562
540
|
raise SystemExit(result.returncode)
|
|
563
541
|
|
|
564
542
|
if args.command == "runserver":
|
|
@@ -115,6 +115,7 @@ class JWTAuth:
|
|
|
115
115
|
token_lifetime_seconds: int = 3600,
|
|
116
116
|
refresh_token_lifetime_seconds: int = 60 * 60 * 24 * 30,
|
|
117
117
|
issuer: str | None = None,
|
|
118
|
+
authenticate=None,
|
|
118
119
|
) -> None:
|
|
119
120
|
key = secret_key.encode("utf-8") if isinstance(secret_key, str) else secret_key
|
|
120
121
|
if not isinstance(key, bytes) or len(key) < 32:
|
|
@@ -127,6 +128,7 @@ class JWTAuth:
|
|
|
127
128
|
self.token_lifetime_seconds = token_lifetime_seconds
|
|
128
129
|
self.refresh_token_lifetime_seconds = refresh_token_lifetime_seconds
|
|
129
130
|
self.issuer = issuer
|
|
131
|
+
self.authenticate = authenticate
|
|
130
132
|
|
|
131
133
|
def create_access_token(
|
|
132
134
|
self,
|
|
@@ -187,14 +189,19 @@ class JWTAuth:
|
|
|
187
189
|
if key not in {"sub", "iat", "exp", "iss", "token_use"}}
|
|
188
190
|
return self.create_access_token(payload["sub"], claims=claims)
|
|
189
191
|
|
|
190
|
-
def router(self, authenticate):
|
|
192
|
+
def router(self, authenticate=None):
|
|
191
193
|
"""Build `/auth/token` and `/auth/refresh` routes for an app.
|
|
192
194
|
|
|
193
|
-
`authenticate(username, password)`
|
|
195
|
+
`authenticate(username, password)` may be supplied here or when
|
|
196
|
+
constructing the instance. It must return the user's string ID, or
|
|
194
197
|
`(string_id, claims)`, when credentials are valid; otherwise return None.
|
|
195
198
|
"""
|
|
196
199
|
from .application import Router
|
|
197
200
|
|
|
201
|
+
authenticate = authenticate or self.authenticate
|
|
202
|
+
if authenticate is None:
|
|
203
|
+
raise ValueError("JWT token routes require an authenticate(username, password) callback")
|
|
204
|
+
|
|
198
205
|
router = Router(prefix="/auth")
|
|
199
206
|
|
|
200
207
|
@router.post("/token")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lenapi
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: A small, typed ASGI web framework
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -214,13 +214,13 @@ The repository root contains the lenApi library. Create an application scaffold
|
|
|
214
214
|
|
|
215
215
|
```bash
|
|
216
216
|
python -m pip install -e '.[server]'
|
|
217
|
-
|
|
217
|
+
lenora startproject my_api
|
|
218
218
|
cd my_api
|
|
219
219
|
python lenora.py migration
|
|
220
220
|
python lenora.py runserver
|
|
221
221
|
```
|
|
222
222
|
|
|
223
|
-
The server listens on `127.0.0.1:8009` by default. The generated application contains `app/models.py`, `
|
|
223
|
+
Use the installed `lenora` command to create the first project; `python lenora.py ...` commands are available after entering the generated project. The server listens on `127.0.0.1:8009` by default. The generated application contains `app/models.py`, `app/authentication.py`, `settings.py`, `main.py`, a `tests/` package, a project-local CLI launcher, `alembic.ini`, and a `migrations/` folder. The source tree keeps working applications under `app/`; they are separate from the installable `lenapi` package.
|
|
224
224
|
|
|
225
225
|
`settings.py` centralizes the API title/version, docs and OpenAPI URLs, host, port, database URL, token settings, and shared security storage. `app = LenApi()` loads these project settings automatically, including the title displayed by Swagger. Each generated project gets a unique high-entropy `SECRET_KEY`; `TOKEN` uses it to sign tokens and configures token lifetime and issuer. Keep the key private. Changing it invalidates tokens signed with the old key. Override the host, port, or SQLite URL with `LENAPI_HOST`, `LENAPI_PORT`, and `LENAPI_DATABASE_URL` environment variables.
|
|
226
226
|
|
|
@@ -235,7 +235,7 @@ python -m pip install -e '.[test]'
|
|
|
235
235
|
python -m pytest
|
|
236
236
|
```
|
|
237
237
|
|
|
238
|
-
The `generate_test`, `run_test`, and `run_test_output` commands are application commands. Run them from an application directory, where `main.py` and `settings.py` live. `
|
|
238
|
+
The `test`, `generate_test`, `run_test`, and `run_test_output` commands are application commands. Run them from an application directory, where `main.py` and `settings.py` live. `test` generates endpoint checks temporarily, runs them, and writes a CSV report under `outputs/`. `run_test_output` also writes a CSV report.
|
|
239
239
|
|
|
240
240
|
For the included Student API example, run those commands from `app/student_api`:
|
|
241
241
|
|
|
@@ -243,6 +243,7 @@ For the included Student API example, run those commands from `app/student_api`:
|
|
|
243
243
|
cd app/student_api
|
|
244
244
|
python lenora.py generate_test
|
|
245
245
|
python lenora.py run_test
|
|
246
|
+
python lenora.py test
|
|
246
247
|
```
|
|
247
248
|
|
|
248
249
|
Request bodies are limited to 10 MiB by default, and lenApi allows up to 30 seconds to receive the body. Configure `MAX_REQUEST_BODY_SIZE` (bytes) and `REQUEST_BODY_TIMEOUT_SECONDS` in `settings.py`; set either to `None` to disable that limit. Oversized bodies receive HTTP 413, and bodies that exceed the receive timeout receive HTTP 408. Direct `Application(...)` instances accept the equivalent `max_request_body_size` and `request_body_timeout` arguments.
|
|
@@ -424,20 +425,20 @@ BASIC_AUTH_FIELD = "email" # username, email, phone, or another User field
|
|
|
424
425
|
|
|
425
426
|
The HTTP Basic username slot is treated as the configured identifier, so a client can send `auth=(email, password)` or `auth=(phone, password)`. For a custom identifier, add that field to `User` and migrate the database; then set `BASIC_AUTH_FIELD` to the model field name.
|
|
426
427
|
|
|
427
|
-
The generated `main.py` uses
|
|
428
|
+
The generated `main.py` uses the selected setting to protect `/auth/profile`; Swagger shows the selected scheme. JWT mode also registers `/auth/token` and `/auth/refresh` automatically. Initialize the selected method through `authentication_type`:
|
|
428
429
|
|
|
429
430
|
```python
|
|
430
|
-
from lenapi import Depends
|
|
431
|
+
from lenapi import Depends
|
|
432
|
+
from lenapi.authentication import authentication_type
|
|
433
|
+
from app import models
|
|
431
434
|
import settings
|
|
432
435
|
|
|
433
|
-
|
|
436
|
+
auth_type = settings.AUTHENTICATION # "jwt", "api_key", "basic_login", "session", etc.
|
|
437
|
+
auth = authentication_type(auth_type, settings=settings, user_model=models.User, app=app)
|
|
434
438
|
|
|
435
439
|
@app.get("/protected")
|
|
436
440
|
def protected(user=Depends(auth)):
|
|
437
441
|
return {"authenticated": True}
|
|
438
|
-
|
|
439
|
-
if settings.AUTHENTICATION == "jwt":
|
|
440
|
-
app.include_router(auth.router(authenticate_user))
|
|
441
442
|
```
|
|
442
443
|
|
|
443
444
|
The primary authentication setting chooses one method for the scaffold's default protected route: `jwt`, `api_key`, `basic_login`, `opaque_bearer`, or `session`. Session cookies, HMAC, and OIDC can also be attached as additional callable dependencies on selected routes. This lets an app use API keys generally and require a session cookie on a browser route, or require both on one route:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|