56f1afc834
* feat(dashboard-auth): extend RFC 8252 native sign-in to password providers The desktop app runs password sign-in for gated gateways in an embedded Electron BrowserWindow, where OS password managers (macOS Passwords / iCloud Keychain autofill) cannot reach the form — Chromium-in-Electron has no bridge to them, so users retype credentials by hand even though the /login form already carries the right autocomplete attributes. The existing RFC 8252 native flow (system browser + loopback + PKCE) solves exactly this for OAuth providers, but was explicitly disabled for password providers on the grounds that they have "no IDP round trip to broker". The brokering is still worth having: it moves the credential form into the system browser, where password-manager autofill just works. Gateway-only change; the desktop needs no changes (runNativeLogin is already page-agnostic), and older desktop builds pick the capability up automatically once the gateway advertises it: * /auth/native/authorize now accepts a supports_password provider: register the pending broker authorization as usual, then 302 the system browser to the interactive /login form with the opaque broker_state in the gateway's PKCE cookie (the same server-controlled channel the OAuth branch uses) instead of an IDP redirect. * /auth/password-login: when the server-set PKCE cookie carries a broker handle, a successful credential check completes the pending authorization exactly like the /auth/callback native branch — mint the one-time loopback code, return the loopback redirect (validated loopback-only at authorize time) as `next`, clear the PKCE cookie, and set NO session cookies. A lapsed broker is a clean 400 telling the user to restart sign-in; a failed credential attempt leaves the pending entry intact so the user can retype. * /api/status now advertises "native_pkce" whenever any interactive session provider is registered (previously only for non-password providers), so the desktop selects the system-browser strategy for password-only gateways. Security posture is unchanged from the existing flow: loopback-literal redirect_uri enforcement, PKCE S256 binding, single-use short-TTL codes, constant-time comparison, and the same rate limiter on password attempts. Tests: full authorize → /login → password-login → loopback → token → bearer round trip, wrong-password keeps the pending entry, lapsed broker → 400, no-broker browser login keeps minting cookies, and the /api/status advertisement for password-only gateways. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(dashboard-auth): bind native password completion to the authorize-time provider Review follow-ups for #75808: * /auth/password-login now enforces that body.provider matches the provider recorded in the server-set PKCE cookie by /auth/native/authorize before completing a pending native authorization. /login renders a form for every session provider, so without this a native flow started for provider A could be completed with provider B's credentials, binding B's session into A's pending entry. The mismatch is rejected BEFORE credential verification (no session minted, no oracle) and preserves both the pending entry and the cookie, so the user can still submit the correct provider's form. Covered by a two-password-provider E2E regression test. * Update the two docs spots that still said password-only providers do not advertise native_pkce (website desktop-native-signin guide and the auth_flows type comment in web/src/lib/api.ts). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: map contributor email for #75808 (buffpesos) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
591 lines
21 KiB
Python
591 lines
21 KiB
Python
"""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", "")
|
|
assert "broker=" in set_cookie
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
assert "broker=" in set_cookie
|
|
|
|
|
|
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"
|