caspian-utils 0.0.1
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.
- package/dist/docs/auth.md +690 -0
- package/dist/docs/cache.md +257 -0
- package/dist/docs/commands.md +117 -0
- package/dist/docs/database.md +351 -0
- package/dist/docs/fetch-data.md +260 -0
- package/dist/docs/index.md +65 -0
- package/dist/docs/installation.md +98 -0
- package/dist/docs/metadata.md +196 -0
- package/dist/docs/project-structure.md +193 -0
- package/dist/docs/pulsepoint.md +368 -0
- package/dist/docs/routing.md +231 -0
- package/dist/docs/state.md +264 -0
- package/dist/docs/validation.md +293 -0
- package/package.json +16 -0
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Authentication
|
|
3
|
+
description: Manage session-backed authentication in Caspian with `casp.auth`, `AuthSettings`, the global `auth` object, centralized `auth_config.py`, page decorators, RPC protection, role-based routes, and optional Google or GitHub OAuth providers.
|
|
4
|
+
related:
|
|
5
|
+
title: Related docs
|
|
6
|
+
description: Use the fetch-data guide when sign-in happens through RPC, then use the state guide for transient auth-adjacent request state, validation to guard credentials, and routing or project structure to place auth files correctly.
|
|
7
|
+
links:
|
|
8
|
+
- /docs/fetch-data
|
|
9
|
+
- /docs/state
|
|
10
|
+
- /docs/validation
|
|
11
|
+
- /docs/routing
|
|
12
|
+
- /docs/project-structure
|
|
13
|
+
- /docs/index
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
This page explains the current Caspian authentication API based on the centralized app config pattern in `src/lib/auth/auth_config.py` and the installed `casp.auth` implementation.
|
|
17
|
+
|
|
18
|
+
Treat `casp.auth` as the default authentication layer in Caspian app code. Do not build parallel session helpers, direct cookie-writing utilities, or route-by-route auth conventions when `AuthSettings`, `configure_auth(...)`, `auth`, and the built-in decorators already define the access boundary.
|
|
19
|
+
|
|
20
|
+
## Overview
|
|
21
|
+
|
|
22
|
+
Caspian authentication has two main layers:
|
|
23
|
+
|
|
24
|
+
- app-level policy in `src/lib/auth/auth_config.py`
|
|
25
|
+
- framework runtime behavior in `.venv/Lib/site-packages/casp/auth.py`
|
|
26
|
+
|
|
27
|
+
The main public API includes:
|
|
28
|
+
|
|
29
|
+
- `AuthSettings` for centralized auth configuration
|
|
30
|
+
- `configure_auth(...)` and `get_auth_settings()` for app startup and reads
|
|
31
|
+
- the global `auth` object for session lifecycle work
|
|
32
|
+
- `require_auth`, `require_role`, and `guest_only` for page-level protection
|
|
33
|
+
- `@rpc(require_auth=True, allowed_roles=[...])` for action-level protection
|
|
34
|
+
- `GoogleProvider` and `GithubProvider` for OAuth provider setup
|
|
35
|
+
|
|
36
|
+
Import the common API like this:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from casp.auth import (
|
|
40
|
+
AuthSettings,
|
|
41
|
+
configure_auth,
|
|
42
|
+
auth,
|
|
43
|
+
require_auth,
|
|
44
|
+
require_role,
|
|
45
|
+
guest_only,
|
|
46
|
+
)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Default Auth Rule
|
|
50
|
+
|
|
51
|
+
- Define app-wide auth behavior in `build_auth_settings()` and apply it once at startup with `configure_auth(...)`.
|
|
52
|
+
- Use `auth.sign_in(...)` and `auth.sign_out(...)` instead of setting or clearing session keys directly.
|
|
53
|
+
- Use `@require_auth`, `@require_role`, and `@guest_only` for page access rules.
|
|
54
|
+
- Use `@rpc(require_auth=True, allowed_roles=[...])` for browser-triggered actions that need protection.
|
|
55
|
+
- Use `StateManager` only for transient auth-adjacent request state; keep the authenticated session itself owned by `casp.auth`.
|
|
56
|
+
- Keep secrets and provider credentials in `.env`; keep route visibility, redirects, and RBAC policy in `src/lib/auth/auth_config.py`.
|
|
57
|
+
- Validate login, signup, reset-password, and profile-mutation inputs before hitting the database or external providers.
|
|
58
|
+
|
|
59
|
+
## Framework Internals Note
|
|
60
|
+
|
|
61
|
+
- The centralized app auth config belongs in `src/lib/auth/auth_config.py`.
|
|
62
|
+
- The installed framework implementation lives in `.venv/Lib/site-packages/casp/auth.py`.
|
|
63
|
+
- Treat `auth_config.py` as project code and `casp/auth.py` as framework code.
|
|
64
|
+
- If upstream docs and the installed implementation disagree, prefer the installed implementation for local project guidance.
|
|
65
|
+
|
|
66
|
+
## Centralized Auth Settings
|
|
67
|
+
|
|
68
|
+
Keep application auth policy in `src/lib/auth/auth_config.py`.
|
|
69
|
+
|
|
70
|
+
Example:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from __future__ import annotations
|
|
74
|
+
from casp.auth import AuthSettings
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_auth_settings() -> AuthSettings:
|
|
78
|
+
"""
|
|
79
|
+
Centralized auth configuration.
|
|
80
|
+
|
|
81
|
+
Keep secrets (AUTH_SECRET, AUTH_COOKIE_NAME) in .env.
|
|
82
|
+
Keep app-level session settings in .env (SESSION_LIFETIME_HOURS, etc).
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
return AuthSettings(
|
|
86
|
+
# Token settings
|
|
87
|
+
default_token_validity="1h",
|
|
88
|
+
token_auto_refresh=False,
|
|
89
|
+
|
|
90
|
+
# Route protection
|
|
91
|
+
is_all_routes_private=False,
|
|
92
|
+
public_routes=["/"],
|
|
93
|
+
auth_routes=["/signin", "/signup"],
|
|
94
|
+
private_routes=[], # unused when all-routes-private is True
|
|
95
|
+
|
|
96
|
+
# Role-based access
|
|
97
|
+
is_role_based=False,
|
|
98
|
+
role_identifier="role",
|
|
99
|
+
|
|
100
|
+
# IMPORTANT: current casp.auth expects PATH -> [ROLES]
|
|
101
|
+
role_based_routes={},
|
|
102
|
+
|
|
103
|
+
# Redirects / prefixes
|
|
104
|
+
default_signin_redirect="/dashboard",
|
|
105
|
+
default_signout_redirect="/signin",
|
|
106
|
+
api_auth_prefix="/api/auth",
|
|
107
|
+
)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Important behavior from the current implementation:
|
|
111
|
+
|
|
112
|
+
- `default_token_validity` is parsed by the installed auth runtime with the format `^\d+(s|m|h|d)$`. Use values such as `30m`, `1h`, or `7d`.
|
|
113
|
+
- `token_auto_refresh` only changes behavior when the request lifecycle calls `auth.refresh_session()`. In the installed `auth.py`, the flag alone does not refresh expiry by itself.
|
|
114
|
+
- The framework `AuthSettings` dataclass defaults `is_all_routes_private=True`, but the project example above explicitly changes that to `False`.
|
|
115
|
+
- `public_routes`, `auth_routes`, `private_routes`, and `role_based_routes` are exact path matches in the installed `Auth` methods.
|
|
116
|
+
- `private_routes` matters only when `is_all_routes_private=False`.
|
|
117
|
+
- `role_based_routes` currently expects `PATH -> [ROLES]`, not role names keyed to paths the other way around.
|
|
118
|
+
- `role_identifier` defaults to `role`, and the current `auth.sign_in(...)` flow also normalizes `userRole` into `role` when possible.
|
|
119
|
+
- `api_auth_prefix` defaults to `/api/auth` and should stay centralized here rather than being hard-coded across routes.
|
|
120
|
+
|
|
121
|
+
## Environment Variables
|
|
122
|
+
|
|
123
|
+
The installed auth code reads several values from `.env` when explicit values are not passed.
|
|
124
|
+
|
|
125
|
+
- `AUTH_SECRET` backs `AuthSettings.secret_key`.
|
|
126
|
+
- `AUTH_COOKIE_NAME` backs `AuthSettings.cookie_name`.
|
|
127
|
+
- `SESSION_LIFETIME_HOURS` controls `SessionMiddleware.max_age` in the current `main.py` bootstrap.
|
|
128
|
+
- `APP_ENV=production` enables secure session cookies and the `Secure` flag on the current CSRF cookie.
|
|
129
|
+
- `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, and `GOOGLE_REDIRECT_URI` back `GoogleProvider`.
|
|
130
|
+
- `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` back `GithubProvider`.
|
|
131
|
+
|
|
132
|
+
Keep these secrets out of route files and out of committed source.
|
|
133
|
+
|
|
134
|
+
In the current bootstrap, set `AUTH_COOKIE_NAME` explicitly in `.env`. The pasted `main.py` uses `session` as the fallback `SessionMiddleware` cookie name, while `AuthSettings` falls back to `auth_token` when the env var is absent.
|
|
135
|
+
|
|
136
|
+
## Startup Wiring
|
|
137
|
+
|
|
138
|
+
Apply the centralized settings once during startup, before normal request handling begins.
|
|
139
|
+
|
|
140
|
+
Example:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from casp.auth import configure_auth
|
|
144
|
+
from src.lib.auth.auth_config import build_auth_settings
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
configure_auth(build_auth_settings())
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Use `get_auth_settings()` when other app code only needs to read the resolved settings.
|
|
151
|
+
|
|
152
|
+
## `main.py` Bootstrap
|
|
153
|
+
|
|
154
|
+
In the current app bootstrap, authentication is initialized in `main.py` before route registration and middleware execution.
|
|
155
|
+
|
|
156
|
+
Example:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
from casp.auth import (
|
|
160
|
+
Auth,
|
|
161
|
+
GoogleProvider,
|
|
162
|
+
GithubProvider,
|
|
163
|
+
configure_auth,
|
|
164
|
+
)
|
|
165
|
+
from src.lib.auth.auth_config import build_auth_settings
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def setup_auth():
|
|
169
|
+
configure_auth(build_auth_settings())
|
|
170
|
+
Auth.set_providers(GithubProvider(), GoogleProvider())
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
setup_auth()
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
This does two things:
|
|
177
|
+
|
|
178
|
+
- applies the centralized settings once at startup
|
|
179
|
+
- registers OAuth providers once so `AuthMiddleware` can delegate signin and callback paths through `auth.auth_providers(...)`
|
|
180
|
+
|
|
181
|
+
The current `main.py` also wires the session middleware directly:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
from starlette.middleware.sessions import SessionMiddleware
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
SESSION_LIFETIME_HOURS = int(os.getenv("SESSION_LIFETIME_HOURS", 7))
|
|
188
|
+
IS_PRODUCTION = os.getenv("APP_ENV") == "production"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
app.add_middleware(
|
|
192
|
+
SessionMiddleware,
|
|
193
|
+
secret_key=os.getenv("AUTH_SECRET", "change-me"),
|
|
194
|
+
session_cookie=os.getenv("AUTH_COOKIE_NAME", "session"),
|
|
195
|
+
max_age=SESSION_LIFETIME_HOURS * 3600,
|
|
196
|
+
same_site="lax",
|
|
197
|
+
https_only=IS_PRODUCTION,
|
|
198
|
+
path="/",
|
|
199
|
+
)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Keep this wiring in `main.py`. Keep only the policy values in `src/lib/auth/auth_config.py`.
|
|
203
|
+
|
|
204
|
+
## Session Lifetime Rules
|
|
205
|
+
|
|
206
|
+
The current app bootstrap has two independent expiration controls:
|
|
207
|
+
|
|
208
|
+
- `default_token_validity` controls the auth payload expiration stored by `auth.sign_in(...)`
|
|
209
|
+
- `SESSION_LIFETIME_HOURS` controls the signed session cookie lifetime set by `SessionMiddleware`
|
|
210
|
+
|
|
211
|
+
The effective authenticated session lasts only while both are valid.
|
|
212
|
+
|
|
213
|
+
- If the session cookie expires first, the session disappears even if the auth payload expiration was longer.
|
|
214
|
+
- If the auth payload expires first, `auth.is_authenticated()` clears it even if the session cookie still exists.
|
|
215
|
+
- In the pasted `main.py`, `token_auto_refresh=True` still does nothing by itself because the request flow does not call `auth.refresh_session()`.
|
|
216
|
+
|
|
217
|
+
Keep `SESSION_LIFETIME_HOURS` and `default_token_validity` aligned unless you intentionally want one boundary to end earlier than the other.
|
|
218
|
+
|
|
219
|
+
## Middleware Order
|
|
220
|
+
|
|
221
|
+
The current `main.py` adds middleware in this source order:
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
app.add_middleware(RPCMiddleware)
|
|
225
|
+
app.add_middleware(AuthMiddleware)
|
|
226
|
+
app.add_middleware(CSRFMiddleware)
|
|
227
|
+
app.add_middleware(SessionMiddleware, ...)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Because Starlette runs the last-added middleware first, the effective request order is:
|
|
231
|
+
|
|
232
|
+
1. `SessionMiddleware`
|
|
233
|
+
2. `CSRFMiddleware`
|
|
234
|
+
3. `AuthMiddleware`
|
|
235
|
+
4. `RPCMiddleware`
|
|
236
|
+
5. route handler or RPC endpoint
|
|
237
|
+
|
|
238
|
+
Current behavior by layer:
|
|
239
|
+
|
|
240
|
+
- `SessionMiddleware` provides `request.session` for the rest of the stack.
|
|
241
|
+
- `CSRFMiddleware` ensures `request.session["csrf_token"]` exists and emits a `pp_csrf` cookie.
|
|
242
|
+
- `AuthMiddleware` sets request context with `Auth.set_request(request)`, initializes `StateManager`, runs provider callbacks, skips configured static asset paths, and enforces public, auth, private, and role-based route redirects.
|
|
243
|
+
- `RPCMiddleware` handles `POST` requests with `X-PP-RPC: true` and forwards them to Caspian's RPC handler after auth and session setup are already available.
|
|
244
|
+
|
|
245
|
+
Keep `SessionMiddleware` outermost. If CSRF, auth, or RPC handling runs before it, `request.session` will not be available.
|
|
246
|
+
|
|
247
|
+
## Current `AuthMiddleware` Flow
|
|
248
|
+
|
|
249
|
+
The pasted `main.py` auth middleware currently behaves like this:
|
|
250
|
+
|
|
251
|
+
- bypasses auth checks for `/css/*`, `/js/*`, `/assets/*`, and `/favicon.ico`
|
|
252
|
+
- initializes request-bound auth state with `StateManager.init(request)` and `Auth.set_request(request)`
|
|
253
|
+
- runs OAuth provider signin and callback handling before public or private route checks
|
|
254
|
+
- lets public routes through immediately
|
|
255
|
+
- redirects authenticated users away from auth routes such as `/signin` and `/signup`
|
|
256
|
+
- applies role-based redirects before generic private-route redirects when `is_role_based=True`
|
|
257
|
+
- redirects unauthenticated private-route requests to `/signin?next=/current/path`
|
|
258
|
+
|
|
259
|
+
Because `AuthMiddleware` initializes `StateManager` on every request, auth flows can also use [state.md](./state.md) for transient request-scoped success or error state. Keep identity, session lifetime, and authorization decisions in `auth.sign_in(...)`, `auth.sign_out(...)`, and the auth decorators rather than moving them into the state manager.
|
|
260
|
+
|
|
261
|
+
## The `auth` Object
|
|
262
|
+
|
|
263
|
+
The global `auth` singleton owns the session lifecycle.
|
|
264
|
+
|
|
265
|
+
The current installed methods are:
|
|
266
|
+
|
|
267
|
+
| Method | Purpose | Current behavior |
|
|
268
|
+
| ------------------------------------------------------------ | -------------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
269
|
+
| `auth.sign_in(data, token_validity=None, redirect_to=False)` | Create a session | Stores the payload in the session, sets a CSRF token, and returns either `"ok"` or a `RedirectResponse`. |
|
|
270
|
+
| `auth.sign_out(redirect_to=None)` | Destroy a session | Clears the session and redirects to the explicit target or `default_signout_redirect`. |
|
|
271
|
+
| `auth.is_authenticated()` | Check current auth state | Returns `False` when the payload is missing, malformed, or expired, and clears invalid session data. |
|
|
272
|
+
| `auth.get_payload()` | Read the signed-in payload | Returns the stored dict payload, or wraps non-dict payloads as `{"value": data}`. |
|
|
273
|
+
| `auth.refresh_session()` | Extend expiration | Only updates expiry when `token_auto_refresh=True`. |
|
|
274
|
+
| `auth.check_role(user, allowed_roles)` | Check RBAC access | Reads the configured role field from the payload and compares it to the allowed roles. |
|
|
275
|
+
|
|
276
|
+
Sign-in example:
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
from casp.auth import auth, guest_only
|
|
280
|
+
from casp.layout import render_page
|
|
281
|
+
from casp.rpc import rpc
|
|
282
|
+
from casp.validate import Rule, Validate
|
|
283
|
+
from src.lib.prisma import prisma
|
|
284
|
+
from werkzeug.security import check_password_hash
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@guest_only()
|
|
288
|
+
def page():
|
|
289
|
+
return render_page(__file__)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@rpc()
|
|
293
|
+
async def do_login(email: str, password: str, next: str | None = None):
|
|
294
|
+
clean_email = Validate.email(email)
|
|
295
|
+
password_check = Validate.with_rules(password, [Rule.REQUIRED, Rule.min(8)])
|
|
296
|
+
|
|
297
|
+
if clean_email is None:
|
|
298
|
+
return {"error": "Invalid email address."}
|
|
299
|
+
|
|
300
|
+
if password_check is not True:
|
|
301
|
+
return {"error": password_check}
|
|
302
|
+
|
|
303
|
+
user = await prisma.user.find_unique(
|
|
304
|
+
where={"email": clean_email},
|
|
305
|
+
include={"userRole": True},
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
if not user:
|
|
309
|
+
return {"error": "Invalid credentials."}
|
|
310
|
+
|
|
311
|
+
if not user.password or not check_password_hash(user.password, password):
|
|
312
|
+
return {"error": "Invalid credentials."}
|
|
313
|
+
|
|
314
|
+
user_data = user.to_dict(omit={"password": True})
|
|
315
|
+
redirect_url = next or auth.settings.default_signin_redirect
|
|
316
|
+
|
|
317
|
+
return auth.sign_in(user_data, redirect_to=redirect_url)
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Implementation details that matter:
|
|
321
|
+
|
|
322
|
+
- `auth.sign_in(...)` accepts dict payloads and simpler values, but dict payloads are the normal choice for app auth.
|
|
323
|
+
- When a dict payload includes `userRole`, the installed implementation tries to copy that into a top-level `role` field so RBAC checks can work against the default `role_identifier`.
|
|
324
|
+
- `redirect_to=True` means use `default_signin_redirect`.
|
|
325
|
+
- Without a redirect target, `auth.sign_in(...)` returns the string `"ok"`.
|
|
326
|
+
|
|
327
|
+
## Protecting Routes And Actions
|
|
328
|
+
|
|
329
|
+
Use the smallest protection level that matches the behavior you need.
|
|
330
|
+
|
|
331
|
+
### RPC Action Level
|
|
332
|
+
|
|
333
|
+
Use action-level protection when a page can render publicly or partially, but a specific mutation or read must be authenticated.
|
|
334
|
+
|
|
335
|
+
```python
|
|
336
|
+
from casp.rpc import rpc
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@rpc(require_auth=True, allowed_roles=["admin"], limits="5/minute")
|
|
340
|
+
async def delete_user(user_id: str):
|
|
341
|
+
return {"deleted": user_id}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
Use this pattern for destructive mutations, private data fetches, and admin-only buttons. See `fetch-data.md` for the broader RPC flow.
|
|
345
|
+
|
|
346
|
+
### Page Level
|
|
347
|
+
|
|
348
|
+
Use the page decorators from `casp.auth` when the whole route should enforce an access rule.
|
|
349
|
+
|
|
350
|
+
Authenticated-only page:
|
|
351
|
+
|
|
352
|
+
```python
|
|
353
|
+
from casp.auth import require_auth
|
|
354
|
+
from casp.layout import render_page
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@require_auth()
|
|
358
|
+
def page():
|
|
359
|
+
return render_page(__file__)
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
Role-protected page:
|
|
363
|
+
|
|
364
|
+
```python
|
|
365
|
+
from casp.auth import require_role
|
|
366
|
+
from casp.layout import render_page
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@require_role("admin", "superadmin")
|
|
370
|
+
def page():
|
|
371
|
+
return render_page(__file__)
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Guest-only page:
|
|
375
|
+
|
|
376
|
+
```python
|
|
377
|
+
from casp.auth import guest_only
|
|
378
|
+
from casp.layout import render_page
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@guest_only()
|
|
382
|
+
def page():
|
|
383
|
+
return render_page(__file__)
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
Current decorator behavior from the installed `auth.py`:
|
|
387
|
+
|
|
388
|
+
- `@require_auth()` redirects unauthenticated users to `/signin?next=/requested/path` unless you pass a custom `redirect_to`.
|
|
389
|
+
- `@require_role(...)` redirects unauthenticated users to `/signin?next=/requested/path` and redirects authenticated but unauthorized users to `/unauthorized` unless you override `redirect_to`.
|
|
390
|
+
- `@guest_only()` redirects already-authenticated users to `auth.settings.default_signin_redirect` or the current request's `next` value.
|
|
391
|
+
|
|
392
|
+
### Central Route Rules
|
|
393
|
+
|
|
394
|
+
The current `Auth` implementation also exposes central route checks:
|
|
395
|
+
|
|
396
|
+
- `auth.is_public_route(path)`
|
|
397
|
+
- `auth.is_auth_route(path)`
|
|
398
|
+
- `auth.is_private_route(path)`
|
|
399
|
+
- `auth.get_required_roles(path)`
|
|
400
|
+
|
|
401
|
+
These helpers use exact string comparisons against the routes you configured in `AuthSettings`.
|
|
402
|
+
|
|
403
|
+
## Common Route Workflow
|
|
404
|
+
|
|
405
|
+
A typical credential-based auth setup often uses this route shape:
|
|
406
|
+
|
|
407
|
+
```text
|
|
408
|
+
src/
|
|
409
|
+
app/
|
|
410
|
+
(auth)/
|
|
411
|
+
signin/
|
|
412
|
+
index.py
|
|
413
|
+
index.html
|
|
414
|
+
signup/
|
|
415
|
+
index.py
|
|
416
|
+
index.html
|
|
417
|
+
signout/
|
|
418
|
+
index.py
|
|
419
|
+
dashboard/
|
|
420
|
+
index.py
|
|
421
|
+
index.html
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
Use `guest_only()` on signin and signup routes, use `auth.sign_in(...)` inside the owning RPC action after validation and credential checks, and protect private destinations with `require_auth()` or central route policy.
|
|
425
|
+
|
|
426
|
+
Simple signout route example:
|
|
427
|
+
|
|
428
|
+
```python
|
|
429
|
+
from casp.auth import auth, require_auth
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@require_auth()
|
|
433
|
+
def page():
|
|
434
|
+
return auth.sign_out()
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
Simple protected dashboard example:
|
|
438
|
+
|
|
439
|
+
```python
|
|
440
|
+
from casp.auth import auth, require_auth
|
|
441
|
+
from casp.layout import render_page
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@require_auth()
|
|
445
|
+
def page():
|
|
446
|
+
return render_page(__file__, {
|
|
447
|
+
"user": auth.get_payload(),
|
|
448
|
+
})
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
For signup flows, use the same route ownership pattern as signin: validate the input, create the user, build a safe payload without password fields, and then call `auth.sign_in(...)` with the redirect target you want.
|
|
452
|
+
|
|
453
|
+
Signup example:
|
|
454
|
+
|
|
455
|
+
```python
|
|
456
|
+
from casp.auth import auth, guest_only
|
|
457
|
+
from casp.layout import render_page
|
|
458
|
+
from casp.rpc import rpc
|
|
459
|
+
from casp.validate import Rule, Validate
|
|
460
|
+
from src.lib.prisma import prisma
|
|
461
|
+
from werkzeug.security import generate_password_hash
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
@guest_only()
|
|
465
|
+
def page():
|
|
466
|
+
return render_page(__file__)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@rpc()
|
|
470
|
+
async def do_signup(
|
|
471
|
+
name: str,
|
|
472
|
+
email: str,
|
|
473
|
+
password: str,
|
|
474
|
+
password_confirmation: str,
|
|
475
|
+
):
|
|
476
|
+
clean_name = Validate.string(name)
|
|
477
|
+
clean_email = Validate.email(email)
|
|
478
|
+
password_check = Validate.with_rules(
|
|
479
|
+
password,
|
|
480
|
+
[Rule.REQUIRED, Rule.min(8), Rule.confirmed()],
|
|
481
|
+
confirmation_value=password_confirmation,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
if clean_name is None or len(clean_name) < 2:
|
|
485
|
+
return {"error": "Name must be at least 2 characters."}
|
|
486
|
+
|
|
487
|
+
if clean_email is None:
|
|
488
|
+
return {"error": "Invalid email address."}
|
|
489
|
+
|
|
490
|
+
if password_check is not True:
|
|
491
|
+
return {"error": password_check}
|
|
492
|
+
|
|
493
|
+
existing_user = await prisma.user.find_unique(
|
|
494
|
+
where={"email": clean_email},
|
|
495
|
+
)
|
|
496
|
+
if existing_user:
|
|
497
|
+
return {"error": "An account with that email already exists."}
|
|
498
|
+
|
|
499
|
+
user = await prisma.user.create(
|
|
500
|
+
data={
|
|
501
|
+
"name": clean_name,
|
|
502
|
+
"email": clean_email,
|
|
503
|
+
"password": generate_password_hash(password),
|
|
504
|
+
}
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
user_data = user.to_dict(omit={"password": True})
|
|
508
|
+
return auth.sign_in(user_data, redirect_to=True)
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
Adjust the `create(...)` payload to match your actual Prisma schema. The important pattern is: validate first, hash the password before storage, omit sensitive fields from the session payload, and let `auth.sign_in(...)` create the session.
|
|
512
|
+
|
|
513
|
+
## Account Lifecycle Patterns
|
|
514
|
+
|
|
515
|
+
The installed `casp.auth` implementation covers session creation, session destruction, route protection, RBAC checks, CSRF token seeding, and Google or GitHub OAuth callbacks. It does not implement full account lifecycle flows such as:
|
|
516
|
+
|
|
517
|
+
- password reset token issuance and redemption
|
|
518
|
+
- email verification tokens
|
|
519
|
+
- forced signout across all active devices
|
|
520
|
+
- remember-device or multi-factor workflows
|
|
521
|
+
|
|
522
|
+
Build those flows in app code under `src/app/` and `src/lib/auth/`.
|
|
523
|
+
|
|
524
|
+
Recommended route placement:
|
|
525
|
+
|
|
526
|
+
```text
|
|
527
|
+
src/
|
|
528
|
+
app/
|
|
529
|
+
(auth)/
|
|
530
|
+
forgot-password/
|
|
531
|
+
index.py
|
|
532
|
+
index.html
|
|
533
|
+
reset-password/
|
|
534
|
+
[token]/
|
|
535
|
+
index.py
|
|
536
|
+
index.html
|
|
537
|
+
verify-email/
|
|
538
|
+
[token]/
|
|
539
|
+
index.py
|
|
540
|
+
index.html
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
Recommended workflow:
|
|
544
|
+
|
|
545
|
+
- validate the incoming email, password, or token with `Validate` and `Rule`
|
|
546
|
+
- create a short-lived token in your database or a dedicated token table
|
|
547
|
+
- send the token link with your app's mailer or background job flow
|
|
548
|
+
- verify token expiry and ownership before updating the user record
|
|
549
|
+
- call `auth.sign_in(...)` only after the final credential or verification state is correct
|
|
550
|
+
- call `auth.sign_out()` when a reset or security event should invalidate the current session
|
|
551
|
+
|
|
552
|
+
Keep token generation, hashing helpers, and mail-sending wrappers in `src/lib/auth/` when multiple routes reuse them.
|
|
553
|
+
|
|
554
|
+
## Role-Based Access
|
|
555
|
+
|
|
556
|
+
When role checks are enabled in app policy, Caspian matches the current path against `role_based_routes` and checks the payload field named by `role_identifier`.
|
|
557
|
+
|
|
558
|
+
Example RBAC config:
|
|
559
|
+
|
|
560
|
+
```python
|
|
561
|
+
from casp.auth import AuthSettings
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def build_auth_settings() -> AuthSettings:
|
|
565
|
+
return AuthSettings(
|
|
566
|
+
is_role_based=True,
|
|
567
|
+
role_identifier="role",
|
|
568
|
+
role_based_routes={
|
|
569
|
+
"/report": ["admin"],
|
|
570
|
+
"/admin": ["admin", "superadmin"],
|
|
571
|
+
},
|
|
572
|
+
)
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
In the installed implementation:
|
|
576
|
+
|
|
577
|
+
- `auth.check_role(...)` reads `user[role_identifier]` when the payload is a dict.
|
|
578
|
+
- If you pass a plain string instead of a dict, that string is treated as the role directly.
|
|
579
|
+
- `role_based_routes` is an exact-path lookup with `dict.get(path)`.
|
|
580
|
+
- The installed payload normalization helps when your Prisma include returns a `userRole` object and you want a plain `role` field in the session payload.
|
|
581
|
+
|
|
582
|
+
## OAuth Providers
|
|
583
|
+
|
|
584
|
+
The installed `casp.auth` file includes provider helpers for Google and GitHub OAuth.
|
|
585
|
+
|
|
586
|
+
Available provider classes:
|
|
587
|
+
|
|
588
|
+
- `GoogleProvider(client_id, client_secret, redirect_uri, max_age="30d")`
|
|
589
|
+
- `GithubProvider(client_id, client_secret, max_age="30d")`
|
|
590
|
+
|
|
591
|
+
The current `auth.auth_providers(*providers)` implementation:
|
|
592
|
+
|
|
593
|
+
- redirects to the provider when the request path contains `signin/google` or `signin/github`
|
|
594
|
+
- handles callbacks when the path contains `callback/google` or `callback/github` and a `code` query parameter is present
|
|
595
|
+
- exchanges the code with the provider using `httpx`
|
|
596
|
+
- builds a normalized user payload
|
|
597
|
+
- signs the user in with the provider's `max_age`
|
|
598
|
+
- redirects to `default_signin_redirect` on success
|
|
599
|
+
|
|
600
|
+
Minimal example:
|
|
601
|
+
|
|
602
|
+
```python
|
|
603
|
+
from casp.auth import GoogleProvider, GithubProvider, auth
|
|
604
|
+
from casp.layout import render_page
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
google = GoogleProvider()
|
|
608
|
+
github = GithubProvider()
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def page():
|
|
612
|
+
response = auth.auth_providers(google, github)
|
|
613
|
+
if response:
|
|
614
|
+
return response
|
|
615
|
+
|
|
616
|
+
return render_page(__file__)
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
Use this only when the route is actually handling an auth-provider signin or callback flow.
|
|
620
|
+
|
|
621
|
+
## CSRF Token Helper
|
|
622
|
+
|
|
623
|
+
The installed auth runtime also exposes `get_csrf_token()`.
|
|
624
|
+
|
|
625
|
+
Behavior:
|
|
626
|
+
|
|
627
|
+
- `auth.sign_in(...)` seeds `csrf_token` into the session.
|
|
628
|
+
- `get_csrf_token()` reads the existing token or creates one when a request session exists.
|
|
629
|
+
- If no request or session is available, it returns an empty string.
|
|
630
|
+
|
|
631
|
+
Use this helper when custom form or fetch flows need access to the session CSRF token.
|
|
632
|
+
|
|
633
|
+
## Backwards Compatibility Alias
|
|
634
|
+
|
|
635
|
+
The installed auth file still includes `AuthConfig` as a compatibility alias.
|
|
636
|
+
|
|
637
|
+
It exposes:
|
|
638
|
+
|
|
639
|
+
- property proxies for `PUBLIC_ROUTES`, `PRIVATE_ROUTES`, `AUTH_ROUTES`, `IS_ALL_ROUTES_PRIVATE`, `DEFAULT_SIGNIN_REDIRECT`, and `DEFAULT_SIGNOUT_REDIRECT`
|
|
640
|
+
- `AuthConfig.check_auth_role(...)` as a proxy to `auth.check_role(...)`
|
|
641
|
+
|
|
642
|
+
Prefer `AuthSettings`, `configure_auth(...)`, and `auth.settings` in new code.
|
|
643
|
+
|
|
644
|
+
## Current Implementation Notes
|
|
645
|
+
|
|
646
|
+
- The installed auth runtime is session-backed. It stores the auth payload and CSRF token in `request.session`.
|
|
647
|
+
- Expiration uses timestamps and the current duration parser only accepts `s`, `m`, `h`, and `d` units.
|
|
648
|
+
- Expired or malformed payloads are removed during `auth.is_authenticated()` checks.
|
|
649
|
+
- `auth.get_payload()` returns `None` for missing payloads, a dict for dict payloads, and `{"value": ...}` for non-dict payloads.
|
|
650
|
+
- OAuth callback helpers return `None` on failure, so calling routes should be prepared for a normal page render or explicit error state when provider login fails.
|
|
651
|
+
- The current app bootstrap adds `SessionMiddleware`, `CSRFMiddleware`, `AuthMiddleware`, and `RPCMiddleware`; auth-aware code depends on that ordering so sessions and CSRF state exist before route checks or RPC handling.
|
|
652
|
+
- Password reset, email verification, and other account-recovery workflows are application responsibilities layered on top of `casp.auth`, not built-in auth runtime features.
|
|
653
|
+
- Route lists and RBAC maps are exact-path checks, not wildcard or prefix rules.
|
|
654
|
+
|
|
655
|
+
## Recommended Usage Pattern
|
|
656
|
+
|
|
657
|
+
Validate and authenticate close to the input boundary.
|
|
658
|
+
|
|
659
|
+
Common placement patterns are:
|
|
660
|
+
|
|
661
|
+
- put centralized policy in `src/lib/auth/auth_config.py`
|
|
662
|
+
- call `configure_auth(build_auth_settings())` during startup
|
|
663
|
+
- keep sign-in, signup, signout, and reset-password handlers in the owning route backend under `src/app/**/index.py`
|
|
664
|
+
- use `@rpc()` for browser-triggered auth actions and page decorators for full-route protection
|
|
665
|
+
- use `Validate` and `Rule` from `casp.validate` before password checks, user lookup, persistence, or external provider flows
|
|
666
|
+
- keep reusable auth helpers in `src/lib/auth/`
|
|
667
|
+
|
|
668
|
+
For browser-triggered auth forms, pair this page with `fetch-data.md`. For credential and form validation, pair it with `validation.md`.
|
|
669
|
+
|
|
670
|
+
## AI Routing Notes
|
|
671
|
+
|
|
672
|
+
If an AI agent is deciding how to handle auth in Caspian, apply these rules first.
|
|
673
|
+
|
|
674
|
+
- Treat `casp.auth` as the default authentication layer.
|
|
675
|
+
- Centralize route visibility, redirects, and RBAC policy in `src/lib/auth/auth_config.py`.
|
|
676
|
+
- Apply settings at startup in `main.py` with `configure_auth(build_auth_settings())`.
|
|
677
|
+
- Register provider instances in `main.py` with `Auth.set_providers(...)` when Google or GitHub OAuth is enabled.
|
|
678
|
+
- Use `auth.sign_in(...)` and `auth.sign_out(...)` for session lifecycle changes.
|
|
679
|
+
- Use `@require_auth`, `@require_role`, and `@guest_only` for page-level access rules.
|
|
680
|
+
- Use `@rpc(require_auth=True, allowed_roles=[...])` for protected browser-triggered actions.
|
|
681
|
+
- Keep `SessionMiddleware` outermost so auth, CSRF, and RPC handlers can read `request.session`.
|
|
682
|
+
- Use [state.md](./state.md) only for transient auth-adjacent request state, not as the auth session store.
|
|
683
|
+
- Align `SESSION_LIFETIME_HOURS` with `default_token_validity` unless you intentionally want different cookie and auth-payload expiry windows.
|
|
684
|
+
- Prefer exact route strings in `public_routes`, `auth_routes`, `private_routes`, and `role_based_routes`.
|
|
685
|
+
- Keep auth secrets and OAuth provider credentials in `.env`.
|
|
686
|
+
- Set `AUTH_COOKIE_NAME` explicitly so the session middleware cookie name and auth settings stay aligned.
|
|
687
|
+
- Use `.venv/Lib/site-packages/casp/auth.py` only when the task is about framework auth internals or debugging installed behavior.
|
|
688
|
+
- Use `main.py` when the task is about auth bootstrap, session middleware, or middleware execution order.
|
|
689
|
+
- Pair auth work with `fetch-data.md` for RPC forms and `validation.md` for credential validation.
|
|
690
|
+
- Check `routing.md` and `project-structure.md` before creating new auth routes or shared auth helpers.
|