Skip to content
Open
10 changes: 10 additions & 0 deletions backend/iam/sso/saml/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@
views.FinishACSView.as_view(),
name="saml_finish_acs",
),
path(
"generate-keys/",
views.GenerateSAMLKeyView.as_view(),
name="generate_saml_keys",
),
path(
"download-cert/",
views.DownloadSAMLPublicCertView.as_view(),
name="download_saml_cert",
),
]
),
)
Expand Down
116 changes: 108 additions & 8 deletions backend/iam/sso/saml/views.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,33 @@
from allauth.account.models import EmailAddress
# === Python standard library ===
import json
from datetime import datetime, timedelta
from django.http import HttpRequest, HttpResponseRedirect, HttpResponse
from django.http.response import Http404
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views import View

# === Third-party packages ===
import structlog
from rest_framework.views import APIView
from django.views.decorators.csrf import csrf_exempt
from rest_framework.response import Response
from rest_framework import status

from cryptography import x509
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID

from allauth.account.models import EmailAddress
from allauth.core.exceptions import SignupClosedException
from allauth.socialaccount.adapter import get_account_adapter
from allauth.socialaccount.internal.flows.login import (
pre_social_login,
record_authentication,
)
from allauth.socialaccount.models import PermissionDenied, SocialLogin
from allauth.socialaccount.providers.saml.provider import SAMLProvider
from allauth.socialaccount.providers.saml.views import (
AuthProcess,
LoginSession,
Expand All @@ -18,19 +39,15 @@
httpkit,
render_authentication_error,
)
from allauth.socialaccount.providers.saml.provider import SAMLProvider
from allauth.utils import ValidationError
from django.http import HttpRequest, HttpResponseRedirect
from django.http.response import Http404
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views import View
from rest_framework.views import csrf_exempt

# === Application-specific imports ===
from core.permissions import IsAdministrator # ou une permission plus adaptée
from iam.models import User
from iam.sso.errors import AuthError
from iam.sso.models import SSOSettings
from iam.utils import generate_token
from global_settings.models import GlobalSettings

DEFAULT_SAML_ATTRIBUTE_MAPPING_EMAIL = SAMLProvider.default_attribute_mapping["email"]

Expand Down Expand Up @@ -192,3 +209,86 @@ def dispatch(self, request, organization_slug):
email_object.save()
logger.info("Email verified", user=user)
return HttpResponseRedirect(next_url)


class GenerateSAMLKeyView(SAMLViewMixin, APIView):
"""
Endpoint to generate a key pair (private key + self-signed X.509 certificate).
Accessible only to admins (to be adapted as needed).
"""

permission_classes = [IsAdministrator]

def post(self, request, organization_slug):
try:
data = json.loads(request.body)
except json.JSONDecodeError:
data = {}
cn = data.get("common_name", "saml-sp.example.com")
days = int(data.get("days", 365))

# RSA key generation
key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)

# Self-signed certificate generation
subject = issuer = x509.Name(
[
x509.NameAttribute(NameOID.COMMON_NAME, cn),
]
)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.utcnow())
.not_valid_after(datetime.utcnow() + timedelta(days=days))
.sign(private_key=key, algorithm=hashes.SHA256())
)

private_key_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(), # protect if needed
)

cert_pem = cert.public_bytes(serialization.Encoding.PEM)

provider = self.get_provider(organization_slug)
# Retrieves the 'advanced' dictionary, or creates it if it doesn't exist
advanced_settings = provider.app.settings.get("advanced", {})
advanced_settings["private_key"] = private_key_pem.decode("utf-8")
advanced_settings["x509cert"] = cert_pem.decode("utf-8")

# Re-injects the dict into the application configuration
settings = GlobalSettings.objects.get(name=GlobalSettings.Names.SSO)
settings.value["settings"]["advanced"] = advanced_settings
settings.save()

return Response(
{
"message": f"Key and certificate saved in advanced settings of SP {organization_slug}",
"cert": cert_pem.decode("utf-8"),
},
status=status.HTTP_201_CREATED,
)


class DownloadSAMLPublicCertView(SAMLViewMixin, APIView):
permission_classes = [IsAdministrator]

