Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,598 @@
|
||||
"""E2E + unit tests for the RFC 8252 native-app (system-browser + loopback +
|
||||
PKCE) dashboard-auth flow.
|
||||
|
||||
Covers:
|
||||
* ``native_flow`` broker unit behaviour — PKCE binding, single-use codes,
|
||||
expiry, capacity, replay resistance.
|
||||
* The full ``/auth/native/authorize`` → ``/auth/callback`` →
|
||||
``/auth/native/token`` round trip in-process against ``StubAuthProvider``.
|
||||
* ``/api/status`` capability advertisement (``auth_flows``).
|
||||
* Cookieless bearer authentication of a gated route (the whole point of the
|
||||
feature — a desktop authenticates REST with ``Authorization: Bearer`` and
|
||||
sets/needs no cookie).
|
||||
* ``/auth/native/refresh`` token rotation and terminal-expiry semantics.
|
||||
|
||||
Run: pytest tests/hermes_cli/test_dashboard_auth_native_flow.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import base64
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hermes_cli import web_server
|
||||
from hermes_cli.dashboard_auth import (
|
||||
clear_providers,
|
||||
register_provider,
|
||||
)
|
||||
from hermes_cli.dashboard_auth import native_flow
|
||||
from hermes_cli.dashboard_auth.base import Session
|
||||
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PKCE helpers (desktop side)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _b64url_no_pad(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _make_pkce() -> tuple[str, str]:
|
||||
"""Return ``(verifier, challenge)`` — the desktop's PKCE pair."""
|
||||
verifier = _b64url_no_pad(b"desktop-verifier-secret-material-0123456789abcd")
|
||||
challenge = _b64url_no_pad(hashlib.sha256(verifier.encode("ascii")).digest())
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
class _PasswordOnlyProvider(StubAuthProvider):
|
||||
"""Mirrors the bundled ``basic`` provider's flags: a session provider
|
||||
(``supports_session`` defaults True) that authenticates by username +
|
||||
password and can never be the target of the native OAuth broker flow.
|
||||
``start_login`` raises to prove the route must reject it before ever
|
||||
attempting a redirect."""
|
||||
|
||||
name = "pwonly"
|
||||
display_name = "Password Only (test)"
|
||||
supports_password = True
|
||||
|
||||
def start_login(self, *, redirect_uri):
|
||||
raise AssertionError(
|
||||
"native authorize must reject a password provider before "
|
||||
"calling start_login"
|
||||
)
|
||||
|
||||
|
||||
class _SecondStubProvider(StubAuthProvider):
|
||||
"""A second brokerable OAuth provider, so tests can create an ambiguous
|
||||
multi-provider deployment."""
|
||||
|
||||
name = "stub2"
|
||||
display_name = "Stub IdP Two (test only)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# native_flow broker unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_broker():
|
||||
native_flow._reset_for_tests()
|
||||
# Snapshot the shared app.state auth fields + provider registry so a test
|
||||
# that flips auth_required / registers a stub provider can't leak into a
|
||||
# later test file (e.g. the MCP dashboard-oauth suite shares web_server.app).
|
||||
prev_required = getattr(web_server.app.state, "auth_required", None)
|
||||
prev_host = getattr(web_server.app.state, "bound_host", None)
|
||||
prev_port = getattr(web_server.app.state, "bound_port", None)
|
||||
yield
|
||||
native_flow._reset_for_tests()
|
||||
clear_providers()
|
||||
web_server.app.state.auth_required = prev_required
|
||||
web_server.app.state.bound_host = prev_host
|
||||
web_server.app.state.bound_port = prev_port
|
||||
|
||||
|
||||
def _stub_session(exp_offset: int = 3600) -> Session:
|
||||
now = int(time.time())
|
||||
return Session(
|
||||
user_id="u1",
|
||||
email="u1@example.test",
|
||||
display_name="U One",
|
||||
org_id="org1",
|
||||
provider="stub",
|
||||
expires_at=now + exp_offset,
|
||||
access_token="at-opaque",
|
||||
refresh_token="rt-opaque",
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route-level E2E against StubAuthProvider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gated_client():
|
||||
clear_providers()
|
||||
register_provider(StubAuthProvider())
|
||||
prev_host = getattr(web_server.app.state, "bound_host", None)
|
||||
prev_port = getattr(web_server.app.state, "bound_port", None)
|
||||
prev_required = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.bound_host = "fly-app.fly.dev"
|
||||
web_server.app.state.bound_port = 443
|
||||
web_server.app.state.auth_required = True
|
||||
# follow_redirects=False so we can inspect each 302 leg of the flow.
|
||||
client = TestClient(
|
||||
web_server.app, base_url="https://fly-app.fly.dev",
|
||||
follow_redirects=False,
|
||||
)
|
||||
yield client
|
||||
clear_providers()
|
||||
web_server.app.state.bound_host = prev_host
|
||||
web_server.app.state.bound_port = prev_port
|
||||
web_server.app.state.auth_required = prev_required
|
||||
|
||||
|
||||
def _walk_native_login(client, *, redirect_uri, challenge, state="cli-state"):
|
||||
"""Drive authorize → (stub redirects to callback) → loopback code.
|
||||
|
||||
Returns the ``code`` + ``state`` the gateway put on the loopback redirect.
|
||||
"""
|
||||
# 1. Desktop opens the system browser at /auth/native/authorize.
|
||||
r = client.get(
|
||||
"/auth/native/authorize",
|
||||
params={
|
||||
"provider": "stub",
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"redirect_uri": redirect_uri,
|
||||
"state": state,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 302, r.text
|
||||
# Stub's start_login redirects straight to /auth/callback?code=stub_code.
|
||||
loc = r.headers["location"]
|
||||
parsed = urlparse(loc)
|
||||
cb_qs = parse_qs(parsed.query)
|
||||
# Carry the gateway PKCE cookie forward (holds broker_state + verifier).
|
||||
cookies = r.cookies
|
||||
# 2. Browser hits the gateway callback.
|
||||
r2 = client.get(
|
||||
"/auth/callback",
|
||||
params={"code": cb_qs["code"][0], "state": cb_qs["state"][0]},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r2.status_code == 302, r2.text
|
||||
# 3. The callback 302s to the desktop's loopback redirect_uri.
|
||||
loop = urlparse(r2.headers["location"])
|
||||
assert f"{loop.scheme}://{loop.netloc}" == redirect_uri.rsplit("/", 1)[0] or \
|
||||
loop.netloc in redirect_uri
|
||||
loop_qs = parse_qs(loop.query)
|
||||
# No session cookie must be set on the native callback response.
|
||||
set_cookie = r2.headers.get("set-cookie", "")
|
||||
assert "hermes_session_at" not in set_cookie, (
|
||||
f"native callback must NOT set a session cookie; got {set_cookie!r}"
|
||||
)
|
||||
return loop_qs["code"][0], loop_qs["state"][0]
|
||||
|
||||
|
||||
|
||||
|
||||
def test_native_authorize_rejects_non_loopback_redirect(gated_client):
|
||||
_verifier, challenge = _make_pkce()
|
||||
r = gated_client.get(
|
||||
"/auth/native/authorize",
|
||||
params={
|
||||
"provider": "stub",
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"redirect_uri": "https://evil.example.com/steal",
|
||||
"state": "s",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "loopback" in r.json()["detail"].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Empty-provider auto-select (the desktop omits ``provider``; the gateway
|
||||
# picks when there is exactly one brokerable candidate) — regression #78906
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _native_authorize_params(challenge, **overrides):
|
||||
params = {
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"redirect_uri": "http://127.0.0.1:53999/cb",
|
||||
"state": "s",
|
||||
}
|
||||
params.update(overrides)
|
||||
return params
|
||||
|
||||
|
||||
def test_native_authorize_empty_provider_auto_selects_oauth_with_password_also_registered(
|
||||
gated_client,
|
||||
):
|
||||
"""Regression for #78906: a password provider is a session provider but
|
||||
can never be the target of the native OAuth broker flow, so it must not
|
||||
count toward the empty-provider auto-select. With one OAuth provider +
|
||||
one password provider (the normal SSO-with-password-fallback setup) the
|
||||
desktop's empty-provider request must auto-select the OAuth provider
|
||||
(302), not fail with ``Unknown provider: ''`` (404)."""
|
||||
register_provider(_PasswordOnlyProvider())
|
||||
_verifier, challenge = _make_pkce()
|
||||
r = gated_client.get(
|
||||
"/auth/native/authorize",
|
||||
params=_native_authorize_params(challenge),
|
||||
)
|
||||
assert r.status_code == 302, r.text
|
||||
assert "code=stub_code" in r.headers["location"]
|
||||
|
||||
|
||||
def test_native_authorize_empty_provider_auto_selects_single_oauth(gated_client):
|
||||
"""The common hosted case: exactly one brokerable provider; an empty
|
||||
``provider`` auto-selects it (302), so the desktop needn't hardcode the
|
||||
name."""
|
||||
_verifier, challenge = _make_pkce()
|
||||
r = gated_client.get(
|
||||
"/auth/native/authorize",
|
||||
params=_native_authorize_params(challenge),
|
||||
)
|
||||
assert r.status_code == 302, r.text
|
||||
assert "code=stub_code" in r.headers["location"]
|
||||
|
||||
|
||||
def test_native_authorize_empty_provider_ambiguous_multiple_oauth_404(gated_client):
|
||||
"""Two brokerable providers: the empty-provider convenience cannot pick
|
||||
unambiguously, so the request still fails — the desktop must pass
|
||||
``?provider=`` explicitly."""
|
||||
register_provider(_SecondStubProvider())
|
||||
_verifier, challenge = _make_pkce()
|
||||
r = gated_client.get(
|
||||
"/auth/native/authorize",
|
||||
params=_native_authorize_params(challenge),
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_native_authorize_empty_provider_password_only_brokers_to_login(
|
||||
gated_client,
|
||||
):
|
||||
"""Password-only deployment: an empty ``provider`` selects the lone
|
||||
session provider and — now that native sign-in brokers password
|
||||
providers through the system browser — 302s to ``/login`` with the
|
||||
broker in the PKCE cookie, rather than the old 400."""
|
||||
clear_providers()
|
||||
register_provider(_PasswordOnlyProvider())
|
||||
_verifier, challenge = _make_pkce()
|
||||
r = gated_client.get(
|
||||
"/auth/native/authorize",
|
||||
params=_native_authorize_params(challenge),
|
||||
)
|
||||
assert r.status_code == 302, r.text
|
||||
assert r.headers["location"].endswith("/login")
|
||||
set_cookie = r.headers.get("set-cookie", "")
|
||||
# The PKCE cookie value is URL-encoded on the wire; decode through
|
||||
# the real reader inverse before asserting the broker handle rides
|
||||
# in it.
|
||||
from hermes_cli.dashboard_auth.cookies import parse_pkce_payload
|
||||
wire_value = set_cookie.split("=", 1)[1].split(";", 1)[0]
|
||||
assert "broker" in parse_pkce_payload(wire_value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cookieless bearer auth of a gated route — the core deliverable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bearer_authenticates_gated_route_without_cookie(gated_client):
|
||||
"""A desktop that redeemed tokens can call a gated route with only an
|
||||
``Authorization: Bearer`` header — no cookie in the jar."""
|
||||
verifier, challenge = _make_pkce()
|
||||
code, _state = _walk_native_login(
|
||||
gated_client, redirect_uri="http://127.0.0.1:53999/cb",
|
||||
challenge=challenge,
|
||||
)
|
||||
tokens = gated_client.post(
|
||||
"/auth/native/token",
|
||||
json={"code": code, "code_verifier": verifier},
|
||||
).json()
|
||||
at = tokens["access_token"]
|
||||
|
||||
# /api/auth/me is gated; a cookieless request with the bearer must pass
|
||||
# and identify the user.
|
||||
r = gated_client.get(
|
||||
"/api/auth/me",
|
||||
headers={"Authorization": f"Bearer {at}"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["user_id"] == "stub-user-1"
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capability advertisement on /api/status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
def test_status_loopback_mode_has_no_auth_flows():
|
||||
clear_providers()
|
||||
prev_required = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = False
|
||||
try:
|
||||
client = TestClient(web_server.app, base_url="http://127.0.0.1:8080")
|
||||
body = client.get("/api/status").json()
|
||||
assert body["auth_required"] is False
|
||||
assert body["auth_flows"] == []
|
||||
finally:
|
||||
web_server.app.state.auth_required = prev_required
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Native flow for password providers (system-browser autofill path)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# A password provider has no IDP round trip, but the native flow still buys
|
||||
# the desktop the one thing an embedded webview can never have: the system
|
||||
# browser's OS-password-manager autofill. /auth/native/authorize lands the
|
||||
# browser on /login (broker_state in the PKCE cookie) and a successful
|
||||
# /auth/password-login completes the pending authorization exactly like the
|
||||
# OAuth callback does.
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pw_gated_client():
|
||||
from hermes_cli.dashboard_auth.routes import _reset_password_rate_limit
|
||||
from tests.hermes_cli.test_dashboard_auth_password_login import (
|
||||
PasswordProvider,
|
||||
)
|
||||
|
||||
clear_providers()
|
||||
register_provider(PasswordProvider())
|
||||
_reset_password_rate_limit()
|
||||
prev_host = getattr(web_server.app.state, "bound_host", None)
|
||||
prev_port = getattr(web_server.app.state, "bound_port", None)
|
||||
prev_required = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.bound_host = "fly-app.fly.dev"
|
||||
web_server.app.state.bound_port = 443
|
||||
web_server.app.state.auth_required = True
|
||||
client = TestClient(
|
||||
web_server.app, base_url="https://fly-app.fly.dev",
|
||||
follow_redirects=False,
|
||||
)
|
||||
yield client
|
||||
clear_providers()
|
||||
_reset_password_rate_limit()
|
||||
web_server.app.state.bound_host = prev_host
|
||||
web_server.app.state.bound_port = prev_port
|
||||
web_server.app.state.auth_required = prev_required
|
||||
|
||||
|
||||
def test_status_advertises_native_pkce_for_password_only_gateway(
|
||||
pw_gated_client,
|
||||
):
|
||||
body = pw_gated_client.get("/api/status").json()
|
||||
assert body["auth_required"] is True
|
||||
assert "cookie" in body["auth_flows"]
|
||||
assert "native_pkce" in body["auth_flows"]
|
||||
|
||||
|
||||
def test_native_authorize_password_provider_redirects_to_login(
|
||||
pw_gated_client,
|
||||
):
|
||||
"""Empty ``provider`` auto-picks the single password provider and lands
|
||||
the system browser on /login with the broker in the PKCE cookie."""
|
||||
_verifier, challenge = _make_pkce()
|
||||
r = pw_gated_client.get(
|
||||
"/auth/native/authorize",
|
||||
params={
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"redirect_uri": "http://127.0.0.1:53999/cb",
|
||||
"state": "desk-state",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 302, r.text
|
||||
assert r.headers["location"].endswith("/login")
|
||||
set_cookie = r.headers.get("set-cookie", "")
|
||||
assert "pkce" in set_cookie
|
||||
# Wire value is URL-encoded; decode through the reader inverse.
|
||||
from hermes_cli.dashboard_auth.cookies import parse_pkce_payload
|
||||
wire_value = set_cookie.split("=", 1)[1].split(";", 1)[0]
|
||||
assert "broker" in parse_pkce_payload(wire_value)
|
||||
|
||||
|
||||
def _start_native_password_login(client, *, challenge, state="desk-state"):
|
||||
r = client.get(
|
||||
"/auth/native/authorize",
|
||||
params={
|
||||
"provider": "testpw",
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"redirect_uri": "http://127.0.0.1:53999/cb",
|
||||
"state": state,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 302, r.text
|
||||
return r.cookies
|
||||
|
||||
|
||||
def test_native_password_login_full_roundtrip(pw_gated_client):
|
||||
"""authorize → /login → password-login → loopback code → bearer tokens."""
|
||||
verifier, challenge = _make_pkce()
|
||||
cookies = _start_native_password_login(pw_gated_client, challenge=challenge)
|
||||
|
||||
# The browser form POSTs the credentials; the PKCE cookie rides along.
|
||||
r = pw_gated_client.post(
|
||||
"/auth/password-login",
|
||||
json={"provider": "testpw", "username": "admin", "password": "hunter2"},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
# ``next`` is the desktop's loopback redirect carrying code + state —
|
||||
# NOT a dashboard path.
|
||||
assert body["next"].startswith("http://127.0.0.1:53999/cb?")
|
||||
qs = parse_qs(urlparse(body["next"]).query)
|
||||
assert qs["state"][0] == "desk-state"
|
||||
code = qs["code"][0]
|
||||
# No browser session on the native branch; the PKCE cookie is cleared.
|
||||
set_cookie = r.headers.get("set-cookie", "")
|
||||
assert "hermes_session_at" not in set_cookie, (
|
||||
f"native password login must NOT set a session cookie; got {set_cookie!r}"
|
||||
)
|
||||
assert "pkce" in set_cookie # the clearing Set-Cookie
|
||||
|
||||
# Desktop redeems the loopback code with its PKCE verifier.
|
||||
tokens = pw_gated_client.post(
|
||||
"/auth/native/token",
|
||||
json={"code": code, "code_verifier": verifier},
|
||||
).json()
|
||||
assert tokens["provider"] == "testpw"
|
||||
assert tokens["user_id"] == "admin"
|
||||
|
||||
# Cookieless bearer auth of a gated route — the point of the flow.
|
||||
r2 = pw_gated_client.get(
|
||||
"/api/auth/me",
|
||||
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert r2.json()["user_id"] == "admin"
|
||||
|
||||
|
||||
def test_native_password_login_wrong_password_keeps_pending(pw_gated_client):
|
||||
"""A failed credential attempt must not consume the pending
|
||||
authorization — the user retypes and succeeds on the same broker."""
|
||||
verifier, challenge = _make_pkce()
|
||||
cookies = _start_native_password_login(pw_gated_client, challenge=challenge)
|
||||
|
||||
r = pw_gated_client.post(
|
||||
"/auth/password-login",
|
||||
json={"provider": "testpw", "username": "admin", "password": "wrong"},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
r2 = pw_gated_client.post(
|
||||
"/auth/password-login",
|
||||
json={"provider": "testpw", "username": "admin", "password": "hunter2"},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert r2.json()["next"].startswith("http://127.0.0.1:53999/cb?")
|
||||
|
||||
|
||||
def test_native_password_login_expired_broker_returns_400(pw_gated_client):
|
||||
"""A broker cookie whose pending entry lapsed (TTL) is a clean 400
|
||||
telling the user to restart sign-in — never a silent cookie login."""
|
||||
_verifier, challenge = _make_pkce()
|
||||
cookies = _start_native_password_login(pw_gated_client, challenge=challenge)
|
||||
|
||||
native_flow._reset_for_tests() # simulate the pending TTL lapsing
|
||||
|
||||
r = pw_gated_client.post(
|
||||
"/auth/password-login",
|
||||
json={"provider": "testpw", "username": "admin", "password": "hunter2"},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "restart" in r.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_native_password_login_rejects_cross_provider_completion(
|
||||
pw_gated_client,
|
||||
):
|
||||
"""A native flow started for provider A must not be completable with
|
||||
provider B's credentials: /login renders every provider's form, and the
|
||||
pending authorization is bound to the provider recorded in the
|
||||
server-set PKCE cookie. The mismatch is rejected BEFORE credential
|
||||
verification and preserves the pending entry, so the user can still
|
||||
submit the form the flow was started for."""
|
||||
from tests.hermes_cli.test_dashboard_auth_password_login import (
|
||||
PasswordProvider,
|
||||
)
|
||||
|
||||
class SecondPasswordProvider(PasswordProvider):
|
||||
name = "testpw2"
|
||||
display_name = "Test Password 2"
|
||||
|
||||
register_provider(SecondPasswordProvider())
|
||||
|
||||
verifier, challenge = _make_pkce()
|
||||
# Native flow initiated for provider A ("testpw").
|
||||
cookies = _start_native_password_login(pw_gated_client, challenge=challenge)
|
||||
|
||||
# Valid credentials for provider B ("testpw2") must NOT complete A's
|
||||
# pending authorization.
|
||||
r = pw_gated_client.post(
|
||||
"/auth/password-login",
|
||||
json={
|
||||
"provider": "testpw2", "username": "admin", "password": "hunter2",
|
||||
},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
assert "different provider" in r.json()["detail"]
|
||||
set_cookie = r.headers.get("set-cookie", "")
|
||||
assert "hermes_session_at" not in set_cookie
|
||||
|
||||
# The pending entry survived — provider A completes normally.
|
||||
r2 = pw_gated_client.post(
|
||||
"/auth/password-login",
|
||||
json={
|
||||
"provider": "testpw", "username": "admin", "password": "hunter2",
|
||||
},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
qs = parse_qs(urlparse(r2.json()["next"]).query)
|
||||
tokens = pw_gated_client.post(
|
||||
"/auth/native/token",
|
||||
json={"code": qs["code"][0], "code_verifier": verifier},
|
||||
).json()
|
||||
assert tokens["provider"] == "testpw"
|
||||
|
||||
|
||||
def test_password_login_without_broker_still_mints_cookies(pw_gated_client):
|
||||
"""Guard: an ordinary browser password login (no native broker cookie)
|
||||
keeps the existing cookie-minting behaviour."""
|
||||
r = pw_gated_client.post(
|
||||
"/auth/password-login",
|
||||
json={"provider": "testpw", "username": "admin", "password": "hunter2"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["next"] == "/"
|
||||
set_cookie = r.headers.get("set-cookie", "")
|
||||
assert "hermes_session_at" in set_cookie
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Native refresh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_native_refresh_dead_token_returns_401(gated_client):
|
||||
r = gated_client.post(
|
||||
"/auth/native/refresh",
|
||||
json={"refresh_token": "garbage-not-a-real-rt", "provider": "stub"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
assert r.json()["error"] == "session_expired"
|
||||
Reference in New Issue
Block a user