mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
20df7b3034
* feat: OIDC SSO authentication with PKCE, auto-provisioning, and role mapping Add OpenID Connect as a fourth authentication method, enabling single sign-on via any OIDC provider (Okta, Azure AD, Google, Keycloak). Opt-in via env vars (TURNSTONE_OIDC_ISSUER, CLIENT_ID, CLIENT_SECRET). Security: - Authorization Code Flow with PKCE (S256) - State/nonce parameters with database-backed pending store (multi-node safe) - JWKS signature validation with async fetch + key rotation retry - Algorithm allowlist from JWKS key (not token header) prevents confusion - Identity matching exclusively by (issuer, sub) — prevents account takeover - password_enabled=false enforced server-side, not just UI - Rate limiting on both authorize and callback endpoints - OIDC users get "!oidc" password sentinel (bcrypt rejects naturally) - ID token validated for iss, aud, exp, nonce Features: - Auto-provisioning with username deduplication on first login - Claim-based role mapping with IdP demotion propagation (revokes stale roles) - "Continue with [Provider]" SSO button on login page - OIDC-only mode hides password form - Setup wizard required before OIDC login (admin bootstrap) Storage: migration 018 (oidc_identities + oidc_pending_states tables), 8 new protocol methods on both SQLite and PostgreSQL backends. 66 new tests (2273 total). * fix: address PR #71 review feedback (18 items) Bugs fixed: - OIDC success redirect now fetches permissions via new /auth/whoami endpoint before completing login (fixes permission-gating in UI) - Remove double decodeURIComponent on oidc_error (URLSearchParams already decodes; extra call throws on stray %) - Authorize rate limiter returns redirect instead of JSON 429 (endpoint reached via browser navigation, not fetch) - Lazy JWKS fetch in callback when startup discovery failed (IdP recovery without restart) - Startup exception handlers now log with exc_info=True - PostgreSQL pop_oidc_pending_state uses DELETE...RETURNING for true atomicity (eliminates TOCTOU) Behavior: - New OIDC users without role mapping get builtin-viewer by default (assigned_by="oidc-default", not revoked by role sync) Documentation fixes: - Role mapping: sync semantics (add + revoke stale), not "additive only" - PASSWORD_ENABLED=false blocks ALL password logins including admin - Algorithm: asymmetric allowlist, not per-key derivation - PlantUML diagram updated for role revocation API spec fixes: - Removed error_codes=[302] from callback (302 is success redirect) - Added /auth/whoami to both server + console specs - Regenerated TypeScript SDK OpenAPI snapshots (23 + 51 paths) * fix: address PR #71 round 2 review feedback (10 items) Rate limiting: - Authorize endpoint now calls record() after check() so the rate limiter actually counts attempts (was a no-op before) OIDC resilience: - Split startup try/except: discovery failure disables OIDC, JWKS prefetch failure leaves OIDC enabled for lazy retry on first login - JWKS unavailable message changed to "temporarily unavailable" (was misleadingly "not configured") - create_oidc_pending_state raises on collision instead of OR IGNORE (prevents silent insert drop on state collision) - SQLite pop_oidc_pending_state uses BEGIN IMMEDIATE for write lock (eliminates TOCTOU race) Frontend: - OIDC error display deferred 300ms so showLogin()'s async status fetch doesn't clear it via _switchMode → _clearError API spec: - OIDC authorize/callback endpoints now declare response_code=302 - Added AuthWhoamiResponse Pydantic model for /auth/whoami - Regenerated TypeScript SDK OpenAPI snapshots Documentation: - Diagram: JWKS "cached at startup, refreshed on-demand" (was "hourly") - Added TODO(tech-debt) comments on Host header redirect_uri sites
814 lines
29 KiB
Python
814 lines
29 KiB
Python
"""Tests for turnstone.core.oidc — OIDC authentication support."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import hashlib
|
|
import urllib.parse
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import httpx
|
|
import jwt as pyjwt
|
|
import pytest
|
|
|
|
from turnstone.core.oidc import (
|
|
OIDCConfig,
|
|
OIDCError,
|
|
apply_role_mapping,
|
|
build_authorize_url,
|
|
discover_oidc,
|
|
generate_pkce_pair,
|
|
load_oidc_config,
|
|
provision_oidc_user,
|
|
validate_id_token,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_config(**overrides) -> OIDCConfig:
|
|
"""Build a test OIDCConfig with sensible defaults."""
|
|
defaults = {
|
|
"enabled": True,
|
|
"issuer": "https://idp.example.com",
|
|
"client_id": "my-client",
|
|
"client_secret": "my-secret",
|
|
"scopes": "openid email profile",
|
|
"provider_name": "TestIDP",
|
|
"role_claim": "",
|
|
"role_map": {},
|
|
"password_enabled": True,
|
|
"authorization_endpoint": "https://idp.example.com/authorize",
|
|
"token_endpoint": "https://idp.example.com/token",
|
|
"userinfo_endpoint": "https://idp.example.com/userinfo",
|
|
"jwks_uri": "https://idp.example.com/.well-known/jwks.json",
|
|
}
|
|
defaults.update(overrides)
|
|
return OIDCConfig(**defaults)
|
|
|
|
|
|
def _mock_storage(**overrides):
|
|
"""Build a MagicMock with sensible storage defaults."""
|
|
s = MagicMock()
|
|
s.get_oidc_identity.return_value = overrides.get("identity")
|
|
s.get_user.return_value = overrides.get("user")
|
|
s.get_user_by_username.return_value = overrides.get("user_by_username")
|
|
s.get_role.return_value = overrides.get("role")
|
|
return s
|
|
|
|
|
|
def _mock_async_client(mock_get):
|
|
"""Build a patched httpx.AsyncClient context manager for async tests."""
|
|
|
|
class _AsyncCtx:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *args):
|
|
pass
|
|
|
|
async def get(self, url):
|
|
return await mock_get(url)
|
|
|
|
return _AsyncCtx()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config Loading
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLoadOIDCConfig:
|
|
def test_load_oidc_config_from_env(self, monkeypatch):
|
|
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
|
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
|
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
|
monkeypatch.setenv("TURNSTONE_OIDC_SCOPES", "openid")
|
|
monkeypatch.setenv("TURNSTONE_OIDC_PROVIDER_NAME", "Okta")
|
|
|
|
with patch("turnstone.core.config.load_config", return_value={}):
|
|
cfg = load_oidc_config()
|
|
|
|
assert cfg.enabled is True
|
|
assert cfg.issuer == "https://auth.example.com"
|
|
assert cfg.client_id == "cid"
|
|
assert cfg.client_secret == "csecret"
|
|
assert cfg.scopes == "openid"
|
|
assert cfg.provider_name == "Okta"
|
|
|
|
def test_load_oidc_config_disabled_when_missing(self, monkeypatch):
|
|
monkeypatch.delenv("TURNSTONE_OIDC_ISSUER", raising=False)
|
|
monkeypatch.delenv("TURNSTONE_OIDC_CLIENT_ID", raising=False)
|
|
monkeypatch.delenv("TURNSTONE_OIDC_CLIENT_SECRET", raising=False)
|
|
|
|
with patch("turnstone.core.config.load_config", return_value={}):
|
|
cfg = load_oidc_config()
|
|
|
|
assert cfg.enabled is False
|
|
|
|
def test_load_oidc_config_partial_env(self, monkeypatch):
|
|
"""Only issuer set, no client_id -> enabled=False."""
|
|
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
|
monkeypatch.delenv("TURNSTONE_OIDC_CLIENT_ID", raising=False)
|
|
monkeypatch.delenv("TURNSTONE_OIDC_CLIENT_SECRET", raising=False)
|
|
|
|
with patch("turnstone.core.config.load_config", return_value={}):
|
|
cfg = load_oidc_config()
|
|
|
|
assert cfg.enabled is False
|
|
assert cfg.issuer == "https://auth.example.com"
|
|
assert cfg.client_id == ""
|
|
|
|
def test_load_oidc_config_role_map_parsing(self, monkeypatch):
|
|
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
|
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
|
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
|
monkeypatch.setenv("TURNSTONE_OIDC_ROLE_CLAIM", "roles")
|
|
monkeypatch.setenv("TURNSTONE_OIDC_ROLE_MAP", "admin:builtin-admin,eng:builtin-operator")
|
|
|
|
with patch("turnstone.core.config.load_config", return_value={}):
|
|
cfg = load_oidc_config()
|
|
|
|
assert cfg.role_claim == "roles"
|
|
assert cfg.role_map == {"admin": "builtin-admin", "eng": "builtin-operator"}
|
|
|
|
def test_load_oidc_config_password_enabled_false(self, monkeypatch):
|
|
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
|
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
|
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
|
monkeypatch.setenv("TURNSTONE_OIDC_PASSWORD_ENABLED", "false")
|
|
|
|
with patch("turnstone.core.config.load_config", return_value={}):
|
|
cfg = load_oidc_config()
|
|
|
|
assert cfg.enabled is True
|
|
assert cfg.password_enabled is False
|
|
|
|
def test_load_oidc_config_password_enabled_true(self, monkeypatch):
|
|
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
|
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
|
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
|
monkeypatch.setenv("TURNSTONE_OIDC_PASSWORD_ENABLED", "true")
|
|
|
|
with patch("turnstone.core.config.load_config", return_value={}):
|
|
cfg = load_oidc_config()
|
|
|
|
assert cfg.password_enabled is True
|
|
|
|
def test_load_oidc_config_role_map_empty_entries(self, monkeypatch):
|
|
"""Role map with empty/whitespace entries should be silently skipped."""
|
|
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
|
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
|
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
|
monkeypatch.setenv("TURNSTONE_OIDC_ROLE_MAP", "admin:builtin-admin, , :, foo:")
|
|
|
|
with patch("turnstone.core.config.load_config", return_value={}):
|
|
cfg = load_oidc_config()
|
|
|
|
assert cfg.role_map == {"admin": "builtin-admin"}
|
|
|
|
def test_load_oidc_config_defaults(self, monkeypatch):
|
|
"""Defaults for scopes and provider_name when not set."""
|
|
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
|
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
|
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
|
monkeypatch.delenv("TURNSTONE_OIDC_SCOPES", raising=False)
|
|
monkeypatch.delenv("TURNSTONE_OIDC_PROVIDER_NAME", raising=False)
|
|
|
|
with patch("turnstone.core.config.load_config", return_value={}):
|
|
cfg = load_oidc_config()
|
|
|
|
assert cfg.scopes == "openid email profile"
|
|
assert cfg.provider_name == "SSO"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PKCE
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPKCE:
|
|
def test_generate_pkce_pair(self):
|
|
verifier, challenge = generate_pkce_pair()
|
|
|
|
# Verifier should be URL-safe base64
|
|
assert isinstance(verifier, str)
|
|
assert len(verifier) > 40 # 48 bytes -> ~64 chars
|
|
|
|
# Challenge should be base64url SHA-256 of verifier
|
|
expected_digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
expected_challenge = base64.urlsafe_b64encode(expected_digest).rstrip(b"=").decode("ascii")
|
|
assert challenge == expected_challenge
|
|
|
|
def test_pkce_challenge_matches_verifier(self):
|
|
"""Manually compute challenge and verify it matches."""
|
|
verifier, challenge = generate_pkce_pair()
|
|
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
manual_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
assert challenge == manual_challenge
|
|
|
|
def test_pkce_pair_uniqueness(self):
|
|
"""Each call should produce a unique pair."""
|
|
v1, c1 = generate_pkce_pair()
|
|
v2, c2 = generate_pkce_pair()
|
|
assert v1 != v2
|
|
assert c1 != c2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Authorization URL
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBuildAuthorizeURL:
|
|
def test_build_authorize_url_contains_required_params(self):
|
|
config = _make_config()
|
|
verifier, _ = generate_pkce_pair()
|
|
url = build_authorize_url(
|
|
config=config,
|
|
redirect_uri="https://app.example.com/callback",
|
|
state="test-state",
|
|
nonce="test-nonce",
|
|
code_verifier=verifier,
|
|
)
|
|
|
|
assert url.startswith("https://idp.example.com/authorize?")
|
|
assert "response_type=code" in url
|
|
assert "client_id=my-client" in url
|
|
assert "redirect_uri=" in url
|
|
assert "scope=openid" in url
|
|
assert "state=test-state" in url
|
|
assert "nonce=test-nonce" in url
|
|
assert "code_challenge=" in url
|
|
assert "code_challenge_method=S256" in url
|
|
|
|
def test_build_authorize_url_pkce(self):
|
|
"""code_challenge in URL should be correct S256 of the verifier."""
|
|
config = _make_config()
|
|
verifier, _ = generate_pkce_pair()
|
|
|
|
url = build_authorize_url(
|
|
config=config,
|
|
redirect_uri="https://app.example.com/callback",
|
|
state="s",
|
|
nonce="n",
|
|
code_verifier=verifier,
|
|
)
|
|
|
|
# Extract code_challenge from URL
|
|
parsed = urllib.parse.urlparse(url)
|
|
params = urllib.parse.parse_qs(parsed.query)
|
|
actual_challenge = params["code_challenge"][0]
|
|
|
|
# Compute expected challenge
|
|
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
expected = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
assert actual_challenge == expected
|
|
|
|
def test_build_authorize_url_redirect_uri_encoded(self):
|
|
config = _make_config()
|
|
verifier, _ = generate_pkce_pair()
|
|
redirect = "https://app.example.com/callback?extra=1"
|
|
|
|
url = build_authorize_url(
|
|
config=config,
|
|
redirect_uri=redirect,
|
|
state="s",
|
|
nonce="n",
|
|
code_verifier=verifier,
|
|
)
|
|
|
|
# The redirect_uri should be URL-encoded
|
|
parsed = urllib.parse.urlparse(url)
|
|
params = urllib.parse.parse_qs(parsed.query)
|
|
assert params["redirect_uri"][0] == redirect
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ID Token Validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateIDToken:
|
|
_FAKE_JWKS = {"keys": [{"kid": "key1", "kty": "RSA", "n": "abc", "e": "AQAB"}]}
|
|
|
|
def test_validate_id_token_nonce_mismatch(self):
|
|
"""Nonce mismatch should raise OIDCError."""
|
|
config = _make_config()
|
|
|
|
mock_pyjwk = MagicMock()
|
|
mock_pyjwk.return_value.key = "fake-key"
|
|
|
|
with (
|
|
patch("jwt.get_unverified_header", return_value={"kid": "key1", "alg": "RS256"}),
|
|
patch("jwt.PyJWK", mock_pyjwk),
|
|
patch("jwt.decode", return_value={"sub": "user1", "nonce": "wrong-nonce"}),
|
|
pytest.raises(OIDCError, match="nonce mismatch"),
|
|
):
|
|
validate_id_token(
|
|
raw_token="fake.jwt.token",
|
|
jwks_data=self._FAKE_JWKS,
|
|
config=config,
|
|
nonce="expected-nonce",
|
|
)
|
|
|
|
def test_validate_id_token_success(self):
|
|
"""Successful validation returns decoded claims."""
|
|
config = _make_config()
|
|
|
|
mock_pyjwk = MagicMock()
|
|
mock_pyjwk.return_value.key = "fake-key"
|
|
|
|
expected_claims = {
|
|
"sub": "user1",
|
|
"email": "user@example.com",
|
|
"nonce": "test-nonce",
|
|
}
|
|
|
|
with (
|
|
patch("jwt.get_unverified_header", return_value={"kid": "key1", "alg": "RS256"}),
|
|
patch("jwt.PyJWK", mock_pyjwk),
|
|
patch("jwt.decode", return_value=expected_claims) as mock_decode,
|
|
):
|
|
claims = validate_id_token(
|
|
raw_token="fake.jwt.token",
|
|
jwks_data=self._FAKE_JWKS,
|
|
config=config,
|
|
nonce="test-nonce",
|
|
)
|
|
|
|
assert claims == expected_claims
|
|
mock_decode.assert_called_once_with(
|
|
"fake.jwt.token",
|
|
"fake-key",
|
|
algorithms=[
|
|
"RS256",
|
|
"RS384",
|
|
"RS512",
|
|
"ES256",
|
|
"ES384",
|
|
"ES512",
|
|
"PS256",
|
|
"PS384",
|
|
"PS512",
|
|
],
|
|
audience="my-client",
|
|
issuer="https://idp.example.com",
|
|
)
|
|
|
|
def test_validate_id_token_kid_not_found(self):
|
|
"""Unknown kid raises OIDCError with descriptive message."""
|
|
config = _make_config()
|
|
jwks_data = {"keys": [{"kid": "other-key", "kty": "RSA"}]}
|
|
|
|
with (
|
|
patch("jwt.get_unverified_header", return_value={"kid": "unknown", "alg": "RS256"}),
|
|
pytest.raises(OIDCError, match="not found in JWKS"),
|
|
):
|
|
validate_id_token(
|
|
raw_token="bad.token",
|
|
jwks_data=jwks_data,
|
|
config=config,
|
|
nonce="n",
|
|
)
|
|
|
|
def test_validate_id_token_invalid_jwt(self):
|
|
"""Invalid JWT raises OIDCError."""
|
|
config = _make_config()
|
|
|
|
mock_pyjwk = MagicMock()
|
|
mock_pyjwk.return_value.key = "fake-key"
|
|
|
|
with (
|
|
patch("jwt.get_unverified_header", return_value={"kid": "key1", "alg": "RS256"}),
|
|
patch("jwt.PyJWK", mock_pyjwk := MagicMock(return_value=MagicMock(key="fake-key"))),
|
|
patch("jwt.decode", side_effect=pyjwt.InvalidTokenError("expired")),
|
|
pytest.raises(OIDCError, match="ID token validation failed"),
|
|
):
|
|
validate_id_token(
|
|
raw_token="expired.token",
|
|
jwks_data=self._FAKE_JWKS,
|
|
config=config,
|
|
nonce="n",
|
|
)
|
|
|
|
def test_validate_id_token_invalid_header(self):
|
|
"""Malformed token header raises OIDCError."""
|
|
config = _make_config()
|
|
|
|
with (
|
|
patch("jwt.get_unverified_header", side_effect=pyjwt.DecodeError("bad header")),
|
|
pytest.raises(OIDCError, match="Invalid ID token header"),
|
|
):
|
|
validate_id_token(
|
|
raw_token="garbage",
|
|
jwks_data=self._FAKE_JWKS,
|
|
config=config,
|
|
nonce="n",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# User Provisioning
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestProvisionOIDCUser:
|
|
def test_provision_oidc_user_existing(self):
|
|
"""Existing identity -> returns existing user, updates last_login."""
|
|
config = _make_config()
|
|
existing_user = {
|
|
"user_id": "u1",
|
|
"username": "alice",
|
|
"display_name": "Alice",
|
|
"password_hash": "!oidc",
|
|
}
|
|
existing_identity = {
|
|
"issuer": "https://idp.example.com",
|
|
"subject": "sub-123",
|
|
"user_id": "u1",
|
|
"email": "alice@example.com",
|
|
"created": "2024-01-01T00:00:00",
|
|
"last_login": "2024-01-01T00:00:00",
|
|
}
|
|
storage = _mock_storage(identity=existing_identity, user=existing_user)
|
|
|
|
claims = {"sub": "sub-123", "email": "alice@example.com", "name": "Alice"}
|
|
user = provision_oidc_user(storage, config, claims)
|
|
|
|
assert user["user_id"] == "u1"
|
|
assert user["username"] == "alice"
|
|
storage.update_oidc_identity_login.assert_called_once()
|
|
# Should not create a new user
|
|
storage.create_user.assert_not_called()
|
|
storage.create_oidc_identity.assert_not_called()
|
|
|
|
def test_provision_oidc_user_new(self):
|
|
"""No identity -> creates user + identity."""
|
|
config = _make_config()
|
|
storage = _mock_storage()
|
|
|
|
# After create_user, get_user should return the new user
|
|
new_user = {
|
|
"user_id": "u-new",
|
|
"username": "bob",
|
|
"display_name": "Bob",
|
|
"password_hash": "!oidc",
|
|
}
|
|
storage.get_user.return_value = new_user
|
|
|
|
claims = {"sub": "sub-456", "preferred_username": "bob", "email": "bob@example.com"}
|
|
|
|
with patch("turnstone.core.oidc.uuid") as mock_uuid:
|
|
mock_uuid.uuid4.return_value = MagicMock(hex="u-new-hex-00000000000000000000")
|
|
user = provision_oidc_user(storage, config, claims)
|
|
|
|
assert user["username"] == "bob"
|
|
storage.create_user.assert_called_once()
|
|
storage.create_oidc_identity.assert_called_once()
|
|
# Verify create_oidc_identity was called with correct issuer and sub
|
|
call_args = storage.create_oidc_identity.call_args
|
|
assert call_args[0][0] == "https://idp.example.com" # issuer
|
|
assert call_args[0][1] == "sub-456" # subject
|
|
|
|
def test_provision_oidc_user_username_dedup(self):
|
|
"""First username taken -> appends suffix."""
|
|
config = _make_config()
|
|
storage = _mock_storage()
|
|
|
|
# First call: username "bob" exists; second call: "bob2" doesn't exist
|
|
storage.get_user_by_username.side_effect = [
|
|
{"user_id": "u-other", "username": "bob"}, # "bob" taken
|
|
None, # "bob2" available
|
|
]
|
|
new_user = {
|
|
"user_id": "u-new",
|
|
"username": "bob2",
|
|
"display_name": "Bob",
|
|
"password_hash": "!oidc",
|
|
}
|
|
storage.get_user.return_value = new_user
|
|
|
|
claims = {"sub": "sub-789", "preferred_username": "bob", "email": "bob@example.com"}
|
|
user = provision_oidc_user(storage, config, claims)
|
|
|
|
assert user["username"] == "bob2"
|
|
# create_user should have been called with "bob2" as username
|
|
call_args = storage.create_user.call_args
|
|
assert call_args[0][1] == "bob2"
|
|
|
|
def test_provision_oidc_user_email_prefix(self):
|
|
"""No preferred_username -> uses email prefix."""
|
|
config = _make_config()
|
|
storage = _mock_storage()
|
|
|
|
new_user = {
|
|
"user_id": "u-new",
|
|
"username": "charlie",
|
|
"display_name": "charlie@example.com",
|
|
"password_hash": "!oidc",
|
|
}
|
|
storage.get_user.return_value = new_user
|
|
|
|
claims = {"sub": "sub-abc", "email": "charlie@example.com"}
|
|
provision_oidc_user(storage, config, claims)
|
|
|
|
# create_user should have been called with "charlie" (email prefix)
|
|
call_args = storage.create_user.call_args
|
|
assert call_args[0][1] == "charlie"
|
|
|
|
def test_provision_oidc_user_missing_user_raises(self):
|
|
"""Identity references missing user -> raises OIDCError."""
|
|
config = _make_config()
|
|
existing_identity = {
|
|
"issuer": "https://idp.example.com",
|
|
"subject": "sub-orphan",
|
|
"user_id": "u-gone",
|
|
"email": "gone@example.com",
|
|
"created": "2024-01-01T00:00:00",
|
|
"last_login": "2024-01-01T00:00:00",
|
|
}
|
|
storage = _mock_storage(identity=existing_identity, user=None)
|
|
|
|
claims = {"sub": "sub-orphan", "email": "gone@example.com"}
|
|
with pytest.raises(OIDCError, match="missing user"):
|
|
provision_oidc_user(storage, config, claims)
|
|
|
|
def test_provision_oidc_user_fallback_username(self):
|
|
"""No preferred_username and no email -> falls back to 'user'."""
|
|
config = _make_config()
|
|
storage = _mock_storage()
|
|
new_user = {
|
|
"user_id": "u-new",
|
|
"username": "user",
|
|
"display_name": "",
|
|
"password_hash": "!oidc",
|
|
}
|
|
storage.get_user.return_value = new_user
|
|
|
|
claims = {"sub": "sub-noemail"}
|
|
provision_oidc_user(storage, config, claims)
|
|
|
|
call_args = storage.create_user.call_args
|
|
assert call_args[0][1] == "user"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Role Mapping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestApplyRoleMapping:
|
|
def test_apply_role_mapping_basic(self):
|
|
"""Maps claim value to role."""
|
|
config = _make_config(
|
|
role_claim="groups",
|
|
role_map={"admin": "builtin-admin"},
|
|
)
|
|
storage = _mock_storage(role={"role_id": "builtin-admin", "name": "Admin"})
|
|
|
|
claims = {"sub": "u1", "groups": "admin"}
|
|
apply_role_mapping(storage, "u1", claims, config)
|
|
|
|
storage.assign_role.assert_called_once_with("u1", "builtin-admin", "oidc")
|
|
|
|
def test_apply_role_mapping_list_claim(self):
|
|
"""Claim is a list of strings -> maps each."""
|
|
config = _make_config(
|
|
role_claim="roles",
|
|
role_map={"admin": "builtin-admin", "editor": "builtin-operator"},
|
|
)
|
|
storage = _mock_storage()
|
|
# get_role returns non-None for both roles
|
|
storage.get_role.return_value = {"role_id": "some-role"}
|
|
|
|
claims = {"sub": "u1", "roles": ["admin", "editor"]}
|
|
apply_role_mapping(storage, "u1", claims, config)
|
|
|
|
assert storage.assign_role.call_count == 2
|
|
|
|
def test_apply_role_mapping_no_config(self):
|
|
"""No role_claim configured -> no-op."""
|
|
config = _make_config(role_claim="", role_map={})
|
|
storage = _mock_storage()
|
|
|
|
claims = {"sub": "u1", "roles": "admin"}
|
|
apply_role_mapping(storage, "u1", claims, config)
|
|
|
|
storage.assign_role.assert_not_called()
|
|
|
|
def test_apply_role_mapping_unknown_role(self):
|
|
"""Claim maps to nonexistent role -> skipped."""
|
|
config = _make_config(
|
|
role_claim="groups",
|
|
role_map={"admin": "nonexistent-role"},
|
|
)
|
|
storage = _mock_storage(role=None) # role doesn't exist
|
|
|
|
claims = {"sub": "u1", "groups": "admin"}
|
|
apply_role_mapping(storage, "u1", claims, config)
|
|
|
|
storage.assign_role.assert_not_called()
|
|
|
|
def test_apply_role_mapping_no_matching_claim_value(self):
|
|
"""Claim value not in role_map -> no assignment."""
|
|
config = _make_config(
|
|
role_claim="groups",
|
|
role_map={"admin": "builtin-admin"},
|
|
)
|
|
storage = _mock_storage()
|
|
|
|
claims = {"sub": "u1", "groups": "viewer"} # "viewer" not in role_map
|
|
apply_role_mapping(storage, "u1", claims, config)
|
|
|
|
storage.assign_role.assert_not_called()
|
|
|
|
def test_apply_role_mapping_claim_missing(self):
|
|
"""Claim key not present in claims -> no-op."""
|
|
config = _make_config(
|
|
role_claim="groups",
|
|
role_map={"admin": "builtin-admin"},
|
|
)
|
|
storage = _mock_storage()
|
|
|
|
claims = {"sub": "u1"} # no "groups" key
|
|
apply_role_mapping(storage, "u1", claims, config)
|
|
|
|
storage.assign_role.assert_not_called()
|
|
|
|
def test_apply_role_mapping_no_role_map(self):
|
|
"""role_claim set but role_map empty -> no-op (early return)."""
|
|
config = _make_config(role_claim="groups", role_map={})
|
|
storage = _mock_storage()
|
|
|
|
claims = {"sub": "u1", "groups": "admin"}
|
|
apply_role_mapping(storage, "u1", claims, config)
|
|
|
|
storage.assign_role.assert_not_called()
|
|
|
|
def test_apply_role_mapping_revokes_stale_oidc_roles(self):
|
|
"""Roles previously assigned by OIDC but no longer in claims are revoked."""
|
|
config = _make_config(
|
|
role_claim="groups",
|
|
role_map={"admin": "builtin-admin", "eng": "builtin-operator"},
|
|
)
|
|
storage = _mock_storage()
|
|
storage.get_role.return_value = {"role_id": "some-role"}
|
|
# User currently has admin (via OIDC) and a manual role
|
|
storage.list_user_roles.return_value = [
|
|
{"role_id": "builtin-admin", "assigned_by": "oidc"},
|
|
{"role_id": "custom-role", "assigned_by": "admin-ui"},
|
|
]
|
|
|
|
# IdP now only says "eng", not "admin"
|
|
claims = {"sub": "u1", "groups": ["eng"]}
|
|
apply_role_mapping(storage, "u1", claims, config)
|
|
|
|
# builtin-admin should be revoked (OIDC-assigned, no longer in claims)
|
|
storage.unassign_role.assert_called_once_with("u1", "builtin-admin")
|
|
# custom-role should NOT be revoked (not assigned by OIDC)
|
|
|
|
def test_apply_role_mapping_preserves_manual_roles(self):
|
|
"""Manually assigned roles are never revoked by OIDC sync."""
|
|
config = _make_config(
|
|
role_claim="groups",
|
|
role_map={"admin": "builtin-admin"},
|
|
)
|
|
storage = _mock_storage()
|
|
storage.get_role.return_value = {"role_id": "some-role"}
|
|
storage.list_user_roles.return_value = [
|
|
{"role_id": "builtin-admin", "assigned_by": "admin-ui"},
|
|
]
|
|
|
|
# Claims have no groups at all
|
|
claims = {"sub": "u1"}
|
|
apply_role_mapping(storage, "u1", claims, config)
|
|
|
|
# Manual admin role must NOT be revoked
|
|
storage.unassign_role.assert_not_called()
|
|
|
|
def test_apply_role_mapping_revokes_all_oidc_roles_when_claim_absent(self):
|
|
"""When the claim is absent from the token, all OIDC-assigned roles are revoked."""
|
|
config = _make_config(
|
|
role_claim="groups",
|
|
role_map={"admin": "builtin-admin"},
|
|
)
|
|
storage = _mock_storage()
|
|
storage.get_role.return_value = {"role_id": "some-role"}
|
|
storage.list_user_roles.return_value = [
|
|
{"role_id": "builtin-admin", "assigned_by": "oidc"},
|
|
{"role_id": "builtin-operator", "assigned_by": "oidc"},
|
|
]
|
|
|
|
claims = {"sub": "u1"} # no "groups" key
|
|
apply_role_mapping(storage, "u1", claims, config)
|
|
|
|
assert storage.unassign_role.call_count == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Discovery (async)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDiscoverOIDC:
|
|
def test_discover_oidc_success(self):
|
|
"""Mock httpx response, verify endpoints populated."""
|
|
config = _make_config(
|
|
authorization_endpoint="",
|
|
token_endpoint="",
|
|
userinfo_endpoint="",
|
|
jwks_uri="",
|
|
)
|
|
|
|
discovery_doc = {
|
|
"authorization_endpoint": "https://idp.example.com/authorize",
|
|
"token_endpoint": "https://idp.example.com/token",
|
|
"userinfo_endpoint": "https://idp.example.com/userinfo",
|
|
"jwks_uri": "https://idp.example.com/.well-known/jwks.json",
|
|
}
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = discovery_doc
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
async def _run():
|
|
client = _mock_async_client(lambda url: _async_return(mock_response))
|
|
with patch("httpx.AsyncClient", return_value=client):
|
|
result = await discover_oidc(config)
|
|
|
|
assert result.authorization_endpoint == "https://idp.example.com/authorize"
|
|
assert result.token_endpoint == "https://idp.example.com/token"
|
|
assert result.userinfo_endpoint == "https://idp.example.com/userinfo"
|
|
assert result.jwks_uri == "https://idp.example.com/.well-known/jwks.json"
|
|
assert result.enabled is True
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_discover_oidc_failure(self):
|
|
"""Mock httpx error -> enabled=False returned."""
|
|
config = _make_config(
|
|
authorization_endpoint="",
|
|
token_endpoint="",
|
|
userinfo_endpoint="",
|
|
jwks_uri="",
|
|
)
|
|
|
|
async def _failing_get(url):
|
|
raise httpx.ConnectError("connection refused")
|
|
|
|
async def _run():
|
|
client = _mock_async_client(_failing_get)
|
|
with patch("httpx.AsyncClient", return_value=client):
|
|
result = await discover_oidc(config)
|
|
|
|
assert result.enabled is False
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_discover_oidc_no_issuer(self):
|
|
"""Empty issuer -> enabled=False."""
|
|
config = _make_config(issuer="")
|
|
|
|
async def _run():
|
|
result = await discover_oidc(config)
|
|
assert result.enabled is False
|
|
|
|
asyncio.run(_run())
|
|
|
|
def test_discover_oidc_missing_required_endpoints(self):
|
|
"""Discovery doc missing authorization_endpoint -> enabled=False."""
|
|
config = _make_config(
|
|
authorization_endpoint="",
|
|
token_endpoint="",
|
|
userinfo_endpoint="",
|
|
jwks_uri="",
|
|
)
|
|
|
|
# Document missing authorization_endpoint
|
|
discovery_doc = {
|
|
"token_endpoint": "https://idp.example.com/token",
|
|
"jwks_uri": "https://idp.example.com/.well-known/jwks.json",
|
|
}
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = discovery_doc
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
async def _run():
|
|
client = _mock_async_client(lambda url: _async_return(mock_response))
|
|
with patch("httpx.AsyncClient", return_value=client):
|
|
result = await discover_oidc(config)
|
|
|
|
assert result.enabled is False
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
async def _async_return(value):
|
|
"""Helper: return a value from an async function."""
|
|
return value
|