Single Sign-On implementieren: SAML, OAuth, LDAP
Security & Authentication

Single Sign-On implementieren: SAML, OAuth, LDAP

Carola Schulte
Invalid Date
24 min

“Unsere Mitarbeiter haben 12 verschiedene Logins. Das muss doch einfacher gehen.” - Ja, muss es. Und ja, geht es. Single Sign-On (SSO) ist keine Raketenwissenschaft, aber auch kein Wochenend-Projekt. Nach 50+ SSO-Integrationen zeige ich Ihnen, welches Protokoll wann passt, wie die Implementierung aussieht und wo die Fallstricke liegen.

Die harte Wahrheit vorweg: 80% der SSO-Projekte scheitern nicht an der Technik, sondern an der Koordination mit der IT-Abteilung des Kunden. Der Code ist das kleinste Problem.


Quick-Navigation: Welches Protokoll für Sie?

SituationEmpfehlungWarum
Konzern mit Azure ADSAML 2.0Standard in Enterprise, IT kennt es
SaaS-Produkt mit Google/Microsoft LoginOAuth 2.0 / OIDCEinfach für User, schnell implementiert
On-Premise mit Active DirectoryLDAPDirekte AD-Anbindung, kein IdP nötig
Hybrid (Cloud + On-Prem)SAML + LDAP FallbackFlexibel, zukunftssicher

Zur Entscheidungsmatrix


Die drei Protokolle im Vergleich

SAML 2.0 - Der Enterprise-Standard

Security Assertion Markup Language - XML-basiert, seit 2005, der De-facto-Standard in Konzernen.

┌──────────────┐         ┌──────────────┐         ┌──────────────┐
│    User      │         │  Service     │         │   Identity   │
│  (Browser)   │         │  Provider    │         │   Provider   │
│              │         │  (Ihre App)  │         │  (Azure AD)  │
└──────────────┘         └──────────────┘         └──────────────┘
       │                        │                        │
       │  1. Zugriff auf App    │                        │
       │───────────────────────>│                        │
       │                        │                        │
       │  2. Redirect zu IdP    │                        │
       │<───────────────────────│                        │
       │                        │                        │
       │  3. Login beim IdP     │                        │
       │────────────────────────────────────────────────>│
       │                        │                        │
       │  4. SAML Response      │                        │
       │<────────────────────────────────────────────────│
       │                        │                        │
       │  5. POST Response      │                        │
       │───────────────────────>│                        │
       │                        │                        │
       │  6. Session erstellt   │                        │
       │<───────────────────────│                        │

Vorteile:

  • Jede Enterprise-IT kennt es
  • Azure AD, Okta, OneLogin - alle unterstützen es
  • Keine Passwörter in Ihrer App
  • Attribute Mapping (Rollen, Abteilung, etc.)

Nachteile:

  • XML-Hölle (Signaturen, Namespaces)
  • Debugging ist schmerzhaft
  • Zertifikatsmanagement nötig

OAuth 2.0 vs. OpenID Connect - Der Unterschied

Klassisches Missverständnis: OAuth 2.0 und OIDC sind NICHT dasselbe!

OAuth 2.0OpenID Connect (OIDC)
ZweckAutorisierung (Zugriff auf Ressourcen)Authentifizierung (Wer ist der User?)
TokenAccess TokenAccess Token + ID Token
Antwort auf”Darf diese App auf meine Daten?""Wer bin ich?”
User-InfoMuss extra geholt werdenIm ID Token enthalten

OIDC = OAuth 2.0 + Identity Layer (ID Token)

Wenn Sie “Login mit Google/Microsoft” bauen, nutzen Sie OIDC (nicht nur OAuth). Das ID Token ist ein JWT mit User-Informationen (sub, email, name).

┌──────────────┐         ┌──────────────┐         ┌──────────────┐
│    User      │         │  Ihre App    │         │   Google/    │
│  (Browser)   │         │  (Client)    │         │   Microsoft  │
└──────────────┘         └──────────────┘         └──────────────┘
       │                        │                        │
       │  1. "Login mit Google" │                        │
       │───────────────────────>│                        │
       │                        │                        │
       │  2. Redirect zu Google │                        │
       │<───────────────────────│                        │
       │                        │                        │
       │  3. Login + Consent    │                        │
       │────────────────────────────────────────────────>│
       │                        │                        │
       │  4. Redirect mit Code  │                        │
       │<────────────────────────────────────────────────│
       │                        │                        │
       │  5. Code an App        │                        │
       │───────────────────────>│                        │
       │                        │  6. Code gegen Token   │
       │                        │───────────────────────>│
       │                        │                        │
       │                        │  7. Access + ID Token  │
       │                        │<───────────────────────│
       │  8. Session erstellt   │                        │
       │<───────────────────────│                        │

Vorteile:

  • JSON statt XML
  • Moderne Libraries
  • “Login mit Google/Microsoft” in 2 Stunden
  • Gut für SaaS und Consumer-Apps

Nachteile:

  • Weniger Attribute als SAML
  • Nicht jede Enterprise-IT akzeptiert es
  • Token-Management komplexer

LDAP/Active Directory - Direktanbindung

Lightweight Directory Access Protocol - Direkte Abfrage des Unternehmensverzeichnisses.

