mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-15 10:38:31 +00:00
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).
789 lines
31 KiB
Python
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
|