@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.
@@ -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