┌──────────────┐         ┌──────────────┐         ┌──────────────┐
│    User      │         │  Ihre App    │         │   Active     │
│  (Browser)   │         │              │         │   Directory  │
└──────────────┘         └──────────────┘         └──────────────┘
       │                        │                        │
       │  1. Login-Formular     │                        │
       │───────────────────────>│                        │
       │                        │                        │
       │                        │  2. LDAP Bind          │
       │                        │───────────────────────>│
       │                        │                        │
       │                        │  3. Bind OK / Fail     │
       │                        │<───────────────────────│
       │                        │                        │
       │                        │  4. User-Attribute     │
       │                        │───────────────────────>│
       │                        │                        │
       │                        │  5. Gruppen, DN, etc.  │
       │                        │<───────────────────────│
       │  6. Session erstellt   │                        │
       │<───────────────────────│                        │

Vorteile:

  • Kein IdP nötig
  • Echtzeit-Prüfung (User gesperrt = sofort draußen)
  • Alle AD-Attribute verfügbar
  • Funktioniert auch ohne Internet

Nachteile:

  • Passwort geht durch Ihre App (Security-Risiko)
  • VPN/Firewall-Regeln nötig
  • Kein SSO im echten Sinn (User gibt Passwort ein)

Entscheidungsmatrix

KriteriumSAML 2.0OAuth/OIDCLDAP
Enterprise-AkzeptanzSehr hochMittelHoch
Implementierungsaufwand40-80h16-32h24-40h
Echtes SSOJaJaNein
Passwort in AppNeinNeinJa
Offline-fähigNeinNeinJa (mit Cache)
Attribute/RollenSehr gutGutSehr gut
DebuggingSchwerEinfachMittel
Zertifikate nötigJaNeinOptional

Merksatz: SAML für Enterprise, OAuth/OIDC für SaaS, LDAP für On-Premise.


SAML 2.0 Implementierung (PHP)

Schritt 1: Service Provider Metadata

Ihr IdP (z.B. Azure AD) braucht Informationen über Ihre App:

class SamlServiceProvider {
    private string $entityId;
    private string $acsUrl;
    private string $sloUrl;
    private string $certificate;

    public function __construct(array $config) {
        $this->entityId = $config['entity_id']; // z.B. https://ihre-app.de/saml
        $this->acsUrl = $config['acs_url'];     // Assertion Consumer Service
        $this->sloUrl = $config['slo_url'];     // Single Logout
        $this->certificate = $config['certificate'];
    }

    public function generateMetadata(): string {
        $xml = <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
                     entityID="{$this->entityId}">
    <md:SPSSODescriptor AuthnRequestsSigned="true"
                        WantAssertionsSigned="true"
                        protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">

        <md:KeyDescriptor use="signing">
            <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
                <ds:X509Data>
                    <ds:X509Certificate>{$this->getCertificateContent()}</ds:X509Certificate>
                </ds:X509Data>
            </ds:KeyInfo>
        </md:KeyDescriptor>

        <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>

        <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
                                     Location="{$this->acsUrl}"
                                     index="0"
                                     isDefault="true"/>

        <md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
                                Location="{$this->sloUrl}"/>
    </md:SPSSODescriptor>
</md:EntityDescriptor>
XML;

        return $xml;
    }

    private function getCertificateContent(): string {
        $cert = file_get_contents($this->certificate);
        $cert = str_replace(['-----BEGIN CERTIFICATE-----', '-----END CERTIFICATE-----', "\n", "\r"], '', $cert);
        return $cert;
    }
}

Schritt 2: AuthnRequest erstellen

class SamlAuthRequest {
    private string $idpSsoUrl;
    private string $entityId;
    private string $acsUrl;

    public function createRequest(): string {
        $id = '_' . bin2hex(random_bytes(16));
        $issueInstant = gmdate('Y-m-d\TH:i:s\Z');

        $request = <<<XML
<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
                    xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
                    ID="{$id}"
                    Version="2.0"
                    IssueInstant="{$issueInstant}"
                    Destination="{$this->idpSsoUrl}"
                    AssertionConsumerServiceURL="{$this->acsUrl}"
                    ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST">
    <saml:Issuer>{$this->entityId}</saml:Issuer>
    <samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
                        AllowCreate="true"/>
</samlp:AuthnRequest>
XML;

        // Request in Session speichern für Validierung
        $_SESSION['saml_request_id'] = $id;

        return $request;
    }

    public function redirect(): void {
        $request = $this->createRequest();
        $encodedRequest = base64_encode(gzdeflate($request));
        $url = $this->idpSsoUrl . '?' . http_build_query([
            'SAMLRequest' => $encodedRequest,
            'RelayState' => $_SESSION['return_url'] ?? '/',
        ]);

        header('Location: ' . $url);
        exit;
    }
}

Schritt 3: SAML Response verarbeiten

class SamlResponseHandler {
    private string $idpCertificate;
    private string $expectedAudience;
    private int $clockSkewLeeway = 300; // 5 Minuten Toleranz

    public function handleResponse(string $samlResponse): ?SamlUser {
        $xml = base64_decode($samlResponse);

        $doc = new DOMDocument();
        $doc->loadXML($xml);

        // 1. Signatur validieren (NUR signierte Assertion!)
        if (!$this->validateSignature($doc)) {
            throw new SamlException('Ungültige Signatur');
        }

        // 2. Zeitstempel prüfen (mit Leeway!)
        if (!$this->validateTimestamps($doc)) {
            throw new SamlException('Response abgelaufen');
        }

        // 3. Audience prüfen
        if (!$this->validateAudience($doc)) {
            throw new SamlException('Falscher Empfänger');
        }

        // 4. InResponseTo prüfen (gegen Session)
        if (!$this->validateInResponseTo($doc)) {
            throw new SamlException('Request-ID stimmt nicht');
        }

        // 5. User-Daten extrahieren
        return $this->extractUser($doc);
    }

