dmlab / module-sso-core
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
Requires
- php: ~8.2.0||~8.3.0||~8.4.0||~8.5.0
- ext-curl: *
- ext-dom: *
- ext-openssl: *
- magento/framework: ^103.0.7
- onelogin/php-saml: ^4.3.2
- web-token/jwt-framework: ^3.4.10||~4.0.7||^4.1.7
Requires (Dev)
- phpunit/phpunit: ^11.5||^12.5
Suggests
None
Provides
None
Conflicts
- dmlab/module-admin-sso: <0.2.2
- dmlab/module-customer-sso: <0.2.2
Replaces
None
README
Shared OIDC and SAML 2.0 engine for the DMLab SSO suite — the single dependency every login product builds on.
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 — 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 tokensweb-token/jwt-framework ^3.4.10 || ~4.0.7 || ^4.1.7ext-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
ProviderPresetcontract 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
- 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, optionalend_session_endpoint—nullunless https). Theissuermust 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; passfalsewhen the preset'sDiscoveryIssuerPolicyInterfaceopts out (e.g. Entra domain tenant). - Authorization request —
AuthorizationRequestFactory::create()builds the redirect URL withstate,nonce, a PKCES256challenge, and optional preset$extraParams(reserved keys rejected, incl.max_age/request/request_uri/response_mode) and keeps the discovered$issuerin the state. It returns anAuthorizationRequest(implementsAuthorizationStateInterface); the consumer persists that state — issuer included — viaAuthorizationStateStorageInterfacebefore redirecting. - Callback — the consumer reloads the state with
consume($state)(one-time; replay-protected), thenAuthorizationResponseValidator::validateIssuer($issParam, $state, $metadata)rejects a response from another IdP (RFC 9207iss, RFC 9700 §4.4 mix-up). - Token exchange —
TokenClient::exchangeCode()redeems the code with the PKCE verifier at the token endpoint →TokenResponse(carries theid_token). A client secret is sent asclient_secret_basicunless the discoveredtoken_endpoint_auth_methods_supportedlists onlyclient_secret_post; pass$metadata->getTokenEndpointAuthMethodsSupported(). - ID-token validation —
IdTokenValidator::validateForProvider($idToken, $metadata, $clientId, $nonce, $trustedAudiences = [])resolves the JWKS (one refetch on an unknownkid, 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 discoveredid_token_signing_alg_values_supported(nevernone/HS*); anycritheader is rejected. It then checksiss/aud(extra audiences only if in$trustedAudiences)/azp/exp/iat/nbf/nonce— fractional NumericDates allowed, 60s leeway,iatat most 600s old — → validated claims. The lower-levelvalidate()takes an explicitJWKSet. - Normalization —
IdentityFactory::create($claims, $preset)mapssub,iss,email,name,email_verified, and the preset's groups claim into anIdentityInterface.subis 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. - 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
- IdP metadata —
IdpMetadataClient::fetch($url, $entityId = null)(HTTPS only, cached 3600s) orIdpMetadataParser::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 indmlab_ssocore; flush it after an IdP certificate rollover. - AuthnRequest —
AuthnRequestFactory::create($sp, $idp)→AuthnRequest(HTTP-Redirect URL,RelayState, request ID, IdP entity ID; implementsSamlRequestStateInterface). - State save — the product persists it via
SamlRequestStateStorageInterface::save(), bound to the browser (session, or aSameSite=None; Securecookie — the ACS POST is cross-site), and redirects. - ACS POST — the IdP posts
SAMLResponse+RelayStateto the product's ACS URL. consume(RelayState)— one-time; an unknown or usedRelayStategivesnull(unsolicited).ResponseValidator::validate($samlResponse, $sp, $idp, $state)— signature, decryption, audience, destination,InResponseTo, time window, algorithm allow-list, replay →SamlAssertion. Failures throwSamlResponseExceptionwith a stablegetReason()(invalid_signature,expired,replayed,wrong_audience, …) for logs.SamlIdentityFactory::create($assertion, $map)→IdentityInterface(getIssuer()= IdP entity ID,getSubjectId()= NameID orAttributeMapsubjectAttribute; bind accounts on both).AttributeMaplists email/name/group attribute candidates (ADFS, Entra, Okta, Keycloak defaults); email counts as verified only withtrustEmail. An Entra groups overage link setshasDistributedGroups()— fail closed.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;
wantMessagesSignedalso requires a signed Response,requireEncryptedAssertionsrejects plain ones. - SHA-1 signatures/digests and RSA-1.5 key transport are rejected.
- 180s clock skew (fixed by onelogin for
NotBefore/NotOnOrAfter);IssueInstantat most 600s old (maxAssertionAgedi argument). - NameID is required and must not be
transient; bearerSubjectConfirmationDataneedsRecipientandNotOnOrAfter. - 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
allowIdpInitiatedallows 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 toRelayState.
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 inopenid-configurationis the discovery URL (must end in/.well-known/openid-configuration); otherwise/.well-known/openid-configurationis appended. Trailing/is trimmed first. The discoveredissuermust always match (OIDC Discovery §4.3). - Scopes: split on whitespace/commas, deduped, RFC 6749 scope tokens,
openidalways first. - Extra params: one
key=valueper 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.