Files
shelfmark/tests/core/test_oidc_routes.py
T
splitsec2 7583f997bb fix(oidc): catch joserfc's InvalidClaimError so the issuer guidance shows (#1603)
A wrong OIDC issuer, which is the usual IdP setup mistake, sends the
user back to the login page with a bare "Authentication failed". The
callback has a handler that explains it ("OIDC issuer validation failed.
Verify your discovery URL and IdP issuer/external URL configuration."),
and it never runs.

Authlib validates the ID token with joserfc now, so the error that comes
out of `authorize_access_token()` is `joserfc.errors.InvalidClaimError`.
The handler catches the one in `authlib.jose.errors`, which is a
different class with a different base, so the isinstance check is False.
I confirmed it by validating a real `CodeIDToken` with a wrong `iss` on
Authlib 1.9.1. I haven't run it against a live IdP.

The existing tests passed because they raise the old class from a mock.
The new one raises the error from a real `CodeIDToken`, and it fails on
the old import with `'Authentication failed'` in place of the guidance.

Two small things come with it:
- The new class names its attribute `claim` where the old one used
`claim_name`, so that read changes.
- `authlib.jose` is deprecated in favour of joserfc and Authlib's own
warning says it goes in 2.0. This was the only place Shelfmark used it,
so the import moving also takes us off the module that is going away.
joserfc is already installed as an Authlib requirement, so I added it to
`pyproject.toml` (`>=1.6.8`, Authlib's own floor) and the lock gets the
one entry. No new package.

Ruff, basedpyright and the backend suite pass (5424 passed).
2026-10-27 11:06:51 -06:00

789 lines
31 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.oidc.core import CodeIDToken
from flask import Flask, redirect
from joserfc.errors import InvalidClaimError
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_gives_issuer_guidance_for_the_error_authlib_really_raises(
self, mock_get_client, client
):
# Authlib validates the ID token with joserfc, so the error that reaches the route is
# joserfc's. Raising it from a real CodeIDToken keeps this test honest if the class moves.
def validate_wrong_issuer(*_args, **_kwargs):
CodeIDToken(
{
"iss": "https://other.example.com",
"sub": "u",
"aud": "a",
"exp": 4102444800,
"iat": 1,
},
{},
{"iss": {"essential": True, "value": "https://auth.example.com"}},
).validate()
fake_client = Mock()
fake_client.authorize_access_token.side_effect = validate_wrong_issuer
fake_client.load_server_metadata.return_value = {"issuer": "https://auth.example.com"}
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