Skip to content

feat: add generic OpenID Connect provider - #1

Closed
atymic wants to merge 9 commits into
masterfrom
feat/openid-connect-provider
Closed

atymic wants to merge 9 commits into
masterfrom
feat/openid-connect-provider

Conversation

@atymic

@atymic atymic commented Aug 13, 2026

Copy link
Copy Markdown
Member

Ports the provider from SocialiteProviders/Providers#1447 (thanks @adrum) into its own repo, so protocol and security fixes can release on their own cadence.

On top of the port:

  • Multi-issuer support. Each connection in config/oidc.php registers as its own driver (oidc_keycloak, oidc_entra, ...) with isolated config and caches. A plain services.openidconnect block still works for a single IdP.
  • Per-IdP provider classes (entra, keycloak, auth0, okta, google) that derive config from friendlier keys (tenant, realm, domain). Any Provider subclass can be named in config the same way.
  • Pluggable issuer validation. EntraIssuerValidator fixes the multi-tenant {tenantid} issuer mismatch reported on the original PR.
  • 153 tests, CI on PHP 8.2-8.4 including a lowest-deps run.

Docs are in the README plus docs/ for extending, the logout flows and the security model.

atymic added 3 commits August 13, 2026 11:23
Ports the provider from SocialiteProviders/Providers#1447 and adds
multi-connection registration (one driver per config/oidc.php entry),
per-IdP provider classes (entra, keycloak, auth0, okta, google),
pluggable issuer validation with an Entra {tenantid} validator,
and a full test suite (153 tests).
@kohenkatz

Copy link
Copy Markdown

This looks great, thank you for working on it!

One interesting issue I found with the Entra provider, based on my continued testing of the other PR:

Unlike other providers where the email claim returns the actual username that the user would use to log in, Entra returns the email address that is set in the user's contact information, which can be entirely unrelated to the account, and is often left empty. Microsoft instead uses a field called preferred_username to return the user's login email address.

I wonder if the Entra provider should use this field name, or if the general email field name should be configurable. Or perhaps an array of fields, and choose the first one that exists?

(I know that the consuming application can check the raw user data for this other field name, but it seems a shame to not be able to use the built-in functionality when the only difference is in the name.)

atymic added 5 commits August 13, 2026 13:03
Entra returns the contact-info email in the email claim (often empty
or unrelated to the account) and the login identity in
preferred_username. Adds an email_claims config key (first non-empty
claim wins); EntraProvider defaults it to preferred_username then
email, skipping values that aren't email-shaped.
A token with a phone-shaped preferred_username and an attacker-chosen
email claim would fall through to the unverified directory email
(nOAuth). Default email_claims to preferred_username only; the
fallback stays available as an opt-in.
Cross-checked against the test suite added to
SocialiteProviders/Providers#1447: malformed token shapes, non-numeric
exp, HS256 advertised via discovery, falsy-string verify_jwt, logout
state and endpoint query edges, PKCE challenge derivation, and token
response exposure.

@adrum adrum left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did an analysis of my other branch and found these discrepancies

Comment thread src/Provider.php
Comment thread src/Provider.php Outdated

protected function getCacheTtl(): int
{
return (int) ($this->getConfig('cache_ttl') ?: 3600);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return (int) ($this->getConfig('cache_ttl') ?: 3600);

Two separate collapses land on this line. ConfigTrait::getConfig() returns the default whenever the stored value is empty(), so 0, '0' and '' all arrive as null; the ?: then folds anything falsy into 3600 again. Setting cache_ttl to 0 to turn caching off while debugging an IdP still caches the discovery document and JWKS for an hour.

The fix is the helper this file already has — rawConfig() at line 111, which logoutTokenReplayTtl() already uses for exactly this reason:

protected function getCacheTtl(): int
{
    $ttl = $this->rawConfig('cache_ttl');

    return ($ttl === null || $ttl === '') ? 3600 : (int) $ttl;
}

Treating '' as absent matters because an unset environment variable reads as '', which is absence rather than a deliberate 0. Worth applying the same !== '' guard at line 1128 in logoutTokenReplayTtl() for consistency.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, thanks!

Comment thread src/Provider.php Outdated
Comment thread src/Provider.php Outdated
- Restore JWT::$leeway after verification; the global static leaked this
  provider's skew into later validations in long-running workers.
- Read cache_ttl via rawConfig() so an explicit 0 disables caching.
- Keep a fragment on a discovered endpoint behind the query string.
- Namespace the nonce session key to avoid colliding with the app's.
@atymic

atymic commented Aug 16, 2026

Copy link
Copy Markdown
Member Author

Thanks for the cross-check, all four were real. Fixed in ccd8f59, including the nonce rename as one commit with the tests.

Went back over the rest with fresh eyes after this and found a few more in b98628e. Main one is key rotation for tokens with no kid header, which is optional in RFC 7517, so single-key OPs were failing every login after a rotation. It tries each key in the set now, since php-jwt bails on an empty kid before it even tries one. Also at_hash silently skipped for EdDSA (substr($alg, -3) gives "DSA"), a null discovery doc surfacing as a TypeError, and the http timeouts having the same falsy problem as cache_ttl.

199 tests. If your analysis turned up anything else, send it through :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants