Skip to content

[3.0] External authentication (part 2 of 5) — lets members sign in with an OpenID Connect provider - #9488

Open
albertlast wants to merge 4 commits into
SimpleMachines:release-3.0from
albertlast:3.0/auth-oidc
Open

[3.0] External authentication (part 2 of 5) — lets members sign in with an OpenID Connect provider#9488
albertlast wants to merge 4 commits into
SimpleMachines:release-3.0from
albertlast:3.0/auth-oidc

Conversation

@albertlast

@albertlast albertlast commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Do not merge without #9492. On its own this PR lets anyone holding a member's session attach a permanent new way into their account, which a password change and a logout both fail to revoke — so it removes an eviction lever the forum has today. #9492 puts a re-authentication check in front of it. See the merge order note on #9381.

Description

Feature series: external authentication, part 2 of 3. These belong together and are
not bug fixes:

part 1 #9381 groundwork: makes login reusable by something other than the password form
part 2 this PR OpenID Connect sign in (Google, Microsoft, Keycloak, …)
part 3 to come passkeys (WebAuthn), as a passwordless first factor and as a second factor

This branch contains part 1's commits, because GitHub cannot base a PR on a branch that
lives in a fork. Review the last commit only, or wait for #9381 to merge and this will
reduce to it. Part 3 does not depend on this one; both sit on part 1.


Lets a forum hand authentication to an external identity provider instead of being the only
thing that knows a member's password. An admin adds a provider, the login form grows a
button for it, and members manage their own links from their profile.

Protocol. Authorization code flow with PKCE and a confidential client. The ID token is
read from the response to our own back channel POST to the token endpoint, over TLS with the
certificate verified and the client authenticated — the case OpenID Connect Core §3.1.3.7
item 6 allows signature validation to be skipped in. That is why the certificate check in
OidcClient::fetch() is not optional and why the token is never taken from the redirect. It
keeps JWKS handling, and a hard dependency on openssl that SMF does not currently have,
out of this entirely. No new composer dependency.

iss, aud, azp, exp and nonce are all checked, state is checked against the
session and cleared on use, and the discovery document's issuer must agree with the
configured one.

Why not WebFetchApi. It cannot set request headers, which the token and userinfo
endpoints need, and makeSafe() rewrites the host to a literal IP, which defeats the
certificate check. CurlFetcher allows headers but defaults to
CURLOPT_SSL_VERIFYPEER => false, which is not something to inherit for a token exchange.
So the handful of options that matter are set explicitly instead of destabilising a fetcher
that avatars and the proxy also use.

Who a sign in belongs to — the decisions that matter more than the code:

  • The provider's sub is the key. Email never is, unless an admin turns it on per provider
    and the provider says email_verified; otherwise anyone who can get an address issued
    there could walk into the account that uses it here. Off by default.
  • Somebody with no account is handed to the ordinary sign up form rather than having one
    created for them, so the agreement, privacy policy, COPPA and admin approval all still
    apply. The credential is attached once that finishes.
  • A member who is already signed in and returns from a provider gets it linked, not swapped.
  • The last credential cannot be unlinked while it is the member's only way in.

Testing. Run against a real Keycloak on the compose network, driving the whole thing as
a browser would: the login form offers the provider; the redirect carries response_type,
client_id, state, nonce and an S256 code_challenge; the provider's own login form
authenticates; the callback exchanges the code and validates the claims. First run with no
matching account lands on the sign up form; after linking the subject to a member, the same
round trip ends logged in, through part 1's Login2::completeLogin().

Refusals were tested too, all five correct: no code or state, a state we never issued, a
code with no state, a provider-reported error, and replaying a state that was already used.
Credential::remove() refuses to take the last credential from an account with no password
and allows it once there is one. Part 1's 14-check login regression suite still passes
unchanged. Provider table verified on MySQL and PostgreSQL.

Two things caught by testing rather than review, both fixed here: Provider::__construct() merged saved settings with +, which keeps the left operand and so silently discarded
every saved setting; and the callback needed the state cleared before use to stop replay.

Issues References (Fixes|Related|Closes)

  1. Part 2 of the external authentication series; builds on [3.0] External authentication (part 1 of 5) — lets something other than a password log a member in #9381

