loki-mode 5.35.0 → 5.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dashboard/auth.py CHANGED
@@ -4,13 +4,19 @@ Optional Authentication Module for Loki Mode Dashboard.
4
4
  Enterprise feature - disabled by default.
5
5
  Enable with LOKI_ENTERPRISE_AUTH=true environment variable.
6
6
 
7
+ OIDC/SSO support (optional) - enable with LOKI_OIDC_ISSUER + LOKI_OIDC_CLIENT_ID.
8
+ Supports enterprise SSO providers (Okta, Azure AD, Google Workspace).
9
+
7
10
  Token storage: ~/.loki/dashboard/tokens.json
8
11
  """
9
12
 
13
+ import base64
10
14
  import hashlib
11
15
  import json
12
16
  import os
13
17
  import secrets
18
+ import time
19
+ import urllib.request
14
20
  from datetime import datetime, timezone
15
21
  from pathlib import Path
16
22
  from typing import Optional
@@ -23,6 +29,24 @@ ENTERPRISE_AUTH_ENABLED = os.environ.get("LOKI_ENTERPRISE_AUTH", "").lower() in
23
29
  TOKEN_DIR = Path.home() / ".loki" / "dashboard"
24
30
  TOKEN_FILE = TOKEN_DIR / "tokens.json"
25
31
 
32
+ # OIDC Configuration (optional - disabled by default)
33
+ OIDC_ISSUER = os.environ.get("LOKI_OIDC_ISSUER", "") # e.g., https://accounts.google.com
34
+ OIDC_CLIENT_ID = os.environ.get("LOKI_OIDC_CLIENT_ID", "")
35
+ OIDC_AUDIENCE = os.environ.get("LOKI_OIDC_AUDIENCE", "") # Usually same as client_id
36
+ OIDC_ENABLED = bool(OIDC_ISSUER and OIDC_CLIENT_ID)
37
+
38
+ if OIDC_ENABLED:
39
+ import logging as _logging
40
+ _logging.getLogger("loki.auth").warning(
41
+ "OIDC/SSO enabled (EXPERIMENTAL). Claims-based validation only -- "
42
+ "JWT signatures are NOT cryptographically verified. Install PyJWT + "
43
+ "cryptography for production signature verification."
44
+ )
45
+
46
+ # OIDC JWKS cache (issuer URL -> (keys_dict, fetch_timestamp))
47
+ _oidc_jwks_cache = {} # type: dict[str, tuple[dict, float]]
48
+ _OIDC_CACHE_TTL = 3600 # Cache JWKS for 1 hour
49
+
26
50
  # Security scheme (optional)
27
51
  security = HTTPBearer(auto_error=False)
28
52
 
@@ -53,9 +77,20 @@ def _save_tokens(tokens: dict) -> None:
53
77
  json.dump(tokens, f, indent=2, default=str)
54
78
 
55
79
 
56
- def _hash_token(token: str) -> str:
57
- """Hash a token for storage."""
58
- return hashlib.sha256(token.encode()).hexdigest()
80
+ def _hash_token(token: str, salt: str = None) -> tuple[str, str]:
81
+ """Hash a token for storage with a per-token random salt.
82
+
83
+ Args:
84
+ token: The raw token string to hash.
85
+ salt: Optional salt. If None, a new random salt is generated.
86
+
87
+ Returns:
88
+ Tuple of (hex_digest, salt).
89
+ """
90
+ if salt is None:
91
+ salt = secrets.token_hex(16)
92
+ digest = hashlib.sha256((salt + token).encode()).hexdigest()
93
+ return digest, salt
59
94
 
60
95
 
61
96
  def _constant_time_compare(a: str, b: str) -> bool:
@@ -94,7 +129,7 @@ def generate_token(
94
129
 
95
130
  # Generate secure random token
96
131
  raw_token = f"loki_{secrets.token_urlsafe(32)}"
97
- token_hash = _hash_token(raw_token)
132
+ token_hash, token_salt = _hash_token(raw_token)
98
133
  token_id = token_hash[:12]
99
134
 
100
135
  tokens = _load_tokens()
@@ -114,6 +149,7 @@ def generate_token(
114
149
  "id": token_id,
115
150
  "name": name,
116
151
  "hash": token_hash,
152
+ "salt": token_salt,
117
153
  "scopes": scopes or ["*"],
118
154
  "created_at": datetime.now(timezone.utc).isoformat(),
119
155
  "expires_at": expires_at,
@@ -229,11 +265,12 @@ def validate_token(raw_token: str) -> Optional[dict]:
229
265
  if not raw_token or not raw_token.startswith("loki_"):
230
266
  return None
231
267
 
232
- token_hash = _hash_token(raw_token)
233
268
  tokens = _load_tokens()
234
269
 
235
270
  # Find matching token (using constant-time comparison to prevent timing attacks)
236
271
  for token in tokens["tokens"].values():
272
+ stored_salt = token.get("salt", "")
273
+ token_hash, _ = _hash_token(raw_token, salt=stored_salt)
237
274
  if _constant_time_compare(token["hash"], token_hash):
238
275
  # Check if revoked
239
276
  if token.get("revoked"):
@@ -273,6 +310,121 @@ def has_scope(token_info: dict, required_scope: str) -> bool:
273
310
  return "*" in scopes or required_scope in scopes
274
311
 
275
312
 
313
+ # ---------------------------------------------------------------------------
314
+ # OIDC / SSO Support (optional - disabled by default)
315
+ # ---------------------------------------------------------------------------
316
+
317
+
318
+ def _get_oidc_config() -> dict:
319
+ """Fetch OIDC discovery document from the issuer.
320
+
321
+ Results are not cached here; callers should use _get_jwks() which
322
+ handles caching internally.
323
+ """
324
+ if not OIDC_ISSUER:
325
+ return {}
326
+ url = f"{OIDC_ISSUER.rstrip('/')}/.well-known/openid-configuration"
327
+ try:
328
+ with urllib.request.urlopen(url, timeout=10) as resp:
329
+ return json.loads(resp.read())
330
+ except Exception:
331
+ return {}
332
+
333
+
334
+ def _get_jwks() -> dict:
335
+ """Fetch and cache JWKS keys from the OIDC provider.
336
+
337
+ Keys are cached for 1 hour (controlled by _OIDC_CACHE_TTL).
338
+ """
339
+ global _oidc_jwks_cache
340
+ now = time.time()
341
+
342
+ cached = _oidc_jwks_cache.get(OIDC_ISSUER)
343
+ if cached:
344
+ keys, fetched_at = cached
345
+ if now - fetched_at < _OIDC_CACHE_TTL:
346
+ return keys
347
+
348
+ config = _get_oidc_config()
349
+ jwks_uri = config.get("jwks_uri")
350
+ if not jwks_uri:
351
+ return {"keys": []}
352
+ try:
353
+ with urllib.request.urlopen(jwks_uri, timeout=10) as resp:
354
+ keys = json.loads(resp.read())
355
+ _oidc_jwks_cache[OIDC_ISSUER] = (keys, now)
356
+ return keys
357
+ except Exception:
358
+ return {"keys": []}
359
+
360
+
361
+ def _base64url_decode(data: str) -> bytes:
362
+ """Decode base64url-encoded data with padding correction."""
363
+ padding = 4 - len(data) % 4
364
+ if padding != 4:
365
+ data += "=" * padding
366
+ return base64.urlsafe_b64decode(data)
367
+
368
+
369
+ def validate_oidc_token(token_str: str) -> Optional[dict]:
370
+ """Validate an OIDC JWT token.
371
+
372
+ Returns decoded user info dict if valid, None if invalid.
373
+
374
+ This is a claims-based validation that checks:
375
+ - Token structure (3 base64url-encoded parts)
376
+ - Issuer matches OIDC_ISSUER
377
+ - Audience matches OIDC_AUDIENCE or OIDC_CLIENT_ID
378
+ - Token is not expired
379
+
380
+ NOTE: Full cryptographic signature verification requires an RSA
381
+ library (e.g., PyJWT + cryptography). This implementation validates
382
+ claims and relies on HTTPS transport security for the token. For
383
+ production deployments with untrusted networks, consider adding
384
+ PyJWT for full signature verification.
385
+ """
386
+ if not OIDC_ENABLED:
387
+ return None
388
+
389
+ try:
390
+ parts = token_str.split(".")
391
+ if len(parts) != 3:
392
+ return None
393
+
394
+ # Decode payload (claims)
395
+ claims = json.loads(_base64url_decode(parts[1]))
396
+
397
+ # Validate issuer
398
+ if claims.get("iss") != OIDC_ISSUER:
399
+ return None
400
+
401
+ # Validate audience
402
+ aud = claims.get("aud")
403
+ expected_aud = OIDC_AUDIENCE or OIDC_CLIENT_ID
404
+ if isinstance(aud, list):
405
+ if expected_aud not in aud:
406
+ return None
407
+ elif aud != expected_aud:
408
+ return None
409
+
410
+ # Validate expiration
411
+ exp = claims.get("exp")
412
+ if exp and datetime.now(timezone.utc).timestamp() > exp:
413
+ return None
414
+
415
+ # Return user info from claims
416
+ return {
417
+ "id": claims.get("sub", ""),
418
+ "name": claims.get("name", claims.get("email", claims.get("sub", ""))),
419
+ "email": claims.get("email", ""),
420
+ "scopes": ["*"], # OIDC users get full access
421
+ "auth_method": "oidc",
422
+ "issuer": claims.get("iss"),
423
+ }
424
+ except Exception:
425
+ return None
426
+
427
+
276
428
  # FastAPI dependency for optional auth
277
429
  async def get_current_token(
278
430
  request: Request,
@@ -281,33 +433,43 @@ async def get_current_token(
281
433
  """
