@lateos/npm-scan 0.4.1 → 0.6.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 +12 -5
- package/api/README.md +80 -0
- package/api/__init__.py +0 -0
- package/api/api_keys.py +55 -0
- package/api/deps.py +164 -0
- package/api/main.py +44 -0
- package/api/requirements.txt +9 -0
- package/api/routers/__init__.py +0 -0
- package/api/routers/auth.py +80 -0
- package/api/routers/health.py +10 -0
- package/api/routers/scans.py +66 -0
- package/api/routers/sso.py +385 -0
- package/api/routers/webhooks.py +78 -0
- package/api/saml-config.yaml +58 -0
- package/api/saml.py +184 -0
- package/backend/db/pg-schema.sql +155 -0
- package/backend/license.js +76 -4
- package/cli/cli.js +26 -8
- package/deploy/helm/npm-scan/Chart.yaml +16 -0
- package/deploy/helm/npm-scan/templates/_helpers.tpl +9 -0
- package/deploy/helm/npm-scan/templates/api.yaml +66 -0
- package/deploy/helm/npm-scan/templates/ingress.yaml +28 -0
- package/deploy/helm/npm-scan/templates/postgresql.yaml +67 -0
- package/deploy/helm/npm-scan/templates/secrets.yaml +19 -0
- package/deploy/helm/npm-scan/templates/worker.yaml +32 -0
- package/deploy/helm/npm-scan/values.yaml +73 -0
- package/package.json +1 -1
|
@@ -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,78 @@
|
|
|
1
|
+
"""Webhook endpoints — register, list, delete webhooks."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException
|
|
4
|
+
from pydantic import BaseModel, HttpUrl
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
router = APIRouter()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WebhookCreate(BaseModel):
|
|
16
|
+
url: HttpUrl
|
|
17
|
+
events: List[str] = ["scan.completed", "finding.critical"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Webhook(BaseModel):
|
|
21
|
+
id: str
|
|
22
|
+
url: str
|
|
23
|
+
events: List[str]
|
|
24
|
+
active: bool = True
|
|
25
|
+
secret: Optional[str] = None
|
|
26
|
+
created_at: datetime
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
HOOKS_DB: list[Webhook] = []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.post("/webhooks", status_code=201)
|
|
33
|
+
async def create_webhook(hook: WebhookCreate):
|
|
34
|
+
"""Register a new webhook endpoint."""
|
|
35
|
+
wh = Webhook(
|
|
36
|
+
id=hash(str(hook.url) + str(datetime.now())),
|
|
37
|
+
url=str(hook.url),
|
|
38
|
+
events=list(set(hook.events)),
|
|
39
|
+
secret=os.urandom(16).hex(),
|
|
40
|
+
created_at=datetime.now(),
|
|
41
|
+
)
|
|
42
|
+
HOOKS_DB.append(wh)
|
|
43
|
+
return wh
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@router.get("/webhooks")
|
|
47
|
+
async def list_webhooks():
|
|
48
|
+
"""List all registered webhooks."""
|
|
49
|
+
return HOOKS_DB
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.delete("/webhooks/{hook_id}")
|
|
53
|
+
async def delete_webhook(hook_id: str):
|
|
54
|
+
"""Delete a webhook by ID."""
|
|
55
|
+
for i, wh in enumerate(HOOKS_DB):
|
|
56
|
+
if wh.id == hook_id:
|
|
57
|
+
HOOKS_DB.pop(i)
|
|
58
|
+
return {"deleted": hook_id}
|
|
59
|
+
raise HTTPException(status_code=404, detail="Webhook not found")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def dispatch_webhooks(event: str, payload: dict):
|
|
63
|
+
"""Dispatch an event to all subscribed webhooks (called by worker)."""
|
|
64
|
+
import httpx
|
|
65
|
+
|
|
66
|
+
for wh in HOOKS_DB:
|
|
67
|
+
if not wh.active or event not in wh.events:
|
|
68
|
+
continue
|
|
69
|
+
body = json.dumps({"event": event, "payload": payload, "timestamp": datetime.now().isoformat()})
|
|
70
|
+
sig = hmac.new(wh.secret.encode(), body.encode(), hashlib.sha256).hexdigest()
|
|
71
|
+
async with httpx.AsyncClient() as client:
|
|
72
|
+
try:
|
|
73
|
+
await client.post(wh.url, content=body, headers={
|
|
74
|
+
"Content-Type": "application/json",
|
|
75
|
+
"X-Webhook-Signature": sig,
|
|
76
|
+
})
|
|
77
|
+
except Exception:
|
|
78
|
+
pass # log failure in production
|
|
@@ -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
|