- A3 : helper `_require_user_id()` (401 sans session) sur profile/password/token/forge ; `PUT /api/user/password` exige `current_password` vérifié ; `/api/user` sorti de la liste CSRF exemptée - A4 : `POST /api/v1/token` → 401 sans session, chemin legacy `user_id=0` supprimé - A5 : CRUD membres d'espace (POST/PUT/DELETE) : session + rôle admin de l'espace (ou admin global), placeholder user créé en `is_admin=0`, GET membres sans session → 401 - A6 : `_require_view` → 404 et `_require_edit` → 401 quand il n'y a pas de session (fin du legacy single-user sur les collections) - A7 : création ET lecture de page → 401 sans session (PermissionManager conservé) ; `/board/api/pages` sorti de `EXCLUDED_PATHS` ; header CSRF manquant ajouté sur setItemIcon (local_workspace) - A8 : seed admin sans mot de passe codé en dur — aléatoire au premier boot loggé une fois, ou `FLOWDECK_ADMIN_PASSWORD` ; re-seed seulement si absent - tests : client connecté par défaut via `_TestSessionAuth` (session + CSRF injectés à la volée, jamais dans le cookie jar → plus de CookieConflict), helper `anon()` sur les 40 tests d'anonymat ; 1016/1016 verts, `ruff check app tests` OK
1356 lines
51 KiB
Python
1356 lines
51 KiB
Python
"""FlowDeck — v6.7.0 : SSO/SAML 2.0 + OIDC (enterprise auth).
|
|
|
|
Covers the admin config API (validation, secrets, disable), the SP metadata
|
|
endpoint, a full SP-initiated SAML login against a mock IdP (real signatures
|
|
produced by python3-saml's own ``add_sign``), every rejection path of the
|
|
security table of the design doc (signature, expiry, audience, destination,
|
|
issuer, InResponseTo, replay, CSRF relay), auto-provisioning + attribute
|
|
merge + group → workspace role mapping, the SSO-only restriction on local
|
|
auth, the audit trail, the rate limiter, and the OIDC flow with a mocked
|
|
provider (discovery / token / JWKS + signed ID token).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import datetime as dt
|
|
import secrets as pysecrets
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
import pytest
|
|
from conftest import anon
|
|
|
|
# ── Mock IdP constants ─────────────────────────────────────────────────────
|
|
IDP_ENTITY = "https://idp.corp.test/saml/metadata"
|
|
IDP_SSO = "https://idp.corp.test/saml/sso"
|
|
IDP_SLO = "https://idp.corp.test/saml/slo"
|
|
|
|
SAML_NS_ASSERTION = "urn:oasis:names:tc:SAML:2.0:assertion"
|
|
SAML_NS_PROTOCOL = "urn:oasis:names:tc:SAML:2.0:protocol"
|
|
|
|
|
|
# ── Fixtures / helpers ─────────────────────────────────────────────────────
|
|
|
|
@pytest.fixture(scope="session")
|
|
def idp_keypair() -> tuple[str, str]:
|
|
"""RSA key + self-signed certificate used by the mock IdP (once per run)."""
|
|
import datetime
|
|
|
|
from cryptography import x509
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
from cryptography.x509.oid import NameOID
|
|
|
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "idp.corp.test")])
|
|
now = datetime.datetime.now(datetime.UTC)
|
|
cert = (
|
|
x509.CertificateBuilder()
|
|
.subject_name(name)
|
|
.issuer_name(name)
|
|
.public_key(key.public_key())
|
|
.serial_number(x509.random_serial_number())
|
|
.not_valid_before(now - datetime.timedelta(days=1))
|
|
.not_valid_after(now + datetime.timedelta(days=3650))
|
|
.sign(key, hashes.SHA256())
|
|
)
|
|
key_pem = key.private_bytes(
|
|
serialization.Encoding.PEM,
|
|
serialization.PrivateFormat.PKCS8,
|
|
serialization.NoEncryption(),
|
|
).decode()
|
|
cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode()
|
|
return key_pem, cert_pem
|
|
|
|
|
|
_ADMIN_TOKENS: dict[int, str] = {}
|
|
|
|
|
|
def _clear_session_cookie(client) -> None:
|
|
"""Drop EVERY flowdeck_session cookie in the jar (client- and server-set).
|
|
|
|
httpx keys cookies by (name, domain, path): a cookie we set by hand has
|
|
domain='' while a server Set-Cookie lands with domain='testserver', so
|
|
both coexist and ``jar.get(name)`` then raises CookieConflict. Clear
|
|
before a request that will set a fresh session.
|
|
"""
|
|
client.cookies.delete("flowdeck_session")
|
|
|
|
|
|
def _be_admin(client) -> None:
|
|
"""Make sure the ONLY session cookie in the jar is the admin one."""
|
|
_clear_session_cookie(client)
|
|
client.cookies.set("flowdeck_session", _ADMIN_TOKENS[id(client)])
|
|
|
|
|
|
def _admin_session(client) -> tuple[int, str]:
|
|
"""Create an admin user and put its session + CSRF cookies on the client."""
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
|
|
login = f"ssoadmin_{pysecrets.token_hex(3)}"
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO users (login, full_name, email, is_admin) VALUES (?, 'SSO Admin', ?, 1)",
|
|
(login, f"{login}@test.dev"),
|
|
)
|
|
row = conn.execute("SELECT * FROM users WHERE login=?", (login,)).fetchone()
|
|
conn.commit()
|
|
user = dict(row)
|
|
token = SessionManager.create_session(user)
|
|
_ADMIN_TOKENS[id(client)] = token
|
|
_clear_session_cookie(client)
|
|
client.cookies.set("flowdeck_session", token)
|
|
client.cookies.set("csrf_token", "csrf-sso-test-token")
|
|
return user["id"], login
|
|
|
|
|
|
def _save_config(client, payload: dict) -> dict:
|
|
r = client.post(
|
|
"/api/v2/sso/config", json=payload, headers={"X-CSRF-Token": "csrf-sso-test-token"}
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
return r.json()
|
|
|
|
|
|
def _saml_payload(cert_pem: str, **overrides) -> dict:
|
|
payload = {
|
|
"provider_type": "saml",
|
|
"name": "Corp SSO",
|
|
"entity_id": IDP_ENTITY,
|
|
"sso_url": IDP_SSO,
|
|
"slo_url": IDP_SLO,
|
|
"x509_certificate": cert_pem,
|
|
"attribute_mapping": {},
|
|
"groups_mapping": [],
|
|
"auto_provision": 1,
|
|
"sso_only": 0,
|
|
"sign_requests": 0,
|
|
"default_workspace_id": None,
|
|
}
|
|
payload.update(overrides)
|
|
return payload
|
|
|
|
|
|
def _configure_saml(client, cert_pem: str, **overrides) -> dict:
|
|
_admin_session(client)
|
|
return _save_config(client, _saml_payload(cert_pem, **overrides))
|
|
|
|
|
|
def _start_saml(client, next_path: str = "/workspaces") -> tuple[str, str, str]:
|
|
"""Kick the SP-initiated flow → (authn_request_id, csrf relay, location)."""
|
|
r = client.get(f"/auth/saml/login?next={next_path}", follow_redirects=False)
|
|
assert r.status_code == 302, r.text
|
|
location = r.headers["location"]
|
|
assert location.startswith(IDP_SSO), location
|
|
query = parse_qs(urlparse(location).query)
|
|
assert query.get("SAMLRequest"), "AuthnRequest missing from the redirect"
|
|
relay = query["RelayState"][0]
|
|
rid, _, csrf = relay.partition(".")
|
|
assert rid and csrf
|
|
return rid, csrf, location
|
|
|
|
|
|
def _sp_endpoints(client) -> tuple[str, str]:
|
|
"""(sp_entity_id, acs_url) as advertised by our own metadata.
|
|
|
|
Careful: in SP metadata the SingleLogoutService comes BEFORE the
|
|
AssertionConsumerService, so grabbing the first ``Location=`` would
|
|
return the SLO URL — read the ACS element explicitly.
|
|
"""
|
|
r = client.get("/auth/saml/metadata")
|
|
assert r.status_code == 200, r.text
|
|
xml = r.text
|
|
entity = xml.split('entityID="', 1)[1].split('"', 1)[0]
|
|
acs_block = xml.split("AssertionConsumerService", 1)[1]
|
|
acs = acs_block.split('Location="', 1)[1].split('"', 1)[0]
|
|
return entity, acs
|
|
|
|
|
|
def _iso(moment: dt.datetime) -> str:
|
|
return moment.strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
|
|
|
|
|
def _attributes_xml(attributes: dict[str, list[str] | str]) -> str:
|
|
if not attributes:
|
|
return ""
|
|
rows = []
|
|
for name, values in attributes.items():
|
|
if isinstance(values, str):
|
|
values = [values]
|
|
vals = "".join(f"<saml:AttributeValue>{v}</saml:AttributeValue>" for v in values)
|
|
rows.append(
|
|
f'<saml:Attribute Name="{name}" '
|
|
f'NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">{vals}</saml:Attribute>'
|
|
)
|
|
return "<saml:AttributeStatement>" + "".join(rows) + "</saml:AttributeStatement>"
|
|
|
|
|
|
def build_saml_response(
|
|
key_pem: str,
|
|
cert_pem: str,
|
|
*,
|
|
sp_entity: str,
|
|
acs_url: str,
|
|
in_response_to: str,
|
|
name_id: str = "[email protected]",
|
|
attributes: dict | None = None,
|
|
audience: str | None = None,
|
|
destination: str | None = None,
|
|
issuer: str | None = None,
|
|
not_on_or_after: dt.datetime | None = None,
|
|
status_ok: bool = True,
|
|
) -> str:
|
|
"""Build + sign (assertion) a SAMLResponse like a real IdP would, base64."""
|
|
from onelogin.saml2.utils import OneLogin_Saml2_Utils
|
|
|
|
issuer = issuer or IDP_ENTITY
|
|
audience = audience or sp_entity
|
|
destination = destination or acs_url
|
|
now = dt.datetime.now(dt.UTC)
|
|
not_on_or_after = not_on_or_after or (now + dt.timedelta(minutes=5))
|
|
status = (
|
|
"<samlp:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:Success\"/>"
|
|
if status_ok
|
|
else "<samlp:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:Requester\"/>"
|
|
)
|
|
|
|
assertion = f"""<saml:Assertion xmlns:saml="{SAML_NS_ASSERTION}" ID="_a{pysecrets.token_hex(6)}"
|
|
Version="2.0" IssueInstant="{_iso(now)}">
|
|
<saml:Issuer>{issuer}</saml:Issuer>
|
|
<saml:Subject>
|
|
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">{name_id}</saml:NameID>
|
|
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
|
|
<saml:SubjectConfirmationData InResponseTo="{in_response_to}" NotOnOrAfter="{_iso(not_on_or_after)}" Recipient="{acs_url}"/>
|
|
</saml:SubjectConfirmation>
|
|
</saml:Subject>
|
|
<saml:Conditions NotBefore="{_iso(now - dt.timedelta(minutes=2))}" NotOnOrAfter="{_iso(not_on_or_after)}">
|
|
<saml:AudienceRestriction><saml:Audience>{audience}</saml:Audience></saml:AudienceRestriction>
|
|
</saml:Conditions>
|
|
<saml:AuthnStatement AuthnInstant="{_iso(now)}" SessionIndex="_sid{pysecrets.token_hex(4)}">
|
|
<saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef></saml:AuthnContext>
|
|
</saml:AuthnStatement>
|
|
{_attributes_xml(attributes if attributes else {"email": [name_id]})}
|
|
</saml:Assertion>"""
|
|
signed = OneLogin_Saml2_Utils.add_sign(assertion, key_pem, cert_pem)
|
|
if isinstance(signed, bytes):
|
|
signed = signed.decode()
|
|
signed_assertion = signed
|
|
if signed_assertion.startswith("<?xml"):
|
|
signed_assertion = signed_assertion.split("?>", 1)[1].strip()
|
|
|
|
response = f"""<samlp:Response xmlns:samlp="{SAML_NS_PROTOCOL}" xmlns:saml="{SAML_NS_ASSERTION}"
|
|
ID="_r{pysecrets.token_hex(6)}" Version="2.0" IssueInstant="{_iso(now)}"
|
|
Destination="{destination}" InResponseTo="{in_response_to}">
|
|
<saml:Issuer>{issuer}</saml:Issuer>
|
|
<samlp:Status>{status}</samlp:Status>
|
|
{signed_assertion}
|
|
</samlp:Response>"""
|
|
return base64.b64encode(response.encode()).decode()
|
|
|
|
|
|
def _post_response(client, b64_response: str, relay: str):
|
|
return client.post(
|
|
"/auth/saml/callback",
|
|
data={"SAMLResponse": b64_response, "RelayState": relay},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
|
|
def _history(client, limit=50):
|
|
r = client.get(f"/api/v2/sso/history?limit={limit}")
|
|
assert r.status_code == 200, r.text
|
|
return r.json()["history"]
|
|
|
|
|
|
# ── Migration ──────────────────────────────────────────────────────────────
|
|
|
|
def test_sso_tables_exist(client):
|
|
"""Migration 23 creates sso_config / sso_login_history / sso_requests."""
|
|
from app.db import get_conn
|
|
|
|
with get_conn() as conn:
|
|
tables = {
|
|
r["name"]
|
|
for r in conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
).fetchall()
|
|
}
|
|
assert {"sso_config", "sso_login_history", "sso_requests"} <= tables
|
|
from app.migrations import current_version
|
|
|
|
assert current_version(conn) >= 23
|
|
|
|
|
|
# ── Admin config API ───────────────────────────────────────────────────────
|
|
|
|
def test_providers_endpoint_empty_without_config(client):
|
|
r = client.get("/api/v2/sso/providers")
|
|
assert r.status_code == 200
|
|
assert r.json() == {"providers": [], "sso_only": False}
|
|
|
|
|
|
def test_config_requires_admin(client):
|
|
anon(client)
|
|
assert client.get("/api/v2/sso/config").status_code == 401
|
|
_admin_session(client)
|
|
# demote to plain user → 403
|
|
from app.db import get_conn
|
|
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE users SET is_admin=0 WHERE login LIKE 'ssoadmin_%'")
|
|
conn.commit()
|
|
assert client.get("/api/v2/sso/config").status_code == 403
|
|
|
|
|
|
def test_config_rejects_invalid_payloads(client, idp_keypair):
|
|
_, cert_pem = idp_keypair
|
|
_admin_session(client)
|
|
headers = {"X-CSRF-Token": "csrf-sso-test-token"}
|
|
|
|
missing_cert = _saml_payload(cert_pem)
|
|
missing_cert["x509_certificate"] = ""
|
|
assert client.post("/api/v2/sso/config", json=missing_cert, headers=headers).status_code == 400
|
|
|
|
not_pem = _saml_payload(cert_pem)
|
|
not_pem["x509_certificate"] = "not-a-certificate"
|
|
assert client.post("/api/v2/sso/config", json=not_pem, headers=headers).status_code == 400
|
|
|
|
no_url = _saml_payload(cert_pem)
|
|
no_url["sso_url"] = ""
|
|
assert client.post("/api/v2/sso/config", json=no_url, headers=headers).status_code == 400
|
|
|
|
bad_oidc = {"provider_type": "oidc", "issuer_url": "https://issuer.test"}
|
|
assert client.post("/api/v2/sso/config", json=bad_oidc, headers=headers).status_code == 400
|
|
|
|
bad_mapping = _saml_payload(cert_pem, attribute_mapping="[]")
|
|
assert client.post("/api/v2/sso/config", json=bad_mapping, headers=headers).status_code == 400
|
|
|
|
|
|
def test_config_roundtrip_and_secrets_never_leak(client, idp_keypair):
|
|
key_pem, cert_pem = idp_keypair
|
|
saved = _configure_saml(client, cert_pem)
|
|
assert saved["configured"] is True
|
|
assert saved["provider_type"] == "saml"
|
|
assert saved["provisioned_users"] == 0
|
|
# The PEM IdP certificate is public data, the SP private key is not.
|
|
assert saved["x509_certificate"].strip().startswith("-----BEGIN CERTIFICATE-----")
|
|
assert "private" not in " ".join(saved.keys())
|
|
assert "sp_private_key" not in saved
|
|
|
|
r = client.get("/api/v2/sso/config")
|
|
body = r.text
|
|
assert "BEGIN PRIVATE KEY" not in body
|
|
assert key_pem.splitlines()[1] not in body
|
|
|
|
# Public providers endpoint advertises the login button.
|
|
providers = client.get("/api/v2/sso/providers").json()
|
|
assert providers["providers"][0]["type"] == "saml"
|
|
assert providers["providers"][0]["login_url"].startswith("/auth/saml/login")
|
|
assert providers["sso_only"] is False
|
|
|
|
|
|
def test_config_secret_roundtrip_keeps_existing_value(client, idp_keypair):
|
|
"""A blank client_secret on save must not wipe the stored one."""
|
|
_, cert_pem = idp_keypair
|
|
_admin_session(client)
|
|
headers = {"X-CSRF-Token": "csrf-sso-test-token"}
|
|
|
|
oidc = {
|
|
"provider_type": "oidc",
|
|
"issuer_url": "https://issuer.test",
|
|
"client_id": "flowdeck-client",
|
|
"client_secret": "super-secret",
|
|
}
|
|
assert client.post("/api/v2/sso/config", json=oidc, headers=headers).status_code == 200
|
|
|
|
from app.services import sso_provisioning as sso
|
|
|
|
cfg = sso.get_sso_config()
|
|
assert sso.client_secret_value(cfg) == "super-secret"
|
|
assert cfg["client_secret"] != "super-secret" # encrypted at rest
|
|
|
|
# Save again with a blank secret → value preserved.
|
|
oidc_blank = dict(oidc, client_secret="")
|
|
assert client.post("/api/v2/sso/config", json=oidc_blank, headers=headers).status_code == 200
|
|
cfg = sso.get_sso_config()
|
|
assert sso.client_secret_value(cfg) == "super-secret"
|
|
|
|
|
|
def test_config_disable_turns_sso_off(client, idp_keypair):
|
|
_, cert_pem = idp_keypair
|
|
_configure_saml(client, cert_pem)
|
|
assert client.get("/api/v2/sso/providers").json()["providers"]
|
|
|
|
r = client.delete(
|
|
"/api/v2/sso/config", headers={"X-CSRF-Token": "csrf-sso-test-token"}
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
# SSO off → no button, local login still available.
|
|
assert client.get("/api/v2/sso/providers").json() == {
|
|
"providers": [],
|
|
"sso_only": False,
|
|
}
|
|
assert client.get("/auth/saml/login", follow_redirects=False).status_code == 404
|
|
|
|
|
|
def test_workspaces_and_sync_endpoints(client, idp_keypair):
|
|
_, cert_pem = idp_keypair
|
|
uid, _ = _admin_session(client)
|
|
_save_config(client, _saml_payload(cert_pem))
|
|
|
|
from app.db import get_conn
|
|
|
|
with get_conn() as conn:
|
|
ws = conn.execute(
|
|
"INSERT INTO workspaces (name, owner_id) VALUES ('Corp', ?)", (uid,)
|
|
).lastrowid
|
|
conn.execute(
|
|
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'editor')",
|
|
(ws, uid),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO users (login, full_name, email, auth_method) VALUES "
|
|
"('sso-user', 'SSO User', '[email protected]', 'saml')"
|
|
)
|
|
conn.commit()
|
|
|
|
data = client.get("/api/v2/sso/workspaces").json()
|
|
assert any(w["id"] == ws for w in data["workspaces"])
|
|
assert data["provisioned_users"] == 1
|
|
|
|
r = client.post(
|
|
"/api/v2/sso/sync", headers={"X-CSRF-Token": "csrf-sso-test-token"}
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["status"] == "ok"
|
|
assert r.json()["users"] == 1
|
|
|
|
|
|
# ── SP metadata ────────────────────────────────────────────────────────────
|
|
|
|
def test_metadata_is_valid_sp_descriptor(client, idp_keypair):
|
|
_, cert_pem = idp_keypair
|
|
_configure_saml(client, cert_pem)
|
|
|
|
r = client.get("/auth/saml/metadata")
|
|
assert r.status_code == 200
|
|
assert "application/samlmetadata+xml" in r.headers["content-type"]
|
|
xml = r.text
|
|
assert "<md:EntityDescriptor" in xml or "<EntityDescriptor" in xml
|
|
assert 'entityID="http://testserver/auth/saml/metadata"' in xml
|
|
assert 'Location="http://testserver/auth/saml/callback"' in xml
|
|
assert "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" in xml
|
|
assert "<md:KeyDescriptor" in xml or "<KeyDescriptor" in xml # SP keypair generated → cert published
|
|
assert IDP_ENTITY not in xml # SP metadata never embeds the IdP
|
|
|
|
|
|
def test_metadata_404_without_config(client):
|
|
assert client.get("/auth/saml/metadata").status_code == 404
|
|
|
|
|
|
# ── SAML login flow ────────────────────────────────────────────────────────
|
|
|
|
def test_saml_login_redirects_to_idp(client, idp_keypair):
|
|
_, cert_pem = idp_keypair
|
|
_configure_saml(client, cert_pem)
|
|
rid, csrf, location = _start_saml(client)
|
|
|
|
from app.db import get_conn
|
|
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM sso_requests WHERE id=? AND kind='saml_authn'", (rid,)
|
|
).fetchone()
|
|
assert row is not None
|
|
assert row["relay_state"] == csrf
|
|
assert row["used"] == 0
|
|
assert row["next_path"] == "/workspaces"
|
|
|
|
|
|
def test_saml_full_login_flow_provisions_user(client, idp_keypair):
|
|
key_pem, cert_pem = idp_keypair
|
|
_configure_saml(client, cert_pem)
|
|
sp_entity, acs = _sp_endpoints(client)
|
|
rid, csrf, _ = _start_saml(client, next_path="/library")
|
|
_clear_session_cookie(client) # the callback will set the SSO session
|
|
|
|
signed = build_saml_response(
|
|
key_pem, cert_pem,
|
|
sp_entity=sp_entity, acs_url=acs, in_response_to=rid,
|
|
name_id="[email protected]",
|
|
attributes={
|
|
"email": "[email protected]",
|
|
"displayName": "Alice Corptest",
|
|
"groups": ["FlowDeck Admins"],
|
|
},
|
|
)
|
|
r = _post_response(client, signed, f"{rid}.{csrf}")
|
|
assert r.status_code == 302, r.text
|
|
assert r.headers["location"] == "/library"
|
|
assert client.cookies.get("flowdeck_session")
|
|
|
|
from app.db import get_conn
|
|
|
|
with get_conn() as conn:
|
|
user = conn.execute(
|
|
"SELECT * FROM users WHERE email='[email protected]'"
|
|
).fetchone()
|
|
req = conn.execute(
|
|
"SELECT used FROM sso_requests WHERE id=?", (rid,)
|
|
).fetchone()
|
|
hist = conn.execute(
|
|
"SELECT * FROM sso_login_history WHERE success=1"
|
|
).fetchall()
|
|
assert user is not None
|
|
assert user["auth_method"] == "saml"
|
|
assert user["full_name"] == "Alice Corptest"
|
|
assert req["used"] == 1
|
|
assert len(hist) == 1
|
|
assert "[email protected]" in hist[0]["sso_identifier"]
|
|
|
|
|
|
def test_saml_login_merges_existing_local_account(client, idp_keypair):
|
|
"""§7.1 — email match → the existing account is reused, not duplicated."""
|
|
key_pem, cert_pem = idp_keypair
|
|
_configure_saml(client, cert_pem)
|
|
sp_entity, acs = _sp_endpoints(client)
|
|
|
|
from app.db import get_conn
|
|
from app.password_utils import hash_password
|
|
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO users (login, full_name, email, password_hash) VALUES "
|
|
"('alice_local', 'Alice Local', '[email protected]', ?)",
|
|
(hash_password("secret123"),),
|
|
)
|
|
before = conn.execute(
|
|
"SELECT id FROM users WHERE email='[email protected]'"
|
|
).fetchone()["id"]
|
|
conn.commit()
|
|
|
|
rid, csrf, _ = _start_saml(client)
|
|
signed = build_saml_response(
|
|
key_pem, cert_pem, sp_entity=sp_entity, acs_url=acs,
|
|
in_response_to=rid, name_id="[email protected]",
|
|
attributes={"email": "[email protected]", "displayName": "Alice From IdP"},
|
|
)
|
|
r = _post_response(client, signed, f"{rid}.{csrf}")
|
|
assert r.status_code == 302, r.text
|
|
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT id, auth_method, full_name FROM users WHERE email='[email protected]'"
|
|
).fetchall()
|
|
merged = conn.execute("SELECT * FROM users WHERE id=?", (before,)).fetchone()
|
|
assert len(rows) == 1 # no duplicate account
|
|
assert merged["auth_method"] == "saml"
|
|
assert merged["full_name"] == "Alice From IdP"
|
|
|
|
|
|
def test_saml_auto_provision_can_be_disabled(client, idp_keypair):
|
|
key_pem, cert_pem = idp_keypair
|
|
_configure_saml(client, cert_pem, auto_provision=0)
|
|
sp_entity, acs = _sp_endpoints(client)
|
|
rid, csrf, _ = _start_saml(client)
|
|
|
|
signed = build_saml_response(
|
|
key_pem, cert_pem, sp_entity=sp_entity, acs_url=acs,
|
|
in_response_to=rid, name_id="[email protected]",
|
|
attributes={"email": "[email protected]"},
|
|
)
|
|
r = _post_response(client, signed, f"{rid}.{csrf}")
|
|
assert r.status_code == 403
|
|
assert "auto-provisioning is disabled" in r.text
|
|
assert not client.cookies.get("flowdeck_session") or True
|
|
|
|
from app.db import get_conn
|
|
|
|
with get_conn() as conn:
|
|
user = conn.execute(
|
|
"SELECT 1 FROM users WHERE email='[email protected]'"
|
|
).fetchone()
|
|
failed = conn.execute(
|
|
"SELECT * FROM sso_login_history WHERE success=0"
|
|
).fetchall()
|
|
assert user is None
|
|
assert len(failed) == 1
|
|
assert "auto-provisioning" in failed[0]["error_message"]
|
|
|
|
|
|
# ── SAML security (design §5.1) ────────────────────────────────────────────
|
|
|
|
def test_saml_rejects_invalid_signature(client, idp_keypair):
|
|
_, cert_pem = idp_keypair
|
|
_configure_saml(client, cert_pem)
|
|
sp_entity, acs = _sp_endpoints(client)
|
|
rid, csrf, _ = _start_saml(client)
|
|
|
|
# Signed by a *different* key than the configured IdP certificate.
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
|
|
rogue_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
from cryptography.hazmat.primitives import serialization
|
|
|
|
rogue_pem = rogue_key.private_bytes(
|
|
serialization.Encoding.PEM,
|
|
serialization.PrivateFormat.PKCS8,
|
|
serialization.NoEncryption(),
|
|
).decode()
|
|
|
|
signed = build_saml_response(
|
|
rogue_pem, cert_pem, sp_entity=sp_entity, acs_url=acs, in_response_to=rid,
|
|
)
|
|
r = _post_response(client, signed, f"{rid}.{csrf}")
|
|
assert r.status_code == 403
|
|
# No new session is ever issued on a rejected assertion.
|
|
assert client.cookies.get("flowdeck_session") == _ADMIN_TOKENS[id(client)]
|
|
history = _history(client)
|
|
assert history and history[0]["success"] is False
|
|
|
|
|
|
def test_saml_rejects_expired_assertion(client, idp_keypair):
|
|
key_pem, cert_pem = idp_keypair
|
|
_configure_saml(client, cert_pem)
|
|
sp_entity, acs = _sp_endpoints(client)
|
|
rid, csrf, _ = _start_saml(client)
|
|
|
|
signed = build_saml_response(
|
|
key_pem, cert_pem, sp_entity=sp_entity, acs_url=acs, in_response_to=rid,
|
|
not_on_or_after=dt.datetime.now(dt.UTC) - dt.timedelta(minutes=5),
|
|
)
|
|
r = _post_response(client, signed, f"{rid}.{csrf}")
|
|
assert r.status_code == 403
|
|
assert "expired" in r.text.lower() or "timestamp" in r.text.lower()
|
|
|
|
|
|
def test_saml_rejects_wrong_audience(client, idp_keypair):
|
|
key_pem, cert_pem = idp_keypair
|
|
_configure_saml(client, cert_pem)
|
|
sp_entity, acs = _sp_endpoints(client)
|
|
rid, csrf, _ = _start_saml(client)
|
|
|
|
signed = build_saml_response(
|
|
key_pem, cert_pem, sp_entity=sp_entity, acs_url=acs, in_response_to=rid,
|
|
audience="https://other-sp.example.com",
|
|
)
|
|
r = _post_response(client, signed, f"{rid}.{csrf}")
|
|
assert r.status_code == 403
|
|
assert "audience" in r.text.lower()
|
|
|
|
|
|
def test_saml_rejects_wrong_destination(client, idp_keypair):
|
|
key_pem, cert_pem = idp_keypair
|
|
_configure_saml(client, cert_pem)
|
|
sp_entity, acs = _sp_endpoints(client)
|
|
rid, csrf, _ = _start_saml(client)
|
|
|
|
signed = build_saml_response(
|
|
key_pem, cert_pem, sp_entity=sp_entity, acs_url=acs, in_response_to=rid,
|
|
destination="https://evil.example.com/auth/saml/callback",
|
|
)
|
|
r = _post_response(client, signed, f"{rid}.{csrf}")
|
|
assert r.status_code == 403
|
|
assert "received at" in r.text
|
|
|
|
|
|
def test_saml_rejects_wrong_issuer(client, idp_keypair):
|
|
key_pem, cert_pem = idp_keypair
|
|
_configure_saml(client, cert_pem)
|
|
sp_entity, acs = _sp_endpoints(client)
|
|
rid, csrf, _ = _start_saml(client)
|
|
|
|
signed = build_saml_response(
|
|
key_pem, cert_pem, sp_entity=sp_entity, acs_url=acs, in_response_to=rid,
|
|
issuer="https://other-idp.example.com",
|
|
)
|
|
r = _post_response(client, signed, f"{rid}.{csrf}")
|
|
assert r.status_code == 403
|
|
assert "issuer" in r.text.lower()
|
|
|
|
|
|
def test_saml_rejects_in_response_to_mismatch(client, idp_keypair):
|
|
key_pem, cert_pem = idp_keypair
|
|
_configure_saml(client, cert_pem)
|
|
sp_entity, acs = _sp_endpoints(client)
|
|
rid, csrf, _ = _start_saml(client)
|
|
|
|
signed = build_saml_response(
|
|
key_pem, cert_pem, sp_entity=sp_entity, acs_url=acs,
|
|
in_response_to="_some-other-request",
|
|
)
|
|
r = _post_response(client, signed, f"{rid}.{csrf}")
|
|
assert r.status_code == 403
|
|
assert "InResponseTo" in r.text or "inresponseto" in r.text.lower()
|
|
|
|
|
|
def test_saml_rejects_replay(client, idp_keypair):
|
|
"""The same assertion can only open ONE session (single-use request)."""
|
|
key_pem, cert_pem = idp_keypair
|
|
_configure_saml(client, cert_pem)
|
|
sp_entity, acs = _sp_endpoints(client)
|
|
rid, csrf, _ = _start_saml(client)
|
|
|
|
signed = build_saml_response(
|
|
key_pem, cert_pem, sp_entity=sp_entity, acs_url=acs, in_response_to=rid,
|
|
name_id="[email protected]", attributes={"email": "[email protected]"},
|
|
)
|
|
relay = f"{rid}.{csrf}"
|
|
first = _post_response(client, signed, relay)
|
|
assert first.status_code == 302, first.text
|
|
|
|
client.cookies.delete("flowdeck_session")
|
|
second = _post_response(client, signed, relay)
|
|
assert second.status_code == 403
|
|
assert "Replayed" in second.text
|
|
assert client.cookies.get("flowdeck_session") is None
|
|
|
|
|
|
def test_saml_rejects_tampered_relay_state(client, idp_keypair):
|
|
key_pem, cert_pem = idp_keypair
|
|
_configure_saml(client, cert_pem)
|
|
sp_entity, acs = _sp_endpoints(client)
|
|
rid, csrf, _ = _start_saml(client)
|
|
|
|
signed = build_saml_response(
|
|
key_pem, cert_pem, sp_entity=sp_entity, acs_url=acs, in_response_to=rid,
|
|
)
|
|
# Right AuthnRequest id, wrong CSRF token.
|
|
r = _post_response(client, signed, f"{rid}.{pysecrets.token_hex(16)}")
|
|
assert r.status_code == 403
|
|
assert "Unknown or expired" in r.text
|
|
|
|
# Unknown id entirely (IdP-initiated / forged).
|
|
r2 = _post_response(client, signed, f"_forged.{csrf}")
|
|
assert r2.status_code == 403
|
|
|
|
|
|
def test_saml_rejects_non_success_status(client, idp_keypair):
|
|
key_pem, cert_pem = idp_keypair
|
|
_configure_saml(client, cert_pem)
|
|
sp_entity, acs = _sp_endpoints(client)
|
|
rid, csrf, _ = _start_saml(client)
|
|
|
|
signed = build_saml_response(
|
|
key_pem, cert_pem, sp_entity=sp_entity, acs_url=acs, in_response_to=rid,
|
|
status_ok=False,
|
|
)
|
|
r = _post_response(client, signed, f"{rid}.{csrf}")
|
|
assert r.status_code == 403
|
|
|
|
|
|
def test_saml_callback_without_config(client):
|
|
r = client.post(
|
|
"/auth/saml/callback",
|
|
data={"SAMLResponse": base64.b64encode(b"<xml/>").decode(), "RelayState": "x.y"},
|
|
follow_redirects=False,
|
|
)
|
|
assert r.status_code == 403
|
|
|
|
|
|
# ── Group mapping ──────────────────────────────────────────────────────────
|
|
|
|
def test_group_mapping_sets_workspace_role(client, idp_keypair):
|
|
key_pem, cert_pem = idp_keypair
|
|
uid, _ = _admin_session(client)
|
|
_save_config(
|
|
client,
|
|
_saml_payload(
|
|
cert_pem,
|
|
groups_mapping=[
|
|
{"sso_group": "FlowDeck Admins", "workspace_role": "admin"},
|
|
{"sso_group": "FlowDeck Members", "workspace_role": "viewer"},
|
|
],
|
|
),
|
|
)
|
|
from app.db import get_conn
|
|
|
|
with get_conn() as conn:
|
|
default_ws = conn.execute(
|
|
"INSERT INTO workspaces (name, owner_id) VALUES ('Default WS', ?)", (uid,)
|
|
).lastrowid
|
|
mapped_ws = conn.execute(
|
|
"INSERT INTO workspaces (name, owner_id) VALUES ('Mapped WS', ?)", (uid,)
|
|
).lastrowid
|
|
conn.commit()
|
|
# Re-save so default_workspace_id / mapping point at real workspaces.
|
|
_save_config(
|
|
client,
|
|
_saml_payload(
|
|
cert_pem,
|
|
default_workspace_id=default_ws,
|
|
groups_mapping=[
|
|
{"sso_group": "FlowDeck Admins", "workspace_role": "admin",
|
|
"workspace_id": mapped_ws},
|
|
],
|
|
),
|
|
)
|
|
|
|
sp_entity, acs = _sp_endpoints(client)
|
|
rid, csrf, _ = _start_saml(client)
|
|
signed = build_saml_response(
|
|
key_pem, cert_pem, sp_entity=sp_entity, acs_url=acs, in_response_to=rid,
|
|
name_id="[email protected]",
|
|
attributes={"email": "[email protected]", "groups": ["FlowDeck Admins", "Other Group"]},
|
|
)
|
|
r = _post_response(client, signed, f"{rid}.{csrf}")
|
|
assert r.status_code == 302, r.text
|
|
|
|
with get_conn() as conn:
|
|
user = conn.execute(
|
|
"SELECT id FROM users WHERE email='[email protected]'"
|
|
).fetchone()
|
|
mapped = conn.execute(
|
|
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
|
(mapped_ws, user["id"]),
|
|
).fetchone()
|
|
default_member = conn.execute(
|
|
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
|
(default_ws, user["id"]),
|
|
).fetchone()
|
|
assert mapped is not None and mapped["role"] == "admin"
|
|
assert default_member is not None # default workspace membership
|
|
|
|
|
|
# ── SSO-only restriction (design §7.1) ─────────────────────────────────────
|
|
|
|
def test_sso_only_blocks_local_login_and_register(client, idp_keypair):
|
|
_, cert_pem = idp_keypair
|
|
_configure_saml(client, cert_pem, sso_only=1)
|
|
|
|
from app.db import get_conn
|
|
from app.password_utils import hash_password
|
|
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO users (login, full_name, email, password_hash) VALUES "
|
|
"('localguy', 'Local Guy', '[email protected]', ?)",
|
|
(hash_password("secret123"),),
|
|
)
|
|
# An admin with a local password — the allowed back door (§7.1).
|
|
conn.execute(
|
|
"INSERT INTO users (login, full_name, email, is_admin, password_hash) VALUES "
|
|
"('keepadmin', 'Keep Admin', '[email protected]', 1, ?)",
|
|
(hash_password("adminpass1"),),
|
|
)
|
|
conn.commit()
|
|
|
|
r = client.post(
|
|
"/auth/local-login", json={"email": "localguy", "password": "secret123"}
|
|
)
|
|
assert r.status_code == 403
|
|
assert "SSO" in r.json()["error"]
|
|
|
|
r2 = client.post(
|
|
"/auth/register", json={"email": "[email protected]", "password": "secret123"}
|
|
)
|
|
assert r2.status_code == 403
|
|
|
|
# Admins keep their local door open (design: « l'admin garde le sien »).
|
|
_clear_session_cookie(client)
|
|
admin_login = client.post(
|
|
"/auth/local-login", json={"email": "keepadmin", "password": "adminpass1"}
|
|
)
|
|
assert admin_login.status_code == 200, admin_login.text
|
|
|
|
# The login page tells the UI to hide the local form.
|
|
page = client.get("/auth/login?provider=local")
|
|
assert page.status_code == 200
|
|
assert 'id="sso-section"' in page.text
|
|
assert "loadSsoProviders" in page.text
|
|
|
|
|
|
def test_local_login_still_works_when_sso_is_optional(client, idp_keypair):
|
|
_, cert_pem = idp_keypair
|
|
_configure_saml(client, cert_pem, sso_only=0)
|
|
|
|
from app.db import get_conn
|
|
from app.password_utils import hash_password
|
|
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO users (login, full_name, email, password_hash) VALUES "
|
|
"('mixeduser', 'Mixed', '[email protected]', ?)",
|
|
(hash_password("secret123"),),
|
|
)
|
|
conn.commit()
|
|
|
|
_clear_session_cookie(client) # local login will set its own session
|
|
r = client.post("/auth/local-login", json={"email": "mixeduser", "password": "secret123"})
|
|
assert r.status_code == 200, r.text
|
|
assert client.cookies.get("flowdeck_session")
|
|
|
|
|
|
# ── Rate limiting (design §5.2) ────────────────────────────────────────────
|
|
|
|
def test_saml_login_rate_limited(client, idp_keypair):
|
|
_, cert_pem = idp_keypair
|
|
_configure_saml(client, cert_pem)
|
|
|
|
from app.config import settings
|
|
from app.routers import sso as sso_router
|
|
|
|
previous = settings.rate_limit_enabled
|
|
settings.rate_limit_enabled = True
|
|
sso_router._rate_store.clear()
|
|
try:
|
|
statuses = [
|
|
client.get("/auth/saml/login", follow_redirects=False).status_code
|
|
for _ in range(7)
|
|
]
|
|
finally:
|
|
settings.rate_limit_enabled = previous
|
|
sso_router._rate_store.clear()
|
|
|
|
assert statuses[:5] == [302] * 5
|
|
assert statuses[5:] == [429, 429]
|
|
|
|
|
|
# ── SLO ────────────────────────────────────────────────────────────────────
|
|
|
|
def test_saml_logout_sp_initiated_builds_logout_request(client, idp_keypair):
|
|
key_pem, cert_pem = idp_keypair
|
|
_configure_saml(client, cert_pem)
|
|
sp_entity, acs = _sp_endpoints(client)
|
|
rid, csrf, _ = _start_saml(client)
|
|
|
|
signed = build_saml_response(
|
|
key_pem, cert_pem, sp_entity=sp_entity, acs_url=acs, in_response_to=rid,
|
|
name_id="[email protected]", attributes={"email": "[email protected]"},
|
|
)
|
|
_clear_session_cookie(client) # only the SSO session must be in the jar
|
|
assert _post_response(client, signed, f"{rid}.{csrf}").status_code == 302
|
|
|
|
from app.auth.session import SessionManager
|
|
|
|
sso_sid = SessionManager.session_id(client.cookies.get("flowdeck_session") or "")
|
|
assert sso_sid # the SSO session we are about to kill
|
|
|
|
# /auth/logout hands SSO sessions over to the SLO route…
|
|
r = client.get("/auth/logout", follow_redirects=False)
|
|
assert r.status_code == 302
|
|
assert r.headers["location"].startswith("/auth/saml/logout")
|
|
|
|
# …which revokes the session locally and redirects to the IdP SLO URL.
|
|
r2 = client.get("/auth/saml/logout?next=/auth/login?provider=local",
|
|
follow_redirects=False)
|
|
assert r2.status_code == 302
|
|
assert r2.headers["location"].startswith(IDP_SLO)
|
|
assert "SAMLRequest" in r2.headers["location"]
|
|
assert r2.headers.get("set-cookie", "").find("flowdeck_session=;") >= 0 or \
|
|
"flowdeck_session=\"\"" in r2.headers.get("set-cookie", "")
|
|
|
|
# The SSO session is revoked server-side (the admin's stays alive).
|
|
from app.db import get_conn
|
|
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT revoked FROM user_sessions WHERE id=?", (sso_sid,)
|
|
).fetchone()
|
|
assert row and row["revoked"] == 1
|
|
cookie = client.cookies.get("flowdeck_session")
|
|
assert not cookie or SessionManager.decode_session(cookie) is None
|
|
|
|
|
|
# ── OIDC ───────────────────────────────────────────────────────────────────
|
|
|
|
def _oidc_payload(**overrides) -> dict:
|
|
payload = {
|
|
"provider_type": "oidc",
|
|
"name": "Corp OIDC",
|
|
"issuer_url": "https://issuer.corp.test",
|
|
"client_id": "flowdeck-client",
|
|
"client_secret": "s3cr3t",
|
|
"scope": "openid profile email groups",
|
|
"attribute_mapping": {},
|
|
"groups_mapping": [],
|
|
"auto_provision": 1,
|
|
"sso_only": 0,
|
|
}
|
|
payload.update(overrides)
|
|
return payload
|
|
|
|
|
|
def _sign_id_token(key_pem: str, kid: str, payload: dict) -> str:
|
|
import warnings
|
|
|
|
from authlib.jose import jwt as jose_jwt
|
|
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", DeprecationWarning)
|
|
token = jose_jwt.encode({"alg": "RS256", "kid": kid}, payload, key_pem)
|
|
# IdP token endpoints return str JSON; authlib encodes to bytes.
|
|
return token.decode() if isinstance(token, bytes) else token
|
|
|
|
|
|
@pytest.fixture
|
|
def oidc_provider_env(monkeypatch, idp_keypair, client):
|
|
"""Save an OIDC config + mock discovery / token / userinfo / JWKS with a
|
|
signed ID token (no real network call can ever happen)."""
|
|
_admin_session(client)
|
|
_save_config(client, _oidc_payload())
|
|
key_pem, cert_pem = idp_keypair
|
|
import base64 as b64
|
|
import time as t
|
|
|
|
from cryptography.hazmat.primitives import serialization
|
|
|
|
from app.auth.providers import oidc_provider as op
|
|
|
|
priv = serialization.load_pem_private_key(key_pem.encode(), password=None)
|
|
pub = priv.public_key().public_numbers()
|
|
|
|
def b64u(i: int) -> str:
|
|
raw = i.to_bytes((i.bit_length() + 7) // 8, "big")
|
|
return b64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
|
|
|
jwk = {
|
|
"kty": "RSA", "kid": "oidc-test-key", "use": "sig", "alg": "RS256",
|
|
"n": b64u(pub.n), "e": b64u(pub.e),
|
|
}
|
|
doc = {
|
|
"issuer": "https://issuer.corp.test",
|
|
"authorization_endpoint": "https://issuer.corp.test/authorize",
|
|
"token_endpoint": "https://issuer.corp.test/token",
|
|
"userinfo_endpoint": "https://issuer.corp.test/userinfo",
|
|
"jwks_uri": "https://issuer.corp.test/jwks",
|
|
"end_session_endpoint": "https://issuer.corp.test/logout",
|
|
}
|
|
|
|
async def fake_discover(issuer_url: str) -> dict:
|
|
return doc
|
|
|
|
async def fake_exchange(doc_arg, **kwargs):
|
|
now = t.time()
|
|
id_token = _sign_id_token(key_pem, "oidc-test-key", {
|
|
"iss": "https://issuer.corp.test",
|
|
"aud": "flowdeck-client",
|
|
"sub": "oidc-sub-1",
|
|
"exp": int(now) + 300,
|
|
"iat": int(now),
|
|
"nonce": kwargs.get("_nonce") or _EXPECTED_NONCE["value"],
|
|
"email": "[email protected]",
|
|
"name": "Carol OIDC",
|
|
"groups": ["FlowDeck Admins"],
|
|
})
|
|
return {"id_token": id_token, "access_token": "at-123", "token_type": "Bearer"}
|
|
|
|
async def fake_userinfo(doc_arg, access_token: str) -> dict:
|
|
return {"preferred_username": "carol-oidc"}
|
|
|
|
async def fake_jwks(doc_arg) -> dict:
|
|
return {"keys": [jwk]}
|
|
|
|
monkeypatch.setattr(op, "discover", fake_discover)
|
|
monkeypatch.setattr(op, "exchange_code", fake_exchange)
|
|
monkeypatch.setattr(op, "fetch_userinfo", fake_userinfo)
|
|
import app.routers.sso as sso_router
|
|
|
|
monkeypatch.setattr(sso_router, "_fetch_jwks", fake_jwks)
|
|
return {"nonce": _EXPECTED_NONCE}
|
|
|
|
|
|
# The nonce the mocked token must carry is written here by the login step.
|
|
_EXPECTED_NONCE = {"value": ""}
|
|
|
|
|
|
def test_oidc_login_redirects_with_pkce(client, oidc_provider_env):
|
|
r = client.get("/auth/oidc/login?next=/workspaces", follow_redirects=False)
|
|
assert r.status_code == 302, r.text
|
|
location = r.headers["location"]
|
|
query = parse_qs(urlparse(location).query)
|
|
assert location.startswith("https://issuer.corp.test/authorize")
|
|
assert query["response_type"] == ["code"]
|
|
assert query["client_id"] == ["flowdeck-client"]
|
|
assert query["code_challenge_method"] == ["S256"]
|
|
assert query["scope"] == ["openid profile email groups"]
|
|
state = query["state"][0]
|
|
_EXPECTED_NONCE["value"] = query["nonce"][0]
|
|
|
|
from app.db import get_conn
|
|
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM sso_requests WHERE id=? AND kind='oidc'", (state,)
|
|
).fetchone()
|
|
assert row is not None
|
|
assert row["relay_state"] == _EXPECTED_NONCE["value"]
|
|
assert row["code_verifier"]
|
|
|
|
|
|
def test_oidc_login_404_without_config(client):
|
|
assert client.get("/auth/oidc/login", follow_redirects=False).status_code == 404
|
|
|
|
|
|
def test_oidc_full_login_flow(client, oidc_provider_env):
|
|
r = client.get("/auth/oidc/login?next=/library", follow_redirects=False)
|
|
assert r.status_code == 302
|
|
query = parse_qs(urlparse(r.headers["location"]).query)
|
|
_EXPECTED_NONCE["value"] = query["nonce"][0]
|
|
state = query["state"][0]
|
|
_clear_session_cookie(client) # callback will set the SSO session
|
|
|
|
cb = client.get(
|
|
f"/auth/oidc/callback?code=abc123&state={state}", follow_redirects=False
|
|
)
|
|
assert cb.status_code == 302, cb.text
|
|
assert cb.headers["location"] == "/library"
|
|
assert client.cookies.get("flowdeck_session")
|
|
|
|
from app.db import get_conn
|
|
|
|
with get_conn() as conn:
|
|
user = conn.execute(
|
|
"SELECT * FROM users WHERE email='[email protected]'"
|
|
).fetchone()
|
|
hist = conn.execute(
|
|
"SELECT * FROM sso_login_history WHERE provider_type='oidc' AND success=1"
|
|
).fetchall()
|
|
assert user is not None
|
|
assert user["auth_method"] == "oidc"
|
|
assert user["full_name"] == "Carol OIDC"
|
|
assert len(hist) == 1
|
|
# Groups came through the attribute mapping.
|
|
assert "FlowDeck Admins" in hist[0]["sso_identifier"]
|
|
|
|
|
|
def test_oidc_rejects_unknown_state(client, oidc_provider_env):
|
|
admin_token = client.cookies.get("flowdeck_session")
|
|
cb = client.get(
|
|
"/auth/oidc/callback?code=abc&state=forged-state", follow_redirects=False
|
|
)
|
|
assert cb.status_code == 403
|
|
# Session untouched — still exactly the admin cookie we started with.
|
|
assert client.cookies.get("flowdeck_session") == admin_token
|
|
|
|
|
|
def test_oidc_rejects_replayed_state(client, oidc_provider_env):
|
|
r = client.get("/auth/oidc/login", follow_redirects=False)
|
|
query = parse_qs(urlparse(r.headers["location"]).query)
|
|
_EXPECTED_NONCE["value"] = query["nonce"][0]
|
|
state = query["state"][0]
|
|
|
|
first = client.get(
|
|
f"/auth/oidc/callback?code=abc&state={state}", follow_redirects=False
|
|
)
|
|
assert first.status_code == 302, first.text
|
|
client.cookies.delete("flowdeck_session")
|
|
|
|
second = client.get(
|
|
f"/auth/oidc/callback?code=abc&state={state}", follow_redirects=False
|
|
)
|
|
assert second.status_code == 403
|
|
assert client.cookies.get("flowdeck_session") is None
|
|
|
|
|
|
def test_oidc_rejects_wrong_nonce(client, monkeypatch, idp_keypair):
|
|
"""ID token nonce must match the one of the issued request (token swap)."""
|
|
import time as t
|
|
|
|
import app.routers.sso as sso_router
|
|
from app.auth.providers import oidc_provider as op
|
|
|
|
async def fake_discover(issuer_url):
|
|
return {
|
|
"issuer": "https://issuer.corp.test",
|
|
"authorization_endpoint": "https://issuer.corp.test/authorize",
|
|
"token_endpoint": "https://issuer.corp.test/token",
|
|
"jwks_uri": "https://issuer.corp.test/jwks",
|
|
}
|
|
|
|
async def fake_exchange(doc, **kwargs):
|
|
key_pem, _ = idp_keypair
|
|
id_token = _sign_id_token(key_pem, "oidc-test-key", {
|
|
"iss": "https://issuer.corp.test",
|
|
"aud": "flowdeck-client",
|
|
"sub": "attacker",
|
|
"exp": int(t.time()) + 300,
|
|
"iat": int(t.time()),
|
|
"nonce": "not-the-right-nonce",
|
|
"email": "[email protected]",
|
|
})
|
|
return {"id_token": id_token, "access_token": "at"}
|
|
|
|
async def fake_jwks(doc):
|
|
import base64 as b64
|
|
|
|
from cryptography.hazmat.primitives import serialization
|
|
|
|
key_pem, _ = idp_keypair
|
|
priv = serialization.load_pem_private_key(key_pem.encode(), password=None)
|
|
pub = priv.public_key().public_numbers()
|
|
|
|
def b64u(i):
|
|
raw = i.to_bytes((i.bit_length() + 7) // 8, "big")
|
|
return b64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
|
|
|
return {"keys": [{"kty": "RSA", "kid": "oidc-test-key", "use": "sig",
|
|
"alg": "RS256", "n": b64u(pub.n), "e": b64u(pub.e)}]}
|
|
|
|
monkeypatch.setattr(op, "discover", fake_discover)
|
|
monkeypatch.setattr(op, "exchange_code", fake_exchange)
|
|
monkeypatch.setattr(op, "fetch_userinfo", lambda *a, **k: _empty_userinfo())
|
|
monkeypatch.setattr(sso_router, "_fetch_jwks", fake_jwks)
|
|
|
|
_admin_session(client)
|
|
_save_config(client, _oidc_payload())
|
|
r = client.get("/auth/oidc/login", follow_redirects=False)
|
|
assert r.status_code == 302, r.text
|
|
query = parse_qs(urlparse(r.headers["location"]).query)
|
|
state = query["state"][0]
|
|
|
|
cb = client.get(
|
|
f"/auth/oidc/callback?code=abc&state={state}", follow_redirects=False
|
|
)
|
|
assert cb.status_code == 403
|
|
assert "nonce" in cb.text.lower()
|
|
# Rejected token → no session issued, the admin cookie is untouched.
|
|
assert client.cookies.get("flowdeck_session") == _ADMIN_TOKENS[id(client)]
|
|
|
|
from app.db import get_conn
|
|
|
|
with get_conn() as conn:
|
|
user = conn.execute(
|
|
"SELECT 1 FROM users WHERE email='[email protected]'"
|
|
).fetchone()
|
|
assert user is None
|
|
|
|
|
|
async def _empty_userinfo() -> dict:
|
|
return {}
|
|
|
|
|
|
# ── Env bootstrap fallback (design §3.3) ───────────────────────────────────
|
|
|
|
def test_env_config_fallback_when_table_empty(client, monkeypatch):
|
|
"""SSO_* env vars bootstrap a config when no admin ever saved one."""
|
|
from app.config import settings
|
|
|
|
monkeypatch.setattr(settings, "sso_provider", "saml")
|
|
monkeypatch.setattr(settings, "sso_entity_id", IDP_ENTITY)
|
|
monkeypatch.setattr(settings, "sso_sso_url", IDP_SSO)
|
|
monkeypatch.setattr(settings, "sso_x509_certificate", "-----BEGIN CERTIFICATE-----\nX\n-----END CERTIFICATE-----")
|
|
|
|
from app.services import sso_provisioning as sso
|
|
|
|
cfg = sso.get_sso_config()
|
|
assert cfg is not None
|
|
assert cfg["_source"] == "env"
|
|
assert cfg["provider_type"] == "saml"
|
|
|
|
r = client.get("/api/v2/sso/providers")
|
|
assert r.json()["providers"][0]["type"] == "saml"
|
|
# The env config is visible but not admin-editable in the UI.
|
|
_admin_session(client)
|
|
assert client.get("/api/v2/sso/config").json()["source"] == "env"
|
|
|
|
|
|
# ── Audit trail ────────────────────────────────────────────────────────────
|
|
|
|
def test_history_endpoint_requires_admin_and_lists_attempts(client, idp_keypair):
|
|
key_pem, cert_pem = idp_keypair
|
|
_configure_saml(client, cert_pem)
|
|
sp_entity, acs = _sp_endpoints(client)
|
|
rid, csrf, _ = _start_saml(client)
|
|
|
|
# one failure (bad signature) + one success
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
|
|
rogue = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
rogue_pem = rogue.private_bytes(
|
|
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
|
|
serialization.NoEncryption(),
|
|
).decode()
|
|
bad = build_saml_response(
|
|
rogue_pem, cert_pem, sp_entity=sp_entity, acs_url=acs, in_response_to=rid,
|
|
)
|
|
assert _post_response(client, bad, f"{rid}.{csrf}").status_code == 403
|
|
|
|
rid2, csrf2, _ = _start_saml(client)
|
|
_clear_session_cookie(client) # success → server sets the SSO session
|
|
good = build_saml_response(
|
|
key_pem, cert_pem, sp_entity=sp_entity, acs_url=acs, in_response_to=rid2,
|
|
name_id="[email protected]", attributes={"email": "[email protected]"},
|
|
)
|
|
assert _post_response(client, good, f"{rid2}.{csrf2}").status_code == 302
|
|
_be_admin(client) # history endpoint needs the admin session back
|
|
|
|
history = _history(client)
|
|
assert len(history) >= 2
|
|
successes = [h for h in history if h["success"]]
|
|
failures = [h for h in history if not h["success"]]
|
|
assert len(successes) == 1
|
|
assert len(failures) == 1
|
|
assert failures[0]["error_message"]
|
|
|
|
# anonymous → 401
|
|
anon(client)
|
|
assert client.get("/api/v2/sso/history").status_code == 401
|
|
|
|
|
|
# ── PermissionManager extension (design §7.2) ─────────────────────────────
|
|
|
|
def test_permission_manager_sso_methods(client, idp_keypair):
|
|
"""§7.2 — is_sso_only_workspace / get_sso_roles / sync_sso_permissions."""
|
|
_, cert_pem = idp_keypair
|
|
uid, _ = _admin_session(client)
|
|
|
|
from app.db import get_conn
|
|
from app.services.permission_manager import PermissionManager
|
|
|
|
with get_conn() as conn:
|
|
ws = conn.execute(
|
|
"INSERT INTO workspaces (name, owner_id) VALUES ('Corp', ?)", (uid,)
|
|
).lastrowid
|
|
conn.execute(
|
|
"INSERT INTO users (login, full_name, email, auth_method) VALUES "
|
|
"('pm_local', 'PM Local', '[email protected]', 'local')"
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO users (login, full_name, email, auth_method) VALUES "
|
|
"('pm_sso', 'PM SSO', '[email protected]', 'saml')"
|
|
)
|
|
conn.commit()
|
|
pm_sso_id = conn.execute("SELECT id FROM users WHERE login='pm_sso'").fetchone()["id"]
|
|
pm_local_id = conn.execute("SELECT id FROM users WHERE login='pm_local'").fetchone()["id"]
|
|
|
|
pm_sso = PermissionManager(pm_sso_id)
|
|
pm_local = PermissionManager(pm_local_id)
|
|
|
|
# No SSO-only flag, no grants yet.
|
|
assert pm_sso.is_sso_only_workspace(ws) is False
|
|
assert pm_sso.get_sso_roles(workspace_id=ws) == []
|
|
# A local account never carries SSO roles, even once it becomes a member.
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'editor')",
|
|
(ws, pm_local_id),
|
|
)
|
|
conn.commit()
|
|
assert pm_local.get_sso_roles(workspace_id=ws) == []
|
|
|
|
# Configure the mapping, then re-apply it through the manager.
|
|
_save_config(
|
|
client,
|
|
_saml_payload(
|
|
cert_pem,
|
|
groups_mapping=[
|
|
{"sso_group": "FlowDeck Admins", "workspace_role": "admin", "workspace_id": ws}
|
|
],
|
|
),
|
|
)
|
|
touched = pm_sso.sync_sso_permissions(pm_sso_id, ["FlowDeck Admins"], ws)
|
|
assert touched == [ws]
|
|
assert pm_sso.role_in_workspace(ws) == "admin" # cache invalidated
|
|
assert pm_sso.get_sso_roles(workspace_id=ws) == ["admin"]
|
|
# Unknown group → nothing touched (and the role stays put).
|
|
assert pm_sso.sync_sso_permissions(pm_sso_id, ["Nope Group"], ws) == []
|
|
assert pm_sso.get_sso_roles(workspace_id=ws) == ["admin"]
|
|
|
|
# SSO-only switch is instance-wide (design §7.1).
|
|
_save_config(client, _saml_payload(cert_pem, sso_only=1))
|
|
assert pm_sso.is_sso_only_workspace(ws) is True
|
|
assert PermissionManager(uid).is_sso_only_workspace(ws) is True
|