Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions src/kiro_crew/dashboard/token_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2444,6 +2444,130 @@ def _check_pin(token: str) -> tuple[bool, str]:
if path == "/api/auth/logout" and request.method == "POST":
return await handler(request) # type: ignore[operator]

# ── Transparent auth for verified tailnet peers (issue #6132) ──
#
# When trust_identity + allowed_logins are configured and the request
# carries NO credential (no query token, no access/refresh cookie),
# resolve the forwarded peer via the Tailscale daemon. If the peer is
# on the allowlist, mint a boot-bound session cookie directly so the
# operator never has to complete a separate token login.
#
# This block runs BEFORE the normal token-extraction path. Requests
# that already carry a credential skip it entirely and follow the
# existing flow (which has its own peer-resolution + allowlist gate).
_has_credential = bool(request.query.get("token")) or any(
c.startswith(("mc_token_", "mc_refresh_")) for c in request.cookies
)
if (
not _has_credential
and tailnet_trust is not None
and tailnet_trust.trust_identity
and tailnet_trust.allowed_logins
):
_ta_peer = await resolve_forwarded_peer(request, tailnet_trust)
if _ta_peer is not None:
if not login_allowed(_ta_peer.login, tailnet_trust.allowed_logins):
_sel = _sel_fn()
_sel.log_api_access(
caller=_ta_peer.login,
operation="tailnet_transparent_auth",
outcome="denied",
source="token_auth",
resources=path,
error="login not in allowed_logins",
)
_log_auth(
request,
_ta_peer.login,
"denied",
"tailnet login not allowed",
)
return _deny(request, "tailnet login not allowed")

# Peer is verified and on the allowlist. Mint a boot-bound
# session token so it expires on gateway restart (same model
# as the QR phone-access sessions from PR #5763).
_ta_peer_key = peer_pin_key(_ta_peer, tailnet_trust.pin_scope)
_ta_boot = current_boot_id()
_ta_extra: dict[str, str] = {"boot": _ta_boot}
_ta_user = _ta_peer.login
_ta_token = generate_token(
_ta_user,
ttl_seconds=MAX_SESSION_TTL_SECS,
register_nonce=False,
extra=_ta_extra,
)
_ta_session_exp = time.time() + MAX_SESSION_TTL_SECS
bind_token_peer(
_ta_token,
_ta_peer_key,
_ta_session_exp,
proxied=False,
)

# Expose identity to handlers (same as the normal flow).
request["user"] = _ta_user
request["app"] = ""
request["auth_token"] = _ta_token
request["is_dashboard_user"] = True

_sel = _sel_fn()
_sel.log_api_access(
caller=_ta_peer.login,
operation="tailnet_transparent_auth",
outcome="granted",
source="token_auth",
resources=_ta_peer_key,
)
_log_auth(
request,
_ta_peer.login,
"granted",
"transparent tailnet auth",
)

resp = await handler(request) # type: ignore[operator]

# Set the session cookie so subsequent requests carry it and
# take the normal (faster) cookie path instead of re-resolving
# the daemon on every hit.
_ta_cookie_name = f"mc_token_{_cookie_port_from_host(request, port)}"
resp.set_cookie(
_ta_cookie_name,
_ta_token,
httponly=True,
samesite="Lax",
secure=is_https_request(request),
path="/",
max_age=MAX_SESSION_TTL_SECS,
)

# Mint a refresh chain so the session survives access-token
# rotation, but boot-bound so it dies on restart.
try:
_ta_rt, _ta_chain, _, _ta_rt_exp = generate_refresh_token(
_ta_user, boot=_ta_boot, require_peer=True
)
_ta_rt_remaining = int(_ta_rt_exp - time.time())
if _ta_rt_remaining > 0:
resp.set_cookie(
refresh_cookie_name(_cookie_port_from_host(request, port)),
_ta_rt,
httponly=True,
samesite="Lax",
secure=is_https_request(request),
path=REFRESH_COOKIE_PATH,
max_age=min(_ta_rt_remaining, MAX_REFRESH_TTL_SECS),
)
except Exception:
# Refresh is best-effort; session cookie is sufficient.
logger.warning(
"transparent tailnet auth: refresh mint failed",
exc_info=True,
)

return resp

# Extract token from query param or cookie
cookie_name = f"mc_token_{_cookie_port_from_host(request, port)}"
token = request.query.get("token") or ""
Expand Down
57 changes: 53 additions & 4 deletions test/test_token_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3242,13 +3242,62 @@ async def test_plain_ip_mismatch_reason_is_preserved(_tailnet_env) -> None:

@pytest.mark.asyncio
async def test_credential_less_request_never_reaches_the_daemon(_tailnet_env) -> None:
"""An unauthenticated local caller spraying X-Forwarded-For on a static
path must not be able to force whois spawns: resolution is gated on the
request presenting a credential (query token or mc_* cookie)."""
"""Without transparent auth (trust_identity=False), an unauthenticated local
caller spraying X-Forwarded-For must not force whois spawns: resolution is
gated on a credential or on transparent-auth eligibility."""
whois = _tailnet_env(_whois_payload())
mw = token_auth_middleware(tailnet_trust=_tailnet_trust(trust_identity=False))
resp = await mw(_peer_request(query={}, cookies={}), _ok_handler)
assert resp.status == 403 # no token, no trust — denied
whois.assert_not_called()


@pytest.mark.asyncio
async def test_transparent_auth_issues_session_cookie(_tailnet_env) -> None:
"""A credential-less request from a verified peer on the allowlist gets a
200 and a session cookie (transparent auth, issue #6132)."""
_tailnet_env(_whois_payload())
mw = token_auth_middleware(tailnet_trust=_tailnet_trust())
resp = await mw(_peer_request(query={}, cookies={}), _ok_handler)
assert resp.status == 200
cookie = resp.cookies.get("mc_token_5476")
assert cookie is not None, "expected session cookie from transparent auth"


@pytest.mark.asyncio
async def test_transparent_auth_denies_peer_not_on_allowlist(_tailnet_env) -> None:
"""A verified peer whose login is NOT on the allowlist is denied even
without a credential (transparent auth deny path)."""
_tailnet_env(_whois_payload(login="mallory@evil.com"))
mw = token_auth_middleware(tailnet_trust=_tailnet_trust())
resp = await mw(_peer_request(query={}, cookies={}), _ok_handler)
assert resp.status == 403 # no token — denied as today
assert resp.status == 403
assert b"tailnet login not allowed" in resp.body


@pytest.mark.asyncio
async def test_transparent_auth_skipped_when_credential_present(_tailnet_env) -> None:
"""When a credential IS present, the normal token path runs instead of
transparent auth (the peer block is skipped)."""
_tailnet_env(_whois_payload())
mw = token_auth_middleware(tailnet_trust=_tailnet_trust())
token = generate_token("tsuser", ttl_seconds=300)
resp = await mw(_peer_request(query={"token": token}), _ok_handler)
assert resp.status == 200
# Cookie should be set from the normal query-param exchange, not
# transparent auth — verify via the normal path cookie setting.
cookie = resp.cookies.get("mc_token_5476")
assert cookie is not None


@pytest.mark.asyncio
async def test_transparent_auth_skipped_when_no_allowlist(_tailnet_env) -> None:
"""Transparent auth requires allowed_logins. Without it, credential-less
requests are denied as before."""
whois = _tailnet_env(_whois_payload())
mw = token_auth_middleware(tailnet_trust=_tailnet_trust(allowed_logins=()))
resp = await mw(_peer_request(query={}, cookies={}), _ok_handler)
assert resp.status == 403
whois.assert_not_called()


Expand Down
Loading