Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions fvserver/saml_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""
SAML SSO Configuration for Crypt-Server

This module provides SAML 2.0 Single Sign-On support using djangosaml2 and pysaml2.
Configuration is entirely environment variable-based for flexibility.

Supported Identity Providers:
- Microsoft Entra ID (Azure AD)
- Okta
- OneLogin
- Any SAML 2.0 compliant IdP

Enable by setting SAML_ENABLED=true in environment variables.
"""
import os
import saml2
import saml2.saml

# SP Configuration from environment
HOST_NAME = os.environ.get('HOST_NAME', 'https://localhost').rstrip('/')
SAML_SP_ENTITY_ID = os.environ.get('SAML_SP_ENTITY_ID', f'{HOST_NAME}/saml/metadata')
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default SAML_SP_ENTITY_ID is missing the trailing slash (/saml/metadata vs /saml/metadata/). djangosaml2’s metadata endpoint (and the PR description) uses /saml/metadata/, so this default entity ID is likely to mismatch what the SP actually serves and what IdPs will store. Consider defaulting to ${HOST_NAME}/saml/metadata/ (or otherwise ensuring it exactly matches the routed metadata URL).

Suggested change
SAML_SP_ENTITY_ID = os.environ.get('SAML_SP_ENTITY_ID', f'{HOST_NAME}/saml/metadata')
SAML_SP_ENTITY_ID = os.environ.get('SAML_SP_ENTITY_ID', f'{HOST_NAME}/saml/metadata/')

Copilot uses AI. Check for mistakes.

# IdP Configuration from environment
SAML_IDP_ENTITY_ID = os.environ.get('SAML_IDP_ENTITY_ID', '')
SAML_IDP_SSO_URL = os.environ.get('SAML_IDP_SSO_URL', '')
SAML_IDP_SLO_URL = os.environ.get('SAML_IDP_SLO_URL', '')
SAML_METADATA_URL = os.environ.get('SAML_METADATA_URL', '')

# Security options
SAML_WANT_ASSERTIONS_SIGNED = os.environ.get('SAML_WANT_ASSERTIONS_SIGNED', 'true').lower() == 'true'
SAML_WANT_RESPONSE_SIGNED = os.environ.get('SAML_WANT_RESPONSE_SIGNED', 'false').lower() == 'true'

# Debug level (0=off, 1=on)
SAML_DEBUG = 1 if os.environ.get('SAML_LOG_LEVEL', '').upper() == 'DEBUG' else 0

# Build metadata configuration
# Prefer remote metadata URL if provided, otherwise use inline IdP config
_metadata_config = {}
if SAML_METADATA_URL:
_metadata_config = {
'remote': [
{'url': SAML_METADATA_URL},
],
}

# SAML Configuration Dictionary for pysaml2
SAML_CONFIG = {
'entityid': SAML_SP_ENTITY_ID,
'service': {
'sp': {
'name': 'Crypt Server',
'name_id_format': saml2.saml.NAMEID_FORMAT_EMAILADDRESS,
'endpoints': {
'assertion_consumer_service': [
(f'{HOST_NAME}/saml/acs/', saml2.BINDING_HTTP_POST),
],
'single_logout_service': [
(f'{HOST_NAME}/saml/sls/', saml2.BINDING_HTTP_REDIRECT),
(f'{HOST_NAME}/saml/sls/', saml2.BINDING_HTTP_POST),
],
},
'required_attributes': ['email'],
'optional_attributes': ['givenName', 'surname', 'displayName'],
'want_assertions_signed': SAML_WANT_ASSERTIONS_SIGNED,
'want_response_signed': SAML_WANT_RESPONSE_SIGNED,
'allow_unsolicited': True,
'idp': {
Comment on lines +64 to +67
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allow_unsolicited is set to True by default. Accepting unsolicited assertions generally weakens SSO security because it allows IdP-initiated flows without a prior AuthnRequest and can make some replay/phishing scenarios easier. Consider defaulting this to False and making it explicitly opt-in via an environment variable.

Copilot uses AI. Check for mistakes.
SAML_IDP_ENTITY_ID: {
'single_sign_on_service': {
saml2.BINDING_HTTP_REDIRECT: SAML_IDP_SSO_URL,
saml2.BINDING_HTTP_POST: SAML_IDP_SSO_URL,
},
'single_logout_service': {
saml2.BINDING_HTTP_REDIRECT: SAML_IDP_SLO_URL,
},
},
} if SAML_IDP_ENTITY_ID else {},
},
},
'metadata': _metadata_config,
'debug': SAML_DEBUG,
}

# djangosaml2 specific settings
# Attribute mapping: Maps SAML attributes to Django user model fields
# Microsoft Entra ID sends email in 'name' attribute by default
# Okta/OneLogin typically use 'email' attribute
SAML_ATTRIBUTE_MAPPING = {
'name': ('email', 'username'), # Microsoft Entra ID
'email': ('email', 'username'), # Okta, OneLogin
'givenName': ('first_name',),
'surname': ('last_name',),
'firstName': ('first_name',), # Okta format
'lastName': ('last_name',), # Okta format
}

# User provisioning settings
SAML_CREATE_UNKNOWN_USER = True
SAML_DJANGO_USER_MAIN_ATTRIBUTE = 'username'
SAML_USE_NAME_ID_AS_USERNAME = False # Use email from attributes, not NameID
SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP = '__iexact'

# Session settings
SESSION_EXPIRE_AT_BROWSER_CLOSE = True

# URL configuration
LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/'
LOGIN_URL = '/saml/login/'
Comment on lines +103 to +109
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These URL settings (LOGIN_REDIRECT_URL, LOGOUT_REDIRECT_URL, LOGIN_URL) are defined in saml_settings.py but are not imported/exported into the actual Django settings module (system_settings.py). As written, they won’t take effect and may mislead operators reading this file. Consider removing them from this module or wiring them through system_settings.py when SAML_ENABLED is true.

Suggested change
# Session settings
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
# URL configuration
LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/'
LOGIN_URL = '/saml/login/'
# Session settings (Django session behavior should be configured in the main settings module)
SESSION_EXPIRE_AT_BROWSER_CLOSE = True

Copilot uses AI. Check for mistakes.
40 changes: 40 additions & 0 deletions fvserver/system_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,43 @@
}

DEFAULT_AUTO_FIELD = "django.db.models.AutoField"

# SAML SSO Configuration (optional)
# Enable by setting SAML_ENABLED=true in environment
SAML_ENABLED = os.environ.get("SAML_ENABLED", "false").lower() == "true"

if SAML_ENABLED:
# Import SAML configuration
from fvserver.saml_settings import (
SAML_CONFIG,
SAML_ATTRIBUTE_MAPPING,
SAML_CREATE_UNKNOWN_USER,
SAML_DJANGO_USER_MAIN_ATTRIBUTE,
SAML_USE_NAME_ID_AS_USERNAME,
SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP,
SESSION_EXPIRE_AT_BROWSER_CLOSE,
)

# Add djangosaml2 to installed apps
INSTALLED_APPS = INSTALLED_APPS + ("djangosaml2",)

# Add SAML session middleware
MIDDLEWARE.append("djangosaml2.middleware.SamlSessionMiddleware")

# Add SAML authentication backend
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"djangosaml2.backends.Saml2Backend",
]

# Override login URL to use SAML
LOGIN_URL = "/saml/login/"
Comment on lines +216 to +217
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When SAML is enabled, this overrides Django's LOGIN_URL to /saml/login/. That means any @login_required view will redirect to SAML rather than the existing /login/ form, which conflicts with the PR description that local Django login “continues to work alongside SAML”. Consider leaving LOGIN_URL as /login/ and exposing SAML only via /saml/login/, or gate this behavior behind a separate env flag (e.g., SAML_DEFAULT_LOGIN=true).

Copilot uses AI. Check for mistakes.

# Export SAML settings for djangosaml2
globals()["SAML_CONFIG"] = SAML_CONFIG
globals()["SAML_ATTRIBUTE_MAPPING"] = SAML_ATTRIBUTE_MAPPING
globals()["SAML_CREATE_UNKNOWN_USER"] = SAML_CREATE_UNKNOWN_USER
globals()["SAML_DJANGO_USER_MAIN_ATTRIBUTE"] = SAML_DJANGO_USER_MAIN_ATTRIBUTE
globals()["SAML_USE_NAME_ID_AS_USERNAME"] = SAML_USE_NAME_ID_AS_USERNAME
globals()["SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP"] = SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP
globals()["SESSION_EXPIRE_AT_BROWSER_CLOSE"] = SESSION_EXPIRE_AT_BROWSER_CLOSE
6 changes: 6 additions & 0 deletions fvserver/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

# Uncomment the next two lines to enable the admin:
from django.contrib import admin
from django.conf import settings

# admin.autodiscover()
import django.contrib.auth.views as auth_views
Expand Down Expand Up @@ -29,3 +30,8 @@
# Uncomment the next line to enable the admin:
path("admin/", admin.site.urls),
]

# Add SAML URLs if SAML is enabled
if getattr(settings, "SAML_ENABLED", False):
import djangosaml2.urls
urlpatterns.insert(0, path("saml/", include(djangosaml2.urls)))
2 changes: 2 additions & 0 deletions setup/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ django-debug-toolbar==3.7.0
django-encrypted-model-fields==0.6.5
django-extensions==3.2.1
django-iam-dbauth==0.1.4
djangosaml2==1.9.3
docutils==0.19
flake8==5.0.4
gunicorn==22.0.0
Expand All @@ -32,6 +33,7 @@ pycparser==2.21
pycrypto==2.6.1
pyflakes==2.5.0
pylint==2.15.4
pysaml2==7.5.0
pytz==2022.5
regex==2022.9.13
selenium==4.15.1
Expand Down
Loading