paskia 0.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. paskia/__init__.py +3 -0
  2. paskia/_version.py +34 -0
  3. paskia/aaguid/__init__.py +32 -0
  4. paskia/aaguid/combined_aaguid.json +1 -0
  5. paskia/authsession.py +112 -0
  6. paskia/bootstrap.py +190 -0
  7. paskia/config.py +25 -0
  8. paskia/db/__init__.py +415 -0
  9. paskia/db/sql.py +1424 -0
  10. paskia/fastapi/__init__.py +3 -0
  11. paskia/fastapi/__main__.py +335 -0
  12. paskia/fastapi/admin.py +850 -0
  13. paskia/fastapi/api.py +308 -0
  14. paskia/fastapi/auth_host.py +97 -0
  15. paskia/fastapi/authz.py +110 -0
  16. paskia/fastapi/mainapp.py +130 -0
  17. paskia/fastapi/remote.py +504 -0
  18. paskia/fastapi/reset.py +101 -0
  19. paskia/fastapi/session.py +52 -0
  20. paskia/fastapi/user.py +162 -0
  21. paskia/fastapi/ws.py +163 -0
  22. paskia/fastapi/wsutil.py +91 -0
  23. paskia/frontend-build/auth/admin/index.html +18 -0
  24. paskia/frontend-build/auth/assets/AccessDenied-Bc249ASC.css +1 -0
  25. paskia/frontend-build/auth/assets/AccessDenied-C-lL9vbN.js +8 -0
  26. paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +1 -0
  27. paskia/frontend-build/auth/assets/RestrictedAuth-DgdJyscT.css +1 -0
  28. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +1 -0
  29. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-rKFEraYH.js +2 -0
  30. paskia/frontend-build/auth/assets/admin-Cs6Mg773.css +1 -0
  31. paskia/frontend-build/auth/assets/admin-Df5_Damp.js +1 -0
  32. paskia/frontend-build/auth/assets/auth-BU_O38k2.css +1 -0
  33. paskia/frontend-build/auth/assets/auth-Df3pjeSS.js +1 -0
  34. paskia/frontend-build/auth/assets/forward-Dzg-aE1C.js +1 -0
  35. paskia/frontend-build/auth/assets/helpers-DzjFIx78.js +1 -0
  36. paskia/frontend-build/auth/assets/pow-2N9bxgAo.js +1 -0
  37. paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +1 -0
  38. paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +1 -0
  39. paskia/frontend-build/auth/assets/restricted-C0IQufuH.js +1 -0
  40. paskia/frontend-build/auth/index.html +19 -0
  41. paskia/frontend-build/auth/restricted/index.html +16 -0
  42. paskia/frontend-build/int/forward/index.html +18 -0
  43. paskia/frontend-build/int/reset/index.html +15 -0
  44. paskia/globals.py +71 -0
  45. paskia/remoteauth.py +359 -0
  46. paskia/sansio.py +263 -0
  47. paskia/util/frontend.py +75 -0
  48. paskia/util/hostutil.py +76 -0
  49. paskia/util/htmlutil.py +47 -0
  50. paskia/util/passphrase.py +20 -0
  51. paskia/util/permutil.py +32 -0
  52. paskia/util/pow.py +45 -0
  53. paskia/util/querysafe.py +11 -0
  54. paskia/util/sessionutil.py +37 -0
  55. paskia/util/startupbox.py +75 -0
  56. paskia/util/timeutil.py +47 -0
  57. paskia/util/tokens.py +44 -0
  58. paskia/util/useragent.py +10 -0
  59. paskia/util/userinfo.py +159 -0
  60. paskia/util/wordlist.py +54 -0
  61. paskia-0.7.1.dist-info/METADATA +22 -0
  62. paskia-0.7.1.dist-info/RECORD +64 -0
  63. paskia-0.7.1.dist-info/WHEEL +4 -0
  64. paskia-0.7.1.dist-info/entry_points.txt +2 -0
