mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 22:05:20 +01:00
- Moved backend OIDC functionality to external library Authlib to help maintainability - Separated User settings UI into individual components, allowing for standard settings UI decorator components to be used. - Added full support for reverse proxy and CWA users alongside local and OIDC - Added mapping and syncing functionality for OIDC, CWA and reverse proxy users - Added per-user settings into the app-wide config system. Each config can be declared as user-overrideable, and app-wide functionality can now receive user-specific options via standard config calls. - Added per-user audiobook destination config - Updated login modal UI for simplified login, plus custom labels for OIDC login - Added user visibility in header dropdown - Unified "restrict settings to admin" to use app-wide user roles.
260 lines
9.6 KiB
Python
260 lines
9.6 KiB
Python
"""Tests for OIDC Flask route handlers using Authlib transport."""
|
|
|
|
import os
|
|
import tempfile
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pytest
|
|
from flask import Flask, redirect
|
|
|
|
from shelfmark.core.user_db import UserDB
|
|
|
|
|
|
@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"
|
|
|
|
|
|
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
|
|
|
|
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_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_returns_400_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")
|
|
assert resp.status_code == 400
|
|
assert "missing user claims" in resp.get_json()["error"]
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_rejects_when_auto_provision_disabled(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",
|
|
"email": "unknown@example.com",
|
|
"preferred_username": "unknown",
|
|
"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 == 403
|
|
|
|
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
|
def test_callback_allows_pre_created_user_by_verified_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_unverified_email_when_no_provision(
|
|
self, mock_get_client, client, user_db
|
|
):
|
|
config = {**MOCK_OIDC_CONFIG, "OIDC_AUTO_PROVISION": False}
|
|
user = 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",
|
|
"email": "bob@example.com",
|
|
"email_verified": False,
|
|
"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")
|
|
assert resp.status_code == 403
|
|
|
|
updated_user = user_db.get_user(user_id=user["id"])
|
|
assert updated_user["oidc_subject"] is None
|