ccoding 0.2.1__py3-none-any.whl → 0.2.3__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.
ccode/config.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import copy
4
4
  import json
5
+ import secrets
5
6
  import socket
6
7
  import uuid
7
8
  from pathlib import Path
@@ -32,11 +33,13 @@ DEFAULT_REMOTE: dict[str, Any] = {
32
33
  "local": {
33
34
  "host": "127.0.0.1",
34
35
  "port": 8765,
35
- "token": "",
36
+ "appId": "",
37
+ "appKey": "",
36
38
  },
37
39
  "server": {
38
40
  "url": "",
39
- "token": "",
41
+ "appId": "",
42
+ "appKey": "",
40
43
  "device_name": "",
41
44
  "device_id": "",
42
45
  "auto_connect": True,
@@ -103,6 +106,10 @@ def _new_device_id() -> str:
103
106
  return f"ccode-{uuid.uuid4().hex}"
104
107
 
105
108
 
109
+ def _new_app_id() -> str:
110
+ return f"app_{secrets.token_urlsafe(9)}"
111
+
112
+
106
113
  def normalize_remote(remote: Any) -> dict[str, Any]:
107
114
  normalized = copy.deepcopy(DEFAULT_REMOTE)
108
115
  if not isinstance(remote, dict):
@@ -132,18 +139,31 @@ def normalize_remote(remote: Any) -> dict[str, Any]:
132
139
  port = remote.get("port")
133
140
  if isinstance(port, int):
134
141
  normalized["local"]["port"] = port
135
- token = local.get("token") if isinstance(local, dict) else None
136
- if not isinstance(token, str):
137
- token = remote.get("token")
138
- if isinstance(token, str):
139
- normalized["local"]["token"] = token
142
+ local_app_id = local.get("appId") if isinstance(local, dict) else None
143
+ local_app_key = local.get("appKey") if isinstance(local, dict) else None
144
+ local_token = local.get("token") if isinstance(local, dict) else None
145
+ if not isinstance(local_token, str):
146
+ local_token = remote.get("token")
147
+ if isinstance(local_app_id, str):
148
+ normalized["local"]["appId"] = local_app_id
149
+ elif isinstance(local_token, str) and local_token:
150
+ normalized["local"]["appId"] = _new_app_id()
151
+ if isinstance(local_app_key, str):
152
+ normalized["local"]["appKey"] = local_app_key
153
+ elif isinstance(local_token, str):
154
+ normalized["local"]["appKey"] = local_token
140
155
 
141
156
  server = remote.get("server") if isinstance(remote.get("server"), dict) else {}
142
157
  if isinstance(server, dict):
143
- for key in ("url", "token", "device_name", "device_id"):
158
+ server_token = server.get("token")
159
+ for key in ("url", "appId", "appKey", "device_name", "device_id"):
144
160
  value = server.get(key)
145
161
  if isinstance(value, str):
146
162
  normalized["server"][key] = value
163
+ if not normalized["server"].get("appId") and isinstance(server_token, str) and server_token:
164
+ normalized["server"]["appId"] = _new_app_id()
165
+ if not normalized["server"].get("appKey") and isinstance(server_token, str):
166
+ normalized["server"]["appKey"] = server_token
147
167
  for key in ("auto_connect", "verify_tls"):
148
168
  value = server.get(key)
149
169
  if isinstance(value, bool):
ccode/remote.py CHANGED
@@ -15,20 +15,20 @@ from .remote_common import (
15
15
  _new_session_id,
16
16
  _sanitize_session_name,
17
17
  _utc_now,
18
+ AuthResult,
18
19
  asset_text,
19
- auth_token_matches,
20
20
  file_lock,
21
21
  missing_extra_message,
22
+ remote_local_app_id,
23
+ remote_local_app_key,
22
24
  remote_local_host_port,
23
- remote_local_token,
24
25
  remote_local_url,
25
26
  remote_mode,
26
27
  remote_server_config,
27
28
  remote_server_url,
28
29
  rotate_log,
29
- token_matches,
30
+ verify_auth_payload,
30
31
  web_asset_text,
31
- websocket_auth_token,
32
32
  )
33
33
 
34
34
  REMOTE_SESSIONS_PATH = CONFIG_DIR / "remote_sessions.json"
@@ -321,29 +321,33 @@ def ensure_remote_hub(config: dict[str, Any]) -> str | None:
321
321
  return f"Failed to start remote hub. See {REMOTE_SERVER_LOG_PATH} for details."
322
322
 
323
323
 
324
- _TOKEN_CACHE: tuple[float | None, str] = (None, "")
324
+ _AUTH_CACHE: tuple[float | None, str, str] = (None, "", "")
325
325
 
326
326
 
327
- def _current_remote_token() -> str:
328
- global _TOKEN_CACHE
327
+ def _current_remote_credentials() -> tuple[str, str]:
328
+ global _AUTH_CACHE
329
329
  try:
330
330
  mtime = CONFIG_PATH.stat().st_mtime
331
331
  except FileNotFoundError:
332
332
  mtime = None
333
- cached_mtime, cached_token = _TOKEN_CACHE
333
+ cached_mtime, app_id, app_key = _AUTH_CACHE
334
334
  if cached_mtime == mtime:
335
- return cached_token
336
- token = remote_local_token(load_config()).strip()
337
- _TOKEN_CACHE = (mtime, token)
338
- return token
335
+ return app_id, app_key
336
+ config = load_config()
337
+ app_id = remote_local_app_id(config).strip()
338
+ app_key = remote_local_app_key(config).strip()
339
+ _AUTH_CACHE = (mtime, app_id, app_key)
340
+ return app_id, app_key
339
341
 
340
342
 
341
- def _valid_token(token: str | None) -> bool:
342
- return token_matches(_current_remote_token(), token)
343
+ def _valid_auth_payload(payload: dict[str, Any], scope: str) -> AuthResult:
344
+ app_id, app_key = _current_remote_credentials()
345
+ return verify_auth_payload(app_id, app_key, payload, scope)
343
346
 
344
347
 
345
- def _valid_auth_token(token: str | None, authorization: str | None = None) -> bool:
346
- return auth_token_matches(_current_remote_token(), token, authorization)
348
+ def _auth_response(result: AuthResult, response_class: Any) -> Any:
349
+ status_code = 401 if result.error in {"auth_required", "reauth_required"} else 403
350
+ return response_class({"error": result.error or "auth_failed", "message": result.message}, status_code=status_code)
347
351
 
348
352
 
349
353
  async def _attach_websocket_to_session(websocket: Any, session: dict[str, Any]) -> None:
@@ -422,16 +426,18 @@ def create_app() -> Any:
422
426
 
423
427
  @app.get("/health")
424
428
  async def health() -> dict[str, Any]:
425
- return {"ok": True, "name": REMOTE_HUB_NAME, "multi_session": True}
429
+ return {"ok": True, "name": REMOTE_HUB_NAME, "multi_session": True, "remote_kind": "local"}
426
430
 
427
431
  @app.get("/api/sessions")
428
432
  async def sessions(
429
- token: str | None = None,
430
- authorization: str | None = Header(default=None),
433
+ x_ccode_app_id: str | None = Header(default=None),
434
+ x_ccode_timestamp: str | None = Header(default=None),
435
+ x_ccode_sign: str | None = Header(default=None),
431
436
  ) -> Response:
432
- if not _valid_auth_token(token, authorization):
433
- _log_event("api sessions unauthorized")
434
- return JSONResponse({"error": "unauthorized"}, status_code=401)
437
+ auth_result = _valid_auth_payload({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "local.api")
438
+ if not auth_result.ok:
439
+ _log_event(f"api sessions unauthorized error={auth_result.error}")
440
+ return _auth_response(auth_result, JSONResponse)
435
441
  sessions = list_remote_sessions()
436
442
  _log_event(f"api sessions returned count={len(sessions)}")
437
443
  return JSONResponse({"sessions": sessions})
@@ -439,12 +445,14 @@ def create_app() -> Any:
439
445
  @app.get("/api/sessions/{session_id}")
440
446
  async def session_detail(
441
447
  session_id: str,
442
- token: str | None = None,
443
- authorization: str | None = Header(default=None),
448
+ x_ccode_app_id: str | None = Header(default=None),
449
+ x_ccode_timestamp: str | None = Header(default=None),
450
+ x_ccode_sign: str | None = Header(default=None),
444
451
  ) -> Response:
445
- if not _valid_auth_token(token, authorization):
446
- _log_event(f"api session detail unauthorized session_id={session_id}")
447
- return JSONResponse({"error": "unauthorized"}, status_code=401)
452
+ auth_result = _valid_auth_payload({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "local.api")
453
+ if not auth_result.ok:
454
+ _log_event(f"api session detail unauthorized session_id={session_id} error={auth_result.error}")
455
+ return _auth_response(auth_result, JSONResponse)
448
456
  session = get_remote_session(session_id)
449
457
  if session is None or not _session_is_running(session):
450
458
  _log_event(f"api session detail not_found session_id={session_id}")
@@ -454,12 +462,14 @@ def create_app() -> Any:
454
462
 
455
463
  @app.get("/api/session")
456
464
  async def legacy_session(
457
- token: str | None = None,
458
- authorization: str | None = Header(default=None),
465
+ x_ccode_app_id: str | None = Header(default=None),
466
+ x_ccode_timestamp: str | None = Header(default=None),
467
+ x_ccode_sign: str | None = Header(default=None),
459
468
  ) -> Response:
460
- if not _valid_auth_token(token, authorization):
461
- _log_event("legacy api session unauthorized")
462
- return JSONResponse({"error": "unauthorized"}, status_code=401)
469
+ auth_result = _valid_auth_payload({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "local.api")
470
+ if not auth_result.ok:
471
+ _log_event(f"legacy api session unauthorized error={auth_result.error}")
472
+ return _auth_response(auth_result, JSONResponse)
463
473
  sessions = list_remote_sessions()
464
474
  _log_event(f"legacy api session requested running_count={len(sessions)}")
465
475
  if len(sessions) == 1:
@@ -471,9 +481,10 @@ def create_app() -> Any:
471
481
  async def websocket_session(websocket: FastAPIWebSocket, session_id: str) -> None:
472
482
  await websocket.accept()
473
483
  _log_event(f"websocket accepted session_id={session_id}")
474
- if not _valid_token(await websocket_auth_token(websocket)):
475
- _log_event(f"websocket unauthorized session_id={session_id}")
476
- await websocket.send_json({"type": "status", "message": "unauthorized: invalid token"})
484
+ auth_result = _valid_auth_payload(dict(websocket.query_params), "local.ws")
485
+ if not auth_result.ok:
486
+ _log_event(f"websocket unauthorized session_id={session_id} error={auth_result.error}")
487
+ await websocket.send_json({"type": "error", "error": auth_result.error, "message": auth_result.message})
477
488
  await websocket.close(code=1008)
478
489
  return
479
490
  session = get_remote_session(session_id)
@@ -488,9 +499,10 @@ def create_app() -> Any:
488
499
  async def legacy_websocket(websocket: FastAPIWebSocket) -> None:
489
500
  await websocket.accept()
490
501
  _log_event("legacy websocket accepted")
491
- if not _valid_token(await websocket_auth_token(websocket)):
492
- _log_event("legacy websocket unauthorized")
493
- await websocket.send_json({"type": "status", "message": "unauthorized: invalid token"})
502
+ auth_result = _valid_auth_payload(dict(websocket.query_params), "local.ws")
503
+ if not auth_result.ok:
504
+ _log_event(f"legacy websocket unauthorized error={auth_result.error}")
505
+ await websocket.send_json({"type": "error", "error": auth_result.error, "message": auth_result.message})
494
506
  await websocket.close(code=1008)
495
507
  return
496
508
  sessions = list_remote_sessions()
@@ -530,11 +542,11 @@ def run_remote_hub_forever(config: dict[str, Any]) -> str | None:
530
542
 
531
543
  def run_remote_session(config: dict[str, Any], args: list[str]) -> str | None:
532
544
  mode = remote_mode(config)
533
- if mode == "local" and not remote_local_token(config).strip():
534
- return "Remote token is required. Set it in the config screen or press g on the TOKEN field to generate one."
545
+ if mode == "local" and (not remote_local_app_id(config).strip() or not remote_local_app_key(config).strip()):
546
+ return "Remote app ID and app key are required. Set them in the config screen or press g on the APP ID / APP KEY fields to generate them."
535
547
  server = remote_server_config(config)
536
- if mode == "server" and (not remote_server_url(config) or not str(server.get("token") or "").strip()):
537
- return "Remote server URL and client token are required in server mode."
548
+ if mode == "server" and (not remote_server_url(config) or not str(server.get("appId") or "").strip() or not str(server.get("appKey") or "").strip()):
549
+ return "Remote server URL, client app ID, and client app key are required in server mode."
538
550
  error = _check_runtime()
539
551
  if error is not None:
540
552
  return error
@@ -604,7 +616,7 @@ def run_remote_session(config: dict[str, Any], args: list[str]) -> str | None:
604
616
  print(f" {session_name}\n")
605
617
  if host == "0.0.0.0":
606
618
  print("Security warning:")
607
- print(" Listening on 0.0.0.0 exposes all remote sessions to your network. Protect the access URL and token.\n")
619
+ print(" Listening on 0.0.0.0 exposes all remote sessions to your network. Protect the access URL and app credentials.\n")
608
620
  print("This terminal will attach to this tmux session. The web hub stays running independently.")
609
621
  print("Detach with Ctrl+B then D. Browser users can choose this session from the session list.\n")
610
622
 
ccode/remote_cli.py CHANGED
@@ -8,7 +8,7 @@ from pathlib import Path
8
8
  from typing import Any
9
9
 
10
10
  from .config import load_config, mask_secret
11
- from .remote_common import pid_from_path, remote_display_url, remote_local_host_port, remote_local_token, remote_mode, remote_server_config, remote_server_url
11
+ from .remote_common import pid_from_path, remote_display_url, remote_local_app_id, remote_local_app_key, remote_local_host_port, remote_mode, remote_server_config, remote_server_url
12
12
 
13
13
 
14
14
  def _format_bytes(value: int) -> str:
@@ -90,12 +90,14 @@ def print_status(kind: str | None = None) -> int:
90
90
  server = remote_server_config(config)
91
91
  print(f" device name: {server.get('device_name') or '<unset>'}")
92
92
  print(f" device id: {server.get('device_id') or '<unset>'}")
93
- print(f" client token: {mask_secret(str(server.get('token') or ''))}")
93
+ print(f" client app id: {server.get('appId') or '<unset>'}")
94
+ print(f" client app key: {mask_secret(str(server.get('appKey') or ''))}")
94
95
  else:
95
96
  host, port = remote_local_host_port(config)
96
97
  print(f" host: {host}")
97
98
  print(f" port: {port}")
98
- print(f" token: {mask_secret(remote_local_token(config))}")
99
+ print(f" app id: {remote_local_app_id(config) or '<unset>'}")
100
+ print(f" app key: {mask_secret(remote_local_app_key(config))}")
99
101
  print()
100
102
 
101
103
  if effective == "client":
ccode/remote_client.py CHANGED
@@ -10,7 +10,7 @@ import time
10
10
  from typing import Any
11
11
 
12
12
  from .config import CONFIG_DIR, load_config
13
- from .remote_common import file_lock, http_to_ws_url, missing_extra_message, pid_from_path, remote_server_config, remote_server_url, rotate_log
13
+ from .remote_common import build_auth_payload, file_lock, http_to_ws_url, missing_extra_message, pid_from_path, remote_server_app_id, remote_server_app_key, remote_server_config, remote_server_url, rotate_log
14
14
 
15
15
  REMOTE_CLIENT_LOCK_PATH = CONFIG_DIR / "remote_client.lock"
16
16
  REMOTE_CLIENT_PID_PATH = CONFIG_DIR / "remote_client.pid"
@@ -179,9 +179,9 @@ async def open_attach_socket(attach_id: str, session_id: str, config: dict[str,
179
179
  except ImportError:
180
180
  _log_event(missing_extra_message("remote-client", "websockets"))
181
181
  return
182
- server = remote_server_config(config)
182
+ auth_payload = build_auth_payload(remote_server_app_id(config), remote_server_app_key(config), "client.attach")
183
183
  async with websockets.connect(_attach_url(config, attach_id), **_connect_kwargs(config)) as ws:
184
- await ws.send(json.dumps({"type": "auth", "token": server.get("token") or "", "attach_id": attach_id}))
184
+ await ws.send(json.dumps({"type": "auth", **auth_payload, "attach_id": attach_id}))
185
185
  await attach_local_session_to_websocket(ws, session_id)
186
186
 
187
187
 
@@ -217,10 +217,11 @@ async def connect_control_loop(config: dict[str, Any]) -> None:
217
217
  from .remote import scan_remote_sessions
218
218
 
219
219
  running, _dead = scan_remote_sessions(prune=True)
220
+ auth_payload = build_auth_payload(remote_server_app_id(config), remote_server_app_key(config), "client.control")
220
221
  async with websockets.connect(_control_url(config), **_connect_kwargs(config)) as ws:
221
222
  await ws.send(json.dumps({
222
223
  "type": "hello",
223
- "token": server.get("token") or "",
224
+ **auth_payload,
224
225
  "device_id": server.get("device_id") or "",
225
226
  "device_name": server.get("device_name") or "",
226
227
  "version": "0.2.0",
@@ -246,9 +247,8 @@ async def _run_loop(config: dict[str, Any]) -> None:
246
247
  def run_remote_client_forever(config: dict[str, Any]) -> str | None:
247
248
  if not remote_server_url(config):
248
249
  return "Remote server URL is required."
249
- server = remote_server_config(config)
250
- if not str(server.get("token") or "").strip():
251
- return "Remote server client token is required."
250
+ if not remote_server_app_id(config).strip() or not remote_server_app_key(config).strip():
251
+ return "Remote server client app ID and app key are required."
252
252
  if remote_server_url(config).startswith("http://"):
253
253
  _log_event("warning: remote server URL uses plain HTTP/WS")
254
254
  try:
ccode/remote_common.py CHANGED
@@ -1,16 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
- import asyncio
3
+ import base64
4
4
  import contextlib
5
5
  import datetime as dt
6
6
  import fcntl
7
- import json
7
+ import hashlib
8
+ import hmac
8
9
  import os
9
10
  import re
10
11
  import secrets
12
+ import time
13
+ from dataclasses import dataclass
11
14
  from importlib import resources
12
15
  from typing import Any, Iterator
13
- from urllib.parse import quote, urlparse
16
+ from urllib.parse import urlencode, urlparse
14
17
 
15
18
  from .config import CONFIG_DIR
16
19
 
@@ -42,17 +45,25 @@ def remote_local_host_port(config: dict[str, Any]) -> tuple[str, int]:
42
45
  return host, port
43
46
 
44
47
 
45
- def remote_local_token(config: dict[str, Any]) -> str:
46
- return str(remote_local_config(config).get("token") or "")
48
+ def remote_local_app_id(config: dict[str, Any]) -> str:
49
+ return str(remote_local_config(config).get("appId") or "")
50
+
51
+
52
+ def remote_local_app_key(config: dict[str, Any]) -> str:
53
+ return str(remote_local_config(config).get("appKey") or "")
54
+
55
+
56
+ def remote_server_app_id(config: dict[str, Any]) -> str:
57
+ return str(remote_server_config(config).get("appId") or "")
58
+
59
+
60
+ def remote_server_app_key(config: dict[str, Any]) -> str:
61
+ return str(remote_server_config(config).get("appKey") or "")
47
62
 
48
63
 
49
64
  def remote_local_url(config: dict[str, Any], include_token: bool = False) -> str:
50
65
  host, port = remote_local_host_port(config)
51
- url = f"http://{host}:{port}/"
52
- token = remote_local_token(config)
53
- if include_token and token:
54
- return f"{url}?token={quote(token)}"
55
- return url
66
+ return f"http://{host}:{port}/"
56
67
 
57
68
 
58
69
  def remote_server_url(config: dict[str, Any]) -> str:
@@ -98,35 +109,81 @@ def _utc_now() -> str:
98
109
  return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
99
110
 
100
111
 
101
- def token_matches(expected: str, provided: str | None) -> bool:
102
- value = (provided or "").strip()
103
- expected = (expected or "").strip()
104
- return bool(value) and bool(expected) and secrets.compare_digest(value, expected)
112
+ AUTH_MAX_AGE_SECONDS = 24 * 60 * 60
113
+ AUTH_FUTURE_SKEW_SECONDS = 5 * 60
105
114
 
106
115
 
107
- def bearer_token(authorization: str | None) -> str | None:
108
- if not authorization:
109
- return None
110
- scheme, _, value = authorization.partition(" ")
111
- if scheme.lower() != "bearer":
112
- return None
113
- return value.strip() or None
116
+ @dataclass(frozen=True)
117
+ class AuthResult:
118
+ ok: bool
119
+ error: str = ""
120
+ message: str = ""
121
+
122
+
123
+ AUTH_OK = AuthResult(True)
124
+
125
+
126
+ def new_app_id() -> str:
127
+ return f"app_{secrets.token_urlsafe(9)}"
114
128
 
115
129
 
116
- def auth_token_matches(expected: str, token: str | None = None, authorization: str | None = None) -> bool:
117
- return token_matches(expected, token) or token_matches(expected, bearer_token(authorization))
130
+ def new_app_key() -> str:
131
+ return secrets.token_urlsafe(32)
118
132
 
119
133
 
120
- async def websocket_auth_token(websocket: Any) -> str | None:
134
+ def _auth_message(app_id: str, timestamp: str, scope: str) -> bytes:
135
+ return f"v1\nappId={app_id}\ntimestamp={timestamp}\nscope={scope}".encode()
136
+
137
+
138
+ def make_auth_signature(app_key: str, app_id: str, timestamp: str, scope: str) -> str:
139
+ digest = hmac.new(app_key.encode(), _auth_message(app_id, timestamp, scope), hashlib.sha256).digest()
140
+ return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
141
+
142
+
143
+ def build_auth_payload(app_id: str, app_key: str, scope: str, timestamp: int | None = None) -> dict[str, str]:
144
+ timestamp_value = str(int(time.time() if timestamp is None else timestamp))
145
+ return {
146
+ "appId": app_id,
147
+ "timestamp": timestamp_value,
148
+ "sign": make_auth_signature(app_key, app_id, timestamp_value, scope),
149
+ }
150
+
151
+
152
+ def auth_headers(payload: dict[str, str]) -> dict[str, str]:
153
+ return {
154
+ "X-CCODE-App-Id": payload.get("appId", ""),
155
+ "X-CCODE-Timestamp": payload.get("timestamp", ""),
156
+ "X-CCODE-Sign": payload.get("sign", ""),
157
+ }
158
+
159
+
160
+ def auth_query(payload: dict[str, str]) -> str:
161
+ return urlencode({"appId": payload.get("appId", ""), "timestamp": payload.get("timestamp", ""), "sign": payload.get("sign", "")})
162
+
163
+
164
+ def verify_auth_payload(expected_app_id: str, expected_app_key: str, payload: dict[str, Any], scope: str) -> AuthResult:
165
+ app_id = str(payload.get("appId") or "").strip()
166
+ timestamp = str(payload.get("timestamp") or "").strip()
167
+ sign = str(payload.get("sign") or "").strip()
168
+ expected_app_id = (expected_app_id or "").strip()
169
+ expected_app_key = (expected_app_key or "").strip()
170
+ if not expected_app_id or not expected_app_key or not app_id or not timestamp or not sign:
171
+ return AuthResult(False, "auth_required", "Authentication required.")
172
+ if not hmac.compare_digest(expected_app_id, app_id):
173
+ return AuthResult(False, "auth_failed", "Authentication failed.")
121
174
  try:
122
- raw = await asyncio.wait_for(websocket.receive_text(), timeout=10)
123
- payload = json.loads(raw)
124
- except (asyncio.TimeoutError, json.JSONDecodeError):
125
- return None
126
- if payload.get("type") != "auth":
127
- return None
128
- token = payload.get("token")
129
- return token if isinstance(token, str) else None
175
+ timestamp_int = int(timestamp)
176
+ except ValueError:
177
+ return AuthResult(False, "auth_required", "Invalid authentication timestamp.")
178
+ age = int(time.time()) - timestamp_int
179
+ if age > AUTH_MAX_AGE_SECONDS:
180
+ return AuthResult(False, "reauth_required", "Authentication expired, please re-authenticate.")
181
+ if age < -AUTH_FUTURE_SKEW_SECONDS:
182
+ return AuthResult(False, "auth_failed", "Authentication timestamp is invalid.")
183
+ expected_sign = make_auth_signature(expected_app_key, app_id, timestamp, scope)
184
+ if not hmac.compare_digest(expected_sign, sign):
185
+ return AuthResult(False, "auth_failed", "Authentication failed.")
186
+ return AUTH_OK
130
187
 
131
188
 
132
189
  _ASSET_TEXT_CACHE: dict[str, str] = {}
ccode/remote_server.py CHANGED
@@ -8,7 +8,7 @@ import time
8
8
  from dataclasses import dataclass, field
9
9
  from typing import Any
10
10
 
11
- from .remote_common import asset_text, auth_token_matches, missing_extra_message, token_matches, web_asset_text, websocket_auth_token
11
+ from .remote_common import AuthResult, asset_text, missing_extra_message, verify_auth_payload, web_asset_text
12
12
 
13
13
  REMOTE_SERVER_NAME = "ccode-remote-server"
14
14
  ATTACH_TTL_SECONDS = 30
@@ -41,22 +41,33 @@ _PENDING_ATTACHES: dict[str, PendingAttach] = {}
41
41
  DEVICE_OFFLINE_TTL_SECONDS = 24 * 60 * 60
42
42
 
43
43
 
44
- def _admin_token() -> str:
45
- return os.environ.get("CCODE_REMOTE_SERVER_ADMIN_TOKEN", "").strip()
44
+ def _admin_app_id() -> str:
45
+ return os.environ.get("CCODE_REMOTE_SERVER_ADMIN_APP_ID", "").strip()
46
46
 
47
47
 
48
- def _client_token() -> str:
49
- return os.environ.get("CCODE_REMOTE_SERVER_CLIENT_TOKEN", "").strip()
48
+ def _admin_app_key() -> str:
49
+ return os.environ.get("CCODE_REMOTE_SERVER_ADMIN_APP_KEY", "").strip()
50
50
 
51
51
 
52
- def _valid_admin(token: str | None = None, authorization: str | None = None) -> bool:
53
- expected = _admin_token()
54
- return bool(expected) and auth_token_matches(expected, token, authorization)
52
+ def _client_app_id() -> str:
53
+ return os.environ.get("CCODE_REMOTE_SERVER_CLIENT_APP_ID", "").strip()
55
54
 
56
55
 
57
- def _valid_client(token: str | None) -> bool:
58
- expected = _client_token()
59
- return bool(expected) and token_matches(expected, token)
56
+ def _client_app_key() -> str:
57
+ return os.environ.get("CCODE_REMOTE_SERVER_CLIENT_APP_KEY", "").strip()
58
+
59
+
60
+ def _valid_admin(payload: dict[str, Any], scope: str) -> AuthResult:
61
+ return verify_auth_payload(_admin_app_id(), _admin_app_key(), payload, scope)
62
+
63
+
64
+ def _valid_client(payload: dict[str, Any], scope: str) -> AuthResult:
65
+ return verify_auth_payload(_client_app_id(), _client_app_key(), payload, scope)
66
+
67
+
68
+ def _auth_response(result: AuthResult, response_class: Any) -> Any:
69
+ status_code = 401 if result.error in {"auth_required", "reauth_required"} else 403
70
+ return response_class({"error": result.error or "auth_failed", "message": result.message}, status_code=status_code)
60
71
 
61
72
 
62
73
  def _device_payload(device: DeviceConnection) -> dict[str, Any]:
@@ -168,25 +179,42 @@ def create_server_app() -> Any:
168
179
  @app.get("/health")
169
180
  async def health() -> dict[str, Any]:
170
181
  _prune_offline_devices()
171
- return {"ok": True, "name": REMOTE_SERVER_NAME, "devices": len(_DEVICES)}
182
+ return {"ok": True, "name": REMOTE_SERVER_NAME, "devices": len(_DEVICES), "remote_kind": "server"}
172
183
 
173
184
  @app.get("/api/devices")
174
- async def devices(token: str | None = None, authorization: str | None = Header(default=None)) -> Response:
175
- if not _valid_admin(token, authorization):
176
- return JSONResponse({"error": "unauthorized"}, status_code=401)
185
+ async def devices(
186
+ x_ccode_app_id: str | None = Header(default=None),
187
+ x_ccode_timestamp: str | None = Header(default=None),
188
+ x_ccode_sign: str | None = Header(default=None),
189
+ ) -> Response:
190
+ auth_result = _valid_admin({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "server.api")
191
+ if not auth_result.ok:
192
+ return _auth_response(auth_result, JSONResponse)
177
193
  _prune_offline_devices()
178
194
  return JSONResponse({"devices": [_device_payload(device) for device in _DEVICES.values()]})
179
195
 
180
196
  @app.get("/api/sessions")
181
- async def sessions(token: str | None = None, authorization: str | None = Header(default=None)) -> Response:
182
- if not _valid_admin(token, authorization):
183
- return JSONResponse({"error": "unauthorized"}, status_code=401)
197
+ async def sessions(
198
+ x_ccode_app_id: str | None = Header(default=None),
199
+ x_ccode_timestamp: str | None = Header(default=None),
200
+ x_ccode_sign: str | None = Header(default=None),
201
+ ) -> Response:
202
+ auth_result = _valid_admin({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "server.api")
203
+ if not auth_result.ok:
204
+ return _auth_response(auth_result, JSONResponse)
184
205
  return JSONResponse({"sessions": _flatten_sessions()})
185
206
 
186
207
  @app.get("/api/devices/{device_id}/sessions/{session_id}")
187
- async def session_detail(device_id: str, session_id: str, token: str | None = None, authorization: str | None = Header(default=None)) -> Response:
188
- if not _valid_admin(token, authorization):
189
- return JSONResponse({"error": "unauthorized"}, status_code=401)
208
+ async def session_detail(
209
+ device_id: str,
210
+ session_id: str,
211
+ x_ccode_app_id: str | None = Header(default=None),
212
+ x_ccode_timestamp: str | None = Header(default=None),
213
+ x_ccode_sign: str | None = Header(default=None),
214
+ ) -> Response:
215
+ auth_result = _valid_admin({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "server.api")
216
+ if not auth_result.ok:
217
+ return _auth_response(auth_result, JSONResponse)
190
218
  device, session = _get_device_session(device_id, session_id)
191
219
  if device is None or session is None:
192
220
  return JSONResponse({"error": "not_found"}, status_code=404)
@@ -201,7 +229,9 @@ def create_server_app() -> Any:
201
229
  except (asyncio.TimeoutError, json.JSONDecodeError):
202
230
  await websocket.close(code=1008)
203
231
  return
204
- if hello.get("type") != "hello" or not _valid_client(hello.get("token") if isinstance(hello.get("token"), str) else None):
232
+ auth_result = _valid_client(hello, "client.control")
233
+ if hello.get("type") != "hello" or not auth_result.ok:
234
+ await websocket.send_json({"type": "error", "error": auth_result.error or "auth_failed", "message": auth_result.message})
205
235
  await websocket.close(code=1008)
206
236
  return
207
237
  device_id = str(hello.get("device_id") or secrets.token_urlsafe(16))
@@ -237,8 +267,15 @@ def create_server_app() -> Any:
237
267
  await websocket.accept()
238
268
  _prune_pending()
239
269
  pending = _PENDING_ATTACHES.get(attach_id)
240
- token = await websocket_auth_token(websocket)
241
- if pending is None or not _valid_client(token):
270
+ try:
271
+ raw = await asyncio.wait_for(websocket.receive_text(), timeout=10)
272
+ payload = json.loads(raw)
273
+ except (asyncio.TimeoutError, json.JSONDecodeError):
274
+ await websocket.close(code=1008)
275
+ return
276
+ auth_result = _valid_client(payload, "client.attach")
277
+ if pending is None or payload.get("type") != "auth" or not auth_result.ok:
278
+ await websocket.send_json({"type": "error", "error": auth_result.error or "auth_failed", "message": auth_result.message})
242
279
  await websocket.close(code=1008)
243
280
  return
244
281
  pending.client_ws = websocket
@@ -249,9 +286,9 @@ def create_server_app() -> Any:
249
286
  @app.websocket("/ws/devices/{device_id}/sessions/{session_id}")
250
287
  async def browser_attach(websocket: FastAPIWebSocket, device_id: str, session_id: str) -> None:
251
288
  await websocket.accept()
252
- token = await websocket_auth_token(websocket)
253
- if not _valid_admin(token):
254
- await websocket.send_json({"type": "status", "message": "unauthorized: invalid token"})
289
+ auth_result = _valid_admin(dict(websocket.query_params), "server.ws")
290
+ if not auth_result.ok:
291
+ await websocket.send_json({"type": "error", "error": auth_result.error, "message": auth_result.message})
255
292
  await websocket.close(code=1008)
256
293
  return
257
294
  device, session = _get_device_session(device_id, session_id)