    private function validateSignature(DOMDocument $doc): bool {
        $xpath = new DOMXPath($doc);
        $xpath->registerNamespace('ds', 'http://www.w3.org/2000/09/xmldsig#');
        $xpath->registerNamespace('saml', 'urn:oasis:names:tc:SAML:2.0:assertion');

        // WICHTIG: Nur die signierte Assertion akzeptieren!
        // XPath fest verdrahten gegen XML Signature Wrapping
        $signatureNode = $xpath->query('//saml:Assertion/ds:Signature')->item(0);

        if (!$signatureNode) {
            // Fallback: Response-Level Signatur
            $signatureNode = $xpath->query('/samlp:Response/ds:Signature')->item(0);
        }

        if (!$signatureNode) {
            return false;
        }

        // XMLSecurityDSig aus xmlseclibs verwenden
        $objXMLSecDSig = new XMLSecurityDSig();
        $objXMLSecDSig->sigNode = $signatureNode;
        $objXMLSecDSig->canonicalizeSignedInfo();

        $key = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, ['type' => 'public']);
        $key->loadKey($this->idpCertificate, true, true);

        return $objXMLSecDSig->verify($key) === 1;
    }

    private function validateTimestamps(DOMDocument $doc): bool {
        $xpath = new DOMXPath($doc);
        $xpath->registerNamespace('saml', 'urn:oasis:names:tc:SAML:2.0:assertion');

        $now = time();

        // NotBefore prüfen (mit Leeway)
        $notBefore = $xpath->query('//saml:Conditions/@NotBefore')->item(0)?->nodeValue;
        if ($notBefore) {
            $notBeforeTime = strtotime($notBefore);
            if ($now < ($notBeforeTime - $this->clockSkewLeeway)) {
                return false;
            }
        }

        // NotOnOrAfter prüfen (mit Leeway)
        $notOnOrAfter = $xpath->query('//saml:Conditions/@NotOnOrAfter')->item(0)?->nodeValue;
        if ($notOnOrAfter) {
            $notOnOrAfterTime = strtotime($notOnOrAfter);
            if ($now >= ($notOnOrAfterTime + $this->clockSkewLeeway)) {
                return false;
            }
        }

        return true;
    }

    private function extractUser(DOMDocument $doc): SamlUser {
        $xpath = new DOMXPath($doc);
        $xpath->registerNamespace('saml', 'urn:oasis:names:tc:SAML:2.0:assertion');

        // NUR einen Assertion-Knoten parsen!
        $assertions = $xpath->query('//saml:Assertion');
        if ($assertions->length !== 1) {
            throw new SamlException('Genau eine Assertion erwartet');
        }

        $nameId = $xpath->query('//saml:NameID')->item(0)?->nodeValue;

        // Attribute extrahieren
        $attributes = [];
        $attributeNodes = $xpath->query('//saml:Attribute');

        foreach ($attributeNodes as $attr) {
            $name = $attr->getAttribute('Name');
            $value = $xpath->query('.//saml:AttributeValue', $attr)->item(0)?->nodeValue;
            $attributes[$name] = $value;
        }

        return new SamlUser(
            email: $nameId,
            firstName: $attributes['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname'] ?? '',
            lastName: $attributes['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname'] ?? '',
            groups: $this->parseGroups($attributes),
            rawAttributes: $attributes
        );
    }

    private function parseGroups(array $attributes): array {
        $groupClaim = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/groups';
        $groups = $attributes[$groupClaim] ?? '';

        if (is_string($groups)) {
            return [$groups];
        }

        return $groups;
    }
}

SAML Security-Fallen

Die 5 SAML-Angriffe, die Sie kennen müssen

AngriffBeschreibungSchutz
XML Signature WrappingAngreifer schiebt unsignierte Assertion einXPath fest verdrahten, nur signierte Assertion parsen
Multiple AssertionsMehrere Assertions, nur eine signiertGenau EINE Assertion akzeptieren
Replay AttackResponse wird erneut gesendetInResponseTo gegen Session prüfen, Response-ID speichern
Audience ConfusionResponse für andere AppAudience gegen eigene Entity-ID prüfen
Certificate InjectionFalsches IdP-ZertifikatNur pinned/vertrauenswürdige IdP-Zertifikate, Metadata-Signatur prüfen

