mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
e603a6a7d1
* fix(oidc): pin redirect URI via TURNSTONE_OIDC_REDIRECT_BASE env var OIDC redirect_uri was derived from the request Host header, which is unreliable behind reverse proxies. Add TURNSTONE_OIDC_REDIRECT_BASE (env var / config.toml) to pin the externally-reachable origin. Extract _build_oidc_redirect_uri() helper to deduplicate the authorize and callback handlers. Validate redirect_base at load time (must be scheme://host[:port], rejects paths/query strings/invalid schemes). * fix(oidc): reject redirect_base with missing hostname Addresses Copilot review: values like `https://` or `https://:443` passed validation but would produce invalid redirect URIs. * fix(oidc): reject redirect_base with userinfo or invalid port Addresses Copilot round 2: urlparse silently accepts user:pass@host and non-numeric ports. Now explicitly rejects both.
968 lines
37 KiB
Python
968 lines
37 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"
|
|
|
|
def test_load_oidc_config_redirect_base_from_env(self, monkeypatch):
|
|
"""TURNSTONE_OIDC_REDIRECT_BASE populates redirect_base."""
|
|
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_REDIRECT_BASE", "https://app.example.com")
|
|
|
|
with patch("turnstone.core.config.load_config", return_value={}):
|
|
cfg = load_oidc_config()
|
|
|
|
assert cfg.redirect_base == "https://app.example.com"
|
|
|
|
def test_load_oidc_config_redirect_base_strips_trailing_slash(self, monkeypatch):
|
|
"""Trailing slashes are stripped from redirect_base."""
|
|
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_REDIRECT_BASE", "https://app.example.com/")
|
|
|
|
with patch("turnstone.core.config.load_config", return_value={}):
|
|
cfg = load_oidc_config()
|
|
|
|
assert cfg.redirect_base == "https://app.example.com"
|
|
|
|
def test_load_oidc_config_redirect_base_default_empty(self, monkeypatch):
|
|
"""redirect_base defaults to empty string 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_REDIRECT_BASE", raising=False)
|
|
|
|
with patch("turnstone.core.config.load_config", return_value={}):
|
|
cfg = load_oidc_config()
|
|
|
|
assert cfg.redirect_base == ""
|
|
|
|
def test_load_oidc_config_redirect_base_rejects_path(self, monkeypatch):
|
|
"""redirect_base with a path component is rejected (falls back to empty)."""
|
|
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_REDIRECT_BASE", "https://app.example.com/subpath")
|
|
|
|
with patch("turnstone.core.config.load_config", return_value={}):
|
|
cfg = load_oidc_config()
|
|
|
|
assert cfg.redirect_base == ""
|
|
|
|
def test_load_oidc_config_redirect_base_rejects_no_scheme(self, monkeypatch):
|
|
"""redirect_base without a scheme is rejected."""
|
|
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_REDIRECT_BASE", "app.example.com")
|
|
|
|
with patch("turnstone.core.config.load_config", return_value={}):
|
|
cfg = load_oidc_config()
|
|
|
|
assert cfg.redirect_base == ""
|
|
|
|
def test_load_oidc_config_redirect_base_rejects_userinfo(self, monkeypatch):
|
|
"""redirect_base with userinfo (user:pass@host) is rejected."""
|
|
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_REDIRECT_BASE", "https://user:pass@app.example.com")
|
|
|
|
with patch("turnstone.core.config.load_config", return_value={}):
|
|
cfg = load_oidc_config()
|
|
|
|
assert cfg.redirect_base == ""
|
|
|
|
def test_load_oidc_config_redirect_base_rejects_invalid_port(self, monkeypatch):
|
|
"""redirect_base with non-numeric port is rejected."""
|
|
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_REDIRECT_BASE", "https://app.example.com:abc")
|
|
|
|
with patch("turnstone.core.config.load_config", return_value={}):
|
|
cfg = load_oidc_config()
|
|
|
|
assert cfg.redirect_base == ""
|
|
|
|
def test_load_oidc_config_redirect_base_rejects_missing_hostname(self, monkeypatch):
|
|
"""redirect_base without a hostname is rejected."""
|
|
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_REDIRECT_BASE", "https://")
|
|
|
|
with patch("turnstone.core.config.load_config", return_value={}):
|
|
cfg = load_oidc_config()
|
|
|
|
assert cfg.redirect_base == ""
|
|
|
|
def test_load_oidc_config_redirect_base_allows_http(self, monkeypatch):
|
|
"""http:// redirect_base is allowed (with warning) for local dev."""
|
|
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_REDIRECT_BASE", "http://localhost:8000")
|
|
|
|
with patch("turnstone.core.config.load_config", return_value={}):
|
|
cfg = load_oidc_config()
|
|
|
|
assert cfg.redirect_base == "http://localhost:8000"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Redirect URI Builder
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBuildOIDCRedirectURI:
|
|
"""Tests for ``_build_oidc_redirect_uri`` in auth.py."""
|
|
|
|
def _make_request(self, host="app.example.com", scheme="https", forwarded_proto=""):
|
|
"""Build a minimal mock Starlette Request."""
|
|
req = MagicMock()
|
|
headers = {"host": host}
|
|
if forwarded_proto:
|
|
headers["x-forwarded-proto"] = forwarded_proto
|
|
req.headers = headers
|
|
req.url.scheme = scheme
|
|
return req
|
|
|
|
def test_pinned_redirect_base(self):
|
|
"""When redirect_base is set, Host header is ignored."""
|
|
from turnstone.core.auth import _build_oidc_redirect_uri
|
|
|
|
config = _make_config(redirect_base="https://public.example.com")
|
|
req = self._make_request(host="internal-host:8080", scheme="http")
|
|
result = _build_oidc_redirect_uri(req, config)
|
|
assert result == "https://public.example.com/v1/api/auth/oidc/callback"
|
|
|
|
def test_fallback_to_host_header(self):
|
|
"""When redirect_base is empty, redirect URI uses Host header."""
|
|
from turnstone.core.auth import _build_oidc_redirect_uri
|
|
|
|
config = _make_config(redirect_base="")
|
|
req = self._make_request(host="app.example.com", scheme="https")
|
|
result = _build_oidc_redirect_uri(req, config)
|
|
assert result == "https://app.example.com/v1/api/auth/oidc/callback"
|
|
|
|
def test_fallback_x_forwarded_proto(self):
|
|
"""When redirect_base is empty and X-Forwarded-Proto is https, scheme is https."""
|
|
from turnstone.core.auth import _build_oidc_redirect_uri
|
|
|
|
config = _make_config(redirect_base="")
|
|
req = self._make_request(host="app.example.com", scheme="http", forwarded_proto="https")
|
|
result = _build_oidc_redirect_uri(req, config)
|
|
assert result == "https://app.example.com/v1/api/auth/oidc/callback"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|