Skip to content

OIDC: end the provider session when FOG's session ends - #18

Merged
mastacontrola merged 1 commit into
mainfrom
oidc-single-logout
Aug 18, 2026
Merged

OIDC: end the provider session when FOG's session ends#18
mastacontrola merged 1 commit into
mainfrom
oidc-single-logout

Conversation

@mastacontrola

Copy link
Copy Markdown
Member

Closes #15. Consumes the USER_LOGGING_OUT redirect seam from
FOGProject/fogproject#1174.

The problem

Clicking Log out destroyed FOG's session and left the provider's SSO
session completely untouched, so clicking the provider button again
re-authenticated silently and dropped the same person straight back into the
same account. No way to sign in as somebody else short of clearing cookies. On
an account carrying uAuthSource='oidc' — which core refuses a local password
by design — no way to sign in as anybody else at all.

The plugin implemented no logout of any kind. The ID token was decoded during
verification and then discarded, so even the standard id_token_hint was not
available to send.

The setting

opSingleLogout, per provider, off by default — added by an appended
schema step (existing installs) and by createSql() (fresh ones).

Off is not timidity. Ending the provider session is only right where FOG is the
only application behind that provider; where an install shares an IdP with a
mail client and a ticket system, ending the SSO session because somebody left
FOG reaches applications FOG has nothing to do with.

Recorded at sign-in, never fetched at logout

_rememberLogout() stores the end_session_endpoint and the ID token in the
session at callback time; logoutUrl() builds the redirect from that and
nothing else. Discovering at logout would put a network request on the sign-out
path — turning a provider that has gone away into a Log out button that hangs
and then fails, at the exact moment somebody is trying to leave.

Two orderings are load-bearing, both pinned:

  • It records after establishSession(). callback() empties $_SESSION
    wholesale immediately before that call (to stop an identity already in the
    session deciding the new one), so anything stored earlier is thrown away and
    the feature is dead with nothing to show for it.
  • The hook fires before core destroys the session, which is what leaves the
    stored token readable.

The provider row is re-read at logout rather than trusted from the session, so
turning the setting off means from that moment — not from the next time
everybody happens to sign in.

post_logout_redirect_urimanagement/login.php