282
434
  FastAPI dependency for optional token authentication.
283
435
 
284
- When LOKI_ENTERPRISE_AUTH is enabled:
285
- - Requires valid Bearer token
286
- - Returns token info
436
+ Supports two auth methods (tried in order):
437
+ 1. OIDC/SSO (when LOKI_OIDC_ISSUER + LOKI_OIDC_CLIENT_ID are set)
438
+ 2. Token auth (when LOKI_ENTERPRISE_AUTH=true)
287
439
 
288
- When disabled:
440
+ When neither is enabled:
289
441
  - Returns None (allows anonymous access)
290
442
  """
291
- if not ENTERPRISE_AUTH_ENABLED:
292
- # Auth disabled - allow anonymous
443
+ if not ENTERPRISE_AUTH_ENABLED and not OIDC_ENABLED:
444
+ # No auth configured - allow anonymous
293
445
  return None
294
446
 
295
447
  if not credentials:
296
448
  raise HTTPException(
297
449
  status_code=401,
298
- detail="Authentication required (enterprise mode)",
450
+ detail="Authentication required",
299
451
  headers={"WWW-Authenticate": "Bearer"},
300
452
  )
301
453
 
302
- token_info = validate_token(credentials.credentials)
303
- if not token_info:
304
- raise HTTPException(
305
- status_code=401,
306
- detail="Invalid, expired, or revoked token",
307
- headers={"WWW-Authenticate": "Bearer"},
308
- )
454
+ token_str = credentials.credentials
309
455
 
310
- return token_info
456
+ # Try OIDC first (JWTs are typically longer and don't start with loki_)
457
+ if OIDC_ENABLED and not token_str.startswith("loki_"):
458
+ oidc_result = validate_oidc_token(token_str)
459
+ if oidc_result:
460
+ return oidc_result
461
+
462
+ # Fall back to token auth
463
+ if ENTERPRISE_AUTH_ENABLED:
464
+ token_info = validate_token(token_str)
465
+ if token_info:
466
+ return token_info
467
+
468
+ raise HTTPException(
469
+ status_code=401,
470
+ detail="Invalid, expired, or revoked token",
471
+ headers={"WWW-Authenticate": "Bearer"},
472
+ )
311
473
 
312
474
 
313
475
  def require_scope(scope: str):
@@ -318,7 +480,7 @@ def require_scope(scope: str):
318
480
  @app.get("/admin", dependencies=[Depends(require_scope("admin"))])
319
481
  """
