Search by

dmlab / module-sso-core

dmlab

Shared OIDC/SAML engine for DmLab identity products (generic OIDC client, SAML 2.0 service provider, mapping engine, provider preset contract).

Package info

github.com/dmlab-work/module-sso-core

Type:magento2-module

pkg:composer/dmlab/module-sso-core

Statistics

Installs: 236

Dependents: 6

Suggesters: 0

Stars: 0

Open Issues: 0

0.3.0 2026-11-03 09:09 UTC

This package is auto-updated.

Last update: 2026-11-03 10:11:29 UTC


README

Shared OIDC and SAML 2.0 engine for the DMLab SSO suite — the single dependency every login product builds on.

CI codecov Packagist Version Downloads PHP License Magento

Nobody installs this module directly: it comes with Admin SSO and Customer SSO and their provider plugins (Okta, Microsoft Entra ID, Google). It does the sign-in work behind their login buttons — talks to your company's identity provider, checks the sign-in is genuine, and hands back who the user is and which groups they are in. Those modules map the groups to admin roles or customer groups.

It speaks both sign-in languages identity providers use: OpenID Connect and SAML 2.0 — so ADFS, Microsoft Entra ID, Okta, Keycloak and any other standard SAML provider work without provider-specific code.

The Admin SSO settings screen The Admin SSO settings screen — sign-in behind it runs on this engine.

Install

Install a login module; it pulls this engine:

composer require dmlab/module-admin-sso-okta      # or dmlab/module-customer-sso-okta
bin/magento module:enable DmLab_SsoCore DmLab_AdminSso DmLab_AdminSsoOkta
bin/magento setup:upgrade

For the storefront: bin/magento module:enable DmLab_SsoCore DmLab_CustomerSso DmLab_CustomerSsoOkta.

Building your own login module? Require the engine directly: composer require dmlab/module-sso-core.

Requirements

  • Magento 2.4.7+ · PHP 8.2–8.5
  • ext-curl; outbound HTTPS from Magento to the identity provider (http only to localhost)
  • ext-sodium (optional) for EdDSA-signed ID tokens
  • web-token/jwt-framework ^3.4.10 || ~4.0.7 || ^4.1.7
  • ext-dom, ext-openssl, onelogin/php-saml ^4.3.2 (SAML)

Pairs with Admin SSO · Customer SSO.

Architecture

What it provides

Ships protocol only — no UI, controllers or metadata endpoint; config only through the generic preset's reader; no secret storage. Products own configuration, request-state storage and routes, and supply a small ProviderPreset (OIDC) or an SpConfig + IdP metadata (SAML); the engine does the protocol work.

  • Generic OIDC client: discovery, authorization request, token exchange, ID-token validation against JWKS, claim → identity normalization.
  • SAML 2.0 service provider: SP-initiated login (IdP-initiated behind a switch), signed and encrypted assertions, SP metadata, IdP metadata from a URL or pasted XML, attribute → identity normalization.
  • A stateless group/claim mapping engine (IdP values → target keys).
  • A ProviderPreset contract so IdP differences (discovery URL, scopes, groups claim, branding) stay in a provider plugin module — one engine serves any compliant IdP.

SAML replay protection — the only database table:

Table dmlab_ssocore_saml_assertion (setup:upgrade creates it)
Key assertion_hash = SHA-256 of IdP issuer + assertion ID; INSERT on the primary key is atomic
Row lifetime max(NotOnOrAfter, IssueInstant + 600) + 180 seconds
Pruning expired rows deleted on each insert; no cron
Swap Api/SamlReplayGuardInterface (default Model/Saml/DbReplayGuard)