Checkliste:

  • XPath für Signatur fest verdrahten (nicht //ds:Signature)
  • Nur einen Assertion-Knoten parsen
  • Audience und InResponseTo validieren
  • NotBefore/NotOnOrAfter mit Leeway prüfen (300s = 5 Min)
  • Nur vertrauenswürdige IdP-Zertifikate akzeptieren
  • Metadata-Signatur prüfen (wenn dynamisch geladen)

Merksatz: SAML-Validierung ist wie ein Flugzeug-Checklist - eine vergessene Prüfung kann fatal sein.


OAuth 2.0 / OIDC Implementierung

PKCE, State, Nonce - Die Sicherheits-Trias

Drei Parameter, drei Schutzziele:

ParameterSchützt gegenWann nötig
stateCSRF-AngriffeImmer
nonceReplay des ID TokensBei OIDC (ID Token)
PKCECode InterceptionSPAs, Mobile, auch Web-Apps

PKCE (Proof Key for Code Exchange):

  • Auch für Web-Apps empfohlen (nicht nur SPAs!)
  • Schützt den Authorization Code vor Interception

OIDC mit PKCE (Code Flow)

class OidcProvider {
    private string $clientId;
    private string $clientSecret;
    private string $redirectUri;
    private string $authorizationEndpoint;
    private string $tokenEndpoint;
    private int $clockSkewLeeway = 300; // 5 Minuten

    public function getAuthUrl(): string {
        // State für CSRF-Schutz
        $state = bin2hex(random_bytes(16));
        $_SESSION['oauth_state'] = $state;

        // Nonce für Replay-Schutz des ID Tokens
        $nonce = bin2hex(random_bytes(16));
        $_SESSION['oauth_nonce'] = $nonce;

        // PKCE: Code Verifier und Challenge
        $codeVerifier = $this->generateCodeVerifier();
        $_SESSION['oauth_code_verifier'] = $codeVerifier;
        $codeChallenge = $this->generateCodeChallenge($codeVerifier);

        $params = [
            'client_id' => $this->clientId,
            'redirect_uri' => $this->redirectUri,
            'response_type' => 'code',
            'scope' => 'openid email profile',
            'state' => $state,
            'nonce' => $nonce,
            // PKCE Parameter
            'code_challenge' => $codeChallenge,
            'code_challenge_method' => 'S256',
        ];

        return $this->authorizationEndpoint . '?' . http_build_query($params);
    }

    private function generateCodeVerifier(): string {
        // 43-128 Zeichen, URL-safe
        return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
    }

    private function generateCodeChallenge(string $verifier): string {
        return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
    }

    public function handleCallback(string $code, string $state): OAuthUser {
        // 1. State validieren (CSRF-Schutz)
        if (!hash_equals($_SESSION['oauth_state'] ?? '', $state)) {
            throw new OAuthException('State mismatch - möglicher CSRF-Angriff');
        }

        // 2. Code gegen Tokens tauschen (mit PKCE)
        $tokens = $this->exchangeCodeForTokens($code);

        // 3. ID Token validieren
        $idTokenPayload = $this->validateIdToken($tokens['id_token']);

        // 4. Nonce validieren (Replay-Schutz)
        if (!hash_equals($_SESSION['oauth_nonce'] ?? '', $idTokenPayload['nonce'] ?? '')) {
            throw new OAuthException('Nonce mismatch - möglicher Replay-Angriff');
        }

        // Session aufräumen
        unset($_SESSION['oauth_state'], $_SESSION['oauth_nonce'], $_SESSION['oauth_code_verifier']);

        return new OAuthUser(
            id: $idTokenPayload['sub'],
            email: $idTokenPayload['email'],
            emailVerified: $idTokenPayload['email_verified'] ?? false,
            name: $idTokenPayload['name'] ?? '',
            provider: 'oidc'
        );
    }

    private function exchangeCodeForTokens(string $code): array {
        $response = $this->httpPost($this->tokenEndpoint, [
            'client_id' => $this->clientId,
            'client_secret' => $this->clientSecret,
            'code' => $code,
            'grant_type' => 'authorization_code',
            'redirect_uri' => $this->redirectUri,
            // PKCE: Code Verifier mitsenden
            'code_verifier' => $_SESSION['oauth_code_verifier'],
        ]);

        if (isset($response['error'])) {
            throw new OAuthException($response['error_description'] ?? $response['error']);
        }

        return $response;
    }

    private function validateIdToken(string $idToken): array {
        $parts = explode('.', $idToken);
        if (count($parts) !== 3) {
            throw new OAuthException('Ungültiges JWT-Format');
        }

        $payload = json_decode(
            base64_decode(strtr($parts[1], '-_', '+/')),
            true
        );

        // Zeitstempel mit Leeway prüfen
        $now = time();

        // iat (issued at) - nicht zu weit in der Zukunft
        if (isset($payload['iat']) && $payload['iat'] > ($now + $this->clockSkewLeeway)) {
            throw new OAuthException('ID Token issued in the future');
        }

        // exp (expiration) - nicht abgelaufen
        if (isset($payload['exp']) && $payload['exp'] < ($now - $this->clockSkewLeeway)) {
            throw new OAuthException('ID Token expired');
        }

        // Audience prüfen
        $aud = $payload['aud'] ?? '';
        if (is_array($aud) ? !in_array($this->clientId, $aud) : $aud !== $this->clientId) {
            throw new OAuthException('Invalid audience');
        }

        // In Produktion: Signatur gegen JWKS validieren!

        return $payload;
    }

    private function httpPost(string $url, array $data): array {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => http_build_query($data),
        ]);

        $response = curl_exec($ch);
        curl_close($ch);

        return json_decode($response, true);
    }
}

Microsoft Azure AD (Enterprise)

class AzureOAuthProvider {
    private string $tenantId;
    private string $clientId;
    private string $clientSecret;
    private string $redirectUri;

    public function getAuthUrl(): string {
        $state = bin2hex(random_bytes(16));
        $nonce = bin2hex(random_bytes(16));
        $_SESSION['oauth_state'] = $state;
        $_SESSION['oauth_nonce'] = $nonce;

        $baseUrl = "https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize";

        $params = [
            'client_id' => $this->clientId,
            'response_type' => 'code',
            'redirect_uri' => $this->redirectUri,
            'response_mode' => 'query',
            'scope' => 'openid profile email User.Read',
            'state' => $state,
            'nonce' => $nonce,
        ];

        return $baseUrl . '?' . http_build_query($params);
    }

    public function handleCallback(string $code, string $state): OAuthUser {
        if (!hash_equals($_SESSION['oauth_state'] ?? '', $state)) {
            throw new OAuthException('State mismatch');
        }

        $tokenUrl = "https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token";

        $tokens = $this->httpPost($tokenUrl, [
            'client_id' => $this->clientId,
            'client_secret' => $this->clientSecret,
            'code' => $code,
            'grant_type' => 'authorization_code',
            'redirect_uri' => $this->redirectUri,
        ]);

        // Microsoft Graph API für User-Details
        $user = $this->fetchMicrosoftUser($tokens['access_token']);

        return new OAuthUser(
            id: $user['id'],
            email: $user['mail'] ?? $user['userPrincipalName'],
            emailVerified: true,
            name: $user['displayName'],
            picture: null,
            provider: 'azure',
            extra: [
                'department' => $user['department'] ?? null,
                'jobTitle' => $user['jobTitle'] ?? null,
                'officeLocation' => $user['officeLocation'] ?? null,
            ]
        );
    }

