Files
shelfmark/tests/core/test_oidc_routes.py
T
Alex c59ea46540 Frontend update + Misc fixes (#735)
- Updated frontend CSS to Tailwind v4
- Reverted socket IO origin restriction
- Fixed search queries not persisting after auth redirect
- Move advanced search options to left UI selector
- Unlock IRC source to be used for audiobook content_type
- Tweaked security settings env var syncing to be prioritised
- Fix AA "all languages" query generation
- Added language-free AA query as second fallback in case of no results
- Testing moving SeleniumBase scratch files to /tmp via symlink
- Added enhanced logging for activity dismissals and other events
- Removed iFrame restrictions
2026-03-11 18:16:34 +00:00

521 lines
20 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
@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.load_config_file", return_value=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_CLIENT_ID"] == "shelfmark"
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"
scope_str = kwargs["client_kwargs"]["scope"]
assert "openid" in scope_str
assert "email" in scope_str
assert "profile" in scope_str
assert "groups" in scope_str
@patch("shelfmark.core.oidc_routes.load_config_file")
@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.return_value = 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
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
class TestOIDCCallbackEndpoint:
@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
@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
@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_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",
"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_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",
"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",
"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_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