316 lines
12 KiB
Python
316 lines
12 KiB
Python
"""Tests for the generic-OIDC ``/auth/callback`` email resolution gate.
|
|
|
|
These drive the *real* callback route end-to-end for a non-GitHub
|
|
(generic OIDC) provider: a genuinely RS256-signed ``id_token`` is fed
|
|
through the production ``jwt.decode`` path (real signature + ``iss`` /
|
|
``aud`` / ``exp`` verification), and the only thing the tests vary is
|
|
the ``email_verified`` claim.
|
|
|
|
This is the regression coverage for "OIDC login accepts
|
|
unverified email claim (account takeover)". Before the fix the
|
|
callback minted a session for any signature-valid ``id_token``,
|
|
ignoring ``email_verified``; an IdP that lets a user assert an
|
|
arbitrary unverified email could be used to sign in as a victim in an
|
|
allowed domain.
|
|
|
|
The token endpoint (``httpx``) and the JWKS signing-key lookup are the
|
|
only mocked boundaries — everything between the HTTP request and the
|
|
minted session cookie is the production code path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import jwt
|
|
import pytest
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
from jwt.algorithms import RSAAlgorithm
|
|
|
|
from omnigent.server.admin_list import AdminList
|
|
from omnigent.server.auth import UnifiedAuthProvider
|
|
from omnigent.server.oidc import OIDCConfig
|
|
from omnigent.server.routes.auth import _AUTH_STATE_COOKIE_PLAIN, create_auth_router
|
|
from omnigent.stores.permission_store.sqlalchemy_store import SqlAlchemyPermissionStore
|
|
|
|
_TEST_SECRET = bytes.fromhex("aa" * 32)
|
|
_ISSUER = "https://accounts.google.com"
|
|
_CLIENT_ID = "cid"
|
|
|
|
|
|
def _oidc_config(skip_email_verification: bool = False) -> OIDCConfig:
|
|
"""Build a generic-OIDC config over plain HTTP (so TestClient cookies stick).
|
|
|
|
``allowed_domains=None`` means admit-all, so the test isolates the
|
|
``email_verified`` gate from the domain-allowlist check.
|
|
|
|
:param skip_email_verification: Waive the ``email_verified`` gate,
|
|
as ``OMNIGENT_OIDC_SKIP_EMAIL_VERIFICATION`` would.
|
|
"""
|
|
return OIDCConfig(
|
|
issuer=_ISSUER,
|
|
client_id=_CLIENT_ID,
|
|
client_secret="secret",
|
|
redirect_uri="http://localhost:8000/auth/callback",
|
|
cookie_secret=_TEST_SECRET,
|
|
scopes="openid email profile",
|
|
session_ttl_hours=8,
|
|
logout_redirect_uri=None,
|
|
allowed_domains=None,
|
|
provider_type="oidc",
|
|
authorization_endpoint=f"{_ISSUER}/o/oauth2/v2/auth",
|
|
token_endpoint="https://oauth2.googleapis.com/token",
|
|
jwks_uri="https://www.googleapis.com/oauth2/v3/certs",
|
|
userinfo_endpoint=None,
|
|
allow_invites=False,
|
|
skip_email_verification=skip_email_verification,
|
|
)
|
|
|
|
|
|
class _IdpKeys:
|
|
"""An RSA keypair plus the JWKS signing key derived from its public half.
|
|
|
|
:param private_key: The RSA private key used to sign test
|
|
``id_token`` JWTs.
|
|
:param signing_key: A :class:`jwt.PyJWK` wrapping the public key,
|
|
shaped exactly like what ``PyJWKClient.get_signing_key_from_jwt``
|
|
returns — its ``.key`` is consumed by the production
|
|
``jwt.decode`` call for real signature verification.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
jwk_dict = json.loads(RSAAlgorithm.to_jwk(self.private_key.public_key()))
|
|
jwk_dict["alg"] = "RS256"
|
|
self.signing_key = jwt.PyJWK.from_dict(jwk_dict)
|
|
|
|
def sign_id_token(self, claims: dict[str, object]) -> str:
|
|
"""Sign ``claims`` into an RS256 ``id_token``, filling iss/aud/exp.
|
|
|
|
:param claims: Claims to embed, e.g.
|
|
``{"email": "alice@example.com", "email_verified": True}``.
|
|
``iss``/``aud``/``exp``/``iat`` are added if absent.
|
|
:returns: A compact-serialized signed JWT string.
|
|
"""
|
|
now = int(time.time())
|
|
payload: dict[str, object] = {
|
|
"iss": _ISSUER,
|
|
"aud": _CLIENT_ID,
|
|
"iat": now,
|
|
"exp": now + 300,
|
|
"sub": "idp-subject-123",
|
|
**claims,
|
|
}
|
|
return jwt.encode(payload, self.private_key, algorithm="RS256")
|
|
|
|
|
|
@pytest.fixture
|
|
def callback_client(
|
|
tmp_path: Path,
|
|
db_uri: str,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
request: pytest.FixtureRequest,
|
|
) -> Iterator[tuple[TestClient, _IdpKeys]]:
|
|
"""Mount the OIDC router and stub the IdP token endpoint + JWKS lookup.
|
|
|
|
The token endpoint is driven per-test by mutating the mutable
|
|
one-element ``pending_id_token`` list captured by the monkeypatched
|
|
``post`` — exposed on ``app.state.pending_id_token`` so ``_do_callback``
|
|
can set the signed token the IdP should return.
|
|
|
|
Indirect parametrization (``request.param``, default ``False``) sets
|
|
the config's ``skip_email_verification`` flag.
|
|
"""
|
|
keys = _IdpKeys()
|
|
perm_store = SqlAlchemyPermissionStore(db_uri)
|
|
admins = tmp_path / "admins"
|
|
admins.write_text("")
|
|
|
|
config = _oidc_config(skip_email_verification=getattr(request, "param", False))
|
|
provider = UnifiedAuthProvider(source="oidc", oidc_config=config)
|
|
|
|
# The signed id_token the mocked token endpoint will return. Each
|
|
# test sets this before calling /auth/callback.
|
|
pending_id_token: list[str] = [""]
|
|
|
|
async def _fake_post(
|
|
self: httpx.AsyncClient,
|
|
url: str,
|
|
*,
|
|
data: dict[str, str] | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
timeout: float | None = None,
|
|
) -> httpx.Response:
|
|
"""Stand in for the IdP token endpoint, returning the test's id_token."""
|
|
return httpx.Response(200, json={"id_token": pending_id_token[0]})
|
|
|
|
monkeypatch.setattr(httpx.AsyncClient, "post", _fake_post)
|
|
# The production code constructs PyJWKClient(jwks_uri) then calls
|
|
# get_signing_key_from_jwt; return our public key so the real
|
|
# jwt.decode performs genuine signature verification offline.
|
|
monkeypatch.setattr(
|
|
jwt.PyJWKClient,
|
|
"get_signing_key_from_jwt",
|
|
lambda self, token: keys.signing_key,
|
|
)
|
|
|
|
app = FastAPI()
|
|
app.include_router(
|
|
create_auth_router(provider, perm_store, AdminList(admins)),
|
|
prefix="/auth",
|
|
)
|
|
app.state.pending_id_token = pending_id_token
|
|
|
|
with TestClient(app) as client:
|
|
yield client, keys
|
|
|
|
|
|
def _do_callback(client: TestClient, id_token: str) -> httpx.Response:
|
|
"""Drive a full ``/auth/callback`` with a valid state cookie.
|
|
|
|
Crafts the signed state cookie the way ``/auth/login`` would, sets
|
|
the matching ``state`` query param, and stashes ``id_token`` for the
|
|
mocked token endpoint to return. Redirects are not followed so the
|
|
302 (and its ``Set-Cookie``) is observable.
|
|
|
|
:param client: The TestClient mounting the OIDC router.
|
|
:param id_token: The signed ``id_token`` the IdP should return.
|
|
:returns: The raw callback response.
|
|
"""
|
|
client.app.state.pending_id_token[0] = id_token
|
|
state = "state-token-xyz"
|
|
state_jwt = jwt.encode(
|
|
{
|
|
"state": state,
|
|
"code_verifier": "verifier",
|
|
"return_to": "/",
|
|
"exp": int(time.time()) + 300,
|
|
},
|
|
_TEST_SECRET,
|
|
algorithm="HS256",
|
|
)
|
|
client.cookies.set(_AUTH_STATE_COOKIE_PLAIN, state_jwt)
|
|
return client.get(
|
|
f"/auth/callback?code=auth-code&state={state}",
|
|
follow_redirects=False,
|
|
)
|
|
|
|
|
|
def test_callback_verified_email_mints_session(
|
|
callback_client: tuple[TestClient, _IdpKeys],
|
|
) -> None:
|
|
"""A signed id_token with ``email_verified=true`` logs the user in.
|
|
|
|
Proves the golden path still works after the fix: 302 back to the
|
|
app and a session cookie whose ``sub`` is the verified email
|
|
(normalized to lowercase).
|
|
"""
|
|
client, keys = callback_client
|
|
token = keys.sign_id_token({"email": "Alice@Example.com", "email_verified": True})
|
|
|
|
resp = _do_callback(client, token)
|
|
|
|
# 302 redirect (not 400) means the email was accepted as identity.
|
|
assert resp.status_code == 302, resp.text
|
|
session_cookie = resp.cookies.get("ap_session")
|
|
# The session cookie must be set on success; absence would mean the
|
|
# callback bailed before minting (the bug we're guarding against,
|
|
# inverted).
|
|
assert session_cookie is not None
|
|
decoded = jwt.decode(session_cookie, _TEST_SECRET, algorithms=["HS256"])
|
|
# sub is the normalized (lowercased) verified email — proves the
|
|
# decoded claim flowed all the way into the minted session.
|
|
assert decoded["sub"] == "alice@example.com"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"claims",
|
|
[
|
|
pytest.param({"email": "victim@example.com", "email_verified": False}, id="false"),
|
|
pytest.param({"email": "victim@example.com", "email_verified": "false"}, id="str-false"),
|
|
pytest.param({"email": "victim@example.com"}, id="absent"),
|
|
pytest.param({"email": "victim@example.com", "email_verified": None}, id="null"),
|
|
pytest.param({"email": "victim@example.com", "email_verified": 1}, id="int-one"),
|
|
],
|
|
)
|
|
def test_callback_unverified_email_rejected(
|
|
callback_client: tuple[TestClient, _IdpKeys],
|
|
claims: dict[str, object],
|
|
) -> None:
|
|
"""An unverified/absent ``email_verified`` claim is rejected.
|
|
|
|
The id_token is genuinely signature-valid (same RSA key as the
|
|
happy path), so a failure here is *exclusively* the missing
|
|
verification gate, not a signature/iss/aud rejection. Before the
|
|
fix every one of these minted a session for ``victim@example.com``.
|
|
"""
|
|
client, keys = callback_client
|
|
token = keys.sign_id_token(claims)
|
|
|
|
resp = _do_callback(client, token)
|
|
|
|
# 400 (not 302): the callback refused to treat an unverified email
|
|
# as identity. Anything else means the gate let it through.
|
|
assert resp.status_code == 400, resp.text
|
|
assert "Could not determine user email" in resp.json()["error"]
|
|
# No session was minted for the spoofable email.
|
|
assert resp.cookies.get("ap_session") is None
|
|
|
|
|
|
@pytest.mark.parametrize("callback_client", [True], indirect=True)
|
|
@pytest.mark.parametrize(
|
|
"claims",
|
|
[
|
|
pytest.param({"email": "carol@example.com"}, id="absent"),
|
|
pytest.param({"email": "carol@example.com", "email_verified": False}, id="false"),
|
|
],
|
|
)
|
|
def test_callback_skip_verification_flag_admits_unverified(
|
|
callback_client: tuple[TestClient, _IdpKeys],
|
|
claims: dict[str, object],
|
|
) -> None:
|
|
"""With ``skip_email_verification`` on, the gate is waived.
|
|
|
|
Models Okta tiers that drop ``email_verified`` for
|
|
directory-provisioned users: the same absent-claim token rejected
|
|
by default (covered above) mints a session when the operator has
|
|
opted out via ``OMNIGENT_OIDC_SKIP_EMAIL_VERIFICATION``.
|
|
"""
|
|
client, keys = callback_client
|
|
token = keys.sign_id_token(claims)
|
|
|
|
resp = _do_callback(client, token)
|
|
|
|
assert resp.status_code == 302, resp.text
|
|
session_cookie = resp.cookies.get("ap_session")
|
|
assert session_cookie is not None
|
|
decoded = jwt.decode(session_cookie, _TEST_SECRET, algorithms=["HS256"])
|
|
assert decoded["sub"] == "carol@example.com"
|
|
|
|
|
|
@pytest.mark.parametrize("verified_value", [True, "true", "True", "TRUE"])
|
|
def test_callback_accepts_boolean_and_string_true(
|
|
callback_client: tuple[TestClient, _IdpKeys],
|
|
verified_value: object,
|
|
) -> None:
|
|
"""Both boolean ``true`` and the string ``"true"`` are accepted.
|
|
|
|
OIDC Core §5.1 notes ``email_verified`` may arrive as a string;
|
|
accepting ``"true"`` keeps spec-compliant-but-string IdPs working
|
|
while still rejecting ``"false"`` / absent (covered above).
|
|
"""
|
|
client, keys = callback_client
|
|
token = keys.sign_id_token({"email": "bob@example.com", "email_verified": verified_value})
|
|
|
|
resp = _do_callback(client, token)
|
|
|
|
# Accepted as a verified identity → redirect + session.
|
|
assert resp.status_code == 302, resp.text
|
|
assert resp.cookies.get("ap_session") is not None
|