    private function fetchMicrosoftUser(string $accessToken): array {
        $ch = curl_init('https://graph.microsoft.com/v1.0/me');
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . $accessToken,
            ],
        ]);

        $response = curl_exec($ch);
        curl_close($ch);

        return json_decode($response, true);
    }
}

Merksatz: OAuth/OIDC ist JSON statt XML - das allein spart 50% Debugging-Zeit.


Token-Hygiene (OIDC)

Best Practices für Token-Management

Token-TypEmpfohlene LifetimeRotation
Access Token5-15 MinutenBei jedem Refresh
Refresh Token24h - 7 TageRotating (neues bei Nutzung)
ID TokenNur zur AuthentifizierungNicht cachen

Wichtige Regeln:

  1. Access Tokens kurzlebig halten - 5-15 Minuten reichen

  2. Rotating Refresh Tokens - bei jeder Nutzung neues Token ausgeben

  3. Token Revocation bei:

    • Logout
    • Passwort-Änderung
    • Account-Sperrung
    • Verdächtige Aktivität
  4. Key Rotation:

    • JWKS Keys regelmäßig rotieren
    • Alte Keys 24h nach Rotation noch akzeptieren
    • Alarm 30 Tage vor Zertifikats-Ablauf
class TokenRevocationService {
    private PDO $db;

