agilentics / boiler
"""The second factor: what it gates, and what the half-finished state can reach.

A real assertion needs an authenticator, so the cryptography is the library's to
test. What is ours - and what a mistake here would quietly break - is the
*state machine*: a correct password must stop short of a session when a passkey
is enrolled, and the pending state it leaves behind must grant nothing.
"""
import pytest

from app.core import auth as auth_core
from app.core import webauthn as webauthn_core
from app.core.context import Forbidden, Invalid, NotFound
from db.db_declarations import Organization, WebauthnCredential
from utils.db_wrapper import db_session

from .conftest import PASSWORD


def _enrol(session, user, credential_id="cred-1", name="Phone"):
    cred = WebauthnCredential(user_id=user.id, credential_id=credential_id,
                              public_key="not-a-real-key", sign_count=0, name=name)
    session.add(cred)
    session.commit()
    return cred


# --- core -------------------------------------------------------------------


def test_has_credentials_is_the_question_sign_in_asks(session):
    user = auth_core.create_user(session, "dev@example.com", PASSWORD)
    assert webauthn_core.has_credentials(session, user.id) is False
    _enrol(session, user)
    assert webauthn_core.has_credentials(session, user.id) is True


def test_options_refuse_an_account_with_no_authenticator(session):
    user = auth_core.create_user(session, "dev@example.com", PASSWORD)
    rp = webauthn_core.RelyingParty(rp_id="localhost", origin="http://localhost", name="t")
    with pytest.raises(Invalid):
        webauthn_core.authentication_options(session, user, rp)


def test_a_credential_belongs_to_one_account(session):
    """A valid assertion from somebody else's authenticator must not complete
    this user's sign-in, so the lookup is scoped to user_id."""
    org = Organization(name="Acme", slug="acme")
    session.add(org)
    session.commit()

    mine = auth_core.create_user(session, "mine@example.com", PASSWORD)
    theirs = auth_core.create_user(session, "theirs@example.com", PASSWORD)
    auth_core.add_membership(session, mine, org, "owner")
    _enrol(session, theirs, credential_id="theirs-1")

    rp = webauthn_core.RelyingParty(rp_id="localhost", origin="http://localhost", name="t")
    with pytest.raises(Forbidden):
        webauthn_core.verify_authentication(
            session, mine, rp, {"id": "theirs-1"}, "a-challenge")


def test_delete_is_scoped_to_the_caller(session):
    """Nobody can strip another account's second factor."""
    org = Organization(name="Acme", slug="acme")
    session.add(org)
    session.commit()

    mine = auth_core.create_user(session, "mine@example.com", PASSWORD)
    theirs = auth_core.create_user(session, "theirs@example.com", PASSWORD)
    auth_core.add_membership(session, mine, org, "owner")
    cred = _enrol(session, theirs, credential_id="theirs-1")

    principal = auth_core.principal_for_user(session, mine)
    with pytest.raises(NotFound):
        webauthn_core.delete_credential(session, principal, cred.id)
    assert session.query(WebauthnCredential).count() == 1


def test_a_missing_challenge_is_refused(session):
    """Empty rather than wrong: without this the library would be handed b"" as
    the expected challenge, and the error would name the wrong thing."""
    user = auth_core.create_user(session, "dev@example.com", PASSWORD)
    _enrol(session, user)
    rp = webauthn_core.RelyingParty(rp_id="localhost", origin="http://localhost", name="t")
    with pytest.raises(Forbidden):
        webauthn_core.verify_authentication(session, user, rp, {"id": "cred-1"}, "")


# --- the façade -------------------------------------------------------------


def test_a_password_alone_stops_short_when_a_passkey_is_enrolled(account):
    client, user, _ = account
    client.get("/logout")
    _enrol(db_session, user)

    r = client.post("/api/login",
                    json={"email": user.email, "password": PASSWORD})
    assert r.status_code == 200
    assert r.get_json() == {"mfa_required": True}

    # The pending state is not a session: "/" still bounces to the sign-in page.
    assert client.get("/").headers["Location"].endswith("/login")
    # And it reaches no API either.
    assert client.get("/api/me").status_code == 401


def test_the_challenge_endpoint_refuses_a_stranger(app_client):
    """Not an oracle: without the pending state a passer-by cannot ask whether an
    address has a passkey."""
    r = app_client.post("/api/login/webauthn/options")
    assert r.status_code == 401


def test_the_verify_endpoint_refuses_without_a_challenge(account):
    client, user, _ = account
    client.get("/logout")
    _enrol(db_session, user)
    client.post("/api/login", json={"email": user.email, "password": PASSWORD})

    # No options call, so no challenge was ever stashed.
    r = client.post("/api/login/webauthn/verify", json={"credential": {"id": "cred-1"}})
    assert r.status_code == 401


def test_approval_is_rechecked_at_the_second_leg(account):
    """The password leg already refused a pending account, so this can only fire
    if approval was withdrawn between the two calls. It is checked because this
    is the call that mints the session."""
    client, user, _ = account
    client.get("/logout")
    _enrol(db_session, user)
    client.post("/api/login", json={"email": user.email, "password": PASSWORD})

    user.approved_at = None
    db_session.commit()

    r = client.post("/api/login/webauthn/verify", json={"credential": {"id": "cred-1"}})
    # 401 for the missing challenge or 403 for the withdrawn approval - either
    # way no session is minted, which is the property under test.
    assert r.status_code in (401, 403)
    assert client.get("/api/me").status_code == 401