OIDC flow

  1. Discovery — DiscoveryClient::discover($url, $requireIssuerMatch = true) fetches (and caches) .well-known/openid-configuration → ProviderMetadata (issuer, authorization, token, JWKS endpoints, advertised auth methods / signing algs / RFC 9207 support, optional end_session_endpoint — null unless https). The issuer must be https without query/fragment (loopback http allowed for a loopback discovery URL, see below). Per OIDC Discovery §4.3 it (minus a trailing /) must equal the URL prefix before /.well-known/openid-configuration — other URLs are rejected; pass false when the preset's DiscoveryIssuerPolicyInterface opts out (e.g. Entra domain tenant).
  2. Authorization request — AuthorizationRequestFactory::create() builds the redirect URL with state, nonce, a PKCE S256 challenge, and optional preset $extraParams (reserved keys rejected, incl. max_age/request/request_uri/response_mode) and keeps the discovered $issuer in the state. It returns an AuthorizationRequest (implements AuthorizationStateInterface); the consumer persists that state — issuer included — via AuthorizationStateStorageInterface before redirecting.
  3. Callback — the consumer reloads the state with consume($state) (one-time; replay-protected), then AuthorizationResponseValidator::validateIssuer($issParam, $state, $metadata) rejects a response from another IdP (RFC 9207 iss, RFC 9700 §4.4 mix-up).
  4. Token exchange — TokenClient::exchangeCode() redeems the code with the PKCE verifier at the token endpoint → TokenResponse (carries the id_token). A client secret is sent as client_secret_basic unless the discovered token_endpoint_auth_methods_supported lists only client_secret_post; pass $metadata->getTokenEndpointAuthMethodsSupported().
  5. ID-token validation — IdTokenValidator::validateForProvider($idToken, $metadata, $clientId, $nonce, $trustedAudiences = []) resolves the JWKS (one refetch on an unknown kid, or on a signature failure of a kid-less token; at most one per 60s cooldown) and verifies the JWS signature: RS/PS/ES 256–512 and EdDSA, intersected with the discovered id_token_signing_alg_values_supported (never none/HS*); any crit header is rejected. It then checks iss/aud (extra audiences only if in $trustedAudiences)/azp/exp/ iat/nbf/nonce — fractional NumericDates allowed, 60s leeway, iat at most 600s old — → validated claims. The lower-level validate() takes an explicit JWKSet.
  6. Normalization — IdentityFactory::create($claims, $preset) maps sub, iss, email, name, email_verified, and the preset's groups claim into an IdentityInterface. sub is unique only per issuer — bind accounts on (getIssuer(), getSubjectId()). Claims listed in _claim_names (distributed, e.g. Entra groups overage) are not resolved: hasDistributedGroups() is true and groups are incomplete — fail closed.
  7. Mapping — MappingEngine::resolve($groups, $rules, $default) turns IdP groups into target keys (e.g. Magento role ids), ordered by $rules (configured priority), not by the IdP's group order.

Logout (RP-Initiated Logout 1.0) — keep the raw id_token from step 4; on logout, if $metadata->getEndSessionEndpoint() is set, redirect to LogoutUrlBuilder::build($endpoint, $idToken, $clientId, $postLogoutRedirectUri, $state = null) (empty params omitted; the redirect URI must be registered at the IdP).

state guards CSRF, nonce guards replay, PKCE binds the code to the client that started the flow.

Outbound HTTP (discovery, JWKS, token, SAML metadata) uses a fresh HTTPS-only client per request with a 5s connect / 10s total timeout — override connectTimeout, timeout, refetchCooldown (JWKS), cacheLifetime (SAML metadata) via etc/di.xml arguments; IdTokenValidator's maxIatAge likewise (0 disables). Only a loopback http discovery URL (localhost, 127.0.0.1, [::1]) also allows loopback http issuer and endpoints.

SAML flow

  1. IdP metadata — IdpMetadataClient::fetch($url, $entityId = null) (HTTPS only, cached 3600s) or IdpMetadataParser::parse($xml, $entityId = null) → IdpMetadata (entity ID, SSO URL, signing certificates). The HTTPS fetch or the admin paste is the trust anchor; metadata signatures are not checked. Metadata must offer an https HTTP-Redirect SSO endpoint and a signing certificate. Fetched metadata is cached in dmlab_ssocore; flush it after an IdP certificate rollover.
  2. AuthnRequest — AuthnRequestFactory::create($sp, $idp) → AuthnRequest (HTTP-Redirect URL, RelayState, request ID, IdP entity ID; implements SamlRequestStateInterface).
  3. State save — the product persists it via SamlRequestStateStorageInterface::save(), bound to the browser (session, or a SameSite=None; Secure cookie — the ACS POST is cross-site), and redirects.
  4. ACS POST — the IdP posts SAMLResponse + RelayState to the product's ACS URL.
  5. consume(RelayState) — one-time; an unknown or used RelayState gives null (unsolicited).
  6. ResponseValidator::validate($samlResponse, $sp, $idp, $state) — signature, decryption, audience, destination, InResponseTo, time window, algorithm allow-list, replay → SamlAssertion. Failures throw SamlResponseException with a stable getReason() (invalid_signature, expired, replayed, wrong_audience, …) for logs.
  7. SamlIdentityFactory::create($assertion, $map) → IdentityInterface (getIssuer() = IdP entity ID, getSubjectId() = NameID or AttributeMap subjectAttribute; bind accounts on both). AttributeMap lists email/name/group attribute candidates (ADFS, Entra, Okta, Keycloak defaults); email counts as verified only with trustEmail. An Entra groups overage link sets hasDistributedGroups() — fail closed.
  8. MappingEngine::resolve() — groups → target keys, as for OIDC.