def get(self, request, organization_slug):
provider = self.get_provider(organization_slug)
cert_pem = provider.app.settings.get("advanced", {}).get("x509cert")
if not cert_pem:
return HttpResponse(status=404)

response = HttpResponse(cert_pem, content_type="application/x-pem-file")
response["Content-Disposition"] = (
'attachment; filename="ciso-saml-public-cert.pem"'
)
return response
27 changes: 26 additions & 1 deletion backend/iam/sso/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,20 @@ class SSOSettingsWriteSerializer(BaseModelSerializer):
required=False,
source="settings.advanced.want_name_id_encrypted",
)
sp_x509cert = serializers.CharField(
required=False,
allow_blank=True,
allow_null=True,
source="settings.advanced.x509cert",
)
sp_private_key = serializers.CharField(
required=False,
allow_blank=True,
allow_null=True,
trim_whitespace=False,
source="settings.advanced.private_key",
write_only=True,
)
oidc_has_secret = serializers.SerializerMethodField()

def get_oidc_has_secret(self, obj) -> bool:
Expand All @@ -227,10 +241,21 @@ class Meta:
def update(self, instance, validated_data):
settings_object = GlobalSettings.objects.get(name=GlobalSettings.Names.SSO)

# Use stored secret if no secret is transmitted
# Use stored secret and sp_private_key if no transmitted
validated_data["secret"] = validated_data.get(
"secret", settings_object.value.get("secret", "")
)
validated_data["settings"]["advanced"]["private_key"] = (
validated_data.get("settings", {})
.get("advanced", {})
.get(
"private_key",
settings_object.value.get("settings", {})
.get("advanced", {})
.get("private_key", ""),
)
)

validated_data["provider_id"] = validated_data.get("provider", "n/a")
if "settings" not in validated_data:
validated_data["settings"] = {}
Expand Down
4 changes: 2 additions & 2 deletions backend/iam/sso/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ def post(self, request, *args, **kwargs):
next_url=next_url,
headless=True,
)
except:
logger.error("Cannot perform redirection, Check your IdP URLs")
except Exception as e:
logger.error("SSO redirection failed", provider=provider.id, error=str(e))
return render_authentication_error(request, provider, error="failedSSO")


