Ctrl K
ComunidadGuías…

tests/test_backchannel_logout.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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) == []