Not index.php, and this is what matters once #17 lands: index.php is
precisely the page a forced-redirect install bounces back to the provider. A
signed-out user landing there is either signed straight back in by an SSO
session that was not really ended, or sent around the loop.
management/login.php (FOGProject/fogproject#1175) always renders FOG's own
form.

That URL must be registered at the provider as a post-logout redirect URI, the
same way the callback is — providers that follow the spec refuse an
unregistered one and show their own error page instead of coming back. So the
management page prints it read-only next to the checkbox, exactly as it already
prints the redirect URI.

OIDC::redirectUri() now goes through a shared absoluteUrl() helper.
Verified byte-identical for webroots /fog/, / and /deep/path/ — that
value is registered at providers by hand and compared byte for byte.

Verification

tests/oidc-single-logout.test.php — 22 checks, 20/20 mutations caught.
Comments are stripped first; the prose above each method names every symbol the
test searches for.

Worth calling out among the mutations: the two schema halves are pinned
separately. Dropping createSql()'s column serves only installs made after
this commit; dropping the ALTER serves only installs made before it. Shipping
one of the two looks complete in review, because whichever install it was tested
on happened to be the served kind.

sh tests/run-all.sh5 passed, 0 failed.

Not yet exercised end to end against the lab Keycloak — that needs a live
sign-in and sign-out, and is the next thing to do.

Follow-ups

🤖 Generated with Claude Code

https://claude.ai/code/session_017aBSWrDArXHTpKWkkN27LR

Closes #15.

Clicking Log out destroyed FOG's session and left the identity provider's
SSO session completely untouched. Clicking the provider button again then
re-authenticated silently and dropped the same person straight back into
the same account, so there was no way to sign in as somebody else short of
clearing cookies or opening a private window. On an account carrying
uAuthSource='oidc' -- which core refuses a local password by design -- there
was no way to sign in as anybody else at all.

The plugin implemented no logout of any kind. The ID token was decoded
during verification and then discarded, so even the standard id_token_hint
for RP-initiated logout was not available to send.

## The setting

`opSingleLogout`, per provider, **off by default**, added by an appended
schema step so an existing install picks it up and by createSql() so a
fresh one does. Off is not timidity: ending the provider session is only
the right answer where FOG is the only application behind that provider.
Where an install shares an identity provider with a mail client and a
ticket system, ending the SSO session because somebody left FOG reaches
applications FOG has nothing to do with.

## Recorded at sign-in, never fetched at logout

`_rememberLogout()` stores the end_session_endpoint and the ID token in the
session at callback time, and `logoutUrl()` builds the redirect from that
and nothing else. The alternative -- discovering at logout -- puts a
network request on the sign-out path, which turns a provider that has gone
away into a Log out button that hangs and then fails, at the exact moment
somebody is trying to leave.

Two orderings in there are load-bearing and both are pinned by the gate:

- It records **after** establishSession(). callback() empties $_SESSION
  wholesale immediately before that call, to stop an identity already in
  the session deciding the new one, so anything stored earlier is thrown
  away and the feature is simply dead with nothing to show for it.
- The hook fires **before** core destroys the session (fogproject#1174),
  which is what leaves the stored token readable.

The provider row is re-read at logout rather than trusted from the session,
so turning the setting off means from that moment rather than from the next
time everybody happens to sign in, and a provider since deleted or disabled
gets no redirect built from a row that no longer says anything.

## post_logout_redirect_uri points at management/login.php

Not index.php, and this is the part that matters once #17 lands: index.php
is precisely the page a forced-redirect install bounces back to the
provider. A signed-out user landing there is either signed straight back in
by an SSO session that was not really ended, or sent around the loop.
management/login.php (fogproject#1175) always renders FOG's own form.

That URL has to be registered at the provider as a post-logout redirect
URI, the same way the callback does -- providers that follow the spec
refuse an unregistered one and show their own error page instead of coming
back. So the management page prints it read-only next to the checkbox,
exactly as it already prints the redirect URI, rather than leaving an admin
to work out why logout ends somewhere unexpected.

`OIDC::redirectUri()` now goes through a shared `absoluteUrl()` helper.
Verified byte-identical output for webroots `/fog/`, `/` and `/deep/path/`
-- that value is registered at providers by hand and compared byte for
byte, so a changed slash would break every existing install's sign-in.

## Verification

`tests/oidc-single-logout.test.php`, 22 checks, **20/20 mutations caught**,
including both schema halves separately (dropping createSql's column serves
only new installs, dropping the ALTER serves only old ones -- and shipping
one of the two looks complete in review because whichever install it was
tested on happened to be the served kind), the two orderings above, a
network call sneaking into the logout path, and the step being inserted
rather than appended.

`sh tests/run-all.sh` -> 5 passed, 0 failed.

Not exercised end to end against the lab Keycloak yet; that needs a live
sign-in and sign-out and is the next thing to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aBSWrDArXHTpKWkkN27LR
@mastacontrola
mastacontrola merged commit 385cc5e into main Aug 18, 2026
2 checks passed
mastacontrola added a commit that referenced this pull request Aug 18, 2026
…edirects (#20)

Reported from the lab: with single logout and forced redirect both on,
signing out left you on management/login.php -- the break-glass page --
rather than on FOG's ordinary login page.

That was wrong, and the reasoning behind it was wrong in a specific way.
#18 pointed post_logout_redirect_uri at login.php to avoid a loop: index.php
on a forced-redirect install sends the browser back to the provider, and if
the provider session were still alive that is a silent re-login.

But single logout is exactly the case where the provider session is NOT
still alive. It was just ended, by the request that is carrying this
parameter. So the browser goes back to index.php, index.php sends it to the
provider, the provider has no session, and it asks who you are. That is
signing out and signing in as somebody else in one continuous journey,
which is the thing single logout exists to make possible -- and it was
being cut short one page early.

So the two landings are now separate, because they answer different
questions:

  OIDC::postLogoutUri()  -> management/index.php. The provider session HAS
                            been ended, so bouncing back to it is correct.
  OIDC::localLoginUrl()  -> management/login.php. Used where bouncing back
                            really would loop or silently sign the person
                            in again:
                              - OIDCFlow::_fail(), where the provider just
                                refused, so returning to it is a loop
                              - the USER_LOGGING_OUT fallback when single
                                logout is OFF and forced redirect is on,
                                where the SSO session is untouched
                            and for the URL printed beside the
                            forced-redirect setting, which is the escape
                            hatch and must never be the redirecting page.

ADMINS MUST RE-REGISTER: the post-logout redirect URI to register at the
provider changes from .../management/login.php to .../management/index.php.
The management page prints the current value; providers that follow the
spec refuse an unregistered one and show their own error page instead of
returning, so an install upgrading from v1.6.9 has to update it there.

Both gates updated. The single-logout gate now pins the two URLs
separately, pins that they differ (collapsing them re-creates whichever bug
the other was avoiding), and pins post_logout_redirect_uri as a KEY and
value pair rather than merely mentioning the method -- a mutation that
rebound the value to another parameter name survived the first draft.

7/7 mutations caught; sh tests/run-all.sh -> 6 passed, 0 failed.


Claude-Session: https://claude.ai/code/session_017aBSWrDArXHTpKWkkN27LR

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

OIDC: signing out of FOG leaves the provider session open, so you cannot switch users

1 participant