Expand Down
9 changes: 8 additions & 1 deletion frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2206,5 +2206,12 @@
"libraryOverview": "{objectType}: {count}",
"evidenceRevisions": "Revisions",
"addEvidenceRevision": "Add Revision",
"associatedEvidenceRevisions": "Associated versions"
"associatedEvidenceRevisions": "Associated versions",
"privateKey": "Private Key",
"generateDots": "Generate...",
"samlKeysGenerated": "Saml keys successfully generated",
"samlPrivateKeyHelpText": "Write-only: use this field only if you need to supply your own private key/certificate. Otherwise, the application will generate it.",
"samlCertificateHelpText": "You can specify your own certificate or let the application generate it.",
"samlAuthnRequestSignedHelpText": "When enabled, the SAML authentication request is signed with the private key below. The corresponding public certificate is sent to the Identity Provider so it can verify the signature.",
"downloadCertificate": "Download certificate"
}
77 changes: 70 additions & 7 deletions frontend/src/lib/components/Forms/ModelForm/SsoSettingForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
import { m } from '$paraglide/messages';
import { Accordion } from '@skeletonlabs/skeleton-svelte';
import type { SuperValidated } from 'sveltekit-superforms';
import { enhance } from '$app/forms';
import Anchor from '$lib/components/Anchor/Anchor.svelte';
interface Props {
form: SuperValidated<any>;
model: ModelInfo;
Expand Down Expand Up @@ -37,6 +40,26 @@
let openAccordionItems = $state(['saml', 'idp', 'sp']);
let showSecretField = $state(!page.data?.ssoSettings.oidc_has_secret);
let isGenerating = $state(false);
const handleGenerateKeys = ({ cancel }) => {
if (!data.is_enabled || !data.authn_request_signed) {
cancel();
return;
}
isGenerating = true;
return async ({ result, update }) => {
isGenerating = false;
if (result.type === 'success' && result.data?.generatedKeys?.cert) {
$formStore.sp_x509cert = result.data.generatedKeys.cert;
}
await update();
};
};
</script>

<Accordion
Expand Down Expand Up @@ -256,13 +279,6 @@
label={m.allowSingleLabelDomains()}
disabled={!data.is_enabled}
/>
<Checkbox
{form}
field="authn_request_signed"
hidden
label={m.authnRequestSigned()}
disabled={!data.is_enabled}
/>
<TextField
{form}
field="digest_algorithm"
Expand Down Expand Up @@ -355,6 +371,53 @@
label={m.wantNameIDEncrypted()}
disabled={!data.is_enabled}
/>
<Checkbox
{form}
field="authn_request_signed"
helpText={m.samlAuthnRequestSignedHelpText()}
label={m.authnRequestSigned()}
disabled={!data.is_enabled}
/>
<div class="w-full p-4 flex flex-col gap-4 border rounded-md mt-1">
<div class="flex flex-row gap-4 items-center">
<form method="post" action="?/generateSamlKeys" use:enhance={handleGenerateKeys}>
<button
type="submit"
disabled={!data.is_enabled || !data.authn_request_signed}
class="btn preset-filled-secondary-500">{m.generateDots()}</button
>
</form>
{#if data.is_enabled && data.authn_request_signed}
<Anchor
href="settings/saml/download-cert"
class="anchor text-secondary-500"
disabled={!data.is_enabled || !data.authn_request_signed}
>
{m.downloadCertificate()}
</Anchor>
{/if}
</div>
<div class="w-full grid grid-cols-1 md:grid-cols-2 gap-4">
<TextArea
{form}
field="sp_x509cert"
label={m.x509Cert()}
helpText={m.samlCertificateHelpText()}
disabled={!data.is_enabled || !data.authn_request_signed}
cacheLock={cacheLocks['sp_x509cert']}
bind:cachedValue={formDataCache['sp_x509cert']}
/>
<TextArea
{form}
field="sp_private_key"
label={m.privateKey()}
helpText={m.samlPrivateKeyHelpText()}
disabled={!data.is_enabled || !data.authn_request_signed}
cacheLock={cacheLocks['sp_private_key']}
bind:cachedValue={formDataCache['sp_private_key']}
/>
</div>
</div>
{/snippet}
</Accordion.Item>
{/if}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/components/Forms/TextField.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@
});
</script>

<div class={classesContainer}>
<div class={classesContainer} {hidden}>
<div class={classesDisabled(disabled)}>
{#if label !== undefined && !hidden}
{#if $constraints?.required || required}
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/utils/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,8 @@ export const SSOSettingsSchema = z.object({
want_message_signed: z.boolean().optional().nullable(),
want_name_id: z.boolean().optional().nullable(),
want_name_id_encrypted: z.boolean().optional().nullable(),
sp_x509cert: z.string().optional(),
sp_private_key: z.string().optional(),
server_url: z.string().optional().nullable(),
token_auth_method: z
.enum([
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/routes/(app)/(internal)/settings/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,5 +177,18 @@ export const actions: Actions = {
setFlash({ type: 'success', message: m.featureFlagSettingsUpdated() }, event);

return { form };
},
generateSamlKeys: async (event) => {
const response = await event.fetch(`${BASE_API_URL}/accounts/saml/0/generate-keys/`, {
method: 'POST'
});

if (!response.ok) return fail(500, { error: 'Generation failed' });

const { cert } = await response.json();

setFlash({ type: 'success', message: m.samlKeysGenerated() }, event);

return { generatedKeys: { cert } };
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { BASE_API_URL } from '$lib/utils/constants';

import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ fetch, params }) => {
const endpoint = `${BASE_API_URL}/accounts/saml/0/download-cert`;

const res = await fetch(endpoint);
if (!res.ok) {
error(400, 'Error fetching the cert');
}

const cert = await res.text();

return new Response(cert, {
headers: {
'Content-Type': 'application/x-pem-file',
'Content-Disposition': 'attachment; filename="saml-public-cert.pem"'
}
});
};
Loading