tests/test_backchannel_logout.py
Rama main · réplica de lectura del repositorio original.
"""The provider telling us a session ended — issue #28.
appcore verifies the `logout_token` and stops. Its own suite covers the
protocol: the signature, the `events` claim, and the two checks that stop an ID
token being accepted as an instruction to log somebody out. This file covers
what a *verified* notice does here, which is the half appcore cannot know.
## It is session propagation, not revocation
The first thing worth stating, because the feature invites the stronger
reading. A logout notice ends **sessions** and never sets `is_active = False`:
letting a provider disable an account is a much larger authority to hand over,
and it would break the promise that local accounts keep working when the
provider is down. So somebody holding both a password and a provider identity
is signed out and can sign straight back in locally a moment later. That is
the behaviour, not a gap in it.
What the app already does is stronger and separate: deactivating a user calls
`destroy_sessions_for()` immediately, and `load_auth` deletes an inactive
user's session on their next request. Back-channel logout does not reach that
switch and is not meant to.
## `sid` first, `sub` as the blunt instrument
A token names a session (`sid`) or an identity (`sub`). `sid` ends exactly the
session the provider meant. `sub` ends every session for that identity — more
than it asked for, and all that can be done with what it sent.
## Unauthenticated on purpose
No cookie, no CSRF token, no API key. The signature on the token is the whole
authentication, which is what the spec intends and what lets a **public
client** use it — the reporter's deployment is one.
"""
from __future__ import annotations
import pytest
from sqlalchemy import select
# Plain functions only. `idp` and `oidc_on` are FIXTURES and are declared below
# rather than imported: importing a fixture shadows the parameter of the same
# name, which ruff reports as a redefinition and pytest resolves by luck. Third
# time I have made that mistake, so it is written down here as well.
from test_oidc_routes import CLIENT_ID, ISSUER, REDIRECT, _begin, _nonce_for
import factories as fac
from app import auth as auth_mod
from app import federation
from app.models import ExternalIdentity, User, UserSession
from appcore import oidc
from appcore.testing import FakeIdp
from test_routes import make_login, session_csrf
HTML = {"accept": "text/html"}
ENDPOINT = "/oidc/backchannel-logout"
@pytest.fixture
def idp(monkeypatch):
"""A provider that really signs — see `test_oidc_routes` for the original.
`install` redirects both the discovery fetch and PyJWT's key lookup, so the
verification under test is the real one and nothing reaches the network.
"""
return FakeIdp(issuer=ISSUER, client_id=CLIENT_ID).install(monkeypatch)
@pytest.fixture
def oidc_on(app_module, idp):
"""Configured as a deployment would be, and put back afterwards."""
conf = app_module.settings.auth.oidc
before = (conf.enabled, conf.issuer, conf.client_id, conf.client_secret,
conf.redirect_uri, conf.provisioning)
conf.enabled, conf.issuer, conf.client_id = True, ISSUER, CLIENT_ID
conf.client_secret, conf.redirect_uri = "a-secret", REDIRECT
conf.provisioning = "off"
try:
yield conf
finally:
(conf.enabled, conf.issuer, conf.client_id, conf.client_secret,
conf.redirect_uri, conf.provisioning) = before
def _signed_in_through_the_provider(client, session_factory, idp, *, sid="session-1"):
"""Sign in for real, so the session carries whatever the callback stored.
Built by driving the callback rather than by hand: what is under test is
that the `sid` from the ID token reaches the session row, and a fixture
that set it directly would pass with that wiring removed.
"""
make_login(client, session_factory)
with session_factory() as db:
user = db.scalar(select(User))
federation.link(db, user, oidc.Identity(
subject="idp-subject-1", issuer=ISSUER, email="[email protected]",
email_verified=True, name="User"))
db.commit()
client.post("/logout", data={"_csrf": session_csrf(session_factory)},
follow_redirects=False)
state = _begin(client)
# Both claims together. `test_oidc_routes._callback` ASSIGNS `idp.claims`
# rather than merging, so setting `sid` before calling it is silently
# discarded — and nothing fails except the one assertion about `oidc_sid`.
idp.claims = {"nonce": _nonce_for(client, idp), "sid": sid}
resp = client.get(f"/login/oidc/callback?code=abc&state={state}",
follow_redirects=False)
assert resp.headers["location"] == "/", resp.text
return resp
def _sessions(session_factory):
with session_factory() as db:
return db.scalars(select(UserSession)).all()
# --------------------------------------------------------------------------- #
# Capturing the session id at sign-in
# --------------------------------------------------------------------------- #
def test_the_providers_session_id_is_stored(client, session_factory, oidc_on, idp):
"""Without this nothing else here can work, and nothing else would fail:
the endpoint would simply never match a session."""
_signed_in_through_the_provider(client, session_factory, idp, sid="session-7")
with session_factory() as db:
row = db.scalars(select(UserSession)).one()
assert row.oidc_sid == "session-7"
assert row.via_oidc is True
def test_a_provider_that_sends_no_session_id_still_signs_in(
client, session_factory, oidc_on, idp
):
"""`sid` is optional in the spec. Its absence must not break sign-in —
a `sub`-keyed logout still reaches that session."""
make_login(client, session_factory)
with session_factory() as db:
user = db.scalar(select(User))
federation.link(db, user, oidc.Identity(
subject="idp-subject-1", issuer=ISSUER, email="[email protected]",
email_verified=True, name="User"))
db.commit()
client.post("/logout", data={"_csrf": session_csrf(session_factory)},
follow_redirects=False)
state = _begin(client)
idp.claims = {"nonce": _nonce_for(client, idp)}
resp = client.get(f"/login/oidc/callback?code=abc&state={state}",
follow_redirects=False)
assert resp.headers["location"] == "/"
with session_factory() as db:
assert db.scalars(select(UserSession)).one().oidc_sid is None
# --------------------------------------------------------------------------- #
# What a notice ends
# --------------------------------------------------------------------------- #
def test_a_session_keyed_logout_ends_that_session(
client, session_factory, oidc_on, idp
):
_signed_in_through_the_provider(client, session_factory, idp, sid="session-1")
assert client.get("/", headers=HTML, follow_redirects=False).status_code == 200
resp = client.post(ENDPOINT,
data={"logout_token": idp.logout_token(sid="session-1")})
assert resp.status_code == 200
assert _sessions(session_factory) == []
assert client.get("/", headers=HTML,
follow_redirects=False).status_code == 303
def test_a_logout_for_another_session_leaves_this_one_alone(
client, session_factory, oidc_on, idp
):
"""The reason `sid` is preferred over `sub`: one browser signing out at the
provider must not sign the person out of every other one."""
_signed_in_through_the_provider(client, session_factory, idp, sid="session-1")
resp = client.post(ENDPOINT,
data={"logout_token": idp.logout_token(sid="somebody-else")})
assert resp.status_code == 200
assert len(_sessions(session_factory)) == 1
assert client.get("/", headers=HTML, follow_redirects=False).status_code == 200
def test_a_subject_keyed_logout_ends_every_session_for_that_identity(
client, session_factory, oidc_on, idp
):
"""Blunter, and all that can be done with a token carrying only `sub`."""
_signed_in_through_the_provider(client, session_factory, idp, sid="session-1")
resp = client.post(
ENDPOINT, data={"logout_token": idp.logout_token(sid=None,
sub="idp-subject-1")})
assert resp.status_code == 200
assert _sessions(session_factory) == []
def test_a_subject_nobody_here_is_linked_to_ends_nothing(
client, session_factory, oidc_on, idp
):
_signed_in_through_the_provider(client, session_factory, idp, sid="session-1")
resp = client.post(
ENDPOINT, data={"logout_token": idp.logout_token(sid=None,
sub="a-stranger")})
assert resp.status_code == 200
assert len(_sessions(session_factory)) == 1
def test_a_subject_from_another_issuer_does_not_match(
client, session_factory, oidc_on, idp, app_module
):
"""Matched on `(issuer, subject)`, never on subject alone — the rule
`resolve()` follows, for the same reason.
Subjects are only unique WITHIN a provider: `1`, `admin` and a bare uuid
are all plausible in two directories at once. An installation that changed
provider keeps the old `external_identity` rows, so a subject collision
would end the wrong person's sessions. Nothing proved this until the
mutation dropping the issuer clause passed.
"""
_signed_in_through_the_provider(client, session_factory, idp, sid="session-1")
with session_factory() as db:
stranger = fac.make_user(db, "[email protected]")
db.flush()
db.add(ExternalIdentity(user_id=stranger.id,
issuer="https://old-idp.example.test",
subject="idp-subject-1"))
db.flush()
# A PROVIDER session of their own, which is the thing that would be
# wrongly ended. Without one there is nothing for the bug to destroy,
# and the first version of this test passed with the issuer clause
# removed for exactly that reason.
auth_mod.create_session(db, stranger, app_module.settings)
db.scalars(select(UserSession).where(
UserSession.user_id == stranger.id)).one().via_oidc = True
db.commit()
stranger_id = stranger.id
# Signed by OUR provider, so `notice.issuer` is ours. The stranger holds
# the same subject under a DIFFERENT issuer and must be untouched.
resp = client.post(
ENDPOINT, data={"logout_token": idp.logout_token(sid=None,
sub="idp-subject-1")})
assert resp.status_code == 200
with session_factory() as db:
survivors = db.scalars(select(UserSession).where(
UserSession.user_id == stranger_id)).all()
assert len(survivors) == 1
def test_only_a_provider_session_ever_carries_a_sid(client, session_factory,
oidc_on, idp, app_module):
"""The invariant that lets the `sid` lookup skip a `via_oidc` filter.
`oidc_sid` is written in exactly one place and always with `via_oidc`, so
a local session holds NULL and cannot match a sid. If that ever stops being
true, the sid path needs the filter back — and this fails first.
"""
_signed_in_through_the_provider(client, session_factory, idp, sid="session-1")
# A second session made the ordinary way, so there is one of each to
# compare. Built through `create_session` rather than a second login: what
# is being pinned is what that function writes, not what a login route does.
with session_factory() as db:
local = fac.make_user(db, "[email protected]")
db.flush()
auth_mod.create_session(db, local, app_module.settings)
db.commit()
with session_factory() as db:
rows = db.scalars(select(UserSession)).all()
assert len(rows) == 2
for row in rows:
assert (row.oidc_sid is None) is (row.via_oidc is False)
# --------------------------------------------------------------------------- #
# What it must NOT do
# --------------------------------------------------------------------------- #
def test_the_account_is_not_deactivated(client, session_factory, oidc_on, idp):
"""The line this feature must not cross.
Letting a provider disable an account is a much larger authority than
ending a session, and it would break the promise that local accounts keep
working when the provider is down. So the person signs straight back in
locally — which is the intended behaviour, not a gap.
"""
_signed_in_through_the_provider(client, session_factory, idp, sid="session-1")
client.post(ENDPOINT, data={"logout_token": idp.logout_token(sid="session-1")})
with session_factory() as db:
assert db.scalar(select(User)).is_active is True
def test_a_local_session_is_never_touched(client, session_factory, oidc_on, idp):
"""A password sign-in has no provider session, so nothing the provider says
about one can reach it."""
make_login(client, session_factory)
with session_factory() as db:
user = db.scalar(select(User))
federation.link(db, user, oidc.Identity(
subject="idp-subject-1", issuer=ISSUER, email="[email protected]",
email_verified=True, name="User"))
db.commit()
resp = client.post(
ENDPOINT, data={"logout_token": idp.logout_token(sid=None,
sub="idp-subject-1")})
assert resp.status_code == 200
# Still signed in: the session did not begin at the provider.
assert client.get("/", headers=HTML, follow_redirects=False).status_code == 200
# --------------------------------------------------------------------------- #
# What it refuses
# --------------------------------------------------------------------------- #
def test_an_unverifiable_token_is_refused(client, session_factory, oidc_on, idp):
_signed_in_through_the_provider(client, session_factory, idp, sid="session-1")
resp = client.post(ENDPOINT, data={"logout_token": "not-a-token"})
assert resp.status_code == 400
assert len(_sessions(session_factory)) == 1
def test_an_id_token_is_not_a_logout_instruction(
client, session_factory, oidc_on, idp
):
"""appcore refuses it; this is the route proving it does not swallow the
refusal and end the session anyway."""
_signed_in_through_the_provider(client, session_factory, idp, sid="session-1")
resp = client.post(ENDPOINT,
data={"logout_token": idp.id_token(sid="session-1")})
assert resp.status_code == 400
assert len(_sessions(session_factory)) == 1
def test_a_missing_token_is_refused(client, oidc_on, idp):
assert client.post(ENDPOINT, data={}).status_code == 400
def test_the_endpoint_is_closed_when_oidc_is_off(client, session_factory, idp):
"""No provider configured means nothing may claim to be one. 404 rather
than 400: there is no such endpoint on this installation."""
make_login(client, session_factory)
resp = client.post(ENDPOINT,
data={"logout_token": idp.logout_token(sid="session-1")})
assert resp.status_code == 404
def test_it_needs_no_cookie_or_csrf_token(client, session_factory, oidc_on, idp):
"""The point of the endpoint. A provider has neither, and the signature is
what stands in for both — so this must work from a client with no session
at all."""
_signed_in_through_the_provider(client, session_factory, idp, sid="session-1")
token = idp.logout_token(sid="session-1")
client.cookies.clear()
resp = client.post(ENDPOINT, data={"logout_token": token})
assert resp.status_code == 200
assert _sessions(session_factory) == []