feat(dashboard-auth): extend RFC 8252 native sign-in to password providers (system-browser autofill) (#75808)
* 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>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
buffpesos
|
||||
# PR #75808
|
||||
@@ -37,6 +37,15 @@ Wire shape (all gateway-side state lives in this module):
|
||||
``Authorization: Bearer <access_token>`` (via the existing ``token_auth``
|
||||
seam) and mints ws-tickets the same way — no cookies anywhere.
|
||||
|
||||
Password providers ride the same broker with step 2 swapped: there is no
|
||||
upstream IDP, so ``/auth/native/authorize`` sends the system browser to the
|
||||
interactive ``/login`` form (broker_state in the PKCE cookie) and a successful
|
||||
``/auth/password-login`` plays the role of the upstream callback — it calls
|
||||
:func:`complete_pending` and bounces the browser to the loopback redirect.
|
||||
Steps 4–5 are identical. The point of brokering a password login at all is
|
||||
that the system browser can autofill from the OS password manager (macOS
|
||||
Passwords, etc.), which no embedded desktop webview can.
|
||||
|
||||
Security properties this module guarantees:
|
||||
|
||||
* **PKCE binding (RFC 7636).** A gateway code is redeemable only by the client
|
||||
|
||||
@@ -304,6 +304,15 @@ async def auth_native_authorize(
|
||||
``/auth/callback``), carrying the broker_state in the same PKCE cookie the
|
||||
cookie flow uses. On the callback we mint a loopback code (see
|
||||
``auth_callback``); no browser session cookie is ever set for the desktop.
|
||||
|
||||
Password providers have no upstream IDP round trip to broker, but the
|
||||
native flow is still exactly what they want: it moves sign-in out of the
|
||||
desktop's embedded webview (where OS password managers cannot autofill)
|
||||
into the SYSTEM browser (where they can). For a ``supports_password``
|
||||
provider we redirect to the interactive ``/login`` form instead of an
|
||||
IDP, carrying the broker_state in the PKCE cookie; a successful
|
||||
``/auth/password-login`` then completes the pending authorization and
|
||||
bounces the browser to the loopback redirect (see that route).
|
||||
"""
|
||||
# PKCE method must be S256 (RFC 7636 — plain is disallowed for native apps).
|
||||
if code_challenge_method.upper() != "S256":
|
||||
@@ -344,14 +353,11 @@ async def auth_native_authorize(
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Unknown provider: {provider!r}"
|
||||
)
|
||||
if not getattr(p, "supports_session", True) or getattr(
|
||||
p, "supports_password", False
|
||||
):
|
||||
# Native PKCE brokering is only meaningful for redirect/OAuth
|
||||
# providers; a password provider has no IDP round trip to broker.
|
||||
if not getattr(p, "supports_session", True):
|
||||
# Token-only credentials (e.g. drain) are not interactive sign-ins.
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provider does not support native OAuth login: {p.name!r}",
|
||||
detail=f"Provider does not support native login: {p.name!r}",
|
||||
)
|
||||
|
||||
from hermes_cli.dashboard_auth import native_flow
|
||||
@@ -366,6 +372,30 @@ async def auth_native_authorize(
|
||||
except native_flow.NativeFlowError as e:
|
||||
raise HTTPException(status_code=503, detail=str(e))
|
||||
|
||||
if getattr(p, "supports_password", False):
|
||||
# Password provider: no IDP to redirect through. Land the system
|
||||
# browser on the interactive /login form with the broker_state in
|
||||
# the PKCE cookie (the same server-controlled channel the OAuth
|
||||
# branch uses); /auth/password-login picks it up on success and
|
||||
# 302s the browser to the desktop's loopback redirect_uri. The
|
||||
# desktop's challenge/state never touch the cookie — only our
|
||||
# opaque broker_state does.
|
||||
audit_log(
|
||||
AuditEvent.NATIVE_AUTHORIZE_START,
|
||||
provider=p.name,
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
resp = RedirectResponse(
|
||||
url=f"{_prefix(request)}/login", status_code=302
|
||||
)
|
||||
set_pkce_cookie(
|
||||
resp,
|
||||
payload=f"provider={p.name};broker={broker_state}",
|
||||
use_https=detect_https(request),
|
||||
prefix=_prefix(request),
|
||||
)
|
||||
return resp
|
||||
|
||||
try:
|
||||
ls = p.start_login(redirect_uri=_redirect_uri(request))
|
||||
except ProviderError as e:
|
||||
@@ -674,6 +704,15 @@ async def auth_password_login(request: Request, body: _PasswordLoginBody):
|
||||
the credential form POSTs via fetch and navigates client-side, so a
|
||||
302 (which fetch follows opaquely) is the wrong shape here.
|
||||
|
||||
RFC 8252 native-app branch: when ``/auth/native/authorize`` sent this
|
||||
browser to ``/login`` (password provider), the PKCE cookie carries the
|
||||
opaque ``broker=`` handle. Mirroring the ``/auth/callback`` native
|
||||
branch, success then mints a one-time loopback code instead of a
|
||||
browser session: ``next`` is the desktop's loopback redirect_uri
|
||||
(validated at authorize time) carrying ``code`` + ``state``, and NO
|
||||
session cookies are set — the desktop redeems the code at
|
||||
``/auth/native/token`` for bearer tokens it stores itself.
|
||||
|
||||
Failure modes, all deliberately generic so the endpoint can't be used
|
||||
as a username oracle or a provider-enumeration oracle:
|
||||
* unknown provider / provider lacks password support → 404
|
||||
@@ -706,6 +745,41 @@ async def auth_password_login(request: Request, body: _PasswordLoginBody):
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="Unknown provider")
|
||||
|
||||
# Native-app branch discriminator (see docstring): a broker handle in
|
||||
# the PKCE cookie means this sign-in was initiated by
|
||||
# /auth/native/authorize for a desktop app, not a browser session. The
|
||||
# cookie is server-set (never client-supplied), so it is trustworthy —
|
||||
# and it also records WHICH provider the native flow was initiated for.
|
||||
# /login renders a form for every session provider, so without this
|
||||
# check a flow started for provider A could be completed with provider
|
||||
# B's credentials, binding B's session into A's pending authorization.
|
||||
# Enforce equality BEFORE verifying credentials: nothing is minted, the
|
||||
# pending authorization is preserved, and the user can submit the form
|
||||
# the flow was actually started for.
|
||||
broker_state = ""
|
||||
cookie_provider = ""
|
||||
pkce_raw = read_pkce_cookie(request)
|
||||
if pkce_raw:
|
||||
pkce_parts = dict(
|
||||
seg.split("=", 1) for seg in pkce_raw.split(";") if "=" in seg
|
||||
)
|
||||
broker_state = pkce_parts.get("broker", "")
|
||||
cookie_provider = pkce_parts.get("provider", "")
|
||||
if broker_state and cookie_provider != body.provider:
|
||||
audit_log(
|
||||
AuditEvent.NATIVE_TOKEN_FAILURE,
|
||||
provider=body.provider,
|
||||
reason="provider_mismatch",
|
||||
ip=ip,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"This native sign-in was started for a different provider; "
|
||||
"use that provider's form or restart sign-in."
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
session = p.complete_password_login(
|
||||
username=body.username, password=body.password
|
||||
@@ -741,6 +815,48 @@ async def auth_password_login(request: Request, body: _PasswordLoginBody):
|
||||
ip=ip,
|
||||
)
|
||||
|
||||
# Native-app branch: the broker handle was parsed (and its provider
|
||||
# binding enforced) above, before credential verification.
|
||||
if broker_state:
|
||||
from hermes_cli.dashboard_auth import native_flow
|
||||
|
||||
try:
|
||||
pending = native_flow.get_pending(broker_state)
|
||||
gw_code = native_flow.complete_pending(
|
||||
broker_state, session=session
|
||||
)
|
||||
except native_flow.NativeFlowError:
|
||||
audit_log(
|
||||
AuditEvent.NATIVE_TOKEN_FAILURE,
|
||||
provider=body.provider,
|
||||
reason="pending_not_found",
|
||||
ip=ip,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Native login expired or unknown; restart sign-in.",
|
||||
)
|
||||
from urllib.parse import urlencode
|
||||
|
||||
sep = "&" if "?" in pending.redirect_uri else "?"
|
||||
loopback = (
|
||||
f"{pending.redirect_uri}{sep}"
|
||||
f"{urlencode({'code': gw_code, 'state': pending.client_state})}"
|
||||
)
|
||||
audit_log(
|
||||
AuditEvent.NATIVE_CODE_ISSUED,
|
||||
provider=body.provider,
|
||||
user_id=session.user_id,
|
||||
ip=ip,
|
||||
)
|
||||
# The login page's form script navigates to ``next`` — here the
|
||||
# loopback listener, which answers with its own "you can close
|
||||
# this window" page. No session cookies: the desktop is not a
|
||||
# browser session (mirrors the /auth/callback native branch).
|
||||
resp = JSONResponse({"ok": True, "next": loopback})
|
||||
clear_pkce_cookie(resp, prefix=_prefix(request))
|
||||
return resp
|
||||
|
||||
expires_in = max(60, session.expires_at - int(time.time()))
|
||||
landing = _validate_post_login_target(body.next) or "/"
|
||||
resp = JSONResponse({"ok": True, "next": landing})
|
||||
|
||||
@@ -3225,10 +3225,13 @@ async def get_status(profile: Optional[str] = None):
|
||||
# to decide whether it can use the system-browser + loopback + PKCE
|
||||
# flow (no embedded webview, no session cookies) or must fall back to
|
||||
# the legacy embedded-webview cookie flow. "cookie" is always available
|
||||
# in gated mode; "native_pkce" is present only when at least one
|
||||
# registered session provider is a brokerable OAuth provider (not a
|
||||
# password or token-only credential). Absent field / missing
|
||||
# "native_pkce" ⇒ older gateway ⇒ desktop falls back automatically.
|
||||
# in gated mode; "native_pkce" is present when at least one interactive
|
||||
# session provider is registered — OAuth providers broker the upstream
|
||||
# IDP round trip, password providers complete interactively at /login
|
||||
# in the system browser (where OS password managers can autofill; an
|
||||
# embedded webview cannot reach them). Token-only credentials (e.g.
|
||||
# drain) don't count. Absent field / missing "native_pkce" ⇒ older
|
||||
# gateway ⇒ desktop falls back automatically.
|
||||
auth_flows: list[str] = []
|
||||
try:
|
||||
from hermes_cli.dashboard_auth import (
|
||||
@@ -3238,11 +3241,7 @@ async def get_status(profile: Optional[str] = None):
|
||||
auth_providers = [p.name for p in _list_providers()]
|
||||
if auth_required:
|
||||
auth_flows.append("cookie")
|
||||
brokerable = [
|
||||
p for p in _list_session_providers()
|
||||
if not getattr(p, "supports_password", False)
|
||||
]
|
||||
if brokerable:
|
||||
if _list_session_providers():
|
||||
auth_flows.append("native_pkce")
|
||||
except Exception:
|
||||
# Module not importable yet (early startup) — leave as [].
|
||||
|
||||
@@ -269,10 +269,13 @@ def test_native_authorize_empty_provider_ambiguous_multiple_oauth_404(gated_clie
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_native_authorize_empty_provider_password_only_rejected_400(gated_client):
|
||||
"""Password-only deployment: an empty ``provider`` must still select the
|
||||
lone session provider and fail with the explicit 400 explaining that
|
||||
password providers have no native OAuth flow — not a bare 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()
|
||||
@@ -280,8 +283,10 @@ def test_native_authorize_empty_provider_password_only_rejected_400(gated_client
|
||||
"/auth/native/authorize",
|
||||
params=_native_authorize_params(challenge),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "does not support native OAuth login" in r.json()["detail"]
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -335,6 +340,242 @@ def test_status_loopback_mode_has_no_auth_flows():
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+7
-5
@@ -1861,11 +1861,13 @@ export interface StatusResponse {
|
||||
* fail-closed state (the dashboard will refuse to bind). */
|
||||
auth_providers?: string[];
|
||||
/** Supported dashboard auth flows for the client to choose from. In gated
|
||||
* mode always includes ``"cookie"``; includes ``"native_pkce"`` when a
|
||||
* brokerable OAuth provider is registered, signalling that the desktop can
|
||||
* use the RFC 8252 system-browser + loopback + PKCE flow (no embedded
|
||||
* webview, no session cookies). Absent / missing ``"native_pkce"`` ⇒ an
|
||||
* older gateway ⇒ the desktop falls back to the embedded-webview flow. */
|
||||
* mode always includes ``"cookie"``; includes ``"native_pkce"`` when any
|
||||
* interactive session provider is registered (OAuth providers broker the
|
||||
* IDP redirect; password providers complete at /login in the system
|
||||
* browser), signalling that the desktop can use the RFC 8252
|
||||
* system-browser + loopback + PKCE flow (no embedded webview, no session
|
||||
* cookies). Absent / missing ``"native_pkce"`` ⇒ an older gateway ⇒ the
|
||||
* desktop falls back to the embedded-webview flow. */
|
||||
auth_flows?: string[];
|
||||
/** False when the dashboard is running in a hosted/managed layout where
|
||||
* updates are handled by the outer launcher instead of ``hermes update``. */
|
||||
|
||||
@@ -97,13 +97,16 @@ tool blocks the loopback listener, or you close the browser tab — the app
|
||||
|
||||
## For gateway operators
|
||||
|
||||
Native sign-in is available automatically on any gated gateway that has a
|
||||
brokerable OAuth provider registered (e.g. the bundled **Nous** provider). No
|
||||
configuration is required — the `/auth/native/*` routes and the `auth_flows`
|
||||
advertisement are part of the dashboard-auth subsystem. Password-only and
|
||||
token-only providers do not advertise `native_pkce` (there is no upstream
|
||||
redirect to broker), and those deployments continue to use their existing
|
||||
login.
|
||||
Native sign-in is available automatically on any gated gateway with an
|
||||
interactive session provider registered. No configuration is required — the
|
||||
`/auth/native/*` routes and the `auth_flows` advertisement are part of the
|
||||
dashboard-auth subsystem. OAuth providers (e.g. the bundled **Nous** provider)
|
||||
broker the upstream IDP redirect; password providers (e.g. the bundled
|
||||
**basic-auth** plugin) land the system browser on the gateway's `/login`
|
||||
credential form instead — which is what lets OS password managers (macOS
|
||||
Passwords, etc.) autofill the form, something no embedded desktop webview can
|
||||
offer. Token-only credentials (e.g. drain) are not interactive sign-ins and do
|
||||
not advertise `native_pkce`.
|
||||
|
||||
The relevant endpoints (all public, pre-auth bootstrap, same as the existing
|
||||
`/auth/*` OAuth routes):
|
||||
|
||||
Reference in New Issue
Block a user