295 lines
11 KiB
Python
295 lines
11 KiB
Python
"""Tests for the opt-in OIDC invite flow.
|
|
|
|
Covers the pieces that make an individually-invited, off-domain email
|
|
admissible:
|
|
|
|
1. ``OIDCConfig`` parsing of ``OMNIGENT_OIDC_ALLOW_INVITES`` and the
|
|
``base_url`` derivation used to build invite links.
|
|
2. ``SqlAlchemyAccountStore.redeem_oidc_invite`` / ``is_email_invited``
|
|
against a real DB — the OIDC invite reuses the existing
|
|
``account_tokens`` table (no dedicated table): redemption stamps the
|
|
email into ``user_id`` and that redeemed row is the durable pre-auth.
|
|
3. The redeem → admit chain the callback performs (invite token consumed
|
|
once, bound email then admitted past the domain allowlist).
|
|
4. The ``POST /auth/invite`` route: admin-gated, mints a usable link,
|
|
and is absent entirely when invites are disabled.
|
|
|
|
The full browser callback (IdP token exchange) isn't driven here — that
|
|
path is covered by the manual IdP verification in the plan; these tests
|
|
pin every server-side building block it relies on.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import secrets
|
|
import time
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from omnigent.server.accounts_store import SqlAlchemyAccountStore
|
|
from omnigent.server.admin_list import AdminList
|
|
from omnigent.server.auth import UnifiedAuthProvider
|
|
from omnigent.server.oidc import OIDCConfig, mint_session_cookie
|
|
from omnigent.server.oidc_access import OidcAdmissionPolicy
|
|
from omnigent.server.routes.auth import create_auth_router
|
|
from omnigent.stores.permission_store.sqlalchemy_store import SqlAlchemyPermissionStore
|
|
|
|
_TEST_SECRET = bytes.fromhex("aa" * 32)
|
|
|
|
|
|
def _oidc_config(*, allow_invites: bool, allowed_domains: frozenset[str] | None) -> OIDCConfig:
|
|
"""Build an OIDCConfig over plain HTTP (so TestClient cookies work)."""
|
|
return OIDCConfig(
|
|
issuer="https://accounts.google.com",
|
|
client_id="cid",
|
|
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=allowed_domains,
|
|
provider_type="oidc",
|
|
authorization_endpoint="https://accounts.google.com/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=allow_invites,
|
|
)
|
|
|
|
|
|
# ── Config parsing ────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("value", "expected"),
|
|
# Follows the project env-var convention (omnigent.server.auth
|
|
# .env_var_is_truthy): 1/true/yes are truthy (case-insensitive);
|
|
# everything else — including "on" — is false.
|
|
[("1", True), ("true", True), ("YES", True), ("on", False), ("0", False), ("", False)],
|
|
)
|
|
def test_allow_invites_env_parsing(
|
|
monkeypatch: pytest.MonkeyPatch, value: str, expected: bool
|
|
) -> None:
|
|
"""``OMNIGENT_OIDC_ALLOW_INVITES`` parses truthy values; default off.
|
|
|
|
Built via the GitHub provider branch so no network discovery runs.
|
|
"""
|
|
monkeypatch.setenv("OMNIGENT_OIDC_ISSUER", "https://github.com")
|
|
monkeypatch.setenv("OMNIGENT_OIDC_CLIENT_ID", "cid")
|
|
monkeypatch.setenv("OMNIGENT_OIDC_CLIENT_SECRET", "secret")
|
|
monkeypatch.setenv("OMNIGENT_OIDC_REDIRECT_URI", "https://app.example.com/auth/callback")
|
|
monkeypatch.setenv("OMNIGENT_OIDC_COOKIE_SECRET", "aa" * 32)
|
|
monkeypatch.setenv("OMNIGENT_OIDC_ALLOW_INVITES", value)
|
|
|
|
config = OIDCConfig.from_env()
|
|
assert config.allow_invites is expected
|
|
|
|
|
|
def test_base_url_derives_from_redirect_uri() -> None:
|
|
"""``base_url`` strips the callback path to scheme://host[:port]."""
|
|
config = _oidc_config(allow_invites=True, allowed_domains=None)
|
|
assert config.base_url == "http://localhost:8000"
|
|
|
|
|
|
# ── Store: invite redemption binds email to the account_tokens row ──
|
|
|
|
|
|
def _mint_invite(store: SqlAlchemyAccountStore, *, now: int, ttl: int = 3600) -> str:
|
|
"""Mint an invite token (user_id NULL, as the route does) and return its id."""
|
|
token_id = secrets.token_urlsafe(32)
|
|
store.create_token(
|
|
token_id,
|
|
kind="invite",
|
|
user_id=None,
|
|
created_by="admin@example.com",
|
|
created_at=now,
|
|
expires_at=now + ttl,
|
|
)
|
|
return token_id
|
|
|
|
|
|
def test_redeem_oidc_invite_binds_and_is_findable(db_uri: str) -> None:
|
|
"""``redeem_oidc_invite`` stamps the email; ``is_email_invited`` finds it.
|
|
|
|
Proves the OIDC invite reuses the existing ``account_tokens`` table
|
|
(no dedicated table) — redemption writes the email into ``user_id``
|
|
and that redeemed row is the durable pre-authorization.
|
|
"""
|
|
store = SqlAlchemyAccountStore(db_uri)
|
|
now = int(time.time())
|
|
token_id = _mint_invite(store, now=now)
|
|
|
|
assert store.is_email_invited("guest@external.com") is False
|
|
assert store.redeem_oidc_invite(token_id, "guest@external.com", now_epoch_seconds=now) is True
|
|
assert store.is_email_invited("guest@external.com") is True
|
|
|
|
|
|
def test_redeem_oidc_invite_is_single_use(db_uri: str) -> None:
|
|
"""A token redeems at most once; a second redeem returns False."""
|
|
store = SqlAlchemyAccountStore(db_uri)
|
|
now = int(time.time())
|
|
token_id = _mint_invite(store, now=now)
|
|
|
|
assert store.redeem_oidc_invite(token_id, "guest@external.com", now_epoch_seconds=now) is True
|
|
# Second attempt (even by a different email) fails — already redeemed.
|
|
assert store.redeem_oidc_invite(token_id, "other@external.com", now_epoch_seconds=now) is False
|
|
assert store.is_email_invited("other@external.com") is False
|
|
|
|
|
|
def test_redeem_oidc_invite_rejects_expired(db_uri: str) -> None:
|
|
"""An expired token can't be redeemed."""
|
|
store = SqlAlchemyAccountStore(db_uri)
|
|
now = int(time.time())
|
|
token_id = _mint_invite(store, now=now, ttl=10)
|
|
assert (
|
|
store.redeem_oidc_invite(token_id, "guest@external.com", now_epoch_seconds=now + 100)
|
|
is False
|
|
)
|
|
assert store.is_email_invited("guest@external.com") is False
|
|
|
|
|
|
def test_accounts_invite_does_not_count_as_oidc_invited(db_uri: str) -> None:
|
|
"""An accounts-mode invite (user_id stays NULL) never matches is_email_invited.
|
|
|
|
Guards the boundary: only OIDC redemptions stamp ``user_id``, so an
|
|
unredeemed/accounts invite must not admit anyone.
|
|
"""
|
|
store = SqlAlchemyAccountStore(db_uri)
|
|
now = int(time.time())
|
|
_mint_invite(store, now=now) # minted, never redeemed via OIDC
|
|
assert store.is_email_invited("admin@example.com") is False
|
|
|
|
|
|
# ── redeem → admit chain (what the callback does) ──────────────────
|
|
|
|
|
|
def test_redeem_admit_chain(tmp_path: Path, db_uri: str) -> None:
|
|
"""A redeemed invite admits the bound email past the domain allowlist.
|
|
|
|
Mirrors the callback: atomically redeem+bind the single-use token,
|
|
then confirm the admission policy admits the email despite its
|
|
domain not being allowlisted.
|
|
"""
|
|
store = SqlAlchemyAccountStore(db_uri)
|
|
admins = tmp_path / "admins"
|
|
admins.write_text("")
|
|
policy = OidcAdmissionPolicy(
|
|
env_allowed_domains=frozenset({"example.com"}),
|
|
domains_file_path=tmp_path / "allowed_domains",
|
|
admin_list=AdminList(admins),
|
|
invited_lookup=store,
|
|
)
|
|
|
|
# Off-domain email is denied before any invite.
|
|
assert policy.is_admitted("guest@external.com") is False
|
|
|
|
now = int(time.time())
|
|
token_id = _mint_invite(store, now=now)
|
|
assert store.redeem_oidc_invite(token_id, "guest@external.com", now_epoch_seconds=now) is True
|
|
|
|
# Now admitted via the invite bypass, on this and every later login.
|
|
assert policy.is_admitted("guest@external.com") is True
|
|
|
|
|
|
# ── POST /auth/invite route ───────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture
|
|
def oidc_invite_client(tmp_path: Path, db_uri: str) -> Iterator[tuple[TestClient, str, AdminList]]:
|
|
"""OIDC router with invites enabled, mounted on a TestClient.
|
|
|
|
Yields the client, the admin's session JWT (for the Authorization
|
|
header), and the admin list (so tests can list/clear admins).
|
|
"""
|
|
perm_store = SqlAlchemyPermissionStore(db_uri)
|
|
account_store = SqlAlchemyAccountStore(db_uri)
|
|
admins = tmp_path / "admins"
|
|
admins.write_text("")
|
|
admin_list = AdminList(admins)
|
|
|
|
# An admin principal whose JWT we'll present.
|
|
perm_store.ensure_user("admin@example.com", is_admin=True)
|
|
|
|
config = _oidc_config(allow_invites=True, allowed_domains=frozenset({"example.com"}))
|
|
provider = UnifiedAuthProvider(source="oidc", oidc_config=config)
|
|
|
|
app = FastAPI()
|
|
app.include_router(
|
|
create_auth_router(provider, perm_store, admin_list, account_store),
|
|
prefix="/auth",
|
|
)
|
|
admin_jwt = mint_session_cookie(
|
|
user_id="admin@example.com",
|
|
cookie_secret=config.cookie_secret,
|
|
ttl_hours=8,
|
|
provider="oidc",
|
|
)
|
|
with TestClient(app) as client:
|
|
yield client, admin_jwt, admin_list
|
|
|
|
|
|
def test_invite_route_admin_mints_link(
|
|
oidc_invite_client: tuple[TestClient, str, AdminList],
|
|
) -> None:
|
|
"""An admin gets a usable invite URL pointing at /auth/login?invite=."""
|
|
client, admin_jwt, _ = oidc_invite_client
|
|
resp = client.post("/auth/invite", headers={"Authorization": f"Bearer {admin_jwt}"})
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["invite_url"].startswith("http://localhost:8000/auth/login?invite=")
|
|
assert body["token"] in body["invite_url"]
|
|
|
|
|
|
def test_invite_route_requires_auth(
|
|
oidc_invite_client: tuple[TestClient, str, AdminList],
|
|
) -> None:
|
|
"""An unauthenticated caller gets 401."""
|
|
client, _admin_jwt, _ = oidc_invite_client
|
|
resp = client.post("/auth/invite")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
def test_invite_route_rejects_non_admin(
|
|
oidc_invite_client: tuple[TestClient, str, AdminList], db_uri: str
|
|
) -> None:
|
|
"""A non-admin authenticated user gets 403 (can't mint invites)."""
|
|
client, _admin_jwt, _ = oidc_invite_client
|
|
member_jwt = mint_session_cookie(
|
|
user_id="member@example.com",
|
|
cookie_secret=_TEST_SECRET,
|
|
ttl_hours=8,
|
|
provider="oidc",
|
|
)
|
|
resp = client.post("/auth/invite", headers={"Authorization": f"Bearer {member_jwt}"})
|
|
assert resp.status_code == 403
|
|
|
|
|
|
def test_invite_route_absent_when_disabled(tmp_path: Path, db_uri: str) -> None:
|
|
"""With invites disabled, /auth/invite is not mounted (404)."""
|
|
perm_store = SqlAlchemyPermissionStore(db_uri)
|
|
account_store = SqlAlchemyAccountStore(db_uri)
|
|
admins = tmp_path / "admins"
|
|
admins.write_text("")
|
|
config = _oidc_config(allow_invites=False, allowed_domains=None)
|
|
provider = UnifiedAuthProvider(source="oidc", oidc_config=config)
|
|
|
|
app = FastAPI()
|
|
app.include_router(
|
|
create_auth_router(provider, perm_store, AdminList(admins), account_store),
|
|
prefix="/auth",
|
|
)
|
|
admin_jwt = mint_session_cookie(
|
|
user_id="admin@example.com",
|
|
cookie_secret=config.cookie_secret,
|
|
ttl_hours=8,
|
|
provider="oidc",
|
|
)
|
|
with TestClient(app) as client:
|
|
resp = client.post("/auth/invite", headers={"Authorization": f"Bearer {admin_jwt}"})
|
|
assert resp.status_code == 404
|