Files
turnstone/tests/test_oidc_storage.py
metaclassing deff44bcea Addendum to Entra ID's... proclivities (#772)
* OIDC entra capture

* copilot being nitpicky

---------

Co-authored-by: pow3rtool <root@pow3rtools>
2026-07-04 16:48:52 -07:00

661 lines
26 KiB
Python

"""Tests for OIDC identity and pending state storage CRUD (SQLite backend)."""
from __future__ import annotations
import time
import pytest
from turnstone.core.storage import StorageConflictError
# ---------------------------------------------------------------------------
# Atomic OIDC user provisioning
# ---------------------------------------------------------------------------
class TestCreateOIDCUser:
def test_create_oidc_user_success(self, db):
"""Both rows present after one atomic call."""
db.create_oidc_user(
user_id="u-new",
username="alice",
display_name="Alice",
password_hash="!oidc",
issuer="https://idp.example.com",
subject="sub-1",
email="alice@example.com",
)
user = db.get_user("u-new")
assert user is not None
assert user["username"] == "alice"
assert user["password_hash"] == "!oidc"
identity = db.get_oidc_identity("https://idp.example.com", "sub-1")
assert identity is not None
assert identity["user_id"] == "u-new"
assert identity["email"] == "alice@example.com"
def test_create_oidc_user_username_conflict_rolls_back(self, db):
"""Pre-existing username -> StorageConflictError; identity NOT inserted."""
db.create_user("u-existing", "alice", "Alice", "$2b$12$hash")
with pytest.raises(StorageConflictError, match="username"):
db.create_oidc_user(
user_id="u-new",
username="alice",
display_name="Alice2",
password_hash="!oidc",
issuer="https://idp.example.com",
subject="sub-1",
email="alice2@example.com",
)
# The new user_id row must not exist.
assert db.get_user("u-new") is None
# The identity row must not exist.
assert db.get_oidc_identity("https://idp.example.com", "sub-1") is None
# The pre-existing user is untouched.
existing = db.get_user("u-existing")
assert existing is not None
assert existing["password_hash"] == "$2b$12$hash"
def test_create_oidc_user_identity_conflict_rolls_back(self, db):
"""Pre-existing (issuer, subject) -> StorageConflictError; user row rolled back."""
db.create_user("u-other", "other", "Other", "!oidc")
db.create_oidc_identity("https://idp.example.com", "sub-1", "u-other", "other@example.com")
with pytest.raises(StorageConflictError, match="OIDC identity"):
db.create_oidc_user(
user_id="u-new",
username="bob",
display_name="Bob",
password_hash="!oidc",
issuer="https://idp.example.com",
subject="sub-1",
email="bob@example.com",
)
# The candidate user row was rolled back.
assert db.get_user("u-new") is None
assert db.get_user_by_username("bob") is None
# The pre-existing identity still points at the original user.
identity = db.get_oidc_identity("https://idp.example.com", "sub-1")
assert identity is not None
assert identity["user_id"] == "u-other"
def test_create_oidc_user_captures_oid_tid(self, db):
"""Entra oid/tid are persisted and returned on the identity."""
db.create_oidc_user(
user_id="u-oid",
username="carol",
display_name="Carol",
password_hash="!oidc",
issuer="https://idp.example.com",
subject="sub-oid",
email="carol@example.com",
oid="obj-123",
tid="tenant-abc",
)
identity = db.get_oidc_identity("https://idp.example.com", "sub-oid")
assert identity is not None
assert identity["oid"] == "obj-123"
assert identity["tid"] == "tenant-abc"
def test_create_oidc_user_oid_tid_default_empty(self, db):
"""Omitting oid/tid (non-Entra IdP) stores "" — never NULL."""
db.create_oidc_user(
user_id="u-noid",
username="dave",
display_name="Dave",
password_hash="!oidc",
issuer="https://idp.example.com",
subject="sub-noid",
email="dave@example.com",
)
identity = db.get_oidc_identity("https://idp.example.com", "sub-noid")
assert identity is not None
assert identity["oid"] == ""
assert identity["tid"] == ""
# ---------------------------------------------------------------------------
# OIDC Identity CRUD
# ---------------------------------------------------------------------------
class TestOIDCIdentityCRUD:
def test_create_and_get_oidc_identity(self, db):
db.create_oidc_identity("https://idp.example.com", "sub-123", "u1", "alice@example.com")
identity = db.get_oidc_identity("https://idp.example.com", "sub-123")
assert identity is not None
assert identity["issuer"] == "https://idp.example.com"
assert identity["subject"] == "sub-123"
assert identity["user_id"] == "u1"
assert identity["email"] == "alice@example.com"
assert identity["created"] != ""
assert identity["last_login"] != ""
def test_get_oidc_identity_not_found(self, db):
assert db.get_oidc_identity("https://unknown.example.com", "sub-999") is None
def test_create_oidc_identity_idempotent(self, db):
"""Creating twice with same (issuer, subject) does not error (OR IGNORE)."""
db.create_oidc_identity("https://idp.example.com", "sub-123", "u1", "alice@example.com")
db.create_oidc_identity("https://idp.example.com", "sub-123", "u2", "bob@example.com")
identity = db.get_oidc_identity("https://idp.example.com", "sub-123")
assert identity is not None
# OR IGNORE preserves the first insert
assert identity["user_id"] == "u1"
assert identity["email"] == "alice@example.com"
def test_update_oidc_identity_login(self, db):
db.create_oidc_identity("https://idp.example.com", "sub-123", "u1", "alice@example.com")
before = db.get_oidc_identity("https://idp.example.com", "sub-123")
assert before is not None
original_login = before["last_login"]
# Small sleep to ensure timestamp differs
time.sleep(0.05)
result = db.update_oidc_identity_login("https://idp.example.com", "sub-123")
assert result is True
after = db.get_oidc_identity("https://idp.example.com", "sub-123")
assert after is not None
assert after["last_login"] >= original_login
def test_update_oidc_identity_login_nonexistent(self, db):
result = db.update_oidc_identity_login("https://idp.example.com", "sub-999")
assert result is False
def test_update_oidc_identity_login_backfills_oid_tid(self, db):
"""A login carrying oid/tid backfills them onto a pre-existing row."""
db.create_oidc_identity("https://idp.example.com", "sub-bf", "u1", "a@example.com")
before = db.get_oidc_identity("https://idp.example.com", "sub-bf")
assert before is not None and before["oid"] == ""
db.update_oidc_identity_login("https://idp.example.com", "sub-bf", oid="obj-9", tid="ten-9")
after = db.get_oidc_identity("https://idp.example.com", "sub-bf")
assert after is not None
assert after["oid"] == "obj-9"
assert after["tid"] == "ten-9"
def test_update_oidc_identity_login_omitted_does_not_clobber_oid_tid(self, db):
"""A later login WITHOUT oid/tid must not wipe previously-captured values."""
db.create_oidc_identity("https://idp.example.com", "sub-keep", "u1", "a@example.com")
db.update_oidc_identity_login(
"https://idp.example.com", "sub-keep", oid="obj-keep", tid="ten-keep"
)
# Simulate a subsequent login where the token omitted oid/tid.
db.update_oidc_identity_login("https://idp.example.com", "sub-keep")
identity = db.get_oidc_identity("https://idp.example.com", "sub-keep")
assert identity is not None
assert identity["oid"] == "obj-keep"
assert identity["tid"] == "ten-keep"
def test_list_oidc_identities_for_user(self, db):
"""Two identities for same user, list returns both."""
db.create_oidc_identity("https://idp1.example.com", "sub-A", "u1", "alice@idp1.com")
db.create_oidc_identity("https://idp2.example.com", "sub-B", "u1", "alice@idp2.com")
identities = db.list_oidc_identities_for_user("u1")
assert len(identities) == 2
issuers = {i["issuer"] for i in identities}
assert issuers == {"https://idp1.example.com", "https://idp2.example.com"}
def test_list_oidc_identities_for_user_empty(self, db):
assert db.list_oidc_identities_for_user("u-none") == []
def test_list_oidc_identities_excludes_other_users(self, db):
db.create_oidc_identity("https://idp.example.com", "sub-1", "u1", "alice@example.com")
db.create_oidc_identity("https://idp.example.com", "sub-2", "u2", "bob@example.com")
identities = db.list_oidc_identities_for_user("u1")
assert len(identities) == 1
assert identities[0]["user_id"] == "u1"
def test_delete_oidc_identity(self, db):
db.create_oidc_identity("https://idp.example.com", "sub-123", "u1", "alice@example.com")
assert db.delete_oidc_identity("https://idp.example.com", "sub-123") is True
assert db.get_oidc_identity("https://idp.example.com", "sub-123") is None
def test_delete_oidc_identity_nonexistent(self, db):
assert db.delete_oidc_identity("https://idp.example.com", "sub-999") is False
def test_delete_oidc_identity_only_deletes_target(self, db):
"""Deleting one identity does not affect others."""
db.create_oidc_identity("https://idp.example.com", "sub-1", "u1", "a@example.com")
db.create_oidc_identity("https://idp.example.com", "sub-2", "u1", "b@example.com")
db.delete_oidc_identity("https://idp.example.com", "sub-1")
assert db.get_oidc_identity("https://idp.example.com", "sub-1") is None
assert db.get_oidc_identity("https://idp.example.com", "sub-2") is not None
# ---------------------------------------------------------------------------
# OIDC Pending State
# ---------------------------------------------------------------------------
class TestOIDCPendingState:
def test_create_and_pop_pending_state(self, db):
db.create_oidc_pending_state(
state="state-abc",
nonce="nonce-xyz",
code_verifier="verifier-123",
audience="server",
)
result = db.pop_oidc_pending_state("state-abc")
assert result is not None
assert result["state"] == "state-abc"
assert result["nonce"] == "nonce-xyz"
assert result["code_verifier"] == "verifier-123"
assert result["audience"] == "server"
assert result["created_at"] != ""
def test_pop_pending_state_not_found(self, db):
assert db.pop_oidc_pending_state("nonexistent-state") is None
def test_pop_pending_state_expired(self, db):
"""Create with old timestamp, pop returns None."""
# Insert a row with an old created_at timestamp directly
import sqlalchemy as sa
from turnstone.core.storage._schema import oidc_pending_states
with db._engine.connect() as conn:
conn.execute(
sa.insert(oidc_pending_states),
{
"state": "state-old",
"nonce": "nonce-old",
"code_verifier": "verifier-old",
"audience": "server",
"created_at": "2020-01-01T00:00:00",
},
)
conn.commit()
# Default max_age_seconds=300, so a 2020 timestamp is expired
result = db.pop_oidc_pending_state("state-old")
assert result is None
def test_pop_pending_state_consumed(self, db):
"""Pop twice -> second returns None (one-time use)."""
db.create_oidc_pending_state(
state="state-once",
nonce="nonce-1",
code_verifier="verifier-1",
audience="server",
)
first = db.pop_oidc_pending_state("state-once")
assert first is not None
second = db.pop_oidc_pending_state("state-once")
assert second is None
def test_pop_pending_state_custom_max_age(self, db):
"""Custom max_age_seconds allows longer-lived states."""
db.create_oidc_pending_state(
state="state-long",
nonce="nonce-long",
code_verifier="verifier-long",
audience="server",
)
# With very short max_age, it might still be valid since we just created it
result = db.pop_oidc_pending_state("state-long", max_age_seconds=600)
assert result is not None
def test_create_pending_state_duplicate_raises(self, db):
"""Duplicate state insertion raises IntegrityError (no silent drop)."""
import sqlalchemy.exc
db.create_oidc_pending_state("state-dup", "nonce-1", "verifier-1", "server")
with pytest.raises(sqlalchemy.exc.IntegrityError):
db.create_oidc_pending_state("state-dup", "nonce-2", "verifier-2", "server")
def test_cleanup_expired_states(self, db):
"""Create expired + fresh, cleanup removes only expired."""
import sqlalchemy as sa
from turnstone.core.storage._schema import oidc_pending_states
# Insert an expired state directly with old timestamp
with db._engine.connect() as conn:
conn.execute(
sa.insert(oidc_pending_states),
{
"state": "state-expired",
"nonce": "nonce-old",
"code_verifier": "verifier-old",
"audience": "server",
"created_at": "2020-01-01T00:00:00",
},
)
conn.commit()
# Insert a fresh state via normal API
db.create_oidc_pending_state("state-fresh", "nonce-new", "verifier-new", "server")
# Cleanup with default 300s max age
deleted = db.cleanup_expired_oidc_states()
assert deleted == 1
# Fresh state should still exist
result = db.pop_oidc_pending_state("state-fresh")
assert result is not None
def test_cleanup_expired_states_none_expired(self, db):
"""Cleanup with no expired states returns 0."""
db.create_oidc_pending_state("state-1", "nonce-1", "verifier-1", "server")
deleted = db.cleanup_expired_oidc_states()
assert deleted == 0
def test_cleanup_expired_states_all_expired(self, db):
"""Cleanup with all expired states removes all."""
import sqlalchemy as sa
from turnstone.core.storage._schema import oidc_pending_states
with db._engine.connect() as conn:
for i in range(3):
conn.execute(
sa.insert(oidc_pending_states),
{
"state": f"state-{i}",
"nonce": f"nonce-{i}",
"code_verifier": f"verifier-{i}",
"audience": "server",
"created_at": "2020-01-01T00:00:00",
},
)
conn.commit()
deleted = db.cleanup_expired_oidc_states()
assert deleted == 3
def test_cleanup_expired_states_custom_max_age(self, db):
"""Custom max_age_seconds affects what counts as expired."""
from datetime import UTC, datetime, timedelta
import sqlalchemy as sa
from turnstone.core.storage._schema import oidc_pending_states
# Insert a state created 60 seconds ago
old_ts = (datetime.now(UTC) - timedelta(seconds=60)).strftime("%Y-%m-%dT%H:%M:%S")
with db._engine.connect() as conn:
conn.execute(
sa.insert(oidc_pending_states),
{
"state": "state-1",
"nonce": "nonce-1",
"code_verifier": "verifier-1",
"audience": "server",
"created_at": old_ts,
},
)
conn.commit()
# With default max_age=300s the 60s-old state is NOT expired
deleted = db.cleanup_expired_oidc_states(max_age_seconds=300)
assert deleted == 0
# With max_age=30s the 60s-old state IS expired
deleted = db.cleanup_expired_oidc_states(max_age_seconds=30)
assert deleted == 1
def test_pop_expired_cleans_up_row(self, db):
"""Popping an expired state should delete the row (not leave orphan)."""
import sqlalchemy as sa
from turnstone.core.storage._schema import oidc_pending_states
with db._engine.connect() as conn:
conn.execute(
sa.insert(oidc_pending_states),
{
"state": "state-cleanup",
"nonce": "nonce-c",
"code_verifier": "verifier-c",
"audience": "server",
"created_at": "2020-01-01T00:00:00",
},
)
conn.commit()
# Pop returns None (expired)
assert db.pop_oidc_pending_state("state-cleanup") is None
# Row should be gone (cleaned up even though expired)
with db._engine.connect() as conn:
count = conn.execute(
sa.select(sa.func.count())
.select_from(oidc_pending_states)
.where(oidc_pending_states.c.state == "state-cleanup")
).scalar()
assert count == 0
# ---------------------------------------------------------------------------
# count_users / find_existing_usernames
# ---------------------------------------------------------------------------
class TestCountUsers:
def test_count_users_empty(self, db):
assert db.count_users() == 0
def test_count_users_after_inserts(self, db):
db.create_user("u1", "alice", "Alice", "h1")
db.create_user("u2", "bob", "Bob", "h2")
db.create_user("u3", "carol", "Carol", "h3")
assert db.count_users() == 3
class TestFindExistingUsernames:
def test_empty_input_returns_empty_set(self, db):
db.create_user("u1", "alice", "Alice", "h1")
assert db.find_existing_usernames([]) == set()
def test_returns_subset_present_in_db(self, db):
db.create_user("u1", "alice", "Alice", "h1")
db.create_user("u2", "bob", "Bob", "h2")
existing = db.find_existing_usernames(["alice", "bob", "carol", "dave"])
assert existing == {"alice", "bob"}
def test_no_matches_returns_empty_set(self, db):
db.create_user("u1", "alice", "Alice", "h1")
assert db.find_existing_usernames(["bob", "carol"]) == set()
# ---------------------------------------------------------------------------
# replace_oidc_roles
# ---------------------------------------------------------------------------
class TestReplaceOIDCRoles:
def _seed_role(self, db, role_id):
db.create_role(role_id, role_id, role_id, "perm.read", False, "")
def test_inserts_added_roles(self, db):
db.create_user("u1", "alice", "Alice", "h")
self._seed_role(db, "role-a")
self._seed_role(db, "role-b")
added, removed = db.replace_oidc_roles("u1", {"role-a", "role-b"})
assert added == {"role-a", "role-b"}
assert removed == set()
roles = {r["role_id"] for r in db.list_user_roles("u1")}
assert roles == {"role-a", "role-b"}
def test_removes_stale_oidc_roles(self, db):
db.create_user("u1", "alice", "Alice", "h")
self._seed_role(db, "role-a")
self._seed_role(db, "role-b")
db.assign_role("u1", "role-a", "oidc")
db.assign_role("u1", "role-b", "oidc")
added, removed = db.replace_oidc_roles("u1", {"role-a"})
assert added == set()
assert removed == {"role-b"}
roles = {r["role_id"] for r in db.list_user_roles("u1")}
assert roles == {"role-a"}
def test_preserves_non_oidc_roles(self, db):
"""Manually-assigned and oidc-default rows are NOT touched."""
db.create_user("u1", "alice", "Alice", "h")
self._seed_role(db, "role-manual")
self._seed_role(db, "role-default")
self._seed_role(db, "role-oidc-old")
db.assign_role("u1", "role-manual", "admin-ui")
db.assign_role("u1", "role-default", "oidc-default")
db.assign_role("u1", "role-oidc-old", "oidc")
added, removed = db.replace_oidc_roles("u1", set())
# Only the oidc-assigned row was diffed
assert added == set()
assert removed == {"role-oidc-old"}
roles = {r["role_id"]: r["assigned_by"] for r in db.list_user_roles("u1")}
assert roles == {
"role-manual": "admin-ui",
"role-default": "oidc-default",
}
def test_no_op_when_desired_matches_current(self, db):
db.create_user("u1", "alice", "Alice", "h")
self._seed_role(db, "role-a")
db.assign_role("u1", "role-a", "oidc")
added, removed = db.replace_oidc_roles("u1", {"role-a"})
assert added == set()
assert removed == set()
assert {r["role_id"] for r in db.list_user_roles("u1")} == {"role-a"}
def test_empty_user_no_oidc_history(self, db):
db.create_user("u1", "alice", "Alice", "h")
added, removed = db.replace_oidc_roles("u1", set())
assert added == set()
assert removed == set()
def test_desired_role_blocked_by_admin_ui_assignment(self, db):
"""Desired role already held via admin-ui: untouched, no PK conflict."""
db.create_user("u1", "alice", "Alice", "h")
self._seed_role(db, "role-a")
db.assign_role("u1", "role-a", "admin-ui")
added, removed = db.replace_oidc_roles("u1", {"role-a"})
assert added == set()
assert removed == set()
roles = {r["role_id"]: r["assigned_by"] for r in db.list_user_roles("u1")}
assert roles == {"role-a": "admin-ui"}
def test_desired_role_blocked_by_oidc_default_assignment(self, db):
"""Desired role already held via oidc-default fallback: untouched."""
db.create_user("u1", "alice", "Alice", "h")
self._seed_role(db, "role-a")
db.assign_role("u1", "role-a", "oidc-default")
added, removed = db.replace_oidc_roles("u1", {"role-a"})
assert added == set()
assert removed == set()
roles = {r["role_id"]: r["assigned_by"] for r in db.list_user_roles("u1")}
assert roles == {"role-a": "oidc-default"}
def test_desired_role_added_alongside_blocked_role(self, db):
"""Mixed case: one desired role is blocked (admin-ui), the other inserts cleanly."""
db.create_user("u1", "alice", "Alice", "h")
self._seed_role(db, "role-a")
self._seed_role(db, "role-b")
db.assign_role("u1", "role-a", "admin-ui")
added, removed = db.replace_oidc_roles("u1", {"role-a", "role-b"})
assert added == {"role-b"}
assert removed == set()
roles = {r["role_id"]: r["assigned_by"] for r in db.list_user_roles("u1")}
assert roles == {"role-a": "admin-ui", "role-b": "oidc"}
def test_revoke_only_oidc_assigned_roles(self, db):
"""OIDC-assigned roles get revoked when not in desired; admin-ui rows survive."""
db.create_user("u1", "alice", "Alice", "h")
self._seed_role(db, "role-manual")
self._seed_role(db, "role-oidc-old")
self._seed_role(db, "role-default")
db.assign_role("u1", "role-manual", "admin-ui")
db.assign_role("u1", "role-oidc-old", "oidc")
db.assign_role("u1", "role-default", "oidc-default")
added, removed = db.replace_oidc_roles("u1", set())
assert added == set()
assert removed == {"role-oidc-old"}
roles = {r["role_id"]: r["assigned_by"] for r in db.list_user_roles("u1")}
assert roles == {"role-manual": "admin-ui", "role-default": "oidc-default"}
def test_replace_oidc_roles_no_op_steady_state(self, db):
"""Steady-state re-login: claims unchanged, function must short-circuit.
This pins the contract that drives the SQLite optimistic-read fast
path — the common case (token refresh with identical role claims)
must not acquire a write lock.
"""
db.create_user("u1", "alice", "Alice", "h")
self._seed_role(db, "role-a")
self._seed_role(db, "role-b")
db.assign_role("u1", "role-a", "oidc")
db.assign_role("u1", "role-b", "oidc")
added, removed = db.replace_oidc_roles("u1", {"role-a", "role-b"})
assert added == set()
assert removed == set()
# All rows still oidc-assigned with identical membership.
roles = {r["role_id"]: r["assigned_by"] for r in db.list_user_roles("u1")}
assert roles == {"role-a": "oidc", "role-b": "oidc"}
def test_replace_oidc_roles_returns_post_lock_diff(self, db):
"""Returned (added, removed) reflects the post-lock state, not the optimistic read.
The SQLite implementation re-reads under the write lock to defend
against races; the values returned must come from that re-read so
callers (apply_role_mapping audit logs) see the actual transition
that hit the table. Steady-state input must collapse to empty
sets and leave row timestamps unchanged.
"""
db.create_user("u1", "alice", "Alice", "h")
self._seed_role(db, "role-a")
db.assign_role("u1", "role-a", "oidc")
before = db.list_user_roles("u1")
assert len(before) == 1
original_created = before[0]["assignment_created"]
added, removed = db.replace_oidc_roles("u1", {"role-a"})
assert added == set()
assert removed == set()
# No write occurred — the assignment row's timestamp is untouched.
after = db.list_user_roles("u1")
assert len(after) == 1
assert after[0]["assignment_created"] == original_created