    public function revokeAllUserTokens(int $userId, string $reason): void {
        // Alle Refresh Tokens invalidieren
        $this->db->prepare("
            UPDATE refresh_tokens
            SET revoked_at = NOW(), revoke_reason = ?
            WHERE user_id = ? AND revoked_at IS NULL
        ")->execute([$reason, $userId]);

        // Alle aktiven Sessions beenden
        $this->db->prepare("
            DELETE FROM sessions WHERE user_id = ?
        ")->execute([$userId]);

        // Audit-Log
        $this->logRevocation($userId, $reason);
    }

    public function revokeOnPasswordChange(int $userId): void {
        $this->revokeAllUserTokens($userId, 'password_change');
    }

    public function revokeOnSuspiciousActivity(int $userId): void {
        $this->revokeAllUserTokens($userId, 'suspicious_activity');
    }
}

LDAP/Active Directory Implementierung

Sichere LDAP-Anbindung

LDAP-Härtung Checkliste

  • Nur LDAPS (Port 636) oder StartTLS - niemals Klartext!
  • ldap_escape() immer verwenden - LDAP-Injection ist real
  • Account-Lockout beachten - Backoff nach fehlgeschlagenen Versuchen
  • Timeouts setzen - gegen DoS
  • Größenlimits - gegen Resource Exhaustion
  • Leere Passwörter ablehnen - LDAP erlaubt Anonymous Bind!
class SecureLdapAuthenticator {
    private string $host;
    private int $port;
    private string $baseDn;
    private string $adminDn;
    private string $adminPassword;
    private bool $useTls;
    private int $timeout = 10;
    private int $sizeLimit = 1;

    public function __construct(array $config) {
        $this->host = $config['host'];
        // LDAPS (636) oder LDAP mit StartTLS (389)
        $this->port = $config['port'] ?? 636;
        $this->baseDn = $config['base_dn'];
        $this->adminDn = $config['admin_dn'];
        $this->adminPassword = $config['admin_password'];
        $this->useTls = $config['use_tls'] ?? ($this->port === 389);
    }

    public function authenticate(string $username, string $password): ?LdapUser {
        // WICHTIG: Leere Passwörter ablehnen!
        // LDAP erlaubt Anonymous Bind mit leerem Passwort
        if (empty($password) || strlen($password) < 1) {
            return null;
        }

        $connection = $this->connect();

        try {
            // 1. User-DN finden (mit Admin-Credentials)
            $userDn = $this->findUserDn($connection, $username);

            if (!$userDn) {
                // Timing-Attack verhindern
                usleep(random_int(100000, 300000));
                return null;
            }

            // 2. User-Bind versuchen (Passwort prüfen)
            // Suppress Errors wegen Account-Lockout
            if (!@ldap_bind($connection, $userDn, $password)) {
                $this->handleFailedBind($username);
                return null;
            }

            // 3. User-Attribute laden
            return $this->loadUserAttributes($connection, $userDn);

        } finally {
            ldap_close($connection);
        }
    }

    private function connect() {
        // Für LDAPS: ldaps://host:636
        $uri = ($this->port === 636 ? 'ldaps://' : 'ldap://') . $this->host . ':' . $this->port;
        $connection = ldap_connect($uri);

        if (!$connection) {
            throw new LdapException('Verbindung fehlgeschlagen');
        }

        ldap_set_option($connection, LDAP_OPT_PROTOCOL_VERSION, 3);
        ldap_set_option($connection, LDAP_OPT_REFERRALS, 0);

        // Timeouts setzen
        ldap_set_option($connection, LDAP_OPT_NETWORK_TIMEOUT, $this->timeout);
        ldap_set_option($connection, LDAP_OPT_TIMELIMIT, $this->timeout);

        // Größenlimit
        ldap_set_option($connection, LDAP_OPT_SIZELIMIT, $this->sizeLimit);

        // StartTLS für Port 389
        if ($this->useTls && $this->port === 389) {
            if (!ldap_start_tls($connection)) {
                throw new LdapException('TLS-Verbindung fehlgeschlagen - Klartext nicht erlaubt');
            }
        }

        // Admin-Bind für User-Suche
        if (!ldap_bind($connection, $this->adminDn, $this->adminPassword)) {
            throw new LdapException('Admin-Bind fehlgeschlagen');
        }

        return $connection;
    }

    private function findUserDn($connection, string $username): ?string {
        // LDAP-Injection verhindern!
        $escapedUsername = ldap_escape($username, '', LDAP_ESCAPE_FILTER);

        $filter = "(|(sAMAccountName={$escapedUsername})(userPrincipalName={$escapedUsername})(mail={$escapedUsername}))";

        $result = @ldap_search($connection, $this->baseDn, $filter, ['dn'], 0, $this->sizeLimit, $this->timeout);

        if (!$result) {
            return null;
        }

        $entries = ldap_get_entries($connection, $result);

        if ($entries['count'] === 0) {
            return null;
        }

        return $entries[0]['dn'];
    }

    private function handleFailedBind(string $username): void {
        // Rate-Limiting / Backoff implementieren
        // Account-Lockout-Threshold des AD beachten (default: 5 Versuche)
        error_log("LDAP: Failed bind for user: " . $username);
    }

    private function loadUserAttributes($connection, string $userDn): LdapUser {
        $attributes = [
            'sAMAccountName',
            'userPrincipalName',
            'mail',
            'givenName',
            'sn',
            'displayName',
            'memberOf',
            'department',
            'title',
            'telephoneNumber',
        ];

        $result = ldap_read($connection, $userDn, '(objectClass=*)', $attributes);
        $entry = ldap_get_entries($connection, $result)[0];

        return new LdapUser(
            dn: $userDn,
            username: $entry['samaccountname'][0] ?? '',
            email: $entry['mail'][0] ?? $entry['userprincipalname'][0] ?? '',
            firstName: $entry['givenname'][0] ?? '',
            lastName: $entry['sn'][0] ?? '',
            displayName: $entry['displayname'][0] ?? '',
            groups: $this->parseGroups($entry['memberof'] ?? []),
            department: $entry['department'][0] ?? null,
            title: $entry['title'][0] ?? null,
        );
    }

    private function parseGroups(array $memberOf): array {
        $groups = [];

        foreach ($memberOf as $key => $dn) {
            if ($key === 'count') continue;

            if (preg_match('/^CN=([^,]+)/i', $dn, $matches)) {
                $groups[] = $matches[1];
            }
        }

        return $groups;
    }
}

Merksatz: LDAP ohne TLS ist wie Passwörter per Postkarte - jeder kann mitlesen.


Logout-Realität: SLO ist “Best Effort”

Die unbequeme Wahrheit über Single Logout

SLO funktioniert oft nicht zuverlässig:

ProtokollSLO-MethodeProblem
SAMLFront-Channel (Browser-Redirect)Adblocker, Browser-Timeouts, User schließt Tab
SAMLBack-Channel (Server-to-Server)Firewall-Regeln, Timeouts
OIDCFront-Channel LogoutSame-Site-Cookie-Probleme, ITP
OIDCBack-Channel LogoutNicht alle IdPs unterstützen es

Empfehlung: Defense in Depth

  1. Idle-Timeout: Session nach 30-60 Min Inaktivität beenden
  2. Absolute Session-Lifetime: Max. 8-12h, dann neu anmelden
  3. SLO als “nice-to-have”: Nicht darauf verlassen
class RobustSessionManager {
    private const IDLE_TIMEOUT = 1800;      // 30 Minuten
    private const ABSOLUTE_LIFETIME = 28800; // 8 Stunden

    public function validateSession(string $sessionId): ?User {
        $session = $this->getSession($sessionId);

        if (!$session) {
            return null;
        }

        // Absolute Lifetime prüfen
        $createdAt = strtotime($session['created_at']);
        if (time() > ($createdAt + self::ABSOLUTE_LIFETIME)) {
            $this->invalidateSession($sessionId, 'absolute_timeout');
            return null;
        }

        // Idle Timeout prüfen
        $lastActivity = strtotime($session['last_activity']);
        if (time() > ($lastActivity + self::IDLE_TIMEOUT)) {
            $this->invalidateSession($sessionId, 'idle_timeout');
            return null;
        }

        // Session gültig - Aktivität aktualisieren
        $this->touchSession($sessionId);

        return User::fromArray($session);
    }

    private function invalidateSession(string $sessionId, string $reason): void {
        $this->db->prepare("
            UPDATE sessions
            SET invalidated_at = NOW(), invalidate_reason = ?
            WHERE id = ?
        ")->execute([$reason, $sessionId]);
    }
}

Cookies und Browser-Realität

Browser-Einschränkungen beachten

BrowserProblemAuswirkung
Safari (ITP)Third-Party-Cookies geblocktKein Cross-Site SSO in iframes
Firefox (ETP)Tracking ProtectionThird-Party-Cookies eingeschränkt
ChromeThird-Party-Cookie DeprecationAb 2025 komplett weg

Regeln:

  1. Cross-Site SSO: SameSite=None; Secure nötig
  2. Keine SSO-Flows in iframes - funktioniert nicht mehr
  3. First-Party Cookies bevorzugen - auf eigener Domain
// Korrekte Cookie-Konfiguration für SSO
setcookie('session_id', $sessionId, [
    'expires' => time() + 28800,
    'path' => '/',
    'domain' => '.ihre-domain.de', // First-Party!
    'secure' => true,              // Nur HTTPS
    'httponly' => true,            // Kein JS-Zugriff
    'samesite' => 'Lax',           // Oder 'None' für Cross-Site
]);

// Für Cross-Site SSO (z.B. Subdomain-übergreifend):
setcookie('sso_token', $token, [
    'expires' => time() + 28800,
    'path' => '/',
    'domain' => '.ihre-domain.de',
    'secure' => true,              // PFLICHT bei SameSite=None
    'httponly' => true,
    'samesite' => 'None',          // Cross-Site erlauben
]);

Multi-IdP und Home Realm Discovery

Wenn Sie mehrere Kunden-IdPs anbinden müssen:

Domain-Routing

class HomeRealmDiscovery {
    private array $domainToIdpMapping = [
        'kunde-a.de' => 'idp_kunde_a',
        'kunde-b.com' => 'idp_kunde_b',
        'default' => 'idp_local',
    ];

    public function discoverIdp(string $email): string {
        $domain = substr(strrchr($email, '@'), 1);

        return $this->domainToIdpMapping[$domain]
            ?? $this->domainToIdpMapping['default'];
    }

    public function getLoginUrl(string $email): string {
        $idpId = $this->discoverIdp($email);
        $idp = $this->loadIdpConfig($idpId);

        return $idp->getAuthUrl();
    }
}

Auswahl-Screen

class IdpSelector {
    private array $availableIdps = [
        'azure' => ['name' => 'Microsoft Azure AD', 'icon' => 'microsoft.svg'],
        'okta' => ['name' => 'Okta', 'icon' => 'okta.svg'],
        'google' => ['name' => 'Google Workspace', 'icon' => 'google.svg'],
    ];

    public function renderSelector(): string {
        $html = '<div class="idp-selector">';
        $html .= '<h2>Anmelden mit</h2>';

        foreach ($this->availableIdps as $id => $idp) {
            $html .= sprintf(
                '<a href="/sso/login/%s" class="idp-button">
                    <img src="/icons/%s" alt="%s">
                    <span>%s</span>
                </a>',
                $id, $idp['icon'], $idp['name'], $idp['name']
            );
        }

        $html .= '</div>';
        return $html;
    }
}

Aufwand Multi-IdP:

  • Domain-Routing: +8-16h
  • Auswahl-Screen: +4-8h
  • Branding je IdP: +4-8h

SCIM Provisioning

Wann JIT nicht reicht

Just-in-Time Provisioning ist okay für einfache Fälle. Aber bei Enterprise brauchen Sie SCIM (System for Cross-domain Identity Management):

AnforderungJITSCIM
User anlegen bei LoginJaJa
User deaktivieren wenn im IdP gelöschtNeinJa
Gruppen/Rollen synchronisierenBegrenztJa
Lizenzen/Seats verwaltenNeinJa
Compliance-ReportsNeinJa

Aufwand SCIM-Implementierung: +16-40h

SCIM ist ein REST-API-Standard, den Azure AD, Okta und andere IdPs unterstützen. Der IdP pushed User-Änderungen an Ihre App.


Ops-Runbook: Monitoring und Alerting

Was Sie überwachen müssen

Metriken:

  • Login-Fehlerrate (Threshold: >5% = Alert)
  • IdP-Latency (Threshold: >2s = Warning, >5s = Alert)
  • Token-Ablauf-Warnungen (30 Tage vorher)
  • Zertifikats-Expiry (30 Tage vorher = Alert)

Dashboard-Karten:

  • Logins/Minute (Live)
  • Fehlercodes (SAML Status, OAuth Error)
  • Top-Mandanten (bei Multi-Tenant)
  • Aktive Sessions

On-Call-Playbook:

SymptomErste AktionEskalation
IdP nicht erreichbarStatus-Page prüfen, Fallback aktivieren?Kunde-IT kontaktieren
”Certificate expired”Zertifikat erneuern, Metadata aktualisieren-
Massenhaft fehlgeschlagene LoginsAngriff? Rate-Limiting prüfenSecurity-Team
”Token expired” bei allenUhren synchron? NTP prüfenInfra-Team
class SsoMonitoring {
    public function logLoginAttempt(string $provider, bool $success, ?string $error = null): void {
        $this->metrics->increment('sso.login.attempt', [
            'provider' => $provider,
            'success' => $success ? 'true' : 'false',
            'error' => $error ?? 'none',
        ]);

        if (!$success) {
            $this->checkErrorRate($provider);
        }
    }

    private function checkErrorRate(string $provider): void {
        $errorRate = $this->metrics->getErrorRate($provider, '5m');

        if ($errorRate > 0.05) { // > 5%
            $this->alerting->send(
                level: 'warning',
                message: "SSO error rate for {$provider}: {$errorRate}%"
            );
        }
    }

    public function checkCertificateExpiry(): void {
        foreach ($this->getIdpCertificates() as $idp => $certPath) {
            $cert = openssl_x509_parse(file_get_contents($certPath));
            $expiresAt = $cert['validTo_time_t'];
            $daysUntilExpiry = ($expiresAt - time()) / 86400;

            if ($daysUntilExpiry < 30) {
                $this->alerting->send(
                    level: 'critical',
                    message: "IdP certificate for {$idp} expires in {$daysUntilExpiry} days!"
                );
            }
        }
    }
}

Gruppen-zu-Rollen-Mapping

Konfigurationsbasiertes Mapping

class GroupRoleMapper {
    private array $mapping;
    private array $defaultRoles;

    public function __construct(array $config) {
        $this->mapping = $config['group_role_mapping'] ?? [];
        $this->defaultRoles = $config['default_roles'] ?? ['user'];
    }

    public function mapGroupsToRoles(array $groups): array {
        $roles = [];

        foreach ($groups as $group) {
            if (isset($this->mapping[$group])) {
                $mapped = $this->mapping[$group];
                $roles = array_merge($roles, is_array($mapped) ? $mapped : [$mapped]);
                continue;
            }

            foreach ($this->mapping as $pattern => $mappedRoles) {
                if ($this->matchesPattern($group, $pattern)) {
                    $roles = array_merge($roles, is_array($mappedRoles) ? $mappedRoles : [$mappedRoles]);
                }
            }
        }

        if (empty($roles)) {
            $roles = $this->defaultRoles;
        }

        return array_unique($roles);
    }

    private function matchesPattern(string $group, string $pattern): bool {
        if (str_contains($pattern, '*')) {
            $regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/i';
            return preg_match($regex, $group) === 1;
        }

        return false;
    }
}

// Beispiel-Konfiguration
$config = [
    'group_role_mapping' => [
        'APP_Portal_Admin' => ['admin', 'user'],
        'APP_Portal_Manager' => ['manager', 'user'],
        'APP_Portal_User' => ['user'],
        'APP_Portal_*' => ['user'],
        'Domain Admins' => ['admin', 'user'],
    ],
    'default_roles' => ['guest'],
];

Merksatz: Manuelle Rollen-Zuweisungen nicht überschreiben - source-Feld verwenden!


Aufwand und Kosten

Realistische Aufwandsschätzung

KomponenteSAML 2.0OAuth/OIDCLDAP
Basis-Integration24-32h8-16h16-24h
Gruppen-Mapping8-16h4-8h8-16h
Session-Management8-16h8-16h8-16h
Logout (SLO)16-24h4-8h2-4h
Testing mit IdP16-24h8-16h8-16h
Dokumentation4-8h4-8h4-8h
Gesamt76-120h36-72h46-84h

Zusätzliche Aufwände:

FeatureAufwand
Multi-IdP Support+50-100%
SCIM Provisioning+16-40h
Home Realm Discovery+8-16h
Branding je IdP+4-8h

Kosten bei 100 Euro/h:

  • SAML 2.0: 7.600 - 12.000 Euro
  • OAuth/OIDC: 3.600 - 7.200 Euro
  • LDAP: 4.600 - 8.400 Euro

Der versteckte Aufwand

Was niemand einplantAufwand
Warten auf IdP-Konfiguration1-4 Wochen
Debugging mit IT-Abteilung8-24h
Zertifikats-Erneuerung4-8h/Jahr
User-Provisioning-Edge-Cases8-16h

Merksatz: IdP-Konfiguration durch den Kunden dauert länger als die Entwicklung.


Checkliste: SSO-Implementierung

Vor dem Projekt

  • Welcher IdP? (Azure AD, Okta, on-prem ADFS?)
  • Welches Protokoll bevorzugt?
  • Wer konfiguriert den IdP? (Sie oder Kunde-IT?)
  • Welche Attribute werden benötigt?
  • Gruppen-zu-Rollen-Mapping definiert?
  • Just-in-Time oder SCIM Provisioning?
  • Multi-IdP nötig?

Technische Anforderungen

  • IdP-Metadata/Endpoints dokumentiert
  • Test-User im IdP angelegt
  • Zertifikate ausgetauscht (SAML)
  • Client-ID/Secret erstellt (OAuth)
  • Netzwerk-Freigaben (LDAP: Port 636 für LDAPS)

Security

  • HTTPS everywhere
  • Signatur-Validierung aktiv (XPath fest!)
  • CSRF-Schutz (State-Parameter)
  • Nonce für ID Token (OIDC)
  • PKCE implementiert
  • Clock-Skew-Leeway konfiguriert (300s)
  • Session-Timeout konfiguriert (Idle + Absolute)
  • LDAP nur mit TLS
  • Audit-Logging aktiv

Go-Live

  • Fallback bei IdP-Ausfall?
  • Zertifikats-Ablauf überwacht? (30 Tage Alert)
  • Monitoring aktiv? (Error-Rate, Latency)
  • Dokumentation für Kunden-IT
  • On-Call-Playbook erstellt

Fazit: SSO richtig machen

SSO ist kein Projekt, es ist eine Reise. Die Technik ist lösbar, die Koordination ist die Herausforderung.

Meine Empfehlung nach Situation:

SituationEmpfehlung
Neues SaaS-ProduktOAuth/OIDC (schnell, modern)
Enterprise-Kunde mit AzureSAML 2.0 (IT kennt es)
On-Premise, kein IdPLDAP (pragmatisch)
Beides nötigSAML + OAuth parallel

Die wichtigsten Learnings:

  1. OAuth ist nicht OIDC - OIDC = OAuth + ID Token
  2. PKCE auch für Web-Apps - nicht nur SPAs
  3. SLO ist Best Effort - Idle-Timeout als Fallback
  4. Uhren synchronisieren - 300s Leeway für Zeitstempel
  5. Zertifikate im Kalender - 30 Tage vorher Alert
  6. Logging, Logging, Logging - SSO-Probleme ohne Logs? Viel Spaß.

Merksatz: SSO ist 20% Code und 80% Kommunikation.


SSO-Integration geplant?

In 30 Minuten klären wir, welches Protokoll für Ihre Situation passt und was die IT-Abteilung vorbereiten muss.

Carola Schulte

Über Carola Schulte

Software-Architektin mit 25+ Jahren Erfahrung. Spezialisiert auf robuste Business-Apps mit PHP/PostgreSQL, Security-by-Design und DSGVO-konforme Systeme. 1,8M+ Lines of Code in Produktion.

Projekt im Kopf?

Lassen Sie uns besprechen, wie ich Ihre Anforderungen umsetzen kann – kostenlos und unverbindlich.

Kostenloses Erstgespräch