dmlab / module-sso-core
Shared OIDC/SSO engine for DmLab identity products (generic OIDC client, 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: *
- magento/framework: ^103.0.7
- 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 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.
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 providerext-sodium(optional) for EdDSA-signed ID tokensweb-token/jwt-framework ^3.4.10 || ~4.0.7 || ^4.1.7
Pairs with Admin SSO · Customer SSO.
Architecture
What it provides
Ships protocol only — no UI, no config, no secret storage. Products own
configuration and supply a small ProviderPreset; the engine does the OIDC work.
Scope v1 is OIDC (authorization-code + PKCE); SAML is a later phase.
- Generic OIDC client: discovery, authorization request, token exchange, ID-token validation against JWKS, claim → 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.
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. 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) 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) uses a fresh HTTPS-only client per request with a
5s connect / 10s total timeout — override connectTimeout, timeout, refetchCooldown
(JWKS) via etc/di.xml arguments; IdTokenValidator's maxIatAge likewise (0 disables).
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. |
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/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). |
License
OSL-3.0 © DMLab. Commercial licensing and support: https://dmlab.work.