albertlast and others added 4 commits August 10, 2026 08:15
Everything about signing in assumes the password form did it. The steps
that follow a successful check live in Login2::DoLogin(), which is
protected and reads its member from a private property, so nothing else
can reuse them; two factor authentication is looked up by reading the
tfa_secret column wherever the question comes up; and every account is
assumed to have a password worth asking for.

None of that is a problem until something else can vouch for a member,
at which point each one has to be worked around rather than used. So:

Moves the body of DoLogin() to Login2::completeLogin(), taking the member
and the cookie lifetime as arguments. DoLogin() now just calls it, so the
password path is unchanged, and anything else that authenticates a member
can finish the job the same way instead of setting the cookie by hand and
missing the ban check or the login history.

Adds User::getSecondFactors(), which reports the factors a member has and
lets a mod add its own, and asks it instead of reading tfa_secret. It
reads the loaded profile rather than object properties because
verifyTfa() runs before setProperties() does. Checking it in
Login2::checkCookie() now also checks tfa_mode, as verifyTfa() already
did; without that a member could be sent to ?action=logintfa when nothing
was going to ask them for a code, which ends in "You are not allowed to
access this section" rather than a login.

Adds User::hasUsablePassword() for accounts that have no password to
give. The login form refuses them before the legacy hash fallbacks get to
compare anything against an empty string, and validateSession() offers
integrate_reauthenticate so such a member is not simply locked out of the
admin areas. Nothing here creates such an account yet.

Adds a member_auth table for whatever credentials those accounts sign in
with, dropped along with the member, and a login form slot that renders
the methods registered through integrate_authentication_methods.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Migrations can say whether they still apply, and the upgrader reports the
step as skipped when they do not, which keeps a re-run honest instead of
relying on create() quietly ignoring the table it finds.

Compares against Config::$db_prefix rather than Db::$db->prefix, since the
latter is database qualified while list_tables() reports bare names, and
so would never match. That same mismatch is why Table::exists() is no use
here either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Adds sign in with an external identity provider, so a forum can hand
authentication to Google, Microsoft, Keycloak, Authentik or anything else
that speaks the protocol, rather than being the only thing that knows a
member's password.

Uses the authorization code flow with PKCE and a confidential client. The
ID token is read from the response to our own back channel POST to the
token endpoint, over TLS with the certificate verified and the client
authenticated, which is the case OpenID Connect Core 3.1.3.7 item 6 allows
signature validation to be skipped in. That is why the certificate check in
OidcClient::fetch() is not optional, and why the token is never taken from
the redirect. Doing it this way keeps JWKS handling, and a hard dependency
on openssl that SMF does not currently have, out of it entirely.

Deliberately does not go through WebFetchApi. That cannot set request
headers, which the token and userinfo endpoints need, and it rewrites the
host to a literal IP, which defeats the certificate check. CurlFetcher
would allow headers but defaults to CURLOPT_SSL_VERIFYPEER false, which is
not something to inherit for a token exchange.

Whose account a sign in belongs to is decided narrowly. The provider's
subject claim is the key and email never is, unless an admin turns that on
per provider and the provider states the address is verified; otherwise
anyone able to get an address issued there could walk into the account
using it here. Somebody with no account is handed to the ordinary sign up
form rather than having one made for them, so the agreement, the privacy
policy, COPPA and admin approval all still apply, and the credential is
attached once that finishes.

Members manage their own links from their profile, where the last one
cannot be removed while it is the only way they can get in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Every directory carries one, and check-smf-index.php enforces it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
@albertlast albertlast changed the title [3.0] External authentication (part 2 of 3) — lets members sign in with an OpenID Connect provider [3.0] External authentication (part 2 of 4) — lets members sign in with an OpenID Connect provider Aug 10, 2026
@albertlast albertlast changed the title [3.0] External authentication (part 2 of 4) — lets members sign in with an OpenID Connect provider [3.0] External authentication (part 2 of 5) — lets members sign in with an OpenID Connect provider Aug 10, 2026
@jdarwood007 jdarwood007 added this to the 3.0 Alpha 6 milestone Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants