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.
@@ -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.