SpMetadataBuilder::build($sp) returns the SP metadata XML the product serves.

$idp = $idpMetadataClient->fetch($metadataUrl);       // or $idpMetadataParser->parse($xml)
$sp  = new SpConfig($entityId, $acsUrl);               // + certificate/key for signing or encryption
$req = $authnRequestFactory->create($sp, $idp);
$samlStateStorage->save($req);
// redirect user to $req->getUrl()

// --- on ACS POST ---
$relayState = (string)$request->getPost('RelayState');
$state    = $relayState !== '' ? $samlStateStorage->consume($relayState) : null;
$assertion = $responseValidator->validate((string)$request->getPost('SAMLResponse'), $sp, $idp, $state);
$identity = $samlIdentityFactory->create($assertion, new AttributeMap());
$roles    = $mappingEngine->resolve($identity->getGroups(), $groupRules, $defaultRole);

Security:

  • Assertions must always be signed; wantMessagesSigned also requires a signed Response, requireEncryptedAssertions rejects plain ones.
  • SHA-1 signatures/digests and RSA-1.5 key transport are rejected.
  • 180s clock skew (fixed by onelogin for NotBefore/NotOnOrAfter); IssueInstant at most 600s old (maxAssertionAge di argument).
  • NameID is required and must not be transient; bearer SubjectConfirmationData needs Recipient and NotOnOrAfter.
  • The ACS URL is https (http only for localhost), lowercase scheme, no query/fragment, no explicit port 80/443.
  • Each assertion is accepted once (replay table above).
  • IdP-initiated login is off by default. Enabling allowIdpInitiated allows login CSRF: a valid unsolicited assertion posted into a victim's browser logs them in as the attacker. SP-initiated login is CSRF-safe only with browser-bound request state (step 3). Never redirect to RelayState.

The ProviderPreset contract

Api/ProviderPresetInterface is the only IdP-specific surface. A provider plugin module (e.g. admin-sso-okta) implements it and registers the preset into the capability core's PresetRegistry. sso-core itself holds no registry and no IdP-specific code.

Optional capabilities are separate interfaces a preset may also implement (see Public API).

Method Purpose
getCode() Stable machine code (okta, azure).
getLabel() Human-readable IdP name for the admin UI.
buildDiscoveryUrl(array $config) Discovery URL from the product's resolved config.
getDefaultScopes() Scopes to request (always includes openid).
getGroupsClaim() Claim carrying groups (groups), or null.
getButtonLabel() Login-button label.
getButtonIconUrl() Login-button icon URL, or null.

Generic OIDC preset

Model/Preset/GenericOidcPreset (code generic) serves any standard OIDC IdP without a plugin (Keycloak, Authentik, Auth0, Zitadel, …) from admin config alone. It reads raw values from an Api/GenericOidcConfigInterface; Model/Preset/GenericOidcConfig is a ScopeConfig-backed reader for <basePath>/<field> in scopeType (default default; store for the storefront).

Field Reader method Default
issuer_url getIssuerUrl() — (required)
display_name getDisplayName() SSO (button: Sign in with SSO)
scopes getScopes() openid profile email
groups_claim getGroupsClaim() none — blank = no group mapping; consumers ship groups in config.xml
extra_auth_params getExtraAuthParams() none

A consumer wires it with di.xml only (no global preference, so admin and storefront coexist):

<virtualType name="DmLab\AdminSso\Model\Preset\GenericOidcConfig"
             type="DmLab\SsoCore\Model\Preset\GenericOidcConfig">
    <arguments>
        <argument name="basePath" xsi:type="string">dmlab_admin_sso/generic</argument>
    </arguments>
</virtualType>
<virtualType name="DmLab\AdminSso\Model\Preset\GenericOidcPreset"
             type="DmLab\SsoCore\Model\Preset\GenericOidcPreset">
    <arguments>
        <argument name="config" xsi:type="object">DmLab\AdminSso\Model\Preset\GenericOidcConfig</argument>
    </arguments>
