"""API tokens: the credential a machine caller presents instead of a cookie.
The interesting properties are the ones that stop a leaked token from being
worth more than the account it belongs to - it cannot be presented under another
address, it cannot outlive a revocation, and it cannot exceed its user's role.
"""
import base64
import pytest
from app.core import auth as auth_core
from app.core.context import Forbidden, NotFound, Principal, Scope
from db.db_declarations import ApiToken, Organization
PASSWORD = "correcthorsebatterystaple"
@pytest.fixture
def owner(session):
"""An owner of one organisation, and the Principal for them."""
org = Organization(name="Acme", slug="acme")
session.add(org)
session.commit()
user = auth_core.create_user(session, "dev@example.com", PASSWORD, "Dev")
auth_core.add_membership(session, user, org, "owner")
return user, org, auth_core.principal_for_user(session, user)
def test_the_secret_is_returned_once_and_never_stored(session, owner):
_, _, principal = owner
token, secret = auth_core.create_token(session, principal, "CI")
assert secret.startswith(auth_core.TOKEN_PREFIX)
assert secret not in token.token_hash
assert len(token.token_hash) == 64
# The prefix is for telling two apart in a list, and is short enough not to
# help reconstruct either.
assert secret.startswith(token.prefix)
assert "secret" not in token.to_dict()
def test_a_token_authenticates(session, owner):
user, org, principal = owner
_, secret = auth_core.create_token(session, principal, "CI")
resolved = auth_core.authenticate_basic(session, user.email, secret)
assert resolved.user_id == user.id
assert resolved.org_id == org.id
assert resolved.surface == "api"
def test_a_token_carries_its_users_role_and_no_more(session):
"""A viewer's token reads and does not write. The token is the person's
credential, so it cannot be a way to exceed what the person may do."""
org = Organization(name="Acme", slug="acme")
session.add(org)
session.commit()
user = auth_core.create_user(session, "viewer@example.com", PASSWORD)
auth_core.add_membership(session, user, org, "viewer")
# A viewer cannot mint one at all: creating a token needs WRITE.
viewer = auth_core.principal_for_user(session, user)
with pytest.raises(Forbidden):
auth_core.create_token(session, viewer, "nope")
assert Scope.READ in viewer.scopes
assert Scope.WRITE not in viewer.scopes
def test_a_token_cannot_be_presented_under_another_address(session, owner):
"""The email half is not decorative: without this check a leaked token could
be replayed under a different, more privileged account's name."""
_, _, principal = owner
_, secret = auth_core.create_token(session, principal, "CI")
auth_core.create_user(session, "someone-else@example.com", PASSWORD)
with pytest.raises(Forbidden):
auth_core.authenticate_basic(session, "someone-else@example.com", secret)
def test_a_revoked_token_stops_working_but_is_kept(session, owner):
user, _, principal = owner
token, secret = auth_core.create_token(session, principal, "CI")
auth_core.revoke_token(session, principal, token.id)
with pytest.raises(Forbidden):
auth_core.authenticate_basic(session, user.email, secret)
# Revocation is a timestamp, not a delete.
assert session.query(ApiToken).count() == 1
assert session.query(ApiToken).one().revoked_at is not None
def test_an_expired_token_is_refused(session, owner):
from datetime import datetime, timedelta
user, _, principal = owner
token, secret = auth_core.create_token(
session, principal, "CI", expires_at=datetime.utcnow() - timedelta(seconds=1))
with pytest.raises(Forbidden):
auth_core.authenticate_basic(session, user.email, secret)
def test_a_pending_account_cannot_use_its_token(session, owner):
"""Unreachable by the front door today, because minting needs a session. It
is checked because approval can be withdrawn after a token was minted."""
user, _, principal = owner
_, secret = auth_core.create_token(session, principal, "CI")
user.approved_at = None
session.commit()
with pytest.raises(Forbidden):
auth_core.authenticate_basic(session, user.email, secret)
def test_garbage_is_refused_without_a_lookup(session, owner):
user, _, _ = owner
for secret in ("", "not-a-token", "Bearer something"):
with pytest.raises(Forbidden):
auth_core.authenticate_basic(session, user.email, secret)
def test_revoking_someone_elses_token_is_not_found(session, owner):
_, org, principal = owner
token, _ = auth_core.create_token(session, principal, "CI")
other = auth_core.create_user(session, "other@example.com", PASSWORD)
auth_core.add_membership(session, other, org, "owner")
other_principal = auth_core.principal_for_user(session, other)
with pytest.raises(NotFound):
auth_core.revoke_token(session, other_principal, token.id)
def test_list_is_scoped_to_the_caller(session, owner):
_, org, principal = owner
auth_core.create_token(session, principal, "Mine")
other = auth_core.create_user(session, "other@example.com", PASSWORD)
auth_core.add_membership(session, other, org, "owner")
other_principal = auth_core.principal_for_user(session, other)
auth_core.create_token(session, other_principal, "Theirs")
assert [t.name for t in auth_core.list_tokens(session, principal)] == ["Mine"]
assert [t.name for t in auth_core.list_tokens(session, other_principal)] == ["Theirs"]
# --- the façade -------------------------------------------------------------
def test_the_basic_surface_skips_the_session_guard(account):
"""Everything under the Basic prefix authenticates itself, so the guard lets
it through without a cookie - and the endpoint, not the guard, is what
refuses an unauthenticated caller.
The skeleton registers no route under that prefix yet, so a 404 here is the
right answer: it proves the guard did not redirect to /login first.
"""
client, _, _ = account
client.get("/logout")
r = client.get("/api/v1/anything")
assert r.status_code == 404
def test_minting_is_refused_on_a_plan_without_it(account):
"""The paywall, end to end: 402 and not 403, because it is fixed by the
customer upgrading rather than by an administrator granting anything."""
from tests.conftest import csrf_for
client, _, _ = account
r = client.post("/api/tokens", json={"name": "CI"},
headers={"X-CSRF-Token": csrf_for(client)})
assert r.status_code == 402, r.get_json()
assert "upgrade" in r.get_json()["error"].lower()
def test_listing_and_revoking_survive_a_lapsed_plan(account):
"""Minting is gated; seeing and revoking what you already hold is not. Losing
the ability to turn off a credential you cannot see is how a leaked token
outlives the subscription that made it."""
from tests.conftest import csrf_for, on_tier
client, _, org = account
on_tier(org, "silver")
csrf = csrf_for(client)
created = client.post("/api/tokens", json={"name": "CI"},
headers={"X-CSRF-Token": csrf})
token_id = created.get_json()["token"]["id"]
on_tier(org, "free")
assert client.get("/api/tokens").status_code == 200
r = client.delete(f"/api/tokens/{token_id}", headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
def test_a_token_round_trips_through_http(account):
from utils.db_wrapper import db_session
client, user, org = account
from tests.conftest import csrf_for, on_tier
# Minting is a Silver feature - see plans.FEATURES and the gate in
# endpoints/session.api_token_create.
on_tier(org, "silver")
csrf = csrf_for(client)
r = client.post("/api/tokens", json={"name": "CI"},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
secret = r.get_json()["secret"]
# The secret authenticates as Basic, exactly as a script would send it.
pair = base64.b64encode(f"{user.email}:{secret}".encode()).decode()
resolved = auth_core.authenticate_basic(db_session, user.email, secret)
assert resolved.user_id == user.id
assert pair # the header a caller would build from it
listed = client.get("/api/tokens").get_json()["tokens"]
assert [t["name"] for t in listed] == ["CI"]
# Listing never returns the secret.
assert "secret" not in listed[0]