feat(auth): LDAP and SSO users keep their source; directory TLS is real (#5036)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 44s
CI / unit (push) Failing after 1m5s
CI / publish (push) Skipped

#5036 "SSO and LDAP: users keep their auth source, no merging into local
accounts, is_active checked, LDAP TLS real and filter escaped".

- users gain auth_source (local/ldap/oidc) and external_id (migration 0027).
  A provider account is found by (source, subject) only: the LDAP entry DN
  or the OIDC sub. A first sign-in whose username or email another account
  holds is refused with a message naming why; a disabled user stays out.
- Local passwords sign in local accounts only.
- LDAP: port 636 is LDAPS, otherwise StartTLS before any bind; the server
  certificate is checked against the system CAs or a CA pasted in
  Settings -> Auth. The login name is escaped into the filter (RFC 4515),
  and an empty password is refused before the directory sees it.
- OIDC: the ID token must carry this sign-in's nonce and the userinfo sub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 11:10:40 -04:00
co-authored by Claude Opus 5.5
parent b1da073677
commit f944cdb9b0
11 changed files with 405 additions and 41 deletions
+64
View File
@@ -0,0 +1,64 @@
"""Integration: provider accounts are found by (source, subject) against real
Postgres, never merged into a local account by a matching name."""
from __future__ import annotations
import asyncio
import os
import uuid
import pytest
pytestmark = pytest.mark.integration
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
def test_provider_accounts_keep_their_source(app):
from sqlalchemy import select, update
from steward.auth.routes import _provision_external_user
from steward.models.users import User, UserRole
async def _go():
tag = uuid.uuid4().hex[:8]
local = f"loc-{tag}"
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(username=local, email=f"{local}@test", password_hash="x",
role=UserRole.admin))
# A directory entry with a local user's name is refused, not merged.
user, refusal = await _provision_external_user(
app, "ldap", f"uid={local},dc=x", local, "", "admin")
assert user is None and f"named {local}" in refusal and "LDAP" in refusal
# First sign-in creates the account; the next finds it by subject and
# follows the provider's role.
name = f"sso-{tag}"
first, refusal = await _provision_external_user(app, "oidc", f"sub-{tag}", name, "", "viewer")
assert refusal is None
assert (first.auth_source, first.external_id, first.role) == ("oidc", f"sub-{tag}", UserRole.viewer)
again, _ = await _provision_external_user(app, "oidc", f"sub-{tag}", name, "", "operator")
assert again.id == first.id and again.role == UserRole.operator
# Another subject claiming the same username is someone else.
other, refusal = await _provision_external_user(app, "oidc", f"sub2-{tag}", name, "", "admin")
assert other is None and refusal
# Disabled in Steward stays out, whatever the provider says.
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(update(User).where(User.id == first.id).values(is_active=False))
gone, refusal = await _provision_external_user(app, "oidc", f"sub-{tag}", name, "", "admin")
assert gone is None and "disabled" in refusal
async with app.db_sessionmaker() as s:
row = (await s.execute(select(User).where(User.username == local))).scalar_one()
assert (row.auth_source, row.external_id, row.role) == ("local", None, UserRole.admin)
asyncio.run(_go())
+1
View File
@@ -12,6 +12,7 @@ def make_user(role=UserRole.admin):
password_hash=bcrypt.hashpw(b"password", bcrypt.gensalt()).decode(),
role=role,
is_active=True,
auth_source="local",
)
+151
View File
@@ -0,0 +1,151 @@
"""LDAP and SSO sign-in: the directory is reached over checked TLS with the
login name escaped, an SSO token must belong to the sign-in it ends, and a
provider's account never signs in as a local one."""
import base64
import json
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from steward.auth import ldap_auth
from steward.auth.oidc import id_token_refusal
from tests.test_auth import make_user, with_csrf
@pytest.mark.parametrize("raw,escaped", [
("bob", "bob"),
("*", "\\2a"),
("*)(uid=*", "\\2a\\29\\28uid=\\2a"),
("a\\b", "a\\5cb"),
("nul\x00", "nul\\00"),
])
def test_filter_value_escapes_filter_syntax(raw, escaped):
assert ldap_auth.filter_value(raw) == escaped
@pytest.mark.parametrize("cfg,want", [
({"port": 636}, (True, False)),
({"port": 636, "tls": True}, (True, False)),
({"port": 389, "tls": True}, (False, True)),
({"port": 389}, (False, False)),
])
def test_transport(cfg, want):
assert ldap_auth.transport(cfg) == want
class _FakeLdap:
"""Just enough of ldap3 to record what Steward asks the directory."""
ALL = "ALL"
AUTO_BIND_TLS_BEFORE_BIND = "tls-before-bind"
AUTO_BIND_NO_TLS = "no-tls"
def __init__(self, password="pw"):
self.password = password
self.servers, self.binds, self.filters, self.tls = [], [], [], []
fake = self
class Connection:
def __init__(self, server, user, password, auto_bind):
fake.binds.append((user, auto_bind))
if user.startswith("uid=") and password != fake.password:
raise RuntimeError("invalid credentials")
self.entries = []
def search(self, search_base, search_filter, attributes):
fake.filters.append(search_filter)
self.entries = [SimpleNamespace(entry_dn="uid=bob,ou=people,dc=x", uid=["bob"],
mail=["bob@x"], memberOf=["cn=ops,dc=x"])]
def unbind(self):
pass
self.Connection = Connection
def Server(self, host, port, use_ssl, tls, get_info):
self.servers.append({"port": port, "use_ssl": use_ssl, "tls": tls})
return object()
def Tls(self, validate, ca_certs_data):
self.tls.append((validate, ca_certs_data))
return "tls"
_CFG = {"host": "ldap", "port": 389, "tls": True, "bind_dn": "cn=svc", "base_dn": "dc=x",
"user_filter": "(&(objectClass=person)(uid={username}))",
"operator_group_dn": "cn=ops,dc=x", "ca_cert": "PEM"}
def test_ldap_starts_tls_checks_the_certificate_and_escapes_the_name(monkeypatch):
import ssl
fake = _FakeLdap()
monkeypatch.setitem(sys.modules, "ldap3", fake)
info = ldap_auth._ldap_authenticate_sync(_CFG, "bob*", "pw")
assert fake.filters == ["(&(objectClass=person)(uid=bob\\2a))"]
assert fake.servers == [{"port": 389, "use_ssl": False, "tls": "tls"}]
assert fake.tls == [(ssl.CERT_REQUIRED, "PEM")]
assert {b[1] for b in fake.binds} == {"tls-before-bind"}
assert info == {"subject": "uid=bob,ou=people,dc=x", "username": "bob",
"email": "bob@x", "role": "operator"}
def test_ldap_wrong_password_is_refused(monkeypatch):
monkeypatch.setitem(sys.modules, "ldap3", _FakeLdap(password="right"))
assert ldap_auth._ldap_authenticate_sync(_CFG, "bob", "wrong") is None
@pytest.mark.asyncio
async def test_ldap_empty_password_never_reaches_the_directory(monkeypatch):
fake = _FakeLdap(password="")
monkeypatch.setitem(sys.modules, "ldap3", fake)
assert await ldap_auth.ldap_authenticate(_CFG, "bob", "") is None
assert fake.binds == []
def _jwt(claims: dict) -> str:
body = base64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip("=")
return f"e30.{body}.sig"
@pytest.mark.parametrize("token,nonce,sub,ok", [
(_jwt({"nonce": "n1", "sub": "u1"}), "n1", "u1", True),
(_jwt({"nonce": "n2", "sub": "u1"}), "n1", "u1", False), # another sign-in's token
(_jwt({"nonce": "n1", "sub": "u1"}), None, "u1", False), # no sign-in was started
(_jwt({"nonce": "n1", "sub": "u2"}), "n1", "u1", False), # token names someone else
("", "n1", "u1", False),
(_jwt({"nonce": "n1", "sub": "u1"}), "n1", "", False),
])
def test_id_token_must_belong_to_this_sign_in(token, nonce, sub, ok):
assert (id_token_refusal(token, nonce, sub) is None) is ok
@pytest.mark.asyncio
async def test_local_password_does_not_sign_in_a_directory_account(client):
user = make_user()
user.auth_source = "ldap"
headers = await with_csrf(client)
with patch("steward.auth.routes.get_user_count", new=AsyncMock(return_value=1)), \
patch("steward.auth.routes.get_user_by_username", new=AsyncMock(return_value=user)):
response = await client.post("/login", form={"username": "admin", "password": "password"},
headers=headers)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_ldap_sign_in_shows_why_it_was_refused(app, client):
app.config["LDAP"] = {"enabled": True}
headers = await with_csrf(client)
info = {"subject": "uid=admin,dc=x", "username": "admin", "email": "", "role": "viewer"}
with patch("steward.auth.routes.get_user_count", new=AsyncMock(return_value=1)), \
patch("steward.auth.routes.get_user_by_username", new=AsyncMock(return_value=make_user())), \
patch("steward.auth.ldap_auth.ldap_authenticate", new=AsyncMock(return_value=info)), \
patch("steward.auth.routes._provision_external_user",
new=AsyncMock(return_value=(None, "Steward already has an account named admin"))):
response = await client.post("/login", form={"username": "admin", "password": "dir-pw"},
headers=headers)
assert response.status_code == 403
assert "already has an account named admin" in await response.get_data(as_text=True)
async with client.session_transaction() as sess:
assert "user_id" not in sess