320
482
  async def check_scope(token_info: Optional[dict] = Security(get_current_token)):
321
- if not ENTERPRISE_AUTH_ENABLED:
483
+ if not ENTERPRISE_AUTH_ENABLED and not OIDC_ENABLED:
322
484
  return # No auth required
323
485
 
324
486
  if not token_info:
@@ -334,5 +496,10 @@ def require_scope(scope: str):
334
496
 
335
497
 
336
498
  def is_enterprise_mode() -> bool:
337
- """Check if enterprise mode is enabled."""
499
+ """Check if enterprise mode is enabled (token auth or OIDC)."""
338
500
  return ENTERPRISE_AUTH_ENABLED
501
+
502
+
503
+ def is_oidc_mode() -> bool:
504
+ """Check if OIDC/SSO authentication is enabled."""
505
+ return OIDC_ENABLED
@@ -1,8 +1,8 @@
1
1
  # Loki Mode Dashboard Dependencies
2
- fastapi>=0.109.0
3
- uvicorn[standard]>=0.27.0
4
- sqlalchemy>=2.0.0
5
- aiosqlite>=0.19.0
6
- greenlet>=3.0.0
7
- pydantic>=2.0.0
8
- websockets>=12.0
2
+ fastapi==0.115.6
3
+ uvicorn[standard]==0.34.0
4
+ sqlalchemy==2.0.36
5
+ aiosqlite==0.20.0
6
+ greenlet==3.1.1
7
+ pydantic==2.10.4
8
+ websockets==14.1
@@ -0,0 +1,152 @@
1
+ """
2
+ Secret management utilities for Loki Mode.
3
+
4
+ Provides:
5
+ - API key validation (format checks, not auth checks)
6
+ - Credential rotation detection
7
+ - Secure environment loading with masking
8
+ - Secret file support (Docker/K8s secret mounts)
9
+ """
10
+
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import re
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ # Known API key patterns for format validation
19
+ _KEY_PATTERNS = {
20
+ "ANTHROPIC_API_KEY": re.compile(r"^sk-ant-[a-zA-Z0-9_-]{90,}$"),
21
+ "OPENAI_API_KEY": re.compile(r"^sk-[a-zA-Z0-9_-]{40,}$"),
22
+ "GOOGLE_API_KEY": re.compile(r"^AI[a-zA-Z0-9_-]{30,}$"),
23
+ }
24
+
25
+ # Secret file mount paths (Docker/K8s convention)
26
+ _SECRET_MOUNT_PATHS = [
27
+ "/run/secrets", # Docker secrets
28
+ "/var/run/secrets", # K8s secrets
29
+ ]
30
+
31
+
32
+ def validate_api_key(key_name: str, key_value: str) -> dict:
33
+ """Validate an API key format (not authentication).
34
+
35
+ Returns dict with: valid (bool), masked (str), warning (str or None)
36
+ """
37
+ if not key_value:
38
+ return {"valid": False, "masked": "", "warning": "Key is empty"}
39
+
40
+ masked = key_value[:8] + "..." + key_value[-4:] if len(key_value) > 16 else "***"
41
+
42
+ pattern = _KEY_PATTERNS.get(key_name)
43
+ if pattern and not pattern.match(key_value):
44
+ return {
45
+ "valid": False,
46
+ "masked": masked,
47
+ "warning": f"Key format does not match expected pattern for {key_name}",
48
+ }
49
+
50
+ return {"valid": True, "masked": masked, "warning": None}
51
+
52
+
53
+ def load_secret_from_file(key_name: str) -> Optional[str]:
54
+ """Load a secret from Docker/K8s secret mount paths.
55
+
56
+ Checks /run/secrets/{key_name} and /var/run/secrets/{key_name}.
57
+ Returns the secret value or None.
58
+ """
59
+ lower_name = key_name.lower()
60
+ for mount_path in _SECRET_MOUNT_PATHS:
61
+ secret_file = Path(mount_path) / lower_name
62
+ if secret_file.exists():
63
+ try:
64
+ return secret_file.read_text().strip()
65
+ except (PermissionError, IOError):
66
+ pass
67
+ return None
68
+
69
+
70
+ def load_secrets() -> dict:
71
+ """Load all API keys with secret file fallback.
72
+
73
+ Priority: Environment variable > Secret file mount > None
74
+ Returns dict of key_name -> {source, set, masked, valid_format, warning}
75
+ """
76
+ keys = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY"]
77
+ result = {}
78
+
79
+ for key_name in keys:
80
+ env_value = os.environ.get(key_name, "")
81
+ file_value = load_secret_from_file(key_name)
82
+
83
+ if env_value:
84
+ value = env_value
85
+ source = "environment"
86
+ elif file_value:
87
+ value = file_value
88
+ source = "secret_file"
89
+ # Set in environment for child processes
90
+ os.environ[key_name] = value
91
+ else:
92
+ value = ""
93
+ source = "not_set"
94
+
95
+ validation = validate_api_key(key_name, value)
96
+ result[key_name] = {
97
+ "source": source,
98
+ "set": bool(value),
99
+ "masked": validation["masked"],
100
+ "valid_format": validation["valid"],
101
+ "warning": validation["warning"],
102
+ }
103
+
104
+ return result
105
+
106
+
107
+ def get_key_fingerprint(key_value: str) -> str:
108
+ """Get a stable fingerprint for a key (for rotation detection).
109
+
110
+ Uses first 8 chars of SHA-256 hash. Safe to log/store.
111
+ """
112
+ if not key_value:
113
+ return ""
114
+ return hashlib.sha256(key_value.encode()).hexdigest()[:8]
115
+
116
+
117
+ def check_rotation(state_file: str = ".loki/state/key-fingerprints.json") -> list:
118
+ """Check if any API keys have been rotated since last check.
119
+
120
+ Compares current key fingerprints against stored fingerprints.
121
+ Returns list of rotated key names.
122
+ """
123
+ state_path = Path(state_file)
124
+
125
+ # Get current fingerprints
126
+ current = {}
127
+ for key_name in ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY"]:
128
+ value = os.environ.get(key_name, "")
129
+ if value:
130
+ current[key_name] = get_key_fingerprint(value)
131
+
132
+ # Load previous fingerprints
133
+ previous = {}
134
+ if state_path.exists():
135
+ try:
136
+ previous = json.loads(state_path.read_text())
137
+ except Exception:
138
+ pass
139
+
140
+ # Detect rotations
141
+ rotated = []
142
+ for key_name, fp in current.items():
143
+ prev_fp = previous.get(key_name)
144
+ if prev_fp and prev_fp != fp:
145
+ rotated.append(key_name)
146
+
147
+ # Save current fingerprints
148
+ if current:
149
+ state_path.parent.mkdir(parents=True, exist_ok=True)
150
+ state_path.write_text(json.dumps(current, indent=2))
151
+
152
+ return rotated