paskia/fastapi/api.py ADDED
@@ -0,0 +1,308 @@
1
+ import logging
2
+ from contextlib import suppress
3
+ from datetime import datetime, timedelta, timezone
4
+
5
+ from fastapi import (
6
+ Depends,
7
+ FastAPI,
8
+ HTTPException,
9
+ Query,
10
+ Request,
11
+ Response,
12
+ )
13
+ from fastapi.responses import JSONResponse
14
+ from fastapi.security import HTTPBearer
15
+
16
+ from paskia.authsession import (
17
+ EXPIRES,
18
+ get_reset,
19
+ get_session,
20
+ refresh_session_token,
21
+ session_expiry,
22
+ )
23
+ from paskia.fastapi import authz, session, user
24
+ from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
25
+ from paskia.globals import db
26
+ from paskia.globals import passkey as global_passkey
27
+ from paskia.util import frontend, hostutil, htmlutil, passphrase, userinfo
28
+ from paskia.util.tokens import session_key
29
+
30
+ bearer_auth = HTTPBearer(auto_error=True)
31
+
32
+ app = FastAPI()
33
+
34
+ app.mount("/user", user.app)
35
+
36
+
37
+ @app.exception_handler(HTTPException)
38
+ async def http_exception_handler(_request: Request, exc: HTTPException):
39
+ """Ensure auth cookie is cleared on 401 responses (JSON responses only)."""
40
+ if exc.status_code == 401:
41
+ resp = JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
42
+ session.clear_session_cookie(resp)
43
+ return resp
44
+ return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
45
+
46
+
47
+ # Refresh only if at least this much of the session lifetime has been *consumed*.
48
+ # Consumption is derived from (now + EXPIRES) - current_expires.
49
+ # This guarantees a minimum spacing between DB writes even with frequent /validate calls.
50
+ _REFRESH_INTERVAL = timedelta(minutes=5)
51
+
52
+
53
+ @app.exception_handler(ValueError)
54
+ async def value_error_handler(_request: Request, exc: ValueError):
55
+ return JSONResponse(status_code=400, content={"detail": str(exc)})
56
+
57
+
58
+ @app.exception_handler(authz.AuthException)
59
+ async def auth_exception_handler(_request: Request, exc: authz.AuthException):
60
+ """Handle AuthException with auth info for UI."""
61
+ return JSONResponse(
62
+ status_code=exc.status_code,
63
+ content=await authz.auth_error_content(exc),
64
+ )
65
+
66
+
67
+ @app.exception_handler(Exception)
68
+ async def general_exception_handler(
69
+ _request: Request, exc: Exception
70
+ ): # pragma: no cover
71
+ logging.exception("Unhandled exception in API app")
72
+ return JSONResponse(status_code=500, content={"detail": "Internal server error"})
73
+
74
+
75
+ @app.post("/validate")
76
+ async def validate_token(
77
+ request: Request,
78
+ response: Response,
79
+ perm: list[str] = Query([]),
80
+ auth=AUTH_COOKIE,
81
+ ):
82
+ """Validate the current session and extend its expiry.
83
+
84
+ Always refreshes the session (sliding expiration) and re-sets the cookie with a
85
+ renewed max-age. This keeps active users logged in without needing a separate
86
+ refresh endpoint.
87
+ """
88
+ try:
89
+ ctx = await authz.verify(auth, perm, host=request.headers.get("host"))
90
+ except HTTPException:
91
+ # Global handler will clear cookie if 401
92
+ raise
93
+ renewed = False
94
+ if auth:
95
+ current_expiry = session_expiry(ctx.session)
96
+ consumed = EXPIRES - (current_expiry - datetime.now(timezone.utc))
97
+ if not timedelta(0) < consumed < _REFRESH_INTERVAL:
98
+ try:
99
+ await refresh_session_token(
100
+ auth,
101
+ ip=request.client.host if request.client else "",
102
+ user_agent=request.headers.get("user-agent") or "",
103
+ )
104
+ session.set_session_cookie(response, auth)
105
+ renewed = True
106
+ except ValueError:
107
+ # Session disappeared, e.g. due to concurrent logout; global handler will clear
108
+ raise authz.AuthException(
109
+ status_code=401, detail="Session expired", mode="login"
110
+ )
111
+ return {
112
+ "valid": True,
113
+ "user_uuid": str(ctx.session.user_uuid),
114
+ "renewed": renewed,
115
+ }
116
+
117
+
118
+ @app.get("/forward")
119
+ async def forward_authentication(
120
+ request: Request,
121
+ response: Response,
122
+ perm: list[str] = Query([]),
123
+ max_age: str | None = Query(None),
124
+ auth=AUTH_COOKIE,
125
+ ):
126
+ """Forward auth validation for Caddy/Nginx.
127
+
128
+ Query Params:
129
+ - perm: repeated permission IDs the authenticated user must possess (ALL required).
130
+ - max_age: maximum age of authentication (e.g., "5m", "1h", "30s"). If the session
131
+ is older than this, user must re-authenticate.
132
+
133
+ Success: 204 No Content with Remote-* headers describing the authenticated user.
134
+ Failure (unauthenticated / unauthorized): 4xx response.
135
+ - If Accept header contains "text/html": HTML page for authentication
136
+ with data attributes for mode and other metadata.
137
+ - Otherwise: JSON response with error details and an `iframe` field
138
+ pointing to /auth/restricted/?mode=... for iframe-based authentication.
139
+ """
140
+ try:
141
+ ctx = await authz.verify(
142
+ auth, perm, host=request.headers.get("host"), max_age=max_age
143
+ )
144
+ role_permissions = set(ctx.role.permissions or [])
145
+ if ctx.permissions:
146
+ role_permissions.update(permission.id for permission in ctx.permissions)
147
+
148
+ remote_headers: dict[str, str] = {
149
+ "Remote-User": str(ctx.user.uuid),
150
+ "Remote-Name": ctx.user.display_name,
151
+ "Remote-Groups": ",".join(sorted(role_permissions)),
152
+ "Remote-Org": str(ctx.org.uuid),
153
+ "Remote-Org-Name": ctx.org.display_name,
154
+ "Remote-Role": str(ctx.role.uuid),
155
+ "Remote-Role-Name": ctx.role.display_name,
156
+ "Remote-Session-Expires": (
157
+ session_expiry(ctx.session)
158
+ .astimezone(timezone.utc)
159
+ .isoformat()
160
+ .replace("+00:00", "Z")
161
+ if session_expiry(ctx.session).tzinfo
162
+ else session_expiry(ctx.session)
163
+ .replace(tzinfo=timezone.utc)
164
+ .isoformat()
165
+ .replace("+00:00", "Z")
166
+ ),
167
+ "Remote-Credential": str(ctx.session.credential_uuid),
168
+ }
169
+ return Response(status_code=204, headers=remote_headers)
170
+ except authz.AuthException as e:
171
+ # Clear cookie only if session is invalid (not for reauth)
172
+ if e.clear_session:
173
+ session.clear_session_cookie(response)
174
+
175
+ # Check Accept header to decide response format
176
+ accept = request.headers.get("accept", "")
177
+ wants_html = "text/html" in accept
178
+
179
+ if wants_html:
180
+ # Browser request - return full-page HTML with metadata
181
+ data_attrs = {"mode": e.mode, **e.metadata}
182
+ html = (await frontend.read("/int/forward/index.html"))[0]
183
+ html = htmlutil.patch_html_data_attrs(html, **data_attrs)
184
+ return Response(
185
+ html, status_code=e.status_code, media_type="text/html; charset=UTF-8"
186
+ )
187
+ else:
188
+ # API request - return JSON with iframe srcdoc HTML
189
+ return JSONResponse(
190
+ status_code=e.status_code,
191
+ content=await authz.auth_error_content(e),
192
+ )
193
+
194
+
195
+ @app.get("/settings")
196
+ async def get_settings():
197
+ pk = global_passkey.instance
198
+ base_path = hostutil.ui_base_path()
199
+ return {
200
+ "rp_id": pk.rp_id,
201
+ "rp_name": pk.rp_name,
202
+ "ui_base_path": base_path,
203
+ "auth_host": hostutil.dedicated_auth_host(),
204
+ "auth_site_url": hostutil.auth_site_url(),
205
+ "session_cookie": AUTH_COOKIE_NAME,
206
+ }
207
+
208
+
209
+ @app.get("/token-info")
210
+ async def api_token_info(token: str):
211
+ """Get information about a reset token.
212
+
213
+ Returns:
214
+ - type: "reset"
215
+ - user_name: display name of the user
216
+ - token_type: type of reset token
217
+ """
218
+ if not passphrase.is_well_formed(token):
219
+ raise HTTPException(status_code=404, detail="Invalid token")
220
+
221
+ # Check if this is a reset token
222
+ try:
223
+ reset_token = await get_reset(token)
224
+ user = await db.instance.get_user_by_uuid(reset_token.user_uuid)
225
+ return {
226
+ "type": "reset",
227
+ "user_name": user.display_name,
228
+ "token_type": reset_token.token_type,
229
+ }
230
+ except (ValueError, Exception):
231
+ raise HTTPException(status_code=404, detail="Token not found or expired")
232
+
233
+
234
+ @app.post("/user-info")
235
+ async def api_user_info(
236
+ request: Request,
237
+ response: Response,
238
+ reset: str | None = None,
239
+ auth=AUTH_COOKIE,
240
+ ):
241
+ """Get user information including credentials, sessions, and permissions.
242
+
243
+ Can be called with either:
244
+ - A session cookie (auth) for authenticated users
245
+ - A reset token for users in password reset flow
246
+ """
247
+ authenticated = False
248
+ session_record = None
249
+ reset_token = None
250
+ try:
251
+ if reset:
252
+ if not passphrase.is_well_formed(reset):
253
+ raise ValueError("Invalid reset token")
254
+ reset_token = await get_reset(reset)
255
+ target_user_uuid = reset_token.user_uuid
256
+ else:
257
+ if auth is None:
258
+ raise authz.AuthException(
259
+ status_code=401,
260
+ detail="Authentication required",
261
+ mode="login",
262
+ )
263
+ session_record = await get_session(auth, host=request.headers.get("host"))
264
+ authenticated = True
265
+ target_user_uuid = session_record.user_uuid
266
+ except ValueError as e:
267
+ raise HTTPException(401, str(e))
268
+
269
+ # Return minimal response for reset tokens
270
+ if not authenticated and reset_token:
271
+ return await userinfo.format_reset_user_info(target_user_uuid, reset_token)
272
+
273
+ # Return full user info for authenticated users
274
+ assert auth is not None
275
+ assert session_record is not None
276
+
277
+ return await userinfo.format_user_info(
278
+ user_uuid=target_user_uuid,
279
+ auth=auth,
280
+ session_record=session_record,
281
+ request_host=request.headers.get("host"),
282
+ )
283
+
284
+
285
+ @app.post("/logout")
286
+ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
287
+ if not auth:
288
+ return {"message": "Already logged out"}
289
+ try:
290
+ await get_session(auth, host=request.headers.get("host"))
291
+ except ValueError:
292
+ return {"message": "Already logged out"}
293
+ with suppress(Exception):
294
+ await db.instance.delete_session(session_key(auth))
295
+ session.clear_session_cookie(response)
296
+ return {"message": "Logged out successfully"}
297
+
298
+
299
+ @app.post("/set-session")
300
+ async def api_set_session(
301
+ request: Request, response: Response, auth=Depends(bearer_auth)
302
+ ):
303
+ user = await get_session(auth.credentials, host=request.headers.get("host"))
304
+ session.set_session_cookie(response, auth.credentials)
305
+ return {
306
+ "message": "Session cookie set successfully",
307
+ "user_uuid": str(user.user_uuid),
308
+ }
@@ -0,0 +1,97 @@
1
+ """Middleware for handling auth host redirects."""
2
+
3
+ from fastapi import Request, Response
4
+ from fastapi.responses import RedirectResponse
5
+
6
+ from paskia.util import hostutil, passphrase
7
+
8
+
9
+ def is_ui_path(path: str) -> bool:
10
+ """Check if the path is a UI endpoint."""
11
+ ui_paths = {
12
+ "/",
13
+ "/admin",
14
+ "/admin/",
15
+ "/auth",
16
+ "/auth/",
17
+ "/auth/admin",
18
+ "/auth/admin/",
19
+ }
20
+ if path in ui_paths:
21
+ return True
22
+ # Treat reset token pages as UI (dynamic). Accept single-segment tokens.
23
+ if path.startswith("/auth/"):
24
+ token = path[6:]
25
+ if token and "/" not in token and passphrase.is_well_formed(token):
26
+ return True
27
+ else:
28
+ token = path[1:]
29
+ if token and "/" not in token and passphrase.is_well_formed(token):
30
+ return True
31
+ return False
32
+
33
+
34
+ def is_restricted_path(path: str) -> bool:
35
+ """Check if the path is restricted (API/admin endpoints)."""
36
+ return path.startswith(("/auth/api/admin/", "/auth/api/user/", "/auth/ws/"))
37
+
38
+
39
+ def should_redirect_to_auth_host(path: str) -> bool:
40
+ """Determine if the request should be redirected to the auth host."""
41
+ if path in {"/", "/auth", "/auth/"}:
42
+ return False
43
+ return is_ui_path(path) or is_restricted_path(path)
44
+
45
+
46
+ def redirect_to_auth_host(request: Request, cfg: str, path: str) -> Response:
47
+ """Create a redirect response to the auth host."""
48
+ if is_restricted_path(path):
49
+ return Response(status_code=404)
50
+ new_path = (
51
+ path[5:] or "/" if is_ui_path(path) and path.startswith("/auth") else path
52
+ )
53
+ return RedirectResponse(f"{request.url.scheme}://{cfg}{new_path}", 307)
54
+
55
+
56
+ def should_redirect_auth_path_to_root(path: str) -> bool:
57
+ """Check if /auth/ UI path should be redirected to root on auth host."""
58
+ if not path.startswith("/auth/"):
59
+ return False
60
+ ui_paths = {"/auth", "/auth/", "/auth/admin", "/auth/admin/"}
61
+ if path in ui_paths:
62
+ return True
63
+ # Check for reset token
64
+ token = path[6:]
65
+ return bool(token and "/" not in token and passphrase.is_well_formed(token))
66
+
67
+
68
+ def redirect_to_root_on_auth_host(request: Request, cur: str, path: str) -> Response:
69
+ """Create a redirect response to root path on the same host."""
70
+ new_path = path[5:] or "/"
71
+ return RedirectResponse(f"{request.url.scheme}://{cur}{new_path}", 307)
72
+
73
+
74
+ async def redirect_middleware(request: Request, call_next):
75
+ """Middleware to handle auth host redirects."""
76
+ cfg = hostutil.dedicated_auth_host()
77
+ if not cfg:
78
+ return await call_next(request)
79
+
80
+ cur = hostutil.normalize_host(request.headers.get("host"))
81
+ if not cur:
82
+ return await call_next(request)
83
+
84
+ cfg_normalized = hostutil.normalize_host(cfg)
85
+ on_auth_host = cur == cfg_normalized
86
+
87
+ path = request.url.path or "/"
88
+
89
+ if not on_auth_host:
90
+ if not should_redirect_to_auth_host(path):
91
+ return await call_next(request)
92
+ return redirect_to_auth_host(request, cfg, path)
93
+ else:
94
+ # On auth host: force UI endpoints at root
95
+ if should_redirect_auth_path_to_root(path):
96
+ return redirect_to_root_on_auth_host(request, cur, path)
97
+ return await call_next(request)
@@ -0,0 +1,110 @@
1
+ import logging
2
+
3
+ from fastapi import HTTPException
4
+
5
+ from paskia.util import permutil, sessionutil
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class AuthException(HTTPException):
11
+ """Exception raised during authentication/authorization with metadata for the UI.
12
+
13
+ Attributes:
14
+ status_code: HTTP status code (401 for auth, 403 for authz)
15
+ detail: Error message
16
+ mode: UI mode ('login' or 'reauth')
17
+ clear_session: Whether to clear the session cookie (True for invalid sessions)
18
+ metadata: Additional data to pass to the frontend
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ status_code: int,
24
+ detail: str,
25
+ mode: str,
26
+ clear_session: bool = False,
27
+ **metadata,
28
+ ):
29
+ super().__init__(status_code=status_code, detail=detail)
30
+ self.mode = mode
31
+ self.clear_session = clear_session
32
+ self.metadata = metadata
33
+
34
+
35
+ async def auth_error_content(exc: AuthException) -> dict:
36
+ """Generate JSON response content for an AuthException.
37
+
38
+ Returns a dict with detail, mode, and iframe URL for src embedding.
39
+ """
40
+ # Build hash fragment from mode and metadata
41
+ params = {"mode": exc.mode, **exc.metadata}
42
+ fragment = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
43
+ iframe_url = f"/auth/restricted/#{fragment}"
44
+ return {
45
+ "detail": exc.detail,
46
+ "auth": {
47
+ "mode": exc.mode,
48
+ "iframe": iframe_url,
49
+ **exc.metadata,
50
+ },
51
+ }
52
+
53
+
54
+ async def verify(
55
+ auth: str | None,
56
+ perm: list[str],
57
+ match=permutil.has_all,
58
+ host: str | None = None,
59
+ max_age: str | None = None,
60
+ ):
61
+ """Validate session token and optional list of required permissions.
62
+
63
+ Returns the session context.
64
+
65
+ Raises AuthException on failure with metadata for UI rendering.
66
+ """
67
+ if not auth:
68
+ raise AuthException(
69
+ status_code=401,
70
+ detail="Authentication required",
71
+ mode="login",
72
+ )
73
+
74
+ ctx = await permutil.session_context(auth, host)
75
+ if not ctx:
76
+ raise AuthException(
77
+ status_code=401,
78
+ detail="Your session has expired. Please sign in again.",
79
+ mode="login",
80
+ clear_session=True,
81
+ )
82
+ # Check max_age requirement if specified
83
+ if max_age:
84
+ try:
85
+ if not sessionutil.check_session_age(ctx, max_age):
86
+ raise AuthException(
87
+ status_code=401,
88
+ detail="Additional authentication required",
89
+ mode="reauth",
90
+ )
91
+ except ValueError as e:
92
+ # Invalid max_age format - log but don't fail the request
93
+ logger.warning(f"Invalid max_age format '{max_age}': {e}")
94
+
95
+ if not match(ctx, perm):
96
+ # Determine which permissions are missing for clearer diagnostics
97
+ missing = sorted(set(perm) - set(ctx.role.permissions))
98
+ logger.warning(
99
+ "Permission denied: user=%s role=%s missing=%s required=%s granted=%s", # noqa: E501
100
+ getattr(ctx.user, "uuid", "?"),
101
+ getattr(ctx.role, "display_name", "?"),
102
+ missing,
103
+ perm,
104
+ ctx.role.permissions,
105
+ )
106
+ raise AuthException(
107
+ status_code=403, mode="forbidden", detail="Permission required"
108
+ )
109
+
110
+ return ctx
@@ -0,0 +1,130 @@
1
+ import logging
2
+ import os
3
+ from contextlib import asynccontextmanager
4
+ from pathlib import Path
5
+
6
+ from fastapi import FastAPI, HTTPException, Request, Response
7
+ from fastapi.responses import FileResponse, RedirectResponse
8
+ from fastapi.staticfiles import StaticFiles
9
+
10
+ from paskia.fastapi import admin, api, auth_host, ws
11
+ from paskia.fastapi.session import AUTH_COOKIE
12
+ from paskia.util import frontend, hostutil, passphrase
13
+
14
+ # Path to examples/index.html when running from source tree
15
+ _EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
16
+
17
+
18
+ @asynccontextmanager
19
+ async def lifespan(app: FastAPI): # pragma: no cover - startup path
20
+ """Application lifespan to ensure globals (DB, passkey) are initialized in each process.
21
+
22
+ Configuration is passed via PASKIA_CONFIG JSON env variable (set by the CLI entrypoint)
23
+ so that uvicorn reload / multiprocess workers inherit the settings.
24
+ All keys are guaranteed to exist; values are already normalized by __main__.py.
25
+ """
26
+ import json
27
+
28
+ from paskia import globals
29
+
30
+ config = json.loads(os.environ["PASKIA_CONFIG"])
31
+
32
+ try:
33
+ # CLI (__main__) performs bootstrap once; here we skip to avoid duplicate work
34
+ await globals.init(
35
+ rp_id=config["rp_id"],
36
+ rp_name=config["rp_name"],
37
+ origins=config["origins"],
38
+ bootstrap=False,
39
+ )
40
+ except ValueError as e:
41
+ logging.error(f"⚠️ {e}")
42
+ # Re-raise to fail fast
43
+ raise
44
+
45
+ # Restore info level logging after startup (suppressed during uvicorn init in dev mode)
46
+ if frontend.is_dev_mode():
47
+ logging.getLogger("uvicorn").setLevel(logging.INFO)
48
+ logging.getLogger("uvicorn.access").setLevel(logging.INFO)
49
+
50
+ yield
51
+
52
+
53
+ app = FastAPI(lifespan=lifespan)
54
+
55
+ # Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/)
56
+ app.middleware("http")(auth_host.redirect_middleware)
57
+
58
+ app.mount("/auth/api/admin/", admin.app)
59
+ app.mount("/auth/api/", api.app)
60
+ app.mount("/auth/ws/", ws.app)
61
+
62
+ # In dev mode (PASKIA_DEVMODE=1), Vite serves assets directly; skip static files mount
63
+ if not frontend.is_dev_mode():
64
+ app.mount(
65
+ "/auth/assets/",
66
+ StaticFiles(directory=frontend.file("auth", "assets")),
67
+ name="assets",
68
+ )
69
+
70
+
71
+ @app.get("/auth/restricted/")
72
+ async def restricted_view():
73
+ """Serve the restricted/authentication UI for iframe embedding."""
74
+ return Response(*await frontend.read("/auth/restricted/index.html"))
75
+
76
+
77
+ # Navigable URLs are defined here. We support both / and /auth/ as the base path
78
+ # / is used on a dedicated auth site, /auth/ on app domains with auth
79
+
80
+
81
+ @app.get("/")
82
+ @app.get("/auth/")
83
+ async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE):
84
+ """Serve the user profile app.
85
+
86
+ The frontend handles mode detection (host mode vs full profile) based on settings.
87
+ Access control is handled via APIs.
88
+ """
89
+ return Response(*await frontend.read("/auth/index.html"))
90
+
91
+
92
+ @app.get("/admin", include_in_schema=False)
93
+ @app.get("/auth/admin", include_in_schema=False)
94
+ async def admin_root_redirect():
95
+ return RedirectResponse(f"{hostutil.ui_base_path()}admin/", status_code=307)
96
+
97
+
98
+ @app.get("/admin/", include_in_schema=False)
99
+ async def admin_root(request: Request, auth=AUTH_COOKIE):
100
+ return await admin.adminapp(request, auth) # Delegated to admin app
101
+
102
+
103
+ @app.get("/auth/examples/", include_in_schema=False)
104
+ async def examples_page():
105
+ """Serve examples/index.html when running from source tree.
106
+
107
+ This provides a simple test page for API mode authentication flows
108
+ without depending on the Vue frontend build.
109
+ """
110
+ index_file = _EXAMPLES_DIR / "index.html"
111
+ if not index_file.is_file():
112
+ raise HTTPException(
113
+ status_code=404,
114
+ detail="Examples not available (not running from source tree)",
115
+ )
116
+ return FileResponse(index_file, media_type="text/html")
117
+
118
+
119
+ # Note: this catch-all handler must be the last route defined
120
+ @app.get("/{token}")
121
+ @app.get("/auth/{token}")
122
+ async def token_link(token: str):
123
+ """Serve the reset app for reset tokens (password reset / device addition).
124
+
125
+ The frontend will validate the token via /auth/api/token-info.
126
+ """
127
+ if not passphrase.is_well_formed(token):
128
+ raise HTTPException(status_code=404)
129
+
130
+ return Response(*await frontend.read("/int/reset/index.html"))