</virtualType>
<type name="DmLab\AdminSso\Model\PresetRegistry">
    <arguments>
        <argument name="presets" xsi:type="array">
            <item name="generic" xsi:type="object">DmLab\AdminSso\Model\Preset\GenericOidcPreset</item>
        </argument>
    </arguments>
</type>

System config fields use the backend models Model/Config/Backend/{IssuerUrl,Scopes,ExtraAuthParams}, which run the same validation on save. Rules (Model/Preset/GenericOidcConfigParser):

  • Issuer: https, or http only for localhost/127.0.0.1/[::1]; no query, fragment or credentials. A value ending in openid-configuration is the discovery URL (must end in /.well-known/openid-configuration); otherwise /.well-known/openid-configuration is appended. Trailing / is trimmed first. The discovered issuer must always match (OIDC Discovery §4.3).
  • Scopes: split on whitespace/commas, deduped, RFC 6749 scope tokens, openid always first.
  • Extra params: one key=value per line, # comments; reserved (AuthorizationRequestFactory::RESERVED_PARAMS), duplicate, empty or malformed keys are rejected. $config['issuer_url'] / $config['extra_auth_params'] override the stored values.

How products consume the engine

A product wires the flow using only the public surface — build the auth URL from its preset, validate the ID token, normalize an Identity, map groups:

$metadata = $discoveryClient->discover(
    $preset->buildDiscoveryUrl($config),
    !$preset instanceof DiscoveryIssuerPolicyInterface || $preset->requiresIssuerMatch($config)
);
$authRequest = $authorizationRequestFactory->create(
    $metadata->getAuthorizationEndpoint(),
    $clientId,
    $redirectUri,
    $preset->getDefaultScopes(),
    $preset instanceof AuthorizationParamsProviderInterface
        ? $preset->getAuthorizationParams($config) : [],
    $metadata->getIssuer()
);
$stateStorage->save($authRequest);           // persist before redirect
// redirect user to $authRequest->getUrl()

// --- on callback ---
$state  = $stateStorage->consume($stateParam);
$authorizationResponseValidator->validateIssuer($request->getParam('iss'), $state, $metadata);
$tokens = $tokenClient->exchangeCode(
    $metadata->getTokenEndpoint(),
    $code,
    $redirectUri,
    $clientId,
    $state->getCodeVerifier(),
    $clientSecret,
    $metadata->getTokenEndpointAuthMethodsSupported()
);
$claims = $idTokenValidator->validateForProvider(
    $tokens->getIdToken(),
    $metadata,
    $clientId,
    $state->getNonce()
);
$identity = $identityFactory->create($claims, $preset);
// adopt an existing account by email only if $identity->isEmailVerified()
$roles    = $mappingEngine->resolve($identity->getGroups(), $groupRules, $defaultRole);

The engine never stores secrets — the product passes already-resolved client id/secret and owns the AuthorizationStateStorageInterface implementation (session, cache, ...).

Public API (Api/)

Stable contracts products depend on. Full method docs live in the interface files.

Interface Role
Api/ProviderPresetInterface IdP preset a product supplies to the engine.
Api/EmailVerificationResolverInterface Optional preset: resolveEmailVerified($claims) overrides email_verified (null = standard claim).
Api/DiscoveryIssuerPolicyInterface Optional preset: requiresIssuerMatch($config) — false skips the §4.3 issuer check.
Api/GenericOidcConfigInterface Raw settings reader for the generic preset (getIssuerUrl, getDisplayName, getScopes, getGroupsClaim, getExtraAuthParams).
Api/AuthorizationParamsProviderInterface Optional preset: getAuthorizationParams($config) — extra auth params (hd, domain_hint).
Api/Data/IdentityInterface Provider-agnostic normalized identity (getSubjectId, getIssuer, getEmail, getName, getGroups, isEmailVerified, hasDistributedClaim, hasDistributedGroups).
Api/AuthorizationStateStorageInterface Product-owned persistence of the one-time auth state (save, consume).
Api/Data/AuthorizationStateInterface The one-time state itself (getState, getNonce, getCodeVerifier, getIssuer).
Api/SamlRequestStateStorageInterface Product-owned persistence of the one-time SAML request state (save, consume → null on replay).
Api/Data/SamlRequestStateInterface The SAML request state (getRelayState, getRequestId, getIdpEntityId).
Api/SamlReplayGuardInterface assertFirstUse($issuer, $assertionId, $expiresAt) — throws SamlReplayException on reuse.

License

OSL-3.0 © DMLab. Commercial licensing and support: https://dmlab.work.