bamboo-ssh 0.1.0__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.
bamboo_ssh/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Bamboo SSH package."""
2
+
3
+ from .core import TerminalSession, stream_terminal_output
4
+
5
+ __all__ = ["TerminalSession", "stream_terminal_output"]
@@ -0,0 +1,6 @@
1
+ """FastAPI adapter exports."""
2
+
3
+ from .fastapi_app import app, build_arg_parser, main
4
+
5
+ __all__ = ["app", "build_arg_parser", "main"]
6
+
@@ -0,0 +1,236 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import contextlib
6
+ from html import escape
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from urllib.parse import parse_qs
10
+
11
+ import uvicorn
12
+ from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect, status
13
+ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
14
+ from fastapi.staticfiles import StaticFiles
15
+
16
+ from bamboo_ssh.core.session import TerminalSession, stream_terminal_output
17
+
18
+
19
+ SESSION_COOKIE_NAME = "bamboo_ssh_session"
20
+
21
+
22
+ def _resolve_static_dir(
23
+ package_static_dir: Path | None = None,
24
+ checkout_static_dir: Path | None = None,
25
+ ) -> Path:
26
+ package_root = Path(__file__).resolve().parents[1]
27
+ candidates = (
28
+ package_static_dir or package_root / "static",
29
+ checkout_static_dir or package_root.parent / "static",
30
+ )
31
+ for candidate in candidates:
32
+ if candidate.is_dir():
33
+ return candidate
34
+ raise FileNotFoundError(f"Could not find static assets in: {', '.join(str(path) for path in candidates)}")
35
+
36
+
37
+ def _resolve_login_template(package_template_path: Path | None = None) -> Path:
38
+ package_root = Path(__file__).resolve().parents[1]
39
+ candidate = package_template_path or package_root / "ui" / "templates" / "login.html"
40
+ if candidate.is_file():
41
+ return candidate
42
+ raise FileNotFoundError(f"Could not find login template: {candidate}")
43
+
44
+
45
+ def _load_auth_settings(config: Any | None, config_path: str | Path | None) -> Any | None:
46
+ if config is not None:
47
+ auth_settings = getattr(config, "auth", None)
48
+ return auth_settings if auth_settings is not None and getattr(auth_settings, "enabled", False) else None
49
+
50
+ if config_path is None:
51
+ return None
52
+
53
+ resolved_config_path = Path(config_path).expanduser().resolve()
54
+ if not resolved_config_path.exists():
55
+ raise FileNotFoundError(f"Config file not found: {resolved_config_path}")
56
+
57
+ from bamboo_ssh.auth.config import load_config
58
+
59
+ auth_settings = load_config(resolved_config_path).auth
60
+ return auth_settings if auth_settings is not None and auth_settings.enabled else None
61
+
62
+
63
+ def _read_login_template(template_path: Path, error_message: str | None = None) -> str:
64
+ template = template_path.read_text(encoding="utf-8")
65
+ error_html = ""
66
+ if error_message:
67
+ error_html = f'<p class="login-error" role="alert">{escape(error_message)}</p>'
68
+ return template.replace("{{ERROR_BLOCK}}", error_html)
69
+
70
+
71
+ def _parse_form_body(body: bytes) -> dict[str, str]:
72
+ try:
73
+ parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True)
74
+ except UnicodeDecodeError:
75
+ return {}
76
+
77
+ return {key: values[-1] if values else "" for key, values in parsed.items()}
78
+
79
+
80
+ def _validate_session_token(token: str | None, auth_settings: Any | None) -> bool:
81
+ if auth_settings is None or not token:
82
+ return False
83
+
84
+ from bamboo_ssh.auth.sessions import validate_session_token
85
+
86
+ return validate_session_token(token, auth_settings) is not None
87
+
88
+
89
+ def _is_authenticated(cookies: dict[str, str], auth_settings: Any | None) -> bool:
90
+ if auth_settings is None:
91
+ return True
92
+ return _validate_session_token(cookies.get(SESSION_COOKIE_NAME), auth_settings)
93
+
94
+
95
+ def _redirect(location: str) -> RedirectResponse:
96
+ return RedirectResponse(location, status_code=status.HTTP_303_SEE_OTHER)
97
+
98
+
99
+ def _render_login_page(template_path: Path, error_message: str | None = None, status_code: int = 200) -> HTMLResponse:
100
+ return HTMLResponse(_read_login_template(template_path, error_message), status_code=status_code)
101
+
102
+
103
+ def _parse_positive_int(value: str | None, default: int) -> int:
104
+ try:
105
+ parsed = int(value or "")
106
+ except ValueError:
107
+ return default
108
+ return parsed if parsed > 0 else default
109
+
110
+
111
+ def create_app(
112
+ *,
113
+ config: Any | None = None,
114
+ config_path: str | Path | None = None,
115
+ package_static_dir: Path | None = None,
116
+ checkout_static_dir: Path | None = None,
117
+ login_template_path: Path | None = None,
118
+ ) -> FastAPI:
119
+ static_dir = _resolve_static_dir(package_static_dir, checkout_static_dir)
120
+ template_path = _resolve_login_template(login_template_path)
121
+ auth_settings = _load_auth_settings(config, config_path)
122
+
123
+ app = FastAPI(title="Python Web Terminal")
124
+ app.mount("/static", StaticFiles(directory=static_dir), name="static")
125
+
126
+ @app.get("/")
127
+ async def index(request: Request) -> Response:
128
+ if not _is_authenticated(request.cookies, auth_settings):
129
+ return _redirect("/login")
130
+ return FileResponse(static_dir / "index.html")
131
+
132
+ @app.get("/login")
133
+ async def login_page(request: Request) -> Response:
134
+ if auth_settings is None or _is_authenticated(request.cookies, auth_settings):
135
+ return _redirect("/")
136
+ return _render_login_page(template_path)
137
+
138
+ @app.post("/login")
139
+ async def login(request: Request) -> Response:
140
+ if auth_settings is None:
141
+ return _redirect("/")
142
+
143
+ form_data = _parse_form_body(await request.body())
144
+ username = form_data.get("username", "")
145
+ password = form_data.get("password", "")
146
+
147
+ from bamboo_ssh.auth.passwords import verify_password
148
+ from bamboo_ssh.auth.sessions import issue_session_token
149
+
150
+ if username != auth_settings.username or not verify_password(password, auth_settings.password_hash):
151
+ return _render_login_page(template_path, "Invalid username or password.", status.HTTP_401_UNAUTHORIZED)
152
+
153
+ response = _redirect("/")
154
+ response.set_cookie(
155
+ SESSION_COOKIE_NAME,
156
+ issue_session_token(auth_settings),
157
+ httponly=True,
158
+ max_age=auth_settings.session_ttl_seconds,
159
+ path="/",
160
+ samesite="lax",
161
+ secure=request.url.scheme == "https",
162
+ )
163
+ return response
164
+
165
+ @app.post("/logout")
166
+ async def logout() -> RedirectResponse:
167
+ response = _redirect("/login" if auth_settings is not None else "/")
168
+ response.delete_cookie(SESSION_COOKIE_NAME, path="/")
169
+ return response
170
+
171
+ @app.websocket("/ws/terminal")
172
+ async def terminal_socket(websocket: WebSocket) -> None:
173
+ if not _is_authenticated(websocket.cookies, auth_settings):
174
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
175
+ return
176
+
177
+ await websocket.accept()
178
+
179
+ cols = _parse_positive_int(websocket.query_params.get("cols"), 80)
180
+ rows = _parse_positive_int(websocket.query_params.get("rows"), 24)
181
+ command = websocket.query_params.get("command", "")
182
+
183
+ session = TerminalSession.start(cols=cols, rows=rows, command=command)
184
+ send_lock = asyncio.Lock()
185
+
186
+ async def send_json(payload: dict) -> None:
187
+ async with send_lock:
188
+ await websocket.send_json(payload)
189
+
190
+ reader_task = asyncio.create_task(stream_terminal_output(session, send_json))
191
+
192
+ try:
193
+ while True:
194
+ payload = await websocket.receive_json()
195
+ msg_type = payload.get("type")
196
+
197
+ if msg_type == "cmd":
198
+ data = payload.get("data", "")
199
+ if isinstance(data, str) and data:
200
+ session.write(data)
201
+ elif msg_type == "resize":
202
+ next_cols = _parse_positive_int(str(payload.get("cols", "")), cols)
203
+ next_rows = _parse_positive_int(str(payload.get("rows", "")), rows)
204
+ session.resize(next_cols, next_rows)
205
+ cols, rows = next_cols, next_rows
206
+ elif msg_type == "heartbeat":
207
+ await send_json({"type": "heartbeat", "timestamp": payload.get("timestamp")})
208
+ else:
209
+ await send_json({"type": "error", "data": f"unsupported message type: {msg_type}"})
210
+ except WebSocketDisconnect:
211
+ pass
212
+ finally:
213
+ session.close()
214
+ reader_task.cancel()
215
+ with contextlib.suppress(asyncio.CancelledError):
216
+ await reader_task
217
+
218
+ return app
219
+
220
+
221
+ STATIC_DIR = _resolve_static_dir()
222
+ LOGIN_TEMPLATE = _resolve_login_template()
223
+ app = create_app()
224
+
225
+
226
+ def build_arg_parser() -> argparse.ArgumentParser:
227
+ parser = argparse.ArgumentParser(description="Run the Python web terminal server.")
228
+ parser.add_argument("--host", default="127.0.0.1", help="Host interface to bind")
229
+ parser.add_argument("--port", type=int, default=8765, help="Port to listen on")
230
+ parser.add_argument("--config", help="Path to the Bamboo SSH config file.")
231
+ return parser
232
+
233
+
234
+ def main() -> None:
235
+ args = build_arg_parser().parse_args()
236
+ uvicorn.run(create_app(config_path=args.config), host=args.host, port=args.port)
@@ -0,0 +1,14 @@
1
+ """Standalone authentication helpers for Bamboo SSH."""
2
+
3
+ from .config import AuthSettings, BambooSSHConfig, ServerSettings, load_config, save_config
4
+ from .passwords import hash_password, verify_password
5
+
6
+ __all__ = [
7
+ "AuthSettings",
8
+ "BambooSSHConfig",
9
+ "ServerSettings",
10
+ "hash_password",
11
+ "load_config",
12
+ "save_config",
13
+ "verify_password",
14
+ ]
bamboo_ssh/auth/cli.py ADDED
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from dataclasses import replace
5
+ import getpass
6
+ import os
7
+ from pathlib import Path
8
+ import secrets
9
+
10
+ from .config import AuthSettings, BambooSSHConfig, load_config, save_config
11
+
12
+
13
+ def resolve_config_path(config_path: str | None) -> Path:
14
+ if config_path:
15
+ return Path(config_path).expanduser().resolve()
16
+
17
+ config_home = os.environ.get("XDG_CONFIG_HOME")
18
+ if config_home:
19
+ base_directory = Path(config_home)
20
+ else:
21
+ base_directory = Path.home() / ".config"
22
+
23
+ return (base_directory / "bamboo-ssh" / "config.toml").expanduser().resolve()
24
+
25
+
26
+ def run_init(args: argparse.Namespace) -> int:
27
+ from .passwords import hash_password
28
+
29
+ config_path = resolve_config_path(args.config)
30
+ password = _prompt_password()
31
+ existing_config = load_config(config_path) if config_path.exists() else BambooSSHConfig()
32
+ existing_auth = existing_config.auth
33
+
34
+ auth_settings = AuthSettings(
35
+ enabled=True if existing_auth is None else existing_auth.enabled,
36
+ username=args.username,
37
+ password_hash=hash_password(password),
38
+ session_secret=secrets.token_urlsafe(32),
39
+ session_ttl_seconds=43200 if existing_auth is None else existing_auth.session_ttl_seconds,
40
+ )
41
+ save_config(config_path, BambooSSHConfig(server=existing_config.server, auth=auth_settings))
42
+ print(config_path)
43
+ return 0
44
+
45
+
46
+ def run_set_password(args: argparse.Namespace) -> int:
47
+ from .passwords import hash_password
48
+
49
+ config_path = resolve_config_path(args.config)
50
+ if not config_path.exists():
51
+ raise SystemExit(f"Config file not found: {config_path}")
52
+
53
+ config = load_config(config_path)
54
+ if config.auth is None:
55
+ raise SystemExit(f"Config file does not contain an [auth] section: {config_path}")
56
+ if args.username is not None and args.username != config.auth.username:
57
+ raise SystemExit(
58
+ f"Configured username is {config.auth.username!r}, not {args.username!r}."
59
+ )
60
+
61
+ password = _prompt_password()
62
+ updated_auth = replace(config.auth, password_hash=hash_password(password))
63
+ save_config(config_path, BambooSSHConfig(server=config.server, auth=updated_auth))
64
+ print(config_path)
65
+ return 0
66
+
67
+
68
+ def run_show_config_path(args: argparse.Namespace) -> int:
69
+ print(resolve_config_path(args.config))
70
+ return 0
71
+
72
+
73
+ def _prompt_password() -> str:
74
+ try:
75
+ password = getpass.getpass("Password: ")
76
+ confirmation = getpass.getpass("Confirm password: ")
77
+ except (EOFError, KeyboardInterrupt) as exc:
78
+ raise SystemExit("Password prompt aborted.") from exc
79
+
80
+ if not password:
81
+ raise SystemExit("Password must not be empty.")
82
+ if password != confirmation:
83
+ raise SystemExit("Passwords did not match.")
84
+
85
+ return password
@@ -0,0 +1,205 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import tempfile
8
+ import tomllib
9
+ from typing import Any, Mapping
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class ServerSettings:
14
+ host: str = "127.0.0.1"
15
+ port: int = 8765
16
+
17
+ def __post_init__(self) -> None:
18
+ _require_string("server.host", self.host)
19
+ _require_tcp_port("server.port", self.port)
20
+
21
+ @classmethod
22
+ def from_mapping(cls, data: Mapping[str, Any] | None) -> "ServerSettings":
23
+ section = data or {}
24
+ host = section.get("host", "127.0.0.1")
25
+ port = section.get("port", 8765)
26
+
27
+ return cls(host=host, port=port)
28
+
29
+ def to_mapping(self) -> dict[str, Any]:
30
+ return {"host": self.host, "port": self.port}
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class AuthSettings:
35
+ username: str
36
+ password_hash: str
37
+ session_secret: str
38
+ enabled: bool = True
39
+ session_ttl_seconds: int = 43200
40
+
41
+ def __post_init__(self) -> None:
42
+ _require_bool("auth.enabled", self.enabled)
43
+ _require_non_empty_string("auth.username", self.username)
44
+ _require_non_empty_string("auth.password_hash", self.password_hash)
45
+ _require_non_empty_string("auth.session_secret", self.session_secret)
46
+ _require_positive_integer("auth.session_ttl_seconds", self.session_ttl_seconds)
47
+
48
+ @classmethod
49
+ def from_mapping(cls, data: Mapping[str, Any]) -> "AuthSettings":
50
+ enabled = data.get("enabled", True)
51
+ username = data.get("username")
52
+ password_hash = data.get("password_hash")
53
+ session_secret = data.get("session_secret")
54
+ session_ttl_seconds = data.get("session_ttl_seconds", 43200)
55
+
56
+ return cls(
57
+ enabled=enabled,
58
+ username=username,
59
+ password_hash=password_hash,
60
+ session_secret=session_secret,
61
+ session_ttl_seconds=session_ttl_seconds,
62
+ )
63
+
64
+ def to_mapping(self) -> dict[str, Any]:
65
+ return {
66
+ "enabled": self.enabled,
67
+ "username": self.username,
68
+ "password_hash": self.password_hash,
69
+ "session_secret": self.session_secret,
70
+ "session_ttl_seconds": self.session_ttl_seconds,
71
+ }
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class BambooSSHConfig:
76
+ server: ServerSettings = field(default_factory=ServerSettings)
77
+ auth: AuthSettings | None = None
78
+
79
+
80
+ def load_config(path: str | Path) -> BambooSSHConfig:
81
+ config_path = Path(path)
82
+ data = tomllib.loads(config_path.read_text(encoding="utf-8"))
83
+
84
+ server_data = data.get("server")
85
+ if server_data is not None and not isinstance(server_data, dict):
86
+ raise ValueError("server section must be a table")
87
+
88
+ auth_data = data.get("auth")
89
+ if auth_data is not None and not isinstance(auth_data, dict):
90
+ raise ValueError("auth section must be a table")
91
+
92
+ return BambooSSHConfig(
93
+ server=ServerSettings.from_mapping(server_data),
94
+ auth=AuthSettings.from_mapping(auth_data) if auth_data is not None else None,
95
+ )
96
+
97
+
98
+ def save_config(path: str | Path, config: BambooSSHConfig) -> None:
99
+ config_path = Path(path)
100
+ config_path.parent.mkdir(parents=True, exist_ok=True)
101
+ serialized = _serialize_config(config)
102
+
103
+ tmp_file = tempfile.NamedTemporaryFile(
104
+ mode="w",
105
+ encoding="utf-8",
106
+ dir=config_path.parent,
107
+ prefix=f".{config_path.name}.",
108
+ suffix=".tmp",
109
+ delete=False,
110
+ )
111
+ tmp_path = Path(tmp_file.name)
112
+
113
+ try:
114
+ if os.name != "nt":
115
+ os.chmod(tmp_path, 0o600)
116
+
117
+ with tmp_file:
118
+ tmp_file.write(serialized)
119
+ tmp_file.flush()
120
+ os.fsync(tmp_file.fileno())
121
+
122
+ os.replace(tmp_path, config_path)
123
+ _fsync_parent_directory(config_path.parent)
124
+ except Exception:
125
+ if tmp_path.exists():
126
+ tmp_path.unlink()
127
+ raise
128
+
129
+
130
+ def _serialize_config(config: BambooSSHConfig) -> str:
131
+ sections: list[tuple[str, dict[str, Any]]] = [("server", config.server.to_mapping())]
132
+ if config.auth is not None:
133
+ sections.append(("auth", config.auth.to_mapping()))
134
+
135
+ lines: list[str] = []
136
+ for index, (name, values) in enumerate(sections):
137
+ if index:
138
+ lines.append("")
139
+ lines.append(f"[{name}]")
140
+ for key, value in values.items():
141
+ lines.append(f"{key} = {_format_toml_value(value)}")
142
+
143
+ lines.append("")
144
+ return "\n".join(lines)
145
+
146
+
147
+ def _format_toml_value(value: Any) -> str:
148
+ if isinstance(value, bool):
149
+ return "true" if value else "false"
150
+ if isinstance(value, int):
151
+ return str(value)
152
+ if isinstance(value, str):
153
+ return json.dumps(value)
154
+ raise TypeError(f"unsupported TOML value: {value!r}")
155
+
156
+
157
+ def _require_bool(field_name: str, value: Any) -> bool:
158
+ if not isinstance(value, bool):
159
+ raise ValueError(f"{field_name} must be a boolean")
160
+ return value
161
+
162
+
163
+ def _require_string(field_name: str, value: Any) -> str:
164
+ if not isinstance(value, str):
165
+ raise ValueError(f"{field_name} must be a string")
166
+ return value
167
+
168
+
169
+ def _require_non_empty_string(field_name: str, value: Any) -> str:
170
+ text = _require_string(field_name, value)
171
+ if not text:
172
+ raise ValueError(f"{field_name} must not be empty")
173
+ return text
174
+
175
+
176
+ def _require_integer(field_name: str, value: Any) -> int:
177
+ if type(value) is not int:
178
+ raise ValueError(f"{field_name} must be an integer")
179
+ return value
180
+
181
+
182
+ def _require_positive_integer(field_name: str, value: Any) -> int:
183
+ integer = _require_integer(field_name, value)
184
+ if integer <= 0:
185
+ raise ValueError(f"{field_name} must be positive")
186
+ return integer
187
+
188
+
189
+ def _require_tcp_port(field_name: str, value: Any) -> int:
190
+ port = _require_integer(field_name, value)
191
+ if not 1 <= port <= 65535:
192
+ raise ValueError(f"{field_name} must be between 1 and 65535")
193
+ return port
194
+
195
+
196
+ def _fsync_parent_directory(path: Path) -> None:
197
+ if os.name != "posix":
198
+ return
199
+
200
+ flags = getattr(os, "O_RDONLY", 0) | getattr(os, "O_DIRECTORY", 0)
201
+ directory_fd = os.open(path, flags)
202
+ try:
203
+ os.fsync(directory_fd)
204
+ finally:
205
+ os.close(directory_fd)
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from argon2 import PasswordHasher
4
+ from argon2.exceptions import InvalidHash, VerificationError, VerifyMismatchError
5
+
6
+
7
+ _PASSWORD_HASHER = PasswordHasher()
8
+
9
+
10
+ def hash_password(password: str) -> str:
11
+ return _PASSWORD_HASHER.hash(password)
12
+
13
+
14
+ def verify_password(password: str, password_hash: str) -> bool:
15
+ try:
16
+ return _PASSWORD_HASHER.verify(password_hash, password)
17
+ except (InvalidHash, VerificationError, VerifyMismatchError):
18
+ return False
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ from dataclasses import dataclass
6
+ import hashlib
7
+ import hmac
8
+ import json
9
+ import math
10
+ import time
11
+
12
+ from .config import AuthSettings
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class SessionClaims:
17
+ username: str
18
+ issued_at: int
19
+ expires_at: int
20
+
21
+
22
+ def issue_session_token(settings: AuthSettings, *, now: int | float | None = None) -> str:
23
+ issued_at = _current_timestamp(now)
24
+ expires_at = issued_at + settings.session_ttl_seconds
25
+ payload = {
26
+ "username": settings.username,
27
+ "iat": issued_at,
28
+ "exp": expires_at,
29
+ }
30
+ payload_bytes = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
31
+ signature = _sign_payload(payload_bytes, settings.session_secret)
32
+ return f"{_urlsafe_b64encode(payload_bytes)}.{_urlsafe_b64encode(signature)}"
33
+
34
+
35
+ def validate_session_token(
36
+ token: str,
37
+ settings: AuthSettings,
38
+ *,
39
+ now: int | float | None = None,
40
+ ) -> SessionClaims | None:
41
+ try:
42
+ payload_segment, signature_segment = token.split(".", maxsplit=1)
43
+ except ValueError:
44
+ return None
45
+
46
+ try:
47
+ payload_bytes = _urlsafe_b64decode(payload_segment)
48
+ signature = _urlsafe_b64decode(signature_segment)
49
+ except (ValueError, binascii.Error):
50
+ return None
51
+
52
+ expected_signature = _sign_payload(payload_bytes, settings.session_secret)
53
+ if not hmac.compare_digest(signature, expected_signature):
54
+ return None
55
+
56
+ claims = _parse_claims(payload_bytes)
57
+ if claims is None:
58
+ return None
59
+
60
+ current_time = _current_timestamp(now)
61
+ if claims.username != settings.username:
62
+ return None
63
+ if claims.issued_at > current_time:
64
+ return None
65
+ if claims.expires_at <= current_time:
66
+ return None
67
+
68
+ return claims
69
+
70
+
71
+ def _parse_claims(payload_bytes: bytes) -> SessionClaims | None:
72
+ try:
73
+ payload = json.loads(payload_bytes)
74
+ except (json.JSONDecodeError, UnicodeDecodeError):
75
+ return None
76
+
77
+ if not isinstance(payload, dict):
78
+ return None
79
+ if set(payload) != {"username", "iat", "exp"}:
80
+ return None
81
+
82
+ username = payload["username"]
83
+ issued_at = payload["iat"]
84
+ expires_at = payload["exp"]
85
+
86
+ if not isinstance(username, str) or not username:
87
+ return None
88
+ if type(issued_at) is not int or type(expires_at) is not int:
89
+ return None
90
+ if expires_at <= issued_at:
91
+ return None
92
+
93
+ return SessionClaims(username=username, issued_at=issued_at, expires_at=expires_at)
94
+
95
+
96
+ def _current_timestamp(now: int | float | None) -> int:
97
+ if now is None:
98
+ return int(time.time())
99
+ if isinstance(now, float) and not math.isfinite(now):
100
+ raise ValueError("now must be a finite timestamp")
101
+ return int(now)
102
+
103
+
104
+ def _sign_payload(payload_bytes: bytes, session_secret: str) -> bytes:
105
+ return hmac.new(session_secret.encode("utf-8"), payload_bytes, hashlib.sha256).digest()
106
+
107
+
108
+ def _urlsafe_b64encode(data: bytes) -> str:
109
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
110
+
111
+
112
+ def _urlsafe_b64decode(data: str) -> bytes:
113
+ padding = "=" * (-len(data) % 4)
114
+ return base64.b64decode(data + padding, altchars=b"-_", validate=True)