Search by

dmlab / module-sso-core

dmlab

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

Statistics

Installs: 227

Dependents: 6

Suggesters: 0

Stars: 0

Open Issues: 0

0.2.4 2026-10-30 21:54 UTC

This package is auto-updated.

Last update: 2026-10-30 21:55:12 UTC


README

Shared OIDC 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.

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
  • ext-sodium (optional) for EdDSA-signed ID tokens
  • web-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 ProviderPreset contract so IdP differences (discovery URL, scopes, groups claim, branding) stay in a provider plugin module — one engine serves any compliant IdP.

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. 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) 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) 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.