mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-04 14:51:14 +01:00
The OIDC `return_to` sanitizer rejects values starting with `//` and then relies on `urlsplit` to catch anything carrying a netloc. A value such as `/\host` has no netloc, so it is stored in the session and used as the post-login redirect target and browsers resolve the backslash as a path separator, which lands the user outside the app after a successful login. `_normalize_return_to` now also rejects values whose path contains a backslash. That matches the frontend sanitizer in `authRedirect.ts`, which parses with `URL` and already discards those forms, so the two ends agree again. The check covers the path only, so query and fragment backslashes still round-trip, and it also catches the script-root case where `/app/\host` strips to `/\host`. ## Verification - New cases in `tests/core/test_oidc_routes.py` cover the rejected forms, including under a script root, and confirm `/`, `/settings` and `/search?q=x#frag` are unaffected. They fail on current main and pass here. - Full suite (3155), ruff, ruff format, basedpyright, vulture green.
759 lines
30 KiB
Python
759 lines
30 KiB
Python
"""Tests for OIDC Flask route handlers using Authlib transport."""
|
|
|
|
import os
|
|
import tempfile
|
|
from unittest.mock import Mock, patch
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
import pytest
|
|
from authlib.jose.errors import InvalidClaimError
|
|
from flask import Flask, redirect
|
|
|
|
from shelfmark.core.user_db import UserDB
|
|
|
|
|
|
def _get_oidc_error(resp) -> str | None:
|
|
"""Extract the oidc_error query param from a redirect response."""
|
|
assert resp.status_code == 302
|
|
parsed = urlparse(resp.headers["Location"])
|
|
params = parse_qs(parsed.query)
|
|
errors = params.get("oidc_error", [])
|
|
return errors[0] if errors else None
|
|
|
|
|
|
def _config_getter(values: dict[str, object]):
|
|
def _get(key: str, default: object = None, user_id: object = None):
|
|
return values.get(key, default)
|
|
|
|
return _get
|
|
|
|
|
|
@pytest.fixture
|
|
def db_path():
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
yield os.path.join(tmpdir, "shelfmark.db")
|
|
|
|
|
|
@pytest.fixture
|
|
def user_db(db_path):
|
|
db = UserDB(db_path)
|
|
db.initialize()
|
|
return db
|
|
|
|
|
|
MOCK_OIDC_CONFIG = {
|
|
"AUTH_METHOD": "oidc",
|
|
"OIDC_DISCOVERY_URL": "https://auth.example.com/.well-known/openid-configuration",
|
|
"OIDC_CLIENT_ID": "shelfmark",
|
|
"OIDC_CLIENT_SECRET": "secret123",
|
|
"OIDC_SCOPES": ["openid", "email", "profile", "groups"],
|
|
"OIDC_GROUP_CLAIM": "groups",
|
|
"OIDC_ADMIN_GROUP": "shelfmark-admins",
|
|
"OIDC_AUTO_PROVISION": True,
|
|
"OIDC_USE_ADMIN_GROUP": True,
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def app(user_db):
|
|
from shelfmark.core.oidc_routes import register_oidc_routes
|
|
|
|
test_app = Flask(__name__)
|
|
test_app.config["SECRET_KEY"] = "test-secret"
|
|
test_app.config["TESTING"] = True
|
|
register_oidc_routes(test_app, user_db)
|
|
return test_app
|
|
|
|
|
|
@pytest.fixture
|
|
def client(app):
|
|
return app.test_client()
|
|
|
|
|
|
class TestOIDCClientRegistration:
|
|
@patch(
|
|
"shelfmark.core.oidc_routes.app_config.get", side_effect=_config_getter(MOCK_OIDC_CONFIG)
|
|
)
|
|
@patch("shelfmark.core.oidc_routes.oauth.create_client")
|
|
@patch("shelfmark.core.oidc_routes.oauth.register")
|
|
def test_registers_client_with_pkce_and_expected_scopes(
|
|
self, mock_register, mock_create_client, _mock_config
|
|
):
|
|
from shelfmark.core.oidc_routes import _get_oidc_client
|
|
|
|
fake_client = Mock()
|
|
mock_create_client.return_value = fake_client
|
|
|
|
client_obj, config = _get_oidc_client()
|
|
|
|
assert client_obj is fake_client
|
|
assert config["OIDC_DISCOVERY_URL"] == MOCK_OIDC_CONFIG["OIDC_DISCOVERY_URL"]
|
|
kwargs = mock_register.call_args.kwargs
|
|
assert kwargs["name"] == "shelfmark_idp"
|
|
assert kwargs["server_metadata_url"] == MOCK_OIDC_CONFIG["OIDC_DISCOVERY_URL"]
|
|
assert kwargs["overwrite"] is True
|
|
assert kwargs["client_kwargs"]["code_challenge_method"] == "S256"
|
|
assert set(kwargs["client_kwargs"]["scope"].split()) == {
|
|
"openid",
|
|
"email",
|
|
"profile",
|
|
"groups",
|
|
}
|
|
|
|
@patch("shelfmark.core.oidc_routes.app_config.get")
|
|
@patch("shelfmark.core.oidc_routes.oauth.create_client")
|
|
@patch("shelfmark.core.oidc_routes.oauth.register")
|
|
def test_does_not_append_group_claim_when_admin_group_auth_disabled(
|
|
self, mock_register, mock_create_client, mock_config
|
|
):
|
|
from shelfmark.core.oidc_routes import _get_oidc_client
|
|
|
|
config = {
|
|
**MOCK_OIDC_CONFIG,
|
|
"OIDC_SCOPES": ["openid", "email", "profile"],
|
|
"OIDC_USE_ADMIN_GROUP": False,
|
|
"OIDC_GROUP_CLAIM": "groups",
|
|
}
|
|
mock_config.side_effect = _config_getter(config)
|
|
mock_create_client.return_value = Mock()
|
|
|
|
_get_oidc_client()
|
|
|
|
scope_str = mock_register.call_args.kwargs["client_kwargs"]["scope"]
|
|
assert "groups" not in scope_str.split()
|
|
|
|
|
|
class TestOIDCLoginEndpoint:
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_login_redirects_to_provider(self, mock_get_client, client):
|
|
fake_client = Mock()
|
|
fake_client.authorize_redirect.return_value = redirect("https://auth.example.com/authorize")
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
resp = client.get("/api/auth/oidc/login")
|
|
|
|
assert resp.status_code == 302
|
|
assert resp.headers["Location"].startswith("https://auth.example.com/authorize")
|
|
fake_client.authorize_redirect.assert_called_once()
|
|
redirect_uri = fake_client.authorize_redirect.call_args.args[0]
|
|
assert redirect_uri.endswith("/api/auth/oidc/callback")
|
|
|
|
@patch(
|
|
"shelfmark.core.oidc_routes._get_oidc_client", side_effect=ValueError("OIDC not configured")
|
|
)
|
|
def test_login_returns_500_when_not_configured(self, _mock_get_client, client):
|
|
resp = client.get("/api/auth/oidc/login")
|
|
assert resp.status_code == 500
|
|
assert resp.get_json()["error"] == "OIDC not configured"
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_login_stores_valid_return_to_in_session(self, mock_get_client, client):
|
|
fake_client = Mock()
|
|
fake_client.authorize_redirect.return_value = redirect("https://auth.example.com/authorize")
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
resp = client.get("/api/auth/oidc/login?return_to=%2F%3Fq%3DSanderson")
|
|
|
|
assert resp.status_code == 302
|
|
with client.session_transaction() as sess:
|
|
assert sess["oidc_return_to"] == "/?q=Sanderson"
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_login_ignores_unsafe_return_to(self, mock_get_client, client):
|
|
fake_client = Mock()
|
|
fake_client.authorize_redirect.return_value = redirect("https://auth.example.com/authorize")
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
resp = client.get("/api/auth/oidc/login?return_to=https://evil.example.com/phish")
|
|
|
|
assert resp.status_code == 302
|
|
with client.session_transaction() as sess:
|
|
assert "oidc_return_to" not in sess
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_login_ignores_api_return_to(self, mock_get_client, client):
|
|
fake_client = Mock()
|
|
fake_client.authorize_redirect.return_value = redirect("https://auth.example.com/authorize")
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
resp = client.get("/api/auth/oidc/login?return_to=%2Fapi%2Fusers")
|
|
|
|
assert resp.status_code == 302
|
|
with client.session_transaction() as sess:
|
|
assert "oidc_return_to" not in sess
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_login_ignores_backslash_return_to(self, mock_get_client, client):
|
|
fake_client = Mock()
|
|
fake_client.authorize_redirect.return_value = redirect("https://auth.example.com/authorize")
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
resp = client.get("/api/auth/oidc/login?return_to=%2F%5Cevil.example.com%2Fphish")
|
|
|
|
assert resp.status_code == 302
|
|
with client.session_transaction() as sess:
|
|
assert "oidc_return_to" not in sess
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw",
|
|
[
|
|
"/\\evil.example.com",
|
|
"/\\evil.example.com/phish",
|
|
"/\\\\evil.example.com",
|
|
"/\\/evil.example.com",
|
|
],
|
|
)
|
|
def test_normalize_return_to_rejects_backslash_paths(self, app, raw):
|
|
from shelfmark.core.oidc_routes import _normalize_return_to
|
|
|
|
with app.test_request_context("/api/auth/oidc/login"):
|
|
assert _normalize_return_to(raw) is None
|
|
|
|
def test_normalize_return_to_rejects_backslash_under_script_root(self, app):
|
|
from shelfmark.core.oidc_routes import _normalize_return_to
|
|
|
|
with app.test_request_context(
|
|
"/shelfmark/api/auth/oidc/login",
|
|
environ_overrides={"SCRIPT_NAME": "/shelfmark"},
|
|
):
|
|
assert _normalize_return_to("/shelfmark/\\evil.example.com") is None
|
|
|
|
@pytest.mark.parametrize(
|
|
("raw", "expected"),
|
|
[
|
|
("/", "/"),
|
|
("/settings", "/settings"),
|
|
("/search?q=x#frag", "/search?q=x#frag"),
|
|
],
|
|
)
|
|
def test_normalize_return_to_keeps_local_paths(self, app, raw, expected):
|
|
from shelfmark.core.oidc_routes import _normalize_return_to
|
|
|
|
with app.test_request_context("/api/auth/oidc/login"):
|
|
assert _normalize_return_to(raw) == expected
|
|
|
|
|
|
class TestOIDCCallbackEndpoint:
|
|
def test_normalize_claims_returns_empty_dict_for_invalid_mapping(self):
|
|
from shelfmark.core.oidc_routes import _normalize_claims
|
|
|
|
class BadClaims:
|
|
def __iter__(self):
|
|
raise TypeError("bad claims")
|
|
|
|
assert _normalize_claims(BadClaims()) == {}
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_creates_session(self, mock_get_client, client):
|
|
fake_client = Mock()
|
|
fake_client.authorize_access_token.return_value = {
|
|
"userinfo": {
|
|
"sub": "user-123",
|
|
"email": "john@example.com",
|
|
"name": "John Doe",
|
|
"preferred_username": "john",
|
|
"groups": ["users"],
|
|
}
|
|
}
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
|
assert resp.status_code == 302
|
|
fake_client.userinfo.assert_not_called()
|
|
|
|
with client.session_transaction() as sess:
|
|
assert sess["user_id"] == "john"
|
|
assert sess["db_user_id"] is not None
|
|
assert sess["is_admin"] is False
|
|
assert sess.permanent is True
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_redirects_to_original_url_with_query(self, mock_get_client, client):
|
|
fake_client = Mock()
|
|
fake_client.authorize_redirect.return_value = redirect("https://auth.example.com/authorize")
|
|
fake_client.authorize_access_token.return_value = {
|
|
"userinfo": {
|
|
"sub": "user-123",
|
|
"email": "john@example.com",
|
|
"name": "John Doe",
|
|
"preferred_username": "john",
|
|
"groups": ["users"],
|
|
}
|
|
}
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
login_resp = client.get("/api/auth/oidc/login?return_to=%2F%3Fq%3DSanderson")
|
|
assert login_resp.status_code == 302
|
|
|
|
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
|
assert resp.status_code == 302
|
|
|
|
parsed = urlparse(resp.headers["Location"])
|
|
assert parsed.path == "/"
|
|
assert parse_qs(parsed.query) == {"q": ["Sanderson"]}
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_redirects_to_original_url_with_script_root(self, mock_get_client, client):
|
|
fake_client = Mock()
|
|
fake_client.authorize_redirect.return_value = redirect("https://auth.example.com/authorize")
|
|
fake_client.authorize_access_token.return_value = {
|
|
"userinfo": {
|
|
"sub": "user-123",
|
|
"email": "john@example.com",
|
|
"name": "John Doe",
|
|
"preferred_username": "john",
|
|
"groups": ["users"],
|
|
}
|
|
}
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
login_resp = client.get(
|
|
"/api/auth/oidc/login?return_to=%2Frequests%3Fq%3DSanderson",
|
|
environ_overrides={"SCRIPT_NAME": "/shelfmark"},
|
|
)
|
|
assert login_resp.status_code == 302
|
|
|
|
resp = client.get(
|
|
"/api/auth/oidc/callback?code=abc123&state=test-state",
|
|
environ_overrides={"SCRIPT_NAME": "/shelfmark"},
|
|
)
|
|
assert resp.status_code == 302
|
|
|
|
parsed = urlparse(resp.headers["Location"])
|
|
assert parsed.path == "/shelfmark/requests"
|
|
assert parse_qs(parsed.query) == {"q": ["Sanderson"]}
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_sets_admin_from_groups(self, mock_get_client, client):
|
|
fake_client = Mock()
|
|
fake_client.authorize_access_token.return_value = {
|
|
"userinfo": {
|
|
"sub": "admin-123",
|
|
"email": "admin@example.com",
|
|
"preferred_username": "admin",
|
|
"groups": ["users", "shelfmark-admins"],
|
|
}
|
|
}
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
|
|
|
with client.session_transaction() as sess:
|
|
assert sess["is_admin"] is True
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_falls_back_to_userinfo_endpoint(self, mock_get_client, client):
|
|
fake_client = Mock()
|
|
fake_client.authorize_access_token.return_value = {}
|
|
fake_client.userinfo.return_value = {
|
|
"sub": "fallback-123",
|
|
"email": "fallback@example.com",
|
|
"preferred_username": "fallback",
|
|
"groups": [],
|
|
}
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
|
assert resp.status_code == 302
|
|
fake_client.userinfo.assert_called_once_with(token={})
|
|
|
|
with client.session_transaction() as sess:
|
|
assert sess["user_id"] == "fallback"
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_falls_back_to_legacy_userinfo_signature(self, mock_get_client, client):
|
|
fake_client = Mock()
|
|
token = {"userinfo": {"sub": "legacy-sub"}}
|
|
fake_client.authorize_access_token.return_value = token
|
|
fake_client.userinfo.side_effect = [
|
|
TypeError("legacy signature"),
|
|
{
|
|
"sub": "legacy-sub",
|
|
"email": "legacy@example.com",
|
|
"preferred_username": "legacy",
|
|
"groups": [],
|
|
},
|
|
]
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
|
|
|
assert resp.status_code == 302
|
|
assert fake_client.userinfo.call_args_list[0].kwargs == {"token": token}
|
|
assert fake_client.userinfo.call_args_list[1].kwargs == {}
|
|
with client.session_transaction() as sess:
|
|
assert sess["user_id"] == "legacy"
|
|
assert sess["db_user_id"] is not None
|
|
assert sess["is_admin"] is False
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_fetches_userinfo_when_token_claims_are_sparse(self, mock_get_client, client):
|
|
fake_client = Mock()
|
|
token = {"userinfo": {"sub": "sparse-sub"}}
|
|
fake_client.authorize_access_token.return_value = token
|
|
fake_client.userinfo.return_value = {
|
|
"sub": "sparse-sub",
|
|
"email": "sparse@example.com",
|
|
"preferred_username": "sparse-user",
|
|
"groups": [],
|
|
}
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
|
|
|
assert resp.status_code == 302
|
|
fake_client.userinfo.assert_called_once_with(token=token)
|
|
with client.session_transaction() as sess:
|
|
assert sess["user_id"] == "sparse-user"
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_uses_sparse_claims_when_userinfo_fetch_fails(self, mock_get_client, client):
|
|
fake_client = Mock()
|
|
token = {"userinfo": {"sub": "fallback-sub"}}
|
|
fake_client.authorize_access_token.return_value = token
|
|
fake_client.userinfo.side_effect = RuntimeError("userinfo failed")
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
|
|
|
assert resp.status_code == 302
|
|
fake_client.userinfo.assert_called_once_with(token=token)
|
|
with client.session_transaction() as sess:
|
|
assert sess["user_id"] == "fallback-sub"
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_redirects_with_error_when_claims_missing(self, mock_get_client, client):
|
|
fake_client = Mock()
|
|
fake_client.authorize_access_token.return_value = {}
|
|
fake_client.userinfo.side_effect = RuntimeError("userinfo failed")
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
|
error = _get_oidc_error(resp)
|
|
assert error is not None
|
|
assert "missing user claims" in error
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_redirects_with_issuer_guidance_on_invalid_issuer_claim(
|
|
self, mock_get_client, client
|
|
):
|
|
fake_client = Mock()
|
|
fake_client.authorize_access_token.side_effect = InvalidClaimError("iss")
|
|
fake_client.load_server_metadata.return_value = {
|
|
"issuer": "https://auth.example.com/application/o/shelfmark/"
|
|
}
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
|
error = _get_oidc_error(resp)
|
|
assert error is not None
|
|
assert "issuer validation failed" in error
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_tolerates_metadata_lookup_failure_during_claim_diagnostics(
|
|
self, mock_get_client, client
|
|
):
|
|
fake_client = Mock()
|
|
fake_client.authorize_access_token.side_effect = InvalidClaimError("iss")
|
|
fake_client.load_server_metadata.side_effect = RuntimeError("metadata failed")
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
|
error = _get_oidc_error(resp)
|
|
assert error is not None
|
|
assert "issuer validation failed" in error
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_redirects_with_signing_key_guidance_on_empty_jwks(
|
|
self, mock_get_client, client
|
|
):
|
|
fake_client = Mock()
|
|
fake_client.authorize_access_token.side_effect = KeyError("keys")
|
|
fake_client.fetch_jwk_set.return_value = {}
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
|
error = _get_oidc_error(resp)
|
|
assert error is not None
|
|
assert "no token signing keys" in error
|
|
assert "Signing Key" in error
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_uses_generic_error_when_jwks_has_keys(self, mock_get_client, client):
|
|
fake_client = Mock()
|
|
fake_client.authorize_access_token.side_effect = KeyError("keys")
|
|
fake_client.fetch_jwk_set.return_value = {"keys": [{"kty": "RSA", "kid": "abc"}]}
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
|
error = _get_oidc_error(resp)
|
|
assert error == "Authentication failed"
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_uses_generic_error_when_jwks_diagnosis_fails(self, mock_get_client, client):
|
|
fake_client = Mock()
|
|
fake_client.authorize_access_token.side_effect = KeyError("keys")
|
|
fake_client.fetch_jwk_set.side_effect = RuntimeError("jwks fetch failed")
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
|
error = _get_oidc_error(resp)
|
|
assert error == "Authentication failed"
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_redirects_when_auto_provision_disabled_and_no_email_match(
|
|
self, mock_get_client, client
|
|
):
|
|
config = {**MOCK_OIDC_CONFIG, "OIDC_AUTO_PROVISION": False}
|
|
fake_client = Mock()
|
|
fake_client.authorize_access_token.return_value = {
|
|
"userinfo": {
|
|
"sub": "unknown-user",
|
|
"preferred_username": "unknown",
|
|
"groups": [],
|
|
}
|
|
}
|
|
mock_get_client.return_value = (fake_client, config)
|
|
|
|
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
|
error = _get_oidc_error(resp)
|
|
assert error is not None
|
|
assert "Account not found" in error
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_links_pre_created_user_by_email_when_no_provision(
|
|
self, mock_get_client, client, user_db
|
|
):
|
|
config = {**MOCK_OIDC_CONFIG, "OIDC_AUTO_PROVISION": False}
|
|
user_db.create_user(username="alice", email="alice@example.com", password_hash="hash")
|
|
|
|
fake_client = Mock()
|
|
fake_client.authorize_access_token.return_value = {
|
|
"userinfo": {
|
|
"sub": "oidc-alice-sub",
|
|
"email": "alice@example.com",
|
|
"email_verified": True,
|
|
"preferred_username": "alice_oidc",
|
|
"groups": [],
|
|
}
|
|
}
|
|
mock_get_client.return_value = (fake_client, config)
|
|
|
|
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
|
assert resp.status_code == 302
|
|
|
|
with client.session_transaction() as sess:
|
|
assert sess["user_id"] == "alice"
|
|
assert sess.get("db_user_id") is not None
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_does_not_link_when_no_email_and_no_provision(
|
|
self, mock_get_client, client, user_db
|
|
):
|
|
config = {**MOCK_OIDC_CONFIG, "OIDC_AUTO_PROVISION": False}
|
|
user_db.create_user(username="bob", email="bob@example.com", password_hash="hash")
|
|
|
|
fake_client = Mock()
|
|
fake_client.authorize_access_token.return_value = {
|
|
"userinfo": {
|
|
"sub": "oidc-bob-sub",
|
|
"preferred_username": "bob_oidc",
|
|
"groups": [],
|
|
}
|
|
}
|
|
mock_get_client.return_value = (fake_client, config)
|
|
|
|
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
|
error = _get_oidc_error(resp)
|
|
assert error is not None
|
|
assert "Account not found" in error
|
|
|
|
updated_user = user_db.get_user(username="bob")
|
|
assert updated_user["oidc_subject"] is None
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_redirects_on_idp_error(self, mock_get_client, client):
|
|
mock_get_client.return_value = (Mock(), MOCK_OIDC_CONFIG)
|
|
|
|
resp = client.get("/api/auth/oidc/callback?error=access_denied")
|
|
error = _get_oidc_error(resp)
|
|
assert error is not None
|
|
assert "Authentication failed" in error
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_error_redirect_honors_script_root(self, mock_get_client, client):
|
|
mock_get_client.return_value = (Mock(), MOCK_OIDC_CONFIG)
|
|
|
|
resp = client.get(
|
|
"/api/auth/oidc/callback?error=access_denied",
|
|
environ_overrides={"SCRIPT_NAME": "/shelfmark"},
|
|
)
|
|
|
|
assert resp.status_code == 302
|
|
parsed = urlparse(resp.headers["Location"])
|
|
assert parsed.path == "/shelfmark/login"
|
|
error = _get_oidc_error(resp)
|
|
assert error is not None
|
|
assert "Authentication failed" in error
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_preserves_return_to_on_error_redirect(self, mock_get_client, client):
|
|
fake_client = Mock()
|
|
fake_client.authorize_redirect.return_value = redirect("https://auth.example.com/authorize")
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
login_resp = client.get("/api/auth/oidc/login?return_to=%2Frequests%3Fq%3DSanderson")
|
|
assert login_resp.status_code == 302
|
|
|
|
resp = client.get("/api/auth/oidc/callback?error=access_denied")
|
|
|
|
assert resp.status_code == 302
|
|
parsed = urlparse(resp.headers["Location"])
|
|
assert parsed.path == "/login"
|
|
assert parse_qs(parsed.query)["return_to"] == ["/requests?q=Sanderson"]
|
|
error = _get_oidc_error(resp)
|
|
assert error is not None
|
|
assert "Authentication failed" in error
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_redirects_on_generic_exception(self, mock_get_client, client):
|
|
fake_client = Mock()
|
|
fake_client.authorize_access_token.side_effect = RuntimeError("unexpected")
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
|
error = _get_oidc_error(resp)
|
|
assert error is not None
|
|
assert "Authentication failed" in error
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_links_to_existing_user_by_email(self, mock_get_client, client, user_db):
|
|
"""OIDC login with matching email should link to existing local user."""
|
|
user_db.create_user(username="localuser", email="shared@example.com", password_hash="hash")
|
|
|
|
fake_client = Mock()
|
|
fake_client.authorize_access_token.return_value = {
|
|
"userinfo": {
|
|
"sub": "oidc-new-sub",
|
|
"email": "shared@example.com",
|
|
"email_verified": True,
|
|
"preferred_username": "oidcuser",
|
|
"groups": [],
|
|
}
|
|
}
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
|
assert resp.status_code == 302
|
|
|
|
with client.session_transaction() as sess:
|
|
assert sess["user_id"] == "localuser"
|
|
|
|
linked = user_db.get_user(username="localuser")
|
|
assert linked["oidc_subject"] == "oidc-new-sub"
|
|
assert linked["auth_source"] == "oidc"
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_creates_new_user_when_no_email_match(self, mock_get_client, client, user_db):
|
|
"""OIDC login without matching email creates a new user."""
|
|
user_db.create_user(username="existing", email="other@example.com", password_hash="hash")
|
|
|
|
fake_client = Mock()
|
|
fake_client.authorize_access_token.return_value = {
|
|
"userinfo": {
|
|
"sub": "oidc-nomatch",
|
|
"email": "different@example.com",
|
|
"email_verified": True,
|
|
"preferred_username": "newuser",
|
|
"groups": [],
|
|
}
|
|
}
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
|
assert resp.status_code == 302
|
|
|
|
with client.session_transaction() as sess:
|
|
assert sess["user_id"] == "newuser"
|
|
|
|
original = user_db.get_user(username="existing")
|
|
assert original["oidc_subject"] is None
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_does_not_link_with_unverified_email(self, mock_get_client, client, user_db):
|
|
"""OIDC login should not link by email when email_verified is false."""
|
|
user_db.create_user(username="existing", email="shared@example.com", password_hash="hash")
|
|
|
|
fake_client = Mock()
|
|
fake_client.authorize_access_token.return_value = {
|
|
"userinfo": {
|
|
"sub": "oidc-unverified",
|
|
"email": "shared@example.com",
|
|
"email_verified": False,
|
|
"preferred_username": "attackeruser",
|
|
"groups": [],
|
|
}
|
|
}
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
|
assert resp.status_code == 302
|
|
|
|
with client.session_transaction() as sess:
|
|
assert sess["user_id"] == "attackeruser"
|
|
|
|
original = user_db.get_user(username="existing")
|
|
assert original["oidc_subject"] is None
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_rejects_unverified_email_link_when_no_provision(
|
|
self, mock_get_client, client, user_db
|
|
):
|
|
"""OIDC login should not link by unverified email when creation is disabled."""
|
|
config = {**MOCK_OIDC_CONFIG, "OIDC_AUTO_PROVISION": False}
|
|
user_db.create_user(username="existing", email="shared@example.com", password_hash="hash")
|
|
|
|
fake_client = Mock()
|
|
fake_client.authorize_access_token.return_value = {
|
|
"userinfo": {
|
|
"sub": "oidc-unverified-no-provision",
|
|
"email": "shared@example.com",
|
|
"email_verified": False,
|
|
"preferred_username": "attackeruser",
|
|
"groups": [],
|
|
}
|
|
}
|
|
mock_get_client.return_value = (fake_client, config)
|
|
|
|
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
|
error = _get_oidc_error(resp)
|
|
assert error is not None
|
|
assert "Account not found" in error
|
|
|
|
original = user_db.get_user(username="existing")
|
|
assert original["oidc_subject"] is None
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_no_email_link_when_oidc_has_no_email(self, mock_get_client, client, user_db):
|
|
"""OIDC login without email in claims should not attempt email linking."""
|
|
user_db.create_user(username="existing", email="existing@example.com", password_hash="hash")
|
|
|
|
fake_client = Mock()
|
|
fake_client.authorize_access_token.return_value = {
|
|
"userinfo": {
|
|
"sub": "oidc-noemail",
|
|
"preferred_username": "noemailuser",
|
|
"groups": [],
|
|
}
|
|
}
|
|
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
|
|
|
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
|
assert resp.status_code == 302
|
|
|
|
with client.session_transaction() as sess:
|
|
assert sess["user_id"] == "noemailuser"
|
|
|
|
original = user_db.get_user(username="existing")
|
|
assert original["oidc_subject"] is None
|