loki-mode 5.36.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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/loki +317 -30
- package/autonomy/run.sh +310 -6
- package/autonomy/serve.sh +25 -0
- package/dashboard/__init__.py +1 -1
- package/dashboard/audit.py +9 -5
- package/dashboard/auth.py +171 -17
- package/dashboard/secrets.py +152 -0
- package/dashboard/server.py +229 -9
- package/docs/INSTALLATION.md +1 -1
- package/package.json +1 -1
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
|
|
|
@@ -286,6 +310,121 @@ def has_scope(token_info: dict, required_scope: str) -> bool:
|
|
|
286
310
|
return "*" in scopes or required_scope in scopes
|
|
287
311
|
|
|
288
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
|
+
|
|
289
428
|
# FastAPI dependency for optional auth
|
|
290
429
|
async def get_current_token(
|
|
291
430
|
request: Request,
|
|
@@ -294,33 +433,43 @@ async def get_current_token(
|
|
|
294
433
|
"""
|
|
295
434
|
FastAPI dependency for optional token authentication.
|
|
296
435
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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)
|
|
300
439
|
|
|
301
|
-
When
|
|
440
|
+
When neither is enabled:
|
|
302
441
|
- Returns None (allows anonymous access)
|
|
303
442
|
"""
|
|
304
|
-
if not ENTERPRISE_AUTH_ENABLED:
|
|
305
|
-
#
|
|
443
|
+
if not ENTERPRISE_AUTH_ENABLED and not OIDC_ENABLED:
|
|
444
|
+
# No auth configured - allow anonymous
|
|
306
445
|
return None
|
|
307
446
|
|
|
308
447
|
if not credentials:
|
|
309
448
|
raise HTTPException(
|
|
310
449
|
status_code=401,
|
|
311
|
-
detail="Authentication required
|
|
450
|
+
detail="Authentication required",
|
|
312
451
|
headers={"WWW-Authenticate": "Bearer"},
|
|
313
452
|
)
|
|
314
453
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
454
|
+
token_str = credentials.credentials
|
|
455
|
+
|
|
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
|
|
322
461
|
|
|
323
|
-
|
|
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
|
+
)
|
|
324
473
|
|
|
325
474
|
|
|
326
475
|
def require_scope(scope: str):
|
|
@@ -331,7 +480,7 @@ def require_scope(scope: str):
|
|
|
331
480
|
@app.get("/admin", dependencies=[Depends(require_scope("admin"))])
|
|
332
481
|
"""
|
|
333
482
|
async def check_scope(token_info: Optional[dict] = Security(get_current_token)):
|
|
334
|
-
if not ENTERPRISE_AUTH_ENABLED:
|
|
483
|
+
if not ENTERPRISE_AUTH_ENABLED and not OIDC_ENABLED:
|
|
335
484
|
return # No auth required
|
|
336
485
|
|
|
337
486
|
if not token_info:
|
|
@@ -347,5 +496,10 @@ def require_scope(scope: str):
|
|
|
347
496
|
|
|
348
497
|
|
|
349
498
|
def is_enterprise_mode() -> bool:
|
|
350
|
-
"""Check if enterprise mode is enabled."""
|
|
499
|
+
"""Check if enterprise mode is enabled (token auth or OIDC)."""
|
|
351
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
|
|
@@ -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
|