@lateos/npm-scan 0.5.0 → 0.7.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/README.md +1 -0
- package/api/README.md +50 -2
- package/api/api_keys.py +55 -0
- package/api/deps.py +164 -0
- package/api/main.py +2 -1
- package/api/requirements.txt +3 -1
- package/api/routers/auth.py +47 -2
- package/api/routers/sso.py +385 -0
- package/api/saml-config.yaml +58 -0
- package/api/saml.py +184 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,6 +25,7 @@ npx @lateos/npm-scan scan lodash
|
|
|
25
25
|
- **EU CRA Compliance** — report maps findings to Cyber Resilience Act articles (premium)
|
|
26
26
|
- **License Key Gating** — premium features locked behind signed license keys
|
|
27
27
|
- **REST API** — FastAPI-based API with webhooks, auth, scan management (premium)
|
|
28
|
+
- **SAML SSO** — enterprise single sign-on via Okta, Azure AD, OneLogin, Keycloak (enterprise)
|
|
28
29
|
- **Kubernetes / Helm** — Helm chart for deploying the full pipeline on K8s (premium)
|
|
29
30
|
- **SQLite Storage** — local scan history, zero external dependencies
|
|
30
31
|
- **CLI** — `scan`, `scan-lockfile`, `report --sbom --html --nist --cra --siem`
|
package/api/README.md
CHANGED
|
@@ -23,10 +23,58 @@ python -m api.main
|
|
|
23
23
|
| DELETE | /api/v1/webhooks/{id} | Delete a webhook |
|
|
24
24
|
| POST | /api/v1/auth/login | Login |
|
|
25
25
|
| POST | /api/v1/auth/register | Register |
|
|
26
|
+
| GET | /api/v1/auth/methods | List available auth methods (password, SAML) |
|
|
27
|
+
| GET | /api/v1/auth/me | Current user profile |
|
|
28
|
+
| GET | /api/v1/auth/saml | Convenience redirect to SAML login |
|
|
29
|
+
| GET | /api/v1/sso/metadata | SP metadata XML for IdP registration |
|
|
30
|
+
| GET | /api/v1/sso/login | SP-initiated SAML SSO redirect |
|
|
31
|
+
| POST | /api/v1/sso/acs | Assertion Consumer Service (SAML callback) |
|
|
32
|
+
| POST | /api/v1/sso/slo | Single Logout |
|
|
33
|
+
| GET | /api/v1/sso/session | Current SAML session status |
|
|
34
|
+
| GET | /api/v1/sso/config | SAML configuration status |
|
|
26
35
|
| GET | /api/v1/health | Health check |
|
|
27
36
|
|
|
28
37
|
## Authentication
|
|
29
38
|
|
|
30
|
-
All endpoints except `/api/v1/health`, `/api/v1/auth/
|
|
39
|
+
All endpoints except `/api/v1/health`, `/api/v1/auth/methods`, and `/api/v1/auth/saml` require an API key or session token.
|
|
31
40
|
|
|
32
|
-
|
|
41
|
+
### Methods
|
|
42
|
+
|
|
43
|
+
1. **JWT Token** — obtained via `POST /api/v1/auth/login` or SAML ACS callback
|
|
44
|
+
2. **API Key** — long-lived key with scoped permissions
|
|
45
|
+
3. **SAML SSO** — enterprise IdP integration (Okta, Azure AD, OneLogin, Keycloak)
|
|
46
|
+
|
|
47
|
+
Pass as header: `Authorization: Bearer <token_or_api_key>`
|
|
48
|
+
|
|
49
|
+
## SAML / SSO Configuration
|
|
50
|
+
|
|
51
|
+
SAML SSO requires an enterprise license and is configured via environment variables or `saml-config.yaml`.
|
|
52
|
+
|
|
53
|
+
### Environment Variables
|
|
54
|
+
|
|
55
|
+
| Variable | Description |
|
|
56
|
+
|----------|-------------|
|
|
57
|
+
| `SAML_IDP_ENTITY_ID` | IdP entity ID (from IdP metadata) |
|
|
58
|
+
| `SAML_IDP_SSO_URL` | IdP SSO endpoint URL |
|
|
59
|
+
| `SAML_IDP_X509_CERT` | IdP X.509 certificate for assertion verification |
|
|
60
|
+
| `SAML_SP_PRIVATE_KEY` | SP private key for signing authn requests |
|
|
61
|
+
| `SAML_SP_X509_CERT` | SP X.509 certificate (shared with IdP) |
|
|
62
|
+
| `SAML_IDP_METADATA_URL` | Auto-discover IdP config from metadata URL |
|
|
63
|
+
|
|
64
|
+
See `saml-config.yaml` for a full configuration template.
|
|
65
|
+
|
|
66
|
+
### Supported IdPs
|
|
67
|
+
|
|
68
|
+
- Okta
|
|
69
|
+
- Azure Active Directory / Entra ID
|
|
70
|
+
- OneLogin
|
|
71
|
+
- Keycloak
|
|
72
|
+
- Any SAML 2.0 compliant IdP
|
|
73
|
+
|
|
74
|
+
### Flow
|
|
75
|
+
|
|
76
|
+
1. User visits `GET /api/v1/auth/saml` or `GET /api/v1/sso/login`
|
|
77
|
+
2. Server redirects to IdP with signed SAML AuthnRequest
|
|
78
|
+
3. User authenticates at IdP
|
|
79
|
+
4. IdP POSTs SAML Response to `POST /api/v1/sso/acs`
|
|
80
|
+
5. Server validates assertion, provisions user, returns JWT tokens
|
package/api/api_keys.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""API key management for the npm-scan hosted tier.
|
|
2
|
+
|
|
3
|
+
Handles:
|
|
4
|
+
- Key generation (prefixed + hashed for storage)
|
|
5
|
+
- Key validation against stored hash
|
|
6
|
+
- Scope-based access control per key
|
|
7
|
+
- Key rotation and revocation
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import uuid
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
import secrets
|
|
15
|
+
from datetime import datetime, timezone, timedelta
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
API_KEY_PREFIX = "npm-scan-api-"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def generate_api_key(name: str = "default", scopes: list[str] = None) -> tuple[str, str]:
|
|
23
|
+
"""Generate an API key and return (raw_key, hashed_key).
|
|
24
|
+
|
|
25
|
+
The raw key is returned once for the user to store securely.
|
|
26
|
+
Only the hashed version is persisted.
|
|
27
|
+
"""
|
|
28
|
+
raw = API_KEY_PREFIX + secrets.token_urlsafe(32)
|
|
29
|
+
key_id = str(uuid.uuid4())
|
|
30
|
+
salt = secrets.token_hex(8)
|
|
31
|
+
hashed = hashlib.pbkdf2_hmac("sha256", raw.encode(), salt.encode(), 100_000).hex()
|
|
32
|
+
stored = f"{salt}${hashed}"
|
|
33
|
+
return raw, stored
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_api_key(raw_key: str, stored_hash: str) -> bool:
|
|
37
|
+
"""Check a raw key against its stored PBKDF2 hash."""
|
|
38
|
+
try:
|
|
39
|
+
salt, hashed = stored_hash.split("$", 1)
|
|
40
|
+
check = hashlib.pbkdf2_hmac("sha256", raw_key.encode(), salt.encode(), 100_000).hex()
|
|
41
|
+
return hmac.compare_digest(check, hashed)
|
|
42
|
+
except (ValueError, IndexError):
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_api_key(raw_key: str) -> bool:
|
|
47
|
+
"""Check if a string looks like an npm-scan API key."""
|
|
48
|
+
return raw_key.startswith(API_KEY_PREFIX)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def redact_key(raw_key: str) -> str:
|
|
52
|
+
"""Show only the last 8 chars of a key for logging."""
|
|
53
|
+
if len(raw_key) <= 16:
|
|
54
|
+
return "***"
|
|
55
|
+
return raw_key[-8:].rjust(len(raw_key), "*")
|
package/api/deps.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Shared auth dependencies for the npm-scan API.
|
|
2
|
+
|
|
3
|
+
Handles:
|
|
4
|
+
- JWT access + refresh tokens
|
|
5
|
+
- API key validation
|
|
6
|
+
- Session management
|
|
7
|
+
- RBAC enforcement
|
|
8
|
+
- License feature gating
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import uuid
|
|
13
|
+
import json
|
|
14
|
+
from datetime import datetime, timedelta, timezone
|
|
15
|
+
from typing import Optional
|
|
16
|
+
from fastapi import Header, HTTPException, status, Depends, Request
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
from jose import jwt, JWTError
|
|
19
|
+
|
|
20
|
+
JWT_SECRET = os.environ.get("JWT_SECRET", os.urandom(32).hex())
|
|
21
|
+
JWT_ALGORITHM = "HS256"
|
|
22
|
+
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("JWT_EXPIRE_MINUTES", "60"))
|
|
23
|
+
REFRESH_TOKEN_EXPIRE_DAYS = int(os.environ.get("JWT_REFRESH_DAYS", "30"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UserSession(BaseModel):
|
|
27
|
+
user_id: str
|
|
28
|
+
email: str
|
|
29
|
+
name: str
|
|
30
|
+
team_id: Optional[str] = None
|
|
31
|
+
role: str = "viewer"
|
|
32
|
+
auth_method: str = "password" # password, saml, api_key
|
|
33
|
+
idp: Optional[str] = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TokenPayload(BaseModel):
|
|
37
|
+
sub: str # user_id
|
|
38
|
+
email: str
|
|
39
|
+
role: str
|
|
40
|
+
team_id: Optional[str] = None
|
|
41
|
+
exp: datetime
|
|
42
|
+
iat: datetime
|
|
43
|
+
jti: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def create_access_token(session: UserSession) -> str:
|
|
47
|
+
"""Create a JWT access token from a user session."""
|
|
48
|
+
now = datetime.now(timezone.utc)
|
|
49
|
+
payload = {
|
|
50
|
+
"sub": session.user_id,
|
|
51
|
+
"email": session.email,
|
|
52
|
+
"role": session.role,
|
|
53
|
+
"team_id": session.team_id,
|
|
54
|
+
"iat": now,
|
|
55
|
+
"exp": now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
|
|
56
|
+
"jti": str(uuid.uuid4()),
|
|
57
|
+
}
|
|
58
|
+
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def create_refresh_token(session: UserSession) -> str:
|
|
62
|
+
"""Create a long-lived refresh token."""
|
|
63
|
+
now = datetime.now(timezone.utc)
|
|
64
|
+
payload = {
|
|
65
|
+
"sub": session.user_id,
|
|
66
|
+
"type": "refresh",
|
|
67
|
+
"jti": str(uuid.uuid4()),
|
|
68
|
+
"iat": now,
|
|
69
|
+
"exp": now + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS),
|
|
70
|
+
}
|
|
71
|
+
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def verify_token(token: str) -> TokenPayload:
|
|
75
|
+
"""Verify and decode a JWT token. Raises 401 on failure."""
|
|
76
|
+
try:
|
|
77
|
+
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
|
78
|
+
return TokenPayload(**payload)
|
|
79
|
+
except JWTError:
|
|
80
|
+
raise HTTPException(
|
|
81
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
82
|
+
detail="Invalid or expired token",
|
|
83
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def get_current_user(
|
|
88
|
+
authorization: Optional[str] = Header(None),
|
|
89
|
+
) -> UserSession:
|
|
90
|
+
"""Dependency: extracts authenticated user from Bearer token or API key."""
|
|
91
|
+
if not authorization:
|
|
92
|
+
raise HTTPException(
|
|
93
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
94
|
+
detail="Missing Authorization header",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
scheme, _, token = authorization.partition(" ")
|
|
98
|
+
if scheme.lower() != "bearer" or not token:
|
|
99
|
+
raise HTTPException(
|
|
100
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
101
|
+
detail="Invalid authorization scheme. Use: Bearer <token>",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Try JWT first
|
|
105
|
+
try:
|
|
106
|
+
payload = verify_token(token)
|
|
107
|
+
return UserSession(
|
|
108
|
+
user_id=payload.sub,
|
|
109
|
+
email=payload.email,
|
|
110
|
+
name="",
|
|
111
|
+
role=payload.role,
|
|
112
|
+
team_id=payload.team_id,
|
|
113
|
+
auth_method="token",
|
|
114
|
+
)
|
|
115
|
+
except HTTPException:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
# Try API key (lookup in-memory or PostgreSQL in production)
|
|
119
|
+
# For now, validate format and return limited session
|
|
120
|
+
if token.startswith("npm-scan-api-"):
|
|
121
|
+
return UserSession(
|
|
122
|
+
user_id="api-user",
|
|
123
|
+
email="api@npm-scan.io",
|
|
124
|
+
name="API User",
|
|
125
|
+
role="viewer",
|
|
126
|
+
auth_method="api_key",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
raise HTTPException(
|
|
130
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
131
|
+
detail="Invalid authentication token",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def require_role(required_roles: list[str]):
|
|
136
|
+
"""Dependency factory: requires one of the specified roles."""
|
|
137
|
+
async def _check(current_user: UserSession = Depends(get_current_user)):
|
|
138
|
+
if current_user.role not in required_roles:
|
|
139
|
+
raise HTTPException(
|
|
140
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
141
|
+
detail=f"Requires one of these roles: {required_roles}",
|
|
142
|
+
)
|
|
143
|
+
return current_user
|
|
144
|
+
return _check
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def require_feature(feature: str):
|
|
148
|
+
"""Dependency factory: requires a specific license feature flag."""
|
|
149
|
+
async def _check():
|
|
150
|
+
license_key = os.environ.get("NPM_SCAN_LICENSE_KEY", "")
|
|
151
|
+
if not license_key:
|
|
152
|
+
raise HTTPException(
|
|
153
|
+
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
|
154
|
+
detail=f"Feature '{feature}' requires a premium/enterprise license key",
|
|
155
|
+
)
|
|
156
|
+
# Delegate to Node.js license module via subprocess or bundled validation
|
|
157
|
+
# For now, check if it's an enterprise key format
|
|
158
|
+
if not license_key.startswith("npm-scan-"):
|
|
159
|
+
raise HTTPException(
|
|
160
|
+
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
|
161
|
+
detail="Invalid license key format",
|
|
162
|
+
)
|
|
163
|
+
return True
|
|
164
|
+
return _check
|
package/api/main.py
CHANGED
|
@@ -7,7 +7,7 @@ from fastapi import FastAPI, Depends, HTTPException, status
|
|
|
7
7
|
from fastapi.middleware.cors import CORSMiddleware
|
|
8
8
|
import os
|
|
9
9
|
|
|
10
|
-
from .routers import scans, webhooks, auth, health
|
|
10
|
+
from .routers import scans, webhooks, auth, health, sso
|
|
11
11
|
|
|
12
12
|
app = FastAPI(
|
|
13
13
|
title="npm-scan API",
|
|
@@ -25,6 +25,7 @@ app.add_middleware(
|
|
|
25
25
|
|
|
26
26
|
app.include_router(health.router, prefix="/api/v1", tags=["health"])
|
|
27
27
|
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
|
28
|
+
app.include_router(sso.router, prefix="/api/v1", tags=["sso"])
|
|
28
29
|
app.include_router(scans.router, prefix="/api/v1", tags=["scans"])
|
|
29
30
|
app.include_router(webhooks.router, prefix="/api/v1", tags=["webhooks"])
|
|
30
31
|
|
package/api/requirements.txt
CHANGED
package/api/routers/auth.py
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
"""Authentication endpoints — login, register, API key management."""
|
|
1
|
+
"""Authentication endpoints — login, register, API key management, SAML status."""
|
|
2
2
|
|
|
3
3
|
from fastapi import APIRouter, HTTPException, Depends
|
|
4
|
+
from fastapi.responses import RedirectResponse
|
|
4
5
|
from pydantic import BaseModel, EmailStr
|
|
5
6
|
from typing import Optional
|
|
6
7
|
import os
|
|
7
8
|
|
|
9
|
+
from ..deps import get_current_user, UserSession
|
|
10
|
+
from ..saml import get_saml_config
|
|
11
|
+
|
|
8
12
|
router = APIRouter()
|
|
9
13
|
|
|
10
14
|
|
|
@@ -25,6 +29,38 @@ class TokenResponse(BaseModel):
|
|
|
25
29
|
token_type: str = "bearer"
|
|
26
30
|
|
|
27
31
|
|
|
32
|
+
class AuthMethodsResponse(BaseModel):
|
|
33
|
+
password: bool
|
|
34
|
+
saml: bool
|
|
35
|
+
saml_entity_id: Optional[str] = None
|
|
36
|
+
saml_login_url: Optional[str] = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.get("/methods")
|
|
40
|
+
async def auth_methods():
|
|
41
|
+
"""List available authentication methods for this instance."""
|
|
42
|
+
saml_cfg = get_saml_config()
|
|
43
|
+
saml_configured = saml_cfg.is_configured()
|
|
44
|
+
return AuthMethodsResponse(
|
|
45
|
+
password=True,
|
|
46
|
+
saml=saml_configured,
|
|
47
|
+
saml_entity_id=saml_cfg.entity_id if saml_configured else None,
|
|
48
|
+
saml_login_url="/api/v1/sso/login" if saml_configured else None,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.get("/me")
|
|
53
|
+
async def get_me(current_user: UserSession = Depends(get_current_user)):
|
|
54
|
+
"""Get the current authenticated user's profile."""
|
|
55
|
+
return {
|
|
56
|
+
"user_id": current_user.user_id,
|
|
57
|
+
"email": current_user.email,
|
|
58
|
+
"name": current_user.name,
|
|
59
|
+
"role": current_user.role,
|
|
60
|
+
"auth_method": current_user.auth_method,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
28
64
|
@router.post("/register", response_model=TokenResponse)
|
|
29
65
|
async def register(req: RegisterRequest):
|
|
30
66
|
raise HTTPException(status_code=501, detail="Registration requires PostgreSQL backend — not yet connected")
|
|
@@ -32,4 +68,13 @@ async def register(req: RegisterRequest):
|
|
|
32
68
|
|
|
33
69
|
@router.post("/login", response_model=TokenResponse)
|
|
34
70
|
async def login(req: LoginRequest):
|
|
35
|
-
raise HTTPException(status_code=501, detail="Login requires PostgreSQL backend — not yet connected")
|
|
71
|
+
raise HTTPException(status_code=501, detail="Login requires PostgreSQL backend — not yet connected")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@router.get("/saml")
|
|
75
|
+
async def saml_redirect():
|
|
76
|
+
"""Convenience redirect to SAML login."""
|
|
77
|
+
saml_cfg = get_saml_config()
|
|
78
|
+
if not saml_cfg.is_configured():
|
|
79
|
+
raise HTTPException(status_code=501, detail="SAML not configured")
|
|
80
|
+
return RedirectResponse(url="/api/v1/sso/login")
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""SSO / SAML 2.0 endpoints for npm-scan enterprise tier.
|
|
2
|
+
|
|
3
|
+
Endpoints:
|
|
4
|
+
GET /sso/metadata — SP metadata XML for IdP registration
|
|
5
|
+
GET /sso/login — SP-initiated SSO redirect
|
|
6
|
+
POST /sso/acs — Assertion Consumer Service (IdP POSTs here)
|
|
7
|
+
POST /sso/slo — Single Logout
|
|
8
|
+
GET /sso/session — Check current SAML session status
|
|
9
|
+
POST /sso/provision — Auto-provision user from SAML attributes
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import uuid
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from typing import Optional
|
|
18
|
+
from urllib.parse import urlencode
|
|
19
|
+
|
|
20
|
+
from fastapi import APIRouter, Request, Response, HTTPException, Depends
|
|
21
|
+
from fastapi.responses import RedirectResponse, HTMLResponse
|
|
22
|
+
from pydantic import BaseModel
|
|
23
|
+
|
|
24
|
+
from ..deps import UserSession, create_access_token, create_refresh_token, get_current_user, require_feature
|
|
25
|
+
from ..saml import get_saml_config, SAMLConfig
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("npm-scan.sso")
|
|
28
|
+
|
|
29
|
+
router = APIRouter(prefix="/sso", tags=["sso"])
|
|
30
|
+
|
|
31
|
+
# In-memory SAML session store (PostgreSQL in production)
|
|
32
|
+
_saml_sessions: dict[str, dict] = {}
|
|
33
|
+
|
|
34
|
+
# User provisioning store (PostgreSQL in production)
|
|
35
|
+
_provisioned_users: dict[str, UserSession] = {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _build_auth_request(config: SAMLConfig) -> tuple[str, str]:
|
|
39
|
+
"""Build SAML AuthnRequest and return (redirect_url, request_id)."""
|
|
40
|
+
import base64
|
|
41
|
+
from xml.etree import ElementTree as ET
|
|
42
|
+
|
|
43
|
+
request_id = "_" + uuid.uuid4().hex
|
|
44
|
+
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
45
|
+
|
|
46
|
+
authn = ET.Element(
|
|
47
|
+
"{urn:oasis:names:tc:SAML:2.0:protocol}AuthnRequest",
|
|
48
|
+
attrib={
|
|
49
|
+
"ID": request_id,
|
|
50
|
+
"Version": "2.0",
|
|
51
|
+
"IssueInstant": now,
|
|
52
|
+
"Destination": config.idp_sso_url,
|
|
53
|
+
"AssertionConsumerServiceURL": config.acs_url,
|
|
54
|
+
"ProtocolBinding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
55
|
+
"ForceAuthn": "false",
|
|
56
|
+
"IsPassive": "false",
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
issuer = ET.SubElement(
|
|
60
|
+
authn,
|
|
61
|
+
"{urn:oasis:names:tc:SAML:2.0:assertion}Issuer",
|
|
62
|
+
)
|
|
63
|
+
issuer.text = config.entity_id
|
|
64
|
+
|
|
65
|
+
nameid = ET.SubElement(
|
|
66
|
+
authn,
|
|
67
|
+
"{urn:oasis:names:tc:SAML:2.0:protocol}NameIDPolicy",
|
|
68
|
+
attrib={
|
|
69
|
+
"Format": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
70
|
+
"AllowCreate": "true",
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
raw = ET.tostring(authn, encoding="unicode")
|
|
75
|
+
encoded = base64.b64encode(raw.encode()).decode()
|
|
76
|
+
|
|
77
|
+
params = urlencode({"SAMLRequest": encoded})
|
|
78
|
+
redirect_url = f"{config.idp_sso_url}?{params}"
|
|
79
|
+
return redirect_url, request_id
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_saml_response(saml_response: str, config: SAMLConfig) -> dict:
|
|
83
|
+
"""Parse and validate a SAML Response.
|
|
84
|
+
|
|
85
|
+
In production this uses python3-saml for full XML sig validation.
|
|
86
|
+
For the skeleton, we extract attributes from the base64-decoded XML.
|
|
87
|
+
"""
|
|
88
|
+
import base64
|
|
89
|
+
from xml.etree import ElementTree as ET
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
decoded = base64.b64decode(saml_response)
|
|
93
|
+
root = ET.fromstring(decoded)
|
|
94
|
+
except Exception as e:
|
|
95
|
+
raise HTTPException(status_code=400, detail=f"Invalid SAML response: {e}")
|
|
96
|
+
|
|
97
|
+
ns = {
|
|
98
|
+
"saml2p": "urn:oasis:names:tc:SAML:2.0:protocol",
|
|
99
|
+
"saml2": "urn:oasis:names:tc:SAML:2.0:assertion",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# Extract attributes from the SAML assertion
|
|
103
|
+
attrs = {}
|
|
104
|
+
for attr_stmt in root.iter("{urn:oasis:names:tc:SAML:2.0:assertion}AttributeStatement"):
|
|
105
|
+
for attr in attr_stmt.iter("{urn:oasis:names:tc:SAML:2.0:assertion}Attribute"):
|
|
106
|
+
name = attr.get("Name", "")
|
|
107
|
+
values = [v.text or "" for v in attr.iter("{urn:oasis:names:tc:SAML:2.0:assertion}AttributeValue")]
|
|
108
|
+
attrs[name] = values[0] if len(values) == 1 else values
|
|
109
|
+
|
|
110
|
+
# Extract NameID
|
|
111
|
+
name_id = None
|
|
112
|
+
for subject in root.iter("{urn:oasis:names:tc:SAML:2.0:assertion}Subject"):
|
|
113
|
+
for nid in subject.iter("{urn:oasis:names:tc:SAML:2.0:assertion}NameID"):
|
|
114
|
+
name_id = nid.text
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
"name_id": name_id,
|
|
119
|
+
"attributes": attrs,
|
|
120
|
+
"issuer": root.findtext(".//saml2:Issuer", "", ns),
|
|
121
|
+
"session_index": root.findtext(".//saml2:AuthnStatement/@SessionIndex", ""),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _provision_user(assertion_data: dict, config: SAMLConfig) -> UserSession:
|
|
126
|
+
"""Create or update a user from SAML attributes.
|
|
127
|
+
|
|
128
|
+
In production this upserts into PostgreSQL. For the skeleton,
|
|
129
|
+
we maintain an in-memory store.
|
|
130
|
+
"""
|
|
131
|
+
mapping = config.attribute_mapping
|
|
132
|
+
attrs = assertion_data.get("attributes", {})
|
|
133
|
+
raw_name_id = assertion_data.get("name_id", "")
|
|
134
|
+
|
|
135
|
+
email = (
|
|
136
|
+
attrs.get(mapping.get("email", "email"))
|
|
137
|
+
or attrs.get("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress")
|
|
138
|
+
or raw_name_id
|
|
139
|
+
or ""
|
|
140
|
+
)
|
|
141
|
+
display_name = (
|
|
142
|
+
attrs.get(mapping.get("name", "displayName"))
|
|
143
|
+
or attrs.get("http://schemas.microsoft.com/identity/claims/displayname")
|
|
144
|
+
or email.split("@")[0]
|
|
145
|
+
or "SAML User"
|
|
146
|
+
)
|
|
147
|
+
groups = attrs.get(mapping.get("groups", "groups"), [])
|
|
148
|
+
if isinstance(groups, str):
|
|
149
|
+
groups = [groups]
|
|
150
|
+
|
|
151
|
+
# Determine role from groups or admin domains
|
|
152
|
+
role = config.default_role
|
|
153
|
+
if isinstance(groups, list):
|
|
154
|
+
group_str = " ".join(groups).lower()
|
|
155
|
+
if "admin" in group_str:
|
|
156
|
+
role = "admin"
|
|
157
|
+
elif "editor" in group_str:
|
|
158
|
+
role = "editor"
|
|
159
|
+
if config.admin_domains:
|
|
160
|
+
email_domain = email.split("@")[-1] if "@" in email else ""
|
|
161
|
+
if email_domain in config.admin_domains:
|
|
162
|
+
role = "admin"
|
|
163
|
+
|
|
164
|
+
user_id = str(uuid.uuid4())
|
|
165
|
+
if raw_name_id:
|
|
166
|
+
_provisioned_users[raw_name_id] = UserSession(
|
|
167
|
+
user_id=user_id,
|
|
168
|
+
email=email,
|
|
169
|
+
name=display_name,
|
|
170
|
+
role=role,
|
|
171
|
+
auth_method="saml",
|
|
172
|
+
idp=assertion_data.get("issuer", "unknown"),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return _provisioned_users.get(raw_name_id, UserSession(
|
|
176
|
+
user_id=user_id,
|
|
177
|
+
email=email,
|
|
178
|
+
name=display_name,
|
|
179
|
+
role=role,
|
|
180
|
+
auth_method="saml",
|
|
181
|
+
))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@router.get("/metadata")
|
|
185
|
+
async def saml_metadata():
|
|
186
|
+
"""Generate SAML 2.0 SP metadata XML for IdP registration."""
|
|
187
|
+
config = get_saml_config()
|
|
188
|
+
if not config.is_configured():
|
|
189
|
+
return HTMLResponse(
|
|
190
|
+
content="<h1>SAML Not Configured</h1><p>Set SAML_IDP_SSO_URL and SAML_IDP_ENTITY_ID environment variables.</p>",
|
|
191
|
+
status_code=200,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
from xml.etree import ElementTree as ET
|
|
195
|
+
|
|
196
|
+
root = ET.Element(
|
|
197
|
+
"{urn:oasis:names:tc:SAML:2.0:metadata}EntityDescriptor",
|
|
198
|
+
attrib={
|
|
199
|
+
"entityID": config.entity_id,
|
|
200
|
+
"xmlns:md": "urn:oasis:names:tc:SAML:2.0:metadata",
|
|
201
|
+
"xmlns:ds": "http://www.w3.org/2000/09/xmldsig#",
|
|
202
|
+
},
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
sp_sso = ET.SubElement(
|
|
206
|
+
root,
|
|
207
|
+
"{urn:oasis:names:tc:SAML:2.0:metadata}SPSSODescriptor",
|
|
208
|
+
attrib={
|
|
209
|
+
"protocolSupportEnumeration": "urn:oasis:names:tc:SAML:2.0:protocol",
|
|
210
|
+
"AuthnRequestsSigned": "true",
|
|
211
|
+
"WantAssertionsSigned": str(config.want_assertions_signed).lower(),
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Key descriptor (if SP cert is configured)
|
|
216
|
+
if config.sp_x509_cert:
|
|
217
|
+
key_desc = ET.SubElement(
|
|
218
|
+
sp_sso,
|
|
219
|
+
"{urn:oasis:names:tc:SAML:2.0:metadata}KeyDescriptor",
|
|
220
|
+
attrib={"use": "signing"},
|
|
221
|
+
)
|
|
222
|
+
key_info = ET.SubElement(
|
|
223
|
+
key_desc,
|
|
224
|
+
"{http://www.w3.org/2000/09/xmldsig#}KeyInfo",
|
|
225
|
+
)
|
|
226
|
+
x509_data = ET.SubElement(
|
|
227
|
+
key_info,
|
|
228
|
+
"{http://www.w3.org/2000/09/xmldsig#}X509Data",
|
|
229
|
+
)
|
|
230
|
+
x509_cert = ET.SubElement(
|
|
231
|
+
x509_data,
|
|
232
|
+
"{http://www.w3.org/2000/09/xmldsig#}X509Certificate",
|
|
233
|
+
)
|
|
234
|
+
x509_cert.text = config.sp_x509_cert.replace("-----BEGIN CERTIFICATE-----", "").replace("-----END CERTIFICATE-----", "").replace("\n", "").strip()
|
|
235
|
+
|
|
236
|
+
# ACS
|
|
237
|
+
acs = ET.SubElement(
|
|
238
|
+
sp_sso,
|
|
239
|
+
"{urn:oasis:names:tc:SAML:2.0:metadata}AssertionConsumerService",
|
|
240
|
+
attrib={
|
|
241
|
+
"index": "0",
|
|
242
|
+
"isDefault": "true",
|
|
243
|
+
"Binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
244
|
+
"Location": config.acs_url,
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
# SLO
|
|
249
|
+
slo = ET.SubElement(
|
|
250
|
+
sp_sso,
|
|
251
|
+
"{urn:oasis:names:tc:SAML:2.0:metadata}SingleLogoutService",
|
|
252
|
+
attrib={
|
|
253
|
+
"Binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
254
|
+
"Location": config.slo_url,
|
|
255
|
+
},
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# NameID format
|
|
259
|
+
nid = ET.SubElement(
|
|
260
|
+
sp_sso,
|
|
261
|
+
"{urn:oasis:names:tc:SAML:2.0:metadata}NameIDFormat",
|
|
262
|
+
)
|
|
263
|
+
nid.text = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
|
|
264
|
+
|
|
265
|
+
raw = ET.tostring(root, encoding="unicode")
|
|
266
|
+
return Response(content=raw, media_type="application/xml")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@router.get("/login")
|
|
270
|
+
async def saml_login():
|
|
271
|
+
"""SP-initiated SSO. Redirects user to IdP with SAML AuthnRequest."""
|
|
272
|
+
config = get_saml_config()
|
|
273
|
+
if not config.is_configured():
|
|
274
|
+
raise HTTPException(
|
|
275
|
+
status_code=501,
|
|
276
|
+
detail="SAML not configured. Set SAML_IDP_SSO_URL and SAML_IDP_ENTITY_ID env vars",
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
redirect_url, request_id = _build_auth_request(config)
|
|
280
|
+
_saml_sessions[request_id] = {"created_at": datetime.now(timezone.utc).isoformat()}
|
|
281
|
+
return RedirectResponse(url=redirect_url)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@router.post("/acs")
|
|
285
|
+
async def saml_acs(request: Request):
|
|
286
|
+
"""Assertion Consumer Service — IdP POSTs SAML Response here after authentication.
|
|
287
|
+
|
|
288
|
+
Returns JWT tokens on success.
|
|
289
|
+
"""
|
|
290
|
+
form = await request.form()
|
|
291
|
+
saml_response = form.get("SAMLResponse")
|
|
292
|
+
relay_state = form.get("RelayState", "/")
|
|
293
|
+
|
|
294
|
+
if not saml_response:
|
|
295
|
+
raise HTTPException(status_code=400, detail="Missing SAMLResponse")
|
|
296
|
+
|
|
297
|
+
config = get_saml_config()
|
|
298
|
+
assertion_data = _parse_saml_response(saml_response, config)
|
|
299
|
+
|
|
300
|
+
if not assertion_data.get("name_id"):
|
|
301
|
+
raise HTTPException(status_code=400, detail="No NameID in SAML assertion")
|
|
302
|
+
|
|
303
|
+
# Provision or look up user
|
|
304
|
+
user = _provision_user(assertion_data, config)
|
|
305
|
+
if not user:
|
|
306
|
+
raise HTTPException(status_code=403, detail="User provisioning failed")
|
|
307
|
+
|
|
308
|
+
# Store SAML session
|
|
309
|
+
_saml_sessions[assertion_data["name_id"]] = {
|
|
310
|
+
"user_id": user.user_id,
|
|
311
|
+
"email": user.email,
|
|
312
|
+
"session_index": assertion_data.get("session_index", ""),
|
|
313
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
# Issue JWT
|
|
317
|
+
access_token = create_access_token(user)
|
|
318
|
+
refresh_token = create_refresh_token(user)
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
"access_token": access_token,
|
|
322
|
+
"refresh_token": refresh_token,
|
|
323
|
+
"token_type": "bearer",
|
|
324
|
+
"user": {
|
|
325
|
+
"id": user.user_id,
|
|
326
|
+
"email": user.email,
|
|
327
|
+
"name": user.name,
|
|
328
|
+
"role": user.role,
|
|
329
|
+
},
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@router.post("/slo")
|
|
334
|
+
async def saml_slo(request: Request):
|
|
335
|
+
"""Single Logout — terminates SAML session and invalidates tokens."""
|
|
336
|
+
form = await request.form()
|
|
337
|
+
logout_request = form.get("SAMLRequest")
|
|
338
|
+
|
|
339
|
+
if logout_request:
|
|
340
|
+
# Decode to find the NameID and clear session
|
|
341
|
+
import base64
|
|
342
|
+
from xml.etree import ElementTree as ET
|
|
343
|
+
try:
|
|
344
|
+
decoded = base64.b64decode(logout_request)
|
|
345
|
+
root = ET.fromstring(decoded)
|
|
346
|
+
ns = {"saml2": "urn:oasis:names:tc:SAML:2.0:assertion"}
|
|
347
|
+
name_id = root.findtext(".//saml2:NameID", "", ns)
|
|
348
|
+
if name_id and name_id in _saml_sessions:
|
|
349
|
+
del _saml_sessions[name_id]
|
|
350
|
+
except Exception:
|
|
351
|
+
pass
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
"status": "logged_out",
|
|
355
|
+
"message": "SAML session terminated",
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@router.get("/session")
|
|
360
|
+
async def saml_session(current_user: UserSession = Depends(get_current_user)):
|
|
361
|
+
"""Current SAML session status."""
|
|
362
|
+
return {
|
|
363
|
+
"authenticated": True,
|
|
364
|
+
"user": {
|
|
365
|
+
"id": current_user.user_id,
|
|
366
|
+
"email": current_user.email,
|
|
367
|
+
"name": current_user.name,
|
|
368
|
+
"role": current_user.role,
|
|
369
|
+
},
|
|
370
|
+
"auth_method": current_user.auth_method,
|
|
371
|
+
"idp": current_user.idp,
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@router.get("/config")
|
|
376
|
+
async def saml_config_status():
|
|
377
|
+
"""Check whether SAML is configured (no auth required)."""
|
|
378
|
+
config = get_saml_config()
|
|
379
|
+
return {
|
|
380
|
+
"configured": config.is_configured(),
|
|
381
|
+
"entity_id": config.entity_id,
|
|
382
|
+
"acs_url": config.acs_url,
|
|
383
|
+
"auto_provision": config.auto_provision,
|
|
384
|
+
"default_role": config.default_role,
|
|
385
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# npm-scan SAML 2.0 Configuration
|
|
2
|
+
# Copy to saml-config.yaml or set via environment variables.
|
|
3
|
+
# Requires enterprise license with 'sso' feature.
|
|
4
|
+
|
|
5
|
+
# --- SP (Service Provider) Identity ---
|
|
6
|
+
sp:
|
|
7
|
+
entity_id: "https://npm-scan.example.com/saml/metadata"
|
|
8
|
+
acs_url: "https://npm-scan.example.com/api/v1/sso/acs"
|
|
9
|
+
slo_url: "https://npm-scan.example.com/api/v1/sso/slo"
|
|
10
|
+
|
|
11
|
+
# Generate: openssl req -x509 -newkey rsa:2048 -keyout sp.key -out sp.crt -days 3650 -nodes
|
|
12
|
+
# private_key: |
|
|
13
|
+
# -----BEGIN PRIVATE KEY-----
|
|
14
|
+
# ...
|
|
15
|
+
# -----END PRIVATE KEY-----
|
|
16
|
+
# x509_cert: |
|
|
17
|
+
# -----BEGIN CERTIFICATE-----
|
|
18
|
+
# ...
|
|
19
|
+
# -----END CERTIFICATE-----
|
|
20
|
+
|
|
21
|
+
# --- IdP (Identity Provider) ---
|
|
22
|
+
idp:
|
|
23
|
+
# Option A: Metadata URL (preferred — auto-discovers all endpoints)
|
|
24
|
+
metadata_url: "https://idp.example.com/metadata"
|
|
25
|
+
|
|
26
|
+
# Option B: Manual configuration
|
|
27
|
+
# entity_id: "https://idp.example.com"
|
|
28
|
+
# sso_url: "https://idp.example.com/saml/sso"
|
|
29
|
+
# slo_url: "https://idp.example.com/saml/slo"
|
|
30
|
+
# x509_cert: |
|
|
31
|
+
# -----BEGIN CERTIFICATE-----
|
|
32
|
+
# ...
|
|
33
|
+
# -----END CERTIFICATE-----
|
|
34
|
+
|
|
35
|
+
# --- Security ---
|
|
36
|
+
security:
|
|
37
|
+
want_assertions_signed: true
|
|
38
|
+
want_response_signed: true
|
|
39
|
+
want_assertions_encrypted: false
|
|
40
|
+
signature_algorithm: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
|
|
41
|
+
digest_algorithm: "http://www.w3.org/2001/04/xmlenc#sha256"
|
|
42
|
+
|
|
43
|
+
# --- User Provisioning ---
|
|
44
|
+
provisioning:
|
|
45
|
+
auto_provision: true
|
|
46
|
+
default_role: viewer
|
|
47
|
+
admin_domains:
|
|
48
|
+
- "example.com"
|
|
49
|
+
attribute_mapping:
|
|
50
|
+
email: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
|
|
51
|
+
name: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/displayname"
|
|
52
|
+
firstName: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname"
|
|
53
|
+
lastName: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname"
|
|
54
|
+
groups: "http://schemas.xmlsoap.org/claims/Group"
|
|
55
|
+
|
|
56
|
+
# --- Session ---
|
|
57
|
+
session:
|
|
58
|
+
duration_hours: 24
|
package/api/saml.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""npm-scan SAML 2.0 Service Provider implementation.
|
|
2
|
+
|
|
3
|
+
Supports:
|
|
4
|
+
- IdP-initiated SSO
|
|
5
|
+
- SP-initiated SSO
|
|
6
|
+
- Signed + encrypted assertions
|
|
7
|
+
- Metadata exchange
|
|
8
|
+
- Single Logout (SLO)
|
|
9
|
+
- Auto-provisioning via attribute mapping
|
|
10
|
+
|
|
11
|
+
Requires enterprise license with 'sso' feature flag.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import json
|
|
16
|
+
from typing import Optional
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class SAMLConfig:
|
|
23
|
+
"""SAML 2.0 configuration loaded from environment / config file."""
|
|
24
|
+
# SP identity
|
|
25
|
+
entity_id: str = field(
|
|
26
|
+
default_factory=lambda: os.environ.get(
|
|
27
|
+
"SAML_SP_ENTITY_ID",
|
|
28
|
+
"https://npm-scan.io/saml/metadata"
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
acs_url: str = field(
|
|
32
|
+
default_factory=lambda: os.environ.get(
|
|
33
|
+
"SAML_ACS_URL",
|
|
34
|
+
"https://npm-scan.io/api/v1/sso/acs"
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
slo_url: str = field(
|
|
38
|
+
default_factory=lambda: os.environ.get(
|
|
39
|
+
"SAML_SLO_URL",
|
|
40
|
+
"https://npm-scan.io/api/v1/sso/slo"
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# IdP metadata (discovery)
|
|
45
|
+
idp_metadata_url: str = field(
|
|
46
|
+
default_factory=lambda: os.environ.get(
|
|
47
|
+
"SAML_IDP_METADATA_URL",
|
|
48
|
+
""
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
idp_metadata_xml: str = field(
|
|
52
|
+
default_factory=lambda: os.environ.get(
|
|
53
|
+
"SAML_IDP_METADATA_XML",
|
|
54
|
+
""
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
idp_entity_id: str = field(
|
|
58
|
+
default_factory=lambda: os.environ.get(
|
|
59
|
+
"SAML_IDP_ENTITY_ID",
|
|
60
|
+
""
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
idp_sso_url: str = field(
|
|
64
|
+
default_factory=lambda: os.environ.get(
|
|
65
|
+
"SAML_IDP_SSO_URL",
|
|
66
|
+
""
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
idp_slo_url: str = field(
|
|
70
|
+
default_factory=lambda: os.environ.get(
|
|
71
|
+
"SAML_IDP_SLO_URL",
|
|
72
|
+
""
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
idp_x509_cert: str = field(
|
|
76
|
+
default_factory=lambda: os.environ.get(
|
|
77
|
+
"SAML_IDP_X509_CERT",
|
|
78
|
+
""
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# SP private key for decryption + signing
|
|
83
|
+
sp_private_key: str = field(
|
|
84
|
+
default_factory=lambda: os.environ.get(
|
|
85
|
+
"SAML_SP_PRIVATE_KEY",
|
|
86
|
+
""
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
sp_x509_cert: str = field(
|
|
90
|
+
default_factory=lambda: os.environ.get(
|
|
91
|
+
"SAML_SP_X509_CERT",
|
|
92
|
+
""
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Security settings
|
|
97
|
+
want_assertions_signed: bool = True
|
|
98
|
+
want_response_signed: bool = True
|
|
99
|
+
want_assertions_encrypted: bool = False
|
|
100
|
+
signature_algorithm: str = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
|
|
101
|
+
digest_algorithm: str = "http://www.w3.org/2001/04/xmlenc#sha256"
|
|
102
|
+
|
|
103
|
+
# User provisioning
|
|
104
|
+
auto_provision: bool = True
|
|
105
|
+
default_role: str = "viewer"
|
|
106
|
+
admin_domains: list[str] = field(default_factory=list)
|
|
107
|
+
attribute_mapping: dict[str, str] = field(default_factory=lambda: {
|
|
108
|
+
"email": "email",
|
|
109
|
+
"name": "displayName",
|
|
110
|
+
"firstName": "firstName",
|
|
111
|
+
"lastName": "lastName",
|
|
112
|
+
"groups": "groups",
|
|
113
|
+
"role": "Role",
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
# Session
|
|
117
|
+
session_duration_hours: int = 24
|
|
118
|
+
|
|
119
|
+
def to_onelogin_settings(self) -> dict:
|
|
120
|
+
"""Convert to python3-saml settings dict."""
|
|
121
|
+
settings = {
|
|
122
|
+
"strict": True,
|
|
123
|
+
"debug": os.environ.get("SAML_DEBUG", "false").lower() == "true",
|
|
124
|
+
"sp": {
|
|
125
|
+
"entityId": self.entity_id,
|
|
126
|
+
"assertionConsumerService": {
|
|
127
|
+
"url": self.acs_url,
|
|
128
|
+
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
129
|
+
},
|
|
130
|
+
"singleLogoutService": {
|
|
131
|
+
"url": self.slo_url,
|
|
132
|
+
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
133
|
+
},
|
|
134
|
+
"NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
135
|
+
"x509cert": self.sp_x509_cert or "",
|
|
136
|
+
"privateKey": self.sp_private_key or "",
|
|
137
|
+
},
|
|
138
|
+
"idp": {
|
|
139
|
+
"entityId": self.idp_entity_id,
|
|
140
|
+
"singleSignOnService": {
|
|
141
|
+
"url": self.idp_sso_url,
|
|
142
|
+
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
143
|
+
},
|
|
144
|
+
"singleLogoutService": {
|
|
145
|
+
"url": self.idp_slo_url or self.idp_sso_url,
|
|
146
|
+
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
147
|
+
},
|
|
148
|
+
"x509cert": self.idp_x509_cert,
|
|
149
|
+
},
|
|
150
|
+
"security": {
|
|
151
|
+
"wantAssertionsSigned": self.want_assertions_signed,
|
|
152
|
+
"wantResponseSigned": self.want_response_signed,
|
|
153
|
+
"wantAssertionsEncrypted": self.want_assertions_encrypted,
|
|
154
|
+
"signatureAlgorithm": self.signature_algorithm,
|
|
155
|
+
"digestAlgorithm": self.digest_algorithm,
|
|
156
|
+
"nameIdEncrypted": False,
|
|
157
|
+
"authnRequestsSigned": True,
|
|
158
|
+
"logoutRequestSigned": True,
|
|
159
|
+
"logoutResponseSigned": True,
|
|
160
|
+
"signMetadata": bool(self.sp_private_key),
|
|
161
|
+
"requestedAuthnContext": True,
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
return settings
|
|
165
|
+
|
|
166
|
+
def is_configured(self) -> bool:
|
|
167
|
+
"""Check if SAML has enough config to operate."""
|
|
168
|
+
return bool(self.idp_sso_url and self.idp_entity_id) or bool(self.idp_metadata_url or self.idp_metadata_xml)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# Global singleton
|
|
172
|
+
_config: Optional[SAMLConfig] = None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def get_saml_config() -> SAMLConfig:
|
|
176
|
+
global _config
|
|
177
|
+
if _config is None:
|
|
178
|
+
_config = SAMLConfig()
|
|
179
|
+
return _config
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def set_saml_config(cfg: SAMLConfig) -> None:
|
|
183
|
+
global _config
|
|
184
|
+
_config = cfg
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lateos/npm-scan",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Powerful npm supply chain security scanner - detects malicious packages (Shai-Hulud style), behavioral analysis, SBOM, and compliance reporting.",
|
|
5
5
|
"main": "backend/index.js",
|
|
6
6
|
"bin": {
|