From f1971b54422d99eb628317eda0c92acacd5aacfa Mon Sep 17 00:00:00 2001 From: Paulo Date: Tue, 21 Jul 2026 18:49:27 +0200 Subject: [PATCH 01/11] Extract request-actor context from browser sessions --- backend/druks/accounts/context.py | 5 +++++ backend/druks/accounts/dependencies.py | 5 +++-- backend/druks/accounts/sessions.py | 5 ----- backend/druks/workflows.py | 4 ++-- backend/tests/conftest.py | 2 +- backend/tests/test_api_usage.py | 2 +- backend/tests/test_durable_sdk.py | 4 ++-- 7 files changed, 14 insertions(+), 13 deletions(-) create mode 100644 backend/druks/accounts/context.py diff --git a/backend/druks/accounts/context.py b/backend/druks/accounts/context.py new file mode 100644 index 0000000..9327a27 --- /dev/null +++ b/backend/druks/accounts/context.py @@ -0,0 +1,5 @@ +from contextvars import ContextVar + +# The request's authenticated account, stamped by the auth gate — Workflow.start +# reads it so a browser-origin run attributes itself without route ceremony. +current_account_id: ContextVar[str | None] = ContextVar("current_account_id", default=None) diff --git a/backend/druks/accounts/dependencies.py b/backend/druks/accounts/dependencies.py index d00bb70..bae574a 100644 --- a/backend/druks/accounts/dependencies.py +++ b/backend/druks/accounts/dependencies.py @@ -1,6 +1,7 @@ from fastapi import HTTPException, Request from druks.accounts import sessions +from druks.accounts.context import current_account_id from druks.accounts.exceptions import InvalidPatError from druks.accounts.models import Account, PersonalAccessToken @@ -22,7 +23,7 @@ async def current_session_account(request: Request) -> Account: management, so a token can never manage tokens.""" account = await resolve_session_account(request) if account: - sessions.current_account_id.set(account.id) + current_account_id.set(account.id) return account raise HTTPException(status_code=401, detail="Sign in to use this API.") @@ -45,7 +46,7 @@ async def current_account(request: Request) -> Account: detail=str(error), headers={"WWW-Authenticate": f'{_BEARER_CHALLENGE}, error="invalid_token"'}, ) from error - sessions.current_account_id.set(pat.account_id) + current_account_id.set(pat.account_id) return pat.account raise HTTPException( status_code=401, diff --git a/backend/druks/accounts/sessions.py b/backend/druks/accounts/sessions.py index 20d8c61..96a6138 100644 --- a/backend/druks/accounts/sessions.py +++ b/backend/druks/accounts/sessions.py @@ -1,5 +1,4 @@ import secrets -from contextvars import ContextVar from druks.redis import get_client @@ -8,10 +7,6 @@ SESSION_COOKIE = "druks_session" SESSION_TTL_SECONDS = 30 * 24 * 3600 -# The request's signed-in account, stamped by the session gate — Workflow.start -# reads it so a browser-origin run attributes itself without route ceremony. -current_account_id: ContextVar[str | None] = ContextVar("current_account_id", default=None) - async def mint_session(account_id: str) -> str: token = secrets.token_urlsafe(32) diff --git a/backend/druks/workflows.py b/backend/druks/workflows.py index 8c991a8..0294493 100644 --- a/backend/druks/workflows.py +++ b/backend/druks/workflows.py @@ -19,7 +19,7 @@ from pydantic import BaseModel, ConfigDict, Field, create_model from uuid_utils import uuid7 -from druks.accounts.sessions import current_account_id +from druks.accounts.context import current_account_id from druks.durable.activity import get_run_phase, set_run_phase from druks.durable.engine import _step_engine, register_schedule, run_queue, step_session from druks.durable.enums import AgentCallStatus, RunState @@ -636,7 +636,7 @@ async def start( # subject is required (no default) so a run can't silently lose its # timeline by omission — pass subject=None explicitly for a background run. if account_id is None: - # Browser-origin starts inherit the session gate's account; + # Browser-origin starts inherit the request's authenticated account; # dispatchers that know better pass account_id explicitly. account_id = current_account_id.get() if subject is not None: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 56eedef..859328b 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -363,8 +363,8 @@ def configure_app_for_test( async def _test_account(): + from druks.accounts.context import current_account_id from druks.accounts.models import Account - from druks.accounts.sessions import current_account_id account = Account.get_or_create("op@example.com") current_account_id.set(account.id) diff --git a/backend/tests/test_api_usage.py b/backend/tests/test_api_usage.py index 4cdbbb6..e900ef6 100644 --- a/backend/tests/test_api_usage.py +++ b/backend/tests/test_api_usage.py @@ -21,7 +21,7 @@ def client(extension_settings: Settings): def _account_id() -> str: - # The suite's session gate stands in op@example.com (conftest override). + # The suite's auth gate stands in op@example.com (conftest override). from druks.accounts.models import Account return Account.get_or_create("op@example.com").id diff --git a/backend/tests/test_durable_sdk.py b/backend/tests/test_durable_sdk.py index 8a7f9bb..7e45bec 100644 --- a/backend/tests/test_durable_sdk.py +++ b/backend/tests/test_durable_sdk.py @@ -288,9 +288,9 @@ async def test_attribution_rides_the_run_and_survives_resume(rt): async def test_browser_origin_start_inherits_the_ambient_account(rt): - # The session gate stamps the request's account into a contextvar; + # The auth gate stamps the request's account into a contextvar; # start() reads it when no explicit account_id is passed. - from druks.accounts.sessions import current_account_id + from druks.accounts.context import current_account_id account_id = _account_id(rt.engine, "ambient@example.com") token = current_account_id.set(account_id) From 9f6fa0c7841795b4c57dca77e84a3f4587f7f080 Mon Sep 17 00:00:00 2001 From: Paulo Date: Tue, 21 Jul 2026 19:28:09 +0200 Subject: [PATCH 02/11] Replace browser login with edge-asserted identity --- .env.example | 7 + backend/druks/accounts/constants.py | 3 - backend/druks/accounts/dependencies.py | 167 ++++++--- backend/druks/accounts/exceptions.py | 6 + backend/druks/accounts/models.py | 28 +- backend/druks/accounts/routes.py | 121 +------ backend/druks/accounts/schemas.py | 9 + backend/druks/accounts/sessions.py | 25 -- backend/druks/api/app.py | 52 ++- backend/druks/extensions/base.py | 2 +- backend/druks/harnesses/base.py | 51 +-- backend/druks/harnesses/claude.py | 2 +- backend/druks/harnesses/constants.py | 7 +- backend/druks/harnesses/datastructures.py | 6 +- backend/druks/harnesses/exceptions.py | 2 +- backend/druks/harnesses/models.py | 5 + backend/druks/harnesses/routes.py | 91 +++++ backend/druks/settings.py | 20 +- backend/druks/setup_env.py | 41 ++- backend/druks/user_settings/routes.py | 22 +- backend/druks/user_settings/schemas.py | 17 +- backend/tests/conftest.py | 10 +- backend/tests/test_agent_routes.py | 5 +- backend/tests/test_api_settings.py | 10 +- backend/tests/test_auth.py | 253 ------------- backend/tests/test_auth_boundary.py | 67 +++- backend/tests/test_auth_pats.py | 89 +++-- backend/tests/test_durable_sdk.py | 2 +- backend/tests/test_extensions.py | 2 +- ...rness_login.py => test_harness_connect.py} | 80 ++-- backend/tests/test_identity.py | 342 ++++++++++++++++++ backend/tests/test_scaffolding.py | 2 +- backend/tests/test_settings.py | 17 + backend/tests/test_setup_env.py | 24 ++ deploy/README.md | 53 +-- deploy/caddy/Caddyfile | 18 +- deploy/compose.yaml | 10 +- docs/concepts.md | 26 +- docs/configuration.md | 76 ++-- docs/development.md | 10 +- docs/full-local.md | 12 +- docs/troubleshooting.md | 34 +- docs/writing-an-extension.md | 4 +- frontend/src/api/client.test.ts | 47 ++- frontend/src/api/client.ts | 47 +-- frontend/src/api/sse.test.tsx | 38 +- frontend/src/api/sse.ts | 19 +- frontend/src/api/types.ts | 15 +- frontend/src/components/AuthProvider.test.tsx | 68 ---- frontend/src/components/AuthProvider.tsx | 67 ---- frontend/src/components/AuthedApp.tsx | 9 +- .../src/components/HarnessConnect.test.tsx | 28 +- ...arnessLogin.tsx => HarnessConnectFlow.tsx} | 23 +- .../src/components/IdentityBootstrap.test.tsx | 136 +++++++ frontend/src/components/IdentityBootstrap.tsx | 89 +++++ frontend/src/components/Landing.test.tsx | 40 -- frontend/src/components/Onboarding.test.tsx | 48 +++ .../{Landing.tsx => Onboarding.tsx} | 38 +- frontend/src/components/SettingsModal.tsx | 13 +- frontend/src/main.tsx | 6 +- frontend/src/styles.css | 3 +- 61 files changed, 1556 insertions(+), 1008 deletions(-) delete mode 100644 backend/druks/accounts/sessions.py create mode 100644 backend/druks/harnesses/routes.py delete mode 100644 backend/tests/test_auth.py rename backend/tests/{test_harness_login.py => test_harness_connect.py} (62%) create mode 100644 backend/tests/test_identity.py delete mode 100644 frontend/src/components/AuthProvider.test.tsx delete mode 100644 frontend/src/components/AuthProvider.tsx rename frontend/src/components/{HarnessLogin.tsx => HarnessConnectFlow.tsx} (74%) create mode 100644 frontend/src/components/IdentityBootstrap.test.tsx create mode 100644 frontend/src/components/IdentityBootstrap.tsx delete mode 100644 frontend/src/components/Landing.test.tsx create mode 100644 frontend/src/components/Onboarding.test.tsx rename frontend/src/components/{Landing.tsx => Onboarding.tsx} (68%) diff --git a/.env.example b/.env.example index 3bdab5f..17c8e32 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,13 @@ DRUKS_LOG_LEVEL=INFO # python3 -c 'import base64, os; print(base64.b64encode(os.urandom(32)).decode())' DRUKS_SECRETS_KEY= +# Browser identity. `none` = no authentication (loopback dashboard, exactly one +# operator account, created by the first harness connect). Behind a trusted +# identity edge, switch to header mode: +# DRUKS_AUTH_MODE=header +# DRUKS_AUTH_HEADER=X-ExeDev-Email +DRUKS_AUTH_MODE=none + # Webhook authentication and public ingress. DRUKS_WEBHOOK_HOST is used only # for the doctor's external probe; leave it empty when no public ingress exists. DRUKS_WEBHOOK_SECRET=change-me diff --git a/backend/druks/accounts/constants.py b/backend/druks/accounts/constants.py index 6377fc2..7ffc5d5 100644 --- a/backend/druks/accounts/constants.py +++ b/backend/druks/accounts/constants.py @@ -5,9 +5,6 @@ # start; no provider email ever collides with it. SYSTEM_ACCOUNT_ID = "system" -# A signed-in session in Redis: token -> account_id. -SESSION_PREFIX = "druks:session:" - # A personal access token serializes as druks_pat__. The prefix # is the row's lookup key and the only part that may appear in errors, logs, or # lists; the secret exists only inside the copy-once plaintext. diff --git a/backend/druks/accounts/dependencies.py b/backend/druks/accounts/dependencies.py index bae574a..9865a35 100644 --- a/backend/druks/accounts/dependencies.py +++ b/backend/druks/accounts/dependencies.py @@ -1,56 +1,141 @@ +from collections.abc import AsyncIterator + from fastapi import HTTPException, Request -from druks.accounts import sessions from druks.accounts.context import current_account_id -from druks.accounts.exceptions import InvalidPatError +from druks.accounts.exceptions import AuthConfigurationError, InvalidPatError from druks.accounts.models import Account, PersonalAccessToken _BEARER_CHALLENGE = 'Bearer realm="druks"' -async def resolve_session_account(request: Request) -> Account | None: - token = request.cookies.get(sessions.SESSION_COOKIE) - if not token: - return - account_id = await sessions.resolve_session(token) - if not account_id: - return - return Account.get(account_id) +def resolve_pat_account(header: str) -> Account: + """Parse an Authorization header value as exactly ``Bearer ``. + Present means it must authenticate — a malformed or dead credential is a + 401, never a fall-through to edge identity.""" + scheme, _, credential = header.partition(" ") + if scheme == "Bearer" and credential and " " not in credential: + try: + pat = PersonalAccessToken.authenticate(credential) + except InvalidPatError as error: + raise HTTPException( + status_code=401, + detail=str(error), + headers={"WWW-Authenticate": f'{_BEARER_CHALLENGE}, error="invalid_token"'}, + ) from error + return pat.account + raise HTTPException( + status_code=401, + detail="Authorization must be exactly: Bearer .", + headers={"WWW-Authenticate": _BEARER_CHALLENGE}, + ) -async def current_session_account(request: Request) -> Account: - """The signed-in session's account, else 401 — the only door for PAT - management, so a token can never manage tokens.""" - account = await resolve_session_account(request) - if account: - current_account_id.set(account.id) - return account - raise HTTPException(status_code=401, detail="Sign in to use this API.") +def resolve_none_operator() -> Account | None: + """``none`` mode's operator: the only non-system account. None while zero + exist (setup state); more than one is configuration drift — refuse loudly + rather than guess. The startup validator runs this same check.""" + operators = Account.list_non_system() + if len(operators) > 1: + raise AuthConfigurationError( + f"auth mode 'none' expects exactly one operator account, found " + f"{len(operators)} — remove the extras or switch to header mode" + ) + return operators[0] if operators else None -async def current_account(request: Request) -> Account: - """The calling account: the Bearer personal access token when an - Authorization header is present — never falling back to the cookie — - else the signed-in session.""" - header = request.headers.get("Authorization") - # Present-but-empty is still present: it must be challenged, never slide - # to the cookie. - if header is not None: - scheme, _, credential = header.partition(" ") - if scheme == "Bearer" and credential and " " not in credential: - try: - pat = PersonalAccessToken.authenticate(credential) - except InvalidPatError as error: - raise HTTPException( - status_code=401, - detail=str(error), - headers={"WWW-Authenticate": f'{_BEARER_CHALLENGE}, error="invalid_token"'}, - ) from error - current_account_id.set(pat.account_id) - return pat.account +def _resolve_operator(request: Request) -> Account | None: + """Edge/none operator identity for this request; None only in none/zero + (the setup state). Header mode is open enrollment — exactly one nonblank + asserted value, trimmed of outer whitespace, lookup-or-created as the + account (the edge gates who reaches druks at all; CITEXT handles case). + ``none`` mode ignores any asserted header — there is no edge to trust.""" + settings = request.app.state.settings + if settings.auth_mode == "none": + return resolve_none_operator() + values = request.headers.getlist(settings.auth_header) + if len(values) != 1: + raise HTTPException( + status_code=401, + detail=f"The edge must assert exactly one {settings.auth_header} identity.", + ) + email = values[0].strip() + if not email: raise HTTPException( status_code=401, - detail="Authorization must be exactly: Bearer .", - headers={"WWW-Authenticate": _BEARER_CHALLENGE}, + detail=f"The edge asserted a blank {settings.auth_header} identity.", ) - return await current_session_account(request) + return Account.get_or_create(email) + + +def _require_no_bearer(request: Request) -> None: + if request.headers.get("Authorization") is not None: + raise HTTPException( + status_code=401, + detail="This API accepts your edge or local operator identity only, " + "never a bearer token.", + ) + + +def _setup_required() -> HTTPException: + return HTTPException( + status_code=409, + detail="No operator account exists yet — connect a harness to finish setup.", + ) + + +async def current_account(request: Request) -> AsyncIterator[Account]: + """The calling account: the Bearer personal access token when an + Authorization header is present — present-but-empty is still present and + must be challenged — else the edge/none operator identity.""" + header = request.headers.get("Authorization") + if header is not None: + account = resolve_pat_account(header) + else: + account = _resolve_operator(request) + if not account: + raise _setup_required() + token = current_account_id.set(account.id) + try: + yield account + finally: + # The actor must not leak into whatever runs on this task next. + current_account_id.reset(token) + + +async def current_operator_account(request: Request) -> AsyncIterator[Account]: + """The edge/none operator, and only that — the door for PAT management, so + a token can never manage tokens.""" + _require_no_bearer(request) + account = _resolve_operator(request) + if not account: + raise _setup_required() + token = current_account_id.set(account.id) + try: + yield account + finally: + current_account_id.reset(token) + + +async def current_operator_or_setup(request: Request) -> AsyncIterator[Account | None]: + """The edge/none operator for the harness connection flow, where none/zero + is a legal state: the first completed connection creates the operator.""" + _require_no_bearer(request) + account = _resolve_operator(request) + token = current_account_id.set(account.id if account else None) + try: + yield account + finally: + current_account_id.reset(token) + + +async def current_account_or_setup(request: Request) -> AsyncIterator[Account | None]: + """PAT-first identity that reads none/zero as the setup state instead of + refusing — only ``/api/auth/me``, which must answer during onboarding.""" + header = request.headers.get("Authorization") + account = resolve_pat_account(header) if header is not None else _resolve_operator(request) + token = current_account_id.set(account.id if account else None) + try: + yield account + finally: + current_account_id.reset(token) diff --git a/backend/druks/accounts/exceptions.py b/backend/druks/accounts/exceptions.py index a411f00..798764f 100644 --- a/backend/druks/accounts/exceptions.py +++ b/backend/druks/accounts/exceptions.py @@ -1,3 +1,9 @@ class InvalidPatError(Exception): """A presented bearer credential that resolves to no live personal access token — unknown, mismatched, revoked, or expired.""" + + +class AuthConfigurationError(Exception): + """The configured auth mode cannot resolve a single operator identity — + e.g. ``none`` mode with more than one non-system account. Refuses the + request (and startup) instead of guessing which account is the operator.""" diff --git a/backend/druks/accounts/models.py b/backend/druks/accounts/models.py index 6fe6a48..ac7c57c 100644 --- a/backend/druks/accounts/models.py +++ b/backend/druks/accounts/models.py @@ -4,8 +4,8 @@ import secrets from datetime import datetime -from sqlalchemy import ForeignKey, Index, LargeBinary, String, select -from sqlalchemy.dialects.postgresql import CITEXT +from sqlalchemy import ForeignKey, Index, LargeBinary, String, func, select +from sqlalchemy.dialects.postgresql import CITEXT, insert from sqlalchemy.orm import Mapped, mapped_column, relationship from druks.accounts.constants import ( @@ -16,6 +16,7 @@ PAT_PREFIX_LENGTH, PAT_SECRET_BYTES, PAT_TOKEN_TAG, + SYSTEM_ACCOUNT_ID, ) from druks.accounts.exceptions import InvalidPatError from druks.core.models import Uuid7Pk @@ -46,14 +47,29 @@ def get_for_username(cls, username: str) -> "Account | None": @classmethod def get_or_create(cls, username: str) -> "Account": + """Concurrency-safe lookup-or-create: racing requests both INSERT with + ON CONFLICT DO NOTHING, then converge on the one row through the + canonical CITEXT lookup.""" account = cls.get_for_username(username) if account: return account - account = cls(username=username) session = db_session() - session.add(account) - session.flush() - return account + session.execute( + insert(cls) + .values(username=username) + .on_conflict_do_nothing(index_elements=["username"]) + ) + return session.scalars(select(cls).where(cls.username == username)).one() + + @classmethod + def list_non_system(cls) -> list["Account"]: + stmt = select(cls).where(cls.username != SYSTEM_ACCOUNT_ID).order_by(cls.created_at) + return list(db_session().scalars(stmt)) + + @classmethod + def count_non_system(cls) -> int: + stmt = select(func.count()).select_from(cls).where(cls.username != SYSTEM_ACCOUNT_ID) + return db_session().scalars(stmt).one() def _hash_token(token: str) -> bytes: diff --git a/backend/druks/accounts/routes.py b/backend/druks/accounts/routes.py index 8926f8b..48d8c9e 100644 --- a/backend/druks/accounts/routes.py +++ b/backend/druks/accounts/routes.py @@ -1,126 +1,37 @@ -from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response +from fastapi import APIRouter, Body, Depends, HTTPException, Request -from druks.accounts import sessions from druks.accounts.constants import PAT_NAME_LENGTH -from druks.accounts.dependencies import ( - current_account, - current_session_account, - resolve_session_account, -) +from druks.accounts.dependencies import current_account_or_setup, current_operator_account from druks.accounts.models import Account, PersonalAccessToken -from druks.accounts.schemas import AccountResponse, PatResponse -from druks.harnesses.base import Harness -from druks.harnesses.exceptions import LoginError +from druks.accounts.schemas import AccountResponse, IdentityResponse, PatResponse from druks.harnesses.models import HarnessConnection -from druks.harnesses.registry import get_harness -from druks.user_settings.models import HarnessSettings, UserSettings router = APIRouter(prefix="/api/auth", tags=["auth"]) -def _resolve_harness(name: str) -> type[Harness]: - harness = get_harness(name) - if harness: - return harness - raise HTTPException(status_code=404, detail=f"Unknown harness: {name!r}") - - -def _set_session_cookie(request: Request, response: Response, token: str) -> None: - # The shipped edge terminates TLS and proxies loopback HTTP. - scheme = request.headers.get("x-forwarded-proto", request.url.scheme) - response.set_cookie( - sessions.SESSION_COOKIE, - token, - max_age=sessions.SESSION_TTL_SECONDS if token else 0, - httponly=True, - samesite="lax", - secure=scheme == "https", +@router.get("/me", response_model=IdentityResponse, response_model_by_alias=True) +async def get_identity( + request: Request, account: Account | None = Depends(current_account_or_setup) +) -> IdentityResponse: + return IdentityResponse( + auth_mode=request.app.state.settings.auth_mode, + account=AccountResponse.model_validate(account) if account else None, + # An account needs onboarding exactly while it has no harness + # connection; none/zero is onboarding before the account exists. + onboarding_required=not (account and HarnessConnection.list_for_account(account.id)), ) -@router.get("/session", response_model=AccountResponse, response_model_by_alias=True) -async def get_session( - request: Request, response: Response, account: Account = Depends(current_account) -) -> Account: - # Slide the cookie with the Redis TTL; a Bearer request carries none. - token = request.cookies.get(sessions.SESSION_COOKIE) - if token: - _set_session_cookie(request, response, token) - return account - - -@router.post("/harnesses/{name}/login/start") -async def start_login(name: str, request: Request) -> dict[str, str]: - harness = _resolve_harness(name) - account = await resolve_session_account(request) - url, login_id = await harness.login_start(account_id=account.id if account else None) - return {"authorizeUrl": url, "loginId": login_id} - - -@router.post( - "/harnesses/{name}/login/complete", - response_model=AccountResponse, - response_model_by_alias=True, -) -async def complete_login( - name: str, - request: Request, - response: Response, - code: str = Body(..., embed=True), - login_id: str = Body(..., embed=True, alias="loginId"), -) -> Account: - harness = _resolve_harness(name) - session_account = await resolve_session_account(request) - try: - completed = await harness.login_complete(flow_id=login_id, pasted=code) - except LoginError as exc: - raise HTTPException(status_code=422, detail=str(exc)) from exc - if completed.account_id and (not session_account or completed.account_id != session_account.id): - # A bound reconnect must never rebind the login by email fallback. - raise HTTPException( - status_code=422, - detail="This sign-in was started under a different session — start it again.", - ) - account = session_account or Account.get_or_create(completed.provider_email) - # Runs with no actor execute as the fallback account; claim the slot when - # none is set yet. - settings = UserSettings.get() - if not settings.fallback_account_id: - settings.set_fallback_account(account.id) - connection = HarnessConnection.connect( - harness=harness.name, - account=account, - payload=completed.payload, - expires_at=completed.expires_at, - provider_email=completed.provider_email, - ) - # Fresh picker right after login; failures are tagged inside, never raised. - await HarnessSettings.require(harness.name).refresh_models(connection) - old_token = request.cookies.get(sessions.SESSION_COOKIE) - if old_token: - await sessions.drop_session(old_token) # login rotates the token - _set_session_cookie(request, response, await sessions.mint_session(account.id)) - return account - - -@router.post("/logout", status_code=204) -async def logout(request: Request, response: Response) -> None: - token = request.cookies.get(sessions.SESSION_COOKIE) - if token: - await sessions.drop_session(token) - _set_session_cookie(request, response, "") - - @router.get("/personal-tokens", response_model=list[PatResponse], response_model_by_alias=True) async def list_pats( - account: Account = Depends(current_session_account), + account: Account = Depends(current_operator_account), ) -> list[PersonalAccessToken]: return PersonalAccessToken.list_for_account(account.id) @router.post("/personal-tokens") async def create_pat( - account: Account = Depends(current_session_account), + account: Account = Depends(current_operator_account), name: str = Body(..., embed=True), ) -> dict[str, str]: name = name.strip() @@ -139,7 +50,7 @@ async def create_pat( "/personal-tokens/{pat_id}", response_model=PatResponse, response_model_by_alias=True ) async def revoke_pat( - pat_id: str, account: Account = Depends(current_session_account) + pat_id: str, account: Account = Depends(current_operator_account) ) -> PersonalAccessToken: pat = PersonalAccessToken.get(pat_id) if pat and pat.account_id == account.id: diff --git a/backend/druks/accounts/schemas.py b/backend/druks/accounts/schemas.py index b9a26f2..a082e20 100644 --- a/backend/druks/accounts/schemas.py +++ b/backend/druks/accounts/schemas.py @@ -12,6 +12,15 @@ class AccountResponse(BaseResponse): username: str +class IdentityResponse(BaseResponse): + # What /api/auth/me answers: how this deployment authenticates, who the + # request resolved to (null in the none/zero setup state), and whether that + # identity still needs its first harness connection. + auth_mode: str + account: AccountResponse | None + onboarding_required: bool + + class PatResponse(BaseResponse): model_config = ConfigDict(from_attributes=True) diff --git a/backend/druks/accounts/sessions.py b/backend/druks/accounts/sessions.py deleted file mode 100644 index 96a6138..0000000 --- a/backend/druks/accounts/sessions.py +++ /dev/null @@ -1,25 +0,0 @@ -import secrets - -from druks.redis import get_client - -from .constants import SESSION_PREFIX - -SESSION_COOKIE = "druks_session" -SESSION_TTL_SECONDS = 30 * 24 * 3600 - - -async def mint_session(account_id: str) -> str: - token = secrets.token_urlsafe(32) - await get_client().set(f"{SESSION_PREFIX}{token}", account_id, ex=SESSION_TTL_SECONDS) - return token - - -async def resolve_session(token: str) -> str | None: - # GETEX reads and slides the 30-day TTL in one atomic hop. - value = await get_client().getex(f"{SESSION_PREFIX}{token}", ex=SESSION_TTL_SECONDS) - if value: - return value.decode() - - -async def drop_session(token: str) -> None: - await get_client().delete(f"{SESSION_PREFIX}{token}") diff --git a/backend/druks/api/app.py b/backend/druks/api/app.py index 896584d..7fda04b 100644 --- a/backend/druks/api/app.py +++ b/backend/druks/api/app.py @@ -11,14 +11,16 @@ from starlette.datastructures import MutableHeaders from starlette.routing import Route -from druks.accounts.dependencies import current_account +from druks.accounts.dependencies import current_account, resolve_none_operator +from druks.accounts.exceptions import AuthConfigurationError from druks.accounts.routes import router as auth_router from druks.api.artifacts import router as artifacts_router from druks.api.runs import router as runs_router -from druks.database import configure_session, create_engine_from_url, db_session +from druks.database import configure_session, create_engine_from_url, db_session, session_scope from druks.durable.engine import init_dbos, launch, shutdown from druks.events.routes import router as events_router from druks.extensions.loader import iter_extensions, load +from druks.harnesses.routes import router as harness_connection_router from druks.mcp.catalog import load_mcp_catalog from druks.mcp.gateway.exceptions import AgentApiError from druks.mcp.gateway.routes import router as gateway_router @@ -59,6 +61,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # workflows may deliver MCP servers immediately. A bad catalog stops # boot here, loudly. (The suite loads it from a conftest fixture.) load_mcp_catalog(settings.mcp_catalog_path) + if settings.auth_mode == "none": + # A drifted none-mode install (more than one operator account) must + # refuse at boot, not per request; the per-request resolver repeats + # the check for drift that happens while running. + with session_scope(app.state.engine): + resolve_none_operator() # DBOS runs embedded here: this process both serves HTTP and executes # durable workflows. Tests pre-populate app.state.settings and never # reach here — they drive DBOS through their own fixtures. @@ -153,6 +161,16 @@ async def _agent_api_error_handler(request: Request, exc: AgentApiError) -> JSON ) +# Auth-mode drift (e.g. none mode grew a second operator account) is an +# operator problem, not a caller problem: log it loudly, answer 503. +@app.exception_handler(AuthConfigurationError) +async def _auth_configuration_handler( + request: Request, exc: AuthConfigurationError +) -> JSONResponse: + logging.getLogger(__name__).error("auth configuration failure: %s", exc) + return JSONResponse(status_code=503, content={"error": "HTTP_503", "detail": str(exc)}) + + @app.exception_handler(RequestValidationError) async def _validation_exception_handler( request: Request, @@ -195,23 +213,27 @@ async def _unhandled_exception_handler( # Platform-core routers, mounted by hand at their own prefixes. Extension routers # (core, build, usage, …) are discovered and mounted under /api/ by load(). -# /api sits behind the session gate except the login surface and the health -# probe; /_external routes carry their own authentication. The boundary test -# pins the split. -_session_gate = [Depends(current_account)] +# /api sits behind the identity gate except the identity/connection surface and +# the health probe; /_external routes carry their own authentication. The +# boundary test pins the split. The auth and harness-connection routers mount +# ungated because each of their routes carries its own resolver (/me and the +# connection flow must answer during none/zero setup; PAT management admits +# only the operator identity). +_identity_gate = [Depends(current_account)] app.include_router(health_router) # Before the webhook catch-all ({hook_path:path}): declaration order is match order. app.include_router(notifications_external_router) app.include_router(webhooks_router) app.include_router(auth_router) -app.include_router(settings_router, dependencies=_session_gate) -app.include_router(skills_router, dependencies=_session_gate) -app.include_router(mcp_router, dependencies=_session_gate) -app.include_router(notifications_router, dependencies=_session_gate) -app.include_router(events_router, dependencies=_session_gate) -app.include_router(runs_router, dependencies=_session_gate) -app.include_router(gateway_router, dependencies=_session_gate) -app.include_router(artifacts_router, dependencies=_session_gate) +app.include_router(harness_connection_router) +app.include_router(settings_router, dependencies=_identity_gate) +app.include_router(skills_router, dependencies=_identity_gate) +app.include_router(mcp_router, dependencies=_identity_gate) +app.include_router(notifications_router, dependencies=_identity_gate) +app.include_router(events_router, dependencies=_identity_gate) +app.include_router(runs_router, dependencies=_identity_gate) +app.include_router(gateway_router, dependencies=_identity_gate) +app.include_router(artifacts_router, dependencies=_identity_gate) load(app) # Tools derive from every route tagged "agent", so the endpoint composes @@ -219,7 +241,7 @@ async def _unhandled_exception_handler( from druks.mcp.app import create_mcp_app # noqa: E402 # A bare Route at exactly /mcp (a Mount would 307 the no-slash path); PATs -# authenticate it, so it sits outside the session gate. +# authenticate it, so it sits outside the identity gate. mcp_app = create_mcp_app(app) app.router.routes.append( Route("/mcp", mcp_app, methods=["POST", "DELETE"], include_in_schema=False) diff --git a/backend/druks/extensions/base.py b/backend/druks/extensions/base.py index 6627c66..c2c2ca5 100644 --- a/backend/druks/extensions/base.py +++ b/backend/druks/extensions/base.py @@ -227,7 +227,7 @@ def load(cls, app: "FastAPI") -> None: modules = cls.discover() # /api/ wraps the author's own prefix so extensions can't shadow - # the platform or each other; every route sits behind the session gate. + # the platform or each other; every route sits behind the identity gate. prefix = f"/api/{cls.name}" for router in cls.get_routers(modules): app.include_router(router, prefix=prefix, dependencies=[Depends(current_account)]) diff --git a/backend/druks/harnesses/base.py b/backend/druks/harnesses/base.py index 6c51edc..857444f 100644 --- a/backend/druks/harnesses/base.py +++ b/backend/druks/harnesses/base.py @@ -20,11 +20,11 @@ from druks.skills.models import Skill from druks.usage.models import UsageScrape -from .constants import LOGIN_PENDING_PREFIX, REFRESH_LOCK_PREFIX +from .constants import CONNECT_PENDING_PREFIX, REFRESH_LOCK_PREFIX from .datastructures import ( AgentInvocation, CodexToken, - CompletedLogin, + CompletedConnect, HarnessRunResult, OAuthToken, ParsedModels, @@ -33,10 +33,10 @@ SandboxSettings, ) from .exceptions import ( + ConnectError, GrantError, HarnessError, HarnessNotConnectedError, - LoginError, OAuthTokenError, ) from .models import HarnessConnection @@ -48,9 +48,10 @@ _GRANT_TIMEOUT_SECONDS = 30.0 _USAGE_TIMEOUT_SECONDS = 20.0 -# The connect-flow pending state (PKCE verifier + state) lives in Redis this long -# — enough to sign in and paste, short enough that an abandoned attempt clears. -_LOGIN_PENDING_TTL_SECONDS = 600 +# The connect-flow pending state (PKCE verifier + state) lives in Redis this +# long — enough to authorize and paste, short enough that an abandoned attempt +# clears. +_CONNECT_PENDING_TTL_SECONDS = 600 # Per-row refresh lock: five minutes outlives the provider grant timeout and # expires before the next 15-minute cron tick if the holder dies mid-refresh. _REFRESH_LOCK_TTL_SECONDS = 300 @@ -217,9 +218,9 @@ def render_credentials_file(cls, connection_id: str | None = None) -> str: ) @classmethod - async def login_start(cls, *, account_id: str | None = None) -> tuple[str, str]: + async def connect_start(cls, *, account_id: str | None = None) -> tuple[str, str]: """Mint PKCE state under a single-use flow id; return (authorize URL, - flow id). A reconnect under a live session binds ``account_id``.""" + flow id). A flow started by a resolved operator binds ``account_id``.""" verifier = _b64url(secrets.token_bytes(64)) challenge = _b64url(hashlib.sha256(verifier.encode()).digest()) url, state = cls.authorize_url(verifier=verifier, challenge=challenge) @@ -232,34 +233,34 @@ async def login_start(cls, *, account_id: str | None = None) -> tuple[str, str]: } ) await get_client().set( - f"{LOGIN_PENDING_PREFIX}{flow_id}", pending, ex=_LOGIN_PENDING_TTL_SECONDS + f"{CONNECT_PENDING_PREFIX}{flow_id}", pending, ex=_CONNECT_PENDING_TTL_SECONDS ) return url, flow_id @classmethod - async def login_complete(cls, *, flow_id: str, pasted: str) -> CompletedLogin: + async def connect_complete(cls, *, flow_id: str, pasted: str) -> CompletedConnect: """Pop the flow's single-use state, parse the paste, exchange the code. - Raises :class:`LoginError` on failure; the state is gone either way, + Raises :class:`ConnectError` on failure; the state is gone either way, so a retry re-starts cleanly.""" - pending = await get_client().getdel(f"{LOGIN_PENDING_PREFIX}{flow_id}") # single-use + pending = await get_client().getdel(f"{CONNECT_PENDING_PREFIX}{flow_id}") # single-use if not pending: - raise LoginError("This sign-in expired — start it again.") + raise ConnectError("This connect attempt expired — start it again.") expected = json.loads(pending) code, pasted_state = _parse_pasted(pasted) if not code: - raise LoginError("Couldn't find an authorization code in what you pasted.") + raise ConnectError("Couldn't find an authorization code in what you pasted.") if pasted_state and pasted_state != expected["state"]: - raise LoginError("That code is from a different sign-in — start it again.") + raise ConnectError("That code is from a different connect attempt — start it again.") payload, provider_email = await cls.exchange(code=code, verifier=expected["verifier"]) if not provider_email: - raise LoginError( - "The provider returned no account email — sign in with an account " + raise ConnectError( + "The provider returned no account email — authorize with an account " "that has one and try again." ) _, expires_at = cls._refresh_state(payload) - return CompletedLogin( + return CompletedConnect( payload=payload, provider_email=provider_email, expires_at=expires_at, @@ -270,8 +271,8 @@ async def login_complete(cls, *, flow_id: str, pasted: str) -> CompletedLogin: @abstractmethod def authorize_url(cls, *, verifier: str, challenge: str) -> tuple[str, str]: """Build this provider's PKCE authorize URL; return (url, state), where - ``state`` is what the provider echoes back so login_complete can verify - the round-trip.""" + ``state`` is what the provider echoes back so connect_complete can + verify the round-trip.""" @classmethod @abstractmethod @@ -653,8 +654,8 @@ def _parse_pasted(raw: str) -> tuple[str | None, str | None]: async def post_token(url: str, body: dict, *, form: bool) -> dict: """POST an authorization-code exchange (form- or JSON-encoded) and return the - parsed grant. Raises :class:`LoginError` with the provider's error text on any - failure, so the operator sees why the sign-in didn't take.""" + parsed grant. Raises :class:`ConnectError` with the provider's error text on + any failure, so the operator sees why the connect didn't take.""" try: async with httpx.AsyncClient(timeout=_GRANT_TIMEOUT_SECONDS) as client: if form: @@ -663,7 +664,7 @@ async def post_token(url: str, body: dict, *, form: bool) -> dict: response = await client.post(url, json=body) except httpx.HTTPError as exc: logger.warning("token exchange request failed (%s): %s", url, exc, exc_info=True) - raise LoginError("The request to the provider failed — try again.") from exc + raise ConnectError("The request to the provider failed — try again.") from exc if response.status_code != 200: logger.warning( "token exchange returned %s (%s): %s", @@ -672,11 +673,11 @@ async def post_token(url: str, body: dict, *, form: bool) -> dict: response.text[:300], ) detail = response.text.strip()[:300] or f"HTTP {response.status_code}" - raise LoginError(f"The provider rejected the sign-in: {detail}") + raise ConnectError(f"The provider rejected the connect: {detail}") try: return response.json() except ValueError as exc: - raise LoginError("The provider returned an unreadable response.") from exc + raise ConnectError("The provider returned an unreadable response.") from exc def check_returncode(result: HarnessRunResult, *, name: str) -> None: diff --git a/backend/druks/harnesses/claude.py b/backend/druks/harnesses/claude.py index 1777b89..c1ebbff 100644 --- a/backend/druks/harnesses/claude.py +++ b/backend/druks/harnesses/claude.py @@ -217,7 +217,7 @@ def _grant_body(cls, refresh_token: str) -> dict: @classmethod def authorize_url(cls, *, verifier: str, challenge: str) -> tuple[str, str]: # Anthropic's console flow echoes the PKCE verifier back as the OAuth - # state (verified in ENG-687), so that's what login_complete checks. + # state (verified in ENG-687), so that's what connect_complete checks. params = { "code": "true", "client_id": cls._CLIENT_ID, diff --git a/backend/druks/harnesses/constants.py b/backend/druks/harnesses/constants.py index 73c8ecf..960d33d 100644 --- a/backend/druks/harnesses/constants.py +++ b/backend/druks/harnesses/constants.py @@ -1,4 +1,5 @@ -# Redis keys for harness logins: a pending flow's state while the operator -# completes it, and the per-connection SET NX lock that serializes refresh. -LOGIN_PENDING_PREFIX = "druks:login:pending:" +# Redis keys for harness connections: a pending connect flow's state while the +# operator completes it, and the per-connection SET NX lock that serializes +# refresh. +CONNECT_PENDING_PREFIX = "druks:harness:connect:pending:" REFRESH_LOCK_PREFIX = "druks:harness:refresh:" diff --git a/backend/druks/harnesses/datastructures.py b/backend/druks/harnesses/datastructures.py index 4649197..e7ecc48 100644 --- a/backend/druks/harnesses/datastructures.py +++ b/backend/druks/harnesses/datastructures.py @@ -32,12 +32,12 @@ class CodexToken: @dataclass(frozen=True) -class CompletedLogin: +class CompletedConnect: payload: dict provider_email: str expires_at: datetime | None - # The session account a reconnect was bound to at start; None on an - # initial login. + # The operator account the flow was bound to at start; None on an unbound + # none/zero setup flow. account_id: str | None diff --git a/backend/druks/harnesses/exceptions.py b/backend/druks/harnesses/exceptions.py index bfee00a..bcdba55 100644 --- a/backend/druks/harnesses/exceptions.py +++ b/backend/druks/harnesses/exceptions.py @@ -36,7 +36,7 @@ def __init__(self, tag: str) -> None: self.tag = tag -class LoginError(Exception): +class ConnectError(Exception): """A connect flow could not complete — expired/single-use pending state, a paste with no code, a state mismatch, or a provider-rejected exchange. The message is user-facing (surfaced inline in the Settings card).""" diff --git a/backend/druks/harnesses/models.py b/backend/druks/harnesses/models.py index bc315e7..db94be7 100644 --- a/backend/druks/harnesses/models.py +++ b/backend/druks/harnesses/models.py @@ -69,6 +69,11 @@ def get_for_account( def list_all(cls) -> list["HarnessConnection"]: return list(db_session().scalars(select(cls).order_by(cls.harness, cls.id))) + @classmethod + def list_for_account(cls, account_id: str) -> list["HarnessConnection"]: + stmt = select(cls).where(cls.account_id == account_id).order_by(cls.harness) + return list(db_session().scalars(stmt)) + @classmethod def list_for_harness(cls, harness: str) -> list["HarnessConnection"]: stmt = select(cls).where(cls.harness == harness).order_by(cls.id) diff --git a/backend/druks/harnesses/routes.py b/backend/druks/harnesses/routes.py new file mode 100644 index 0000000..6758380 --- /dev/null +++ b/backend/druks/harnesses/routes.py @@ -0,0 +1,91 @@ +from fastapi import APIRouter, Body, Depends, HTTPException + +from druks.accounts.dependencies import current_account, current_operator_or_setup +from druks.accounts.models import Account +from druks.accounts.schemas import AccountResponse +from druks.harnesses.base import Harness +from druks.harnesses.exceptions import ConnectError +from druks.harnesses.models import HarnessConnection +from druks.harnesses.registry import get_harness +from druks.user_settings.models import HarnessSettings, UserSettings +from druks.user_settings.schemas import HarnessResponse + +router = APIRouter(prefix="/api/harnesses", tags=["harnesses"]) + + +def _resolve_harness(name: str) -> type[Harness]: + harness = get_harness(name) + if harness: + return harness + raise HTTPException(status_code=404, detail=f"Unknown harness: {name!r}") + + +@router.post("/{name}/connection/start") +async def start_connection( + name: str, account: Account | None = Depends(current_operator_or_setup) +) -> dict[str, str]: + harness = _resolve_harness(name) + # A resolved operator binds the flow; none/zero starts the unbound setup + # flow whose completion creates the operator. + url, connection_id = await harness.connect_start(account_id=account.id if account else None) + return {"authorizeUrl": url, "connectionId": connection_id} + + +@router.post( + "/{name}/connection/complete", + response_model=AccountResponse, + response_model_by_alias=True, +) +async def complete_connection( + name: str, + account: Account | None = Depends(current_operator_or_setup), + code: str = Body(..., embed=True), + connection_id: str = Body(..., embed=True, alias="connectionId"), +) -> Account: + harness = _resolve_harness(name) + try: + completed = await harness.connect_complete(flow_id=connection_id, pasted=code) + except ConnectError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + if completed.account_id: + # A bound flow must complete under the operator that started it — + # never rebound by email fallback. + if not account or account.id != completed.account_id: + raise HTTPException( + status_code=422, + detail="This connect was started under a different operator — start it again.", + ) + resolved = account + else: + # The unbound none/zero setup flow: the provider-verified email becomes + # the operator. get_or_create is atomic, so concurrent completions of + # the same email converge on one account; different emails surface as + # the none-mode multi-operator refusal. + resolved = Account.get_or_create(completed.provider_email) + # Runs with no actor execute as the fallback account; claim the slot when + # none is set yet. + settings = UserSettings.get() + if not settings.fallback_account_id: + settings.set_fallback_account(resolved.id) + connection = HarnessConnection.connect( + harness=harness.name, + account=resolved, + payload=completed.payload, + expires_at=completed.expires_at, + provider_email=completed.provider_email, + ) + # Fresh picker right after connect; failures are tagged inside, never raised. + await HarnessSettings.require(harness.name).refresh_models(connection) + return resolved + + +@router.delete("/{name}/connection", response_model=HarnessResponse, response_model_by_alias=True) +async def disconnect_harness( + name: str, account: Account = Depends(current_account) +) -> HarnessResponse: + harness = _resolve_harness(name) + connection = HarnessConnection.get_for_account(harness.name, account.id) + if connection: + # Only the requesting account's own connection — never another's. + connection.delete() + return HarnessResponse.from_row(HarnessSettings.require(harness.name), None, account) diff --git a/backend/druks/settings.py b/backend/druks/settings.py index cf345a9..ea83ee6 100644 --- a/backend/druks/settings.py +++ b/backend/druks/settings.py @@ -1,10 +1,10 @@ import base64 import logging from pathlib import Path -from typing import Annotated, Any +from typing import Annotated, Any, Literal import asyncssh -from pydantic import BeforeValidator, Field +from pydantic import BeforeValidator, Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict DEFAULT_DATA_DIR = Path("/var/lib/druks") @@ -89,6 +89,14 @@ class Settings(BaseSettings): alias="DRUKS_DATABASE_URL", ) + # How a browser request resolves an account. ``none``: no authentication — + # loopback-only deployments with a single operator account. ``header``: the + # edge (exe.dev, Teleport, Cloudflare Access, …) authenticates and asserts + # the operator's email in ``auth_header``; druks maps it to an account. + # Bearer personal access tokens resolve first in either mode. + auth_mode: Literal["none", "header"] = Field(default="none", alias="DRUKS_AUTH_MODE") + auth_header: str = Field(default="X-ExeDev-Email", alias="DRUKS_AUTH_HEADER") + webhook_secret: str = Field(default="", alias="DRUKS_WEBHOOK_SECRET") # Public hostname webhook senders POST to (Caddy serves it; see # deploy/compose.yaml). Druks itself only reads it for the doctor's @@ -197,6 +205,14 @@ class Settings(BaseSettings): log_level: str = Field(default="INFO", alias="DRUKS_LOG_LEVEL") + @model_validator(mode="after") + def _header_mode_names_its_header(self) -> "Settings": + if self.auth_mode == "header" and not self.auth_header.strip(): + raise ValueError( + "DRUKS_AUTH_HEADER must name the edge's identity header when DRUKS_AUTH_MODE=header" + ) + return self + @property def logs_dir(self) -> Path: return self.data_dir / "logs" diff --git a/backend/druks/setup_env.py b/backend/druks/setup_env.py index 77d6c9e..e5e4097 100644 --- a/backend/druks/setup_env.py +++ b/backend/druks/setup_env.py @@ -126,6 +126,45 @@ def _secrets_key() -> str: ), ) +# How each install shape resolves browser identity. Both Caddy and druks read +# DRUKS_AUTH_HEADER: Caddy requires the edge's assertion, druks maps it to an +# account. +_EXE_IDENTITY_SECTION = Section( + "IDENTITY — exe.dev authenticates at the edge; druks maps the asserted email", + ( + Entry("DRUKS_AUTH_MODE", "header"), + Entry("DRUKS_AUTH_HEADER", "X-ExeDev-Email"), + ), +) + +_AWS_IDENTITY_SECTION = Section( + "IDENTITY — your edge proxy authenticates; druks maps the asserted email", + ( + Entry("DRUKS_AUTH_MODE", "header"), + Entry( + "DRUKS_AUTH_HEADER", + prompt="Identity header your edge proxy injects (e.g. X-Forwarded-Email)", + required=True, + comment=( + "The header your identity proxy (Teleport, Cloudflare Access, …)\n" + "injects after authenticating. The proxy must strip any\n" + "client-supplied copy before inserting its own." + ), + ), + ), +) + +_LOCAL_IDENTITY_SECTION = Section( + "IDENTITY — loopback dashboard, no authentication; one operator account", + (Entry("DRUKS_AUTH_MODE", "none"),), +) + +_IDENTITY_SECTIONS = { + "exe": _EXE_IDENTITY_SECTION, + "aws": _AWS_IDENTITY_SECTION, + "docker": _LOCAL_IDENTITY_SECTION, +} + # How druks reaches drukbox. The remote shapes run drukbox itself from this # same .env (compose `remote` profile): container on :8780, generated token; # SERVICE_TOKENS is drukbox's accepted-token list, mirrored from the sandbox @@ -213,7 +252,7 @@ def _secrets_key() -> str: def sections_for(provider: str) -> tuple[Section, ...]: - return (*_COMMON_SECTIONS, _PROVIDER_SECTIONS[provider]) + return (*_COMMON_SECTIONS, _IDENTITY_SECTIONS[provider], _PROVIDER_SECTIONS[provider]) def read_env(path: Path) -> dict[str, str]: diff --git a/backend/druks/user_settings/routes.py b/backend/druks/user_settings/routes.py index e290037..ebc4e80 100644 --- a/backend/druks/user_settings/routes.py +++ b/backend/druks/user_settings/routes.py @@ -45,12 +45,12 @@ def _resolve_harness(name: str) -> tuple[type, HarnessSettings]: def _harness_response(settings: HarnessSettings, account: Account) -> HarnessResponse: - login = HarnessConnection.get_for_account(settings.name, account.id) - return HarnessResponse.from_row(settings, login, account) + connection = HarnessConnection.get_for_account(settings.name, account.id) + return HarnessResponse.from_row(settings, connection, account) -# Connection state is the signed-in account's own login — connect/reconnect -# live on the /api/auth login surface, not here. +# Connection state is the requesting operator's own connection — +# connect/disconnect live on the /api/harnesses connection surface, not here. @router.get("/harnesses", response_model=list[HarnessResponse], response_model_by_alias=True) async def list_harness_settings( account: Account = Depends(current_account), @@ -79,20 +79,6 @@ async def update_harness_settings( return _harness_response(row, account) -@router.delete( - "/harnesses/{name}/login", response_model=HarnessResponse, response_model_by_alias=True -) -async def disconnect_harness( - name: str, account: Account = Depends(current_account) -) -> HarnessResponse: - _, row = _resolve_harness(name) - login = HarnessConnection.get_for_account(name, account.id) - if login: - # Only the signed-in account's own login — never another account's. - login.delete() - return _harness_response(row, account) - - @router.get("", response_model=UserSettingsResponse, response_model_by_alias=True) async def get_user_settings() -> UserSettings: return UserSettings.get() diff --git a/backend/druks/user_settings/schemas.py b/backend/druks/user_settings/schemas.py index e720bc5..7987203 100644 --- a/backend/druks/user_settings/schemas.py +++ b/backend/druks/user_settings/schemas.py @@ -26,7 +26,7 @@ class HarnessResponse(BaseResponse): timeout: int fast_mode: bool allowed_models: list[AllowedModel] - # The signed-in account's own connection; false until this account connects. + # The requesting account's own connection; false until this account connects. connected: bool kind: str | None account: str | None @@ -36,7 +36,10 @@ class HarnessResponse(BaseResponse): @classmethod def from_row( - cls, settings: "HarnessSettings", login: "HarnessConnection | None", account: "Account" + cls, + settings: "HarnessSettings", + connection: "HarnessConnection | None", + account: "Account", ) -> "HarnessResponse": return cls( name=settings.name, @@ -46,11 +49,11 @@ def from_row( timeout=settings.timeout, fast_mode=settings.fast_mode, allowed_models=settings.allowed_models, - connected=bool(login), - kind=login.kind if login else None, - account=account.username if login else None, - provider_email=login.provider_email if login else None, - expires_at=login.expires_at if login else None, + connected=bool(connection), + kind=connection.kind if connection else None, + account=account.username if connection else None, + provider_email=connection.provider_email if connection else None, + expires_at=connection.expires_at if connection else None, ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 859328b..bb70b5d 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -72,12 +72,6 @@ async def set(self, key: str, value: str, *, nx: bool = False, ex: int | None = async def get(self, key: str) -> bytes | None: return self._data.get(key) - async def getex(self, key: str, *, ex: int | None = None) -> bytes | None: - value = self._data.get(key) - if value is not None and ex is not None: - self._ttls[key] = ex - return value - async def exists(self, key: str) -> int: return int(key in self._data) @@ -356,8 +350,8 @@ def configure_app_for_test( app.state.settings = settings app.state.engine = engine if authenticated: - # Stand a signed-in account in for the gate; auth tests pass - # authenticated=False and walk the real cookie flow. + # Stand a resolved operator in for the identity gate; identity tests + # pass authenticated=False and walk the real per-request resolvers. app.dependency_overrides[current_account] = _test_account return app diff --git a/backend/tests/test_agent_routes.py b/backend/tests/test_agent_routes.py index 19803ee..2ecd924 100644 --- a/backend/tests/test_agent_routes.py +++ b/backend/tests/test_agent_routes.py @@ -82,7 +82,10 @@ def test_openapi_pins_the_five_agent_routes(client: TestClient): def test_agent_routes_sit_behind_the_gate(tmp_path, db_session): - app = configure_app_for_test(settings=make_settings(tmp_path), authenticated=False) + # Header mode: an unasserted request is a 401, not none-mode's setup 409. + app = configure_app_for_test( + settings=make_settings(tmp_path, auth_mode="header"), authenticated=False + ) with TestClient(app) as anonymous: assert anonymous.get("/api/gates/x").status_code == 401 assert anonymous.get("/api/usage/summary").status_code == 401 diff --git a/backend/tests/test_api_settings.py b/backend/tests/test_api_settings.py index 759a60d..91bd300 100644 --- a/backend/tests/test_api_settings.py +++ b/backend/tests/test_api_settings.py @@ -38,11 +38,11 @@ def test_harness_response_carries_connection_state(tmp_path: Path): assert "expiresAt" in claude -def test_harnesses_show_only_the_signed_in_accounts_connection(tmp_path: Path, db_session): +def test_harnesses_show_only_the_requesting_accounts_connection(tmp_path: Path, db_session): from conftest import connect_harness from druks.harnesses.claude import ClaudeHarness - # The suite's gate stands in op@example.com; another account's + # The suite's identity gate stands in op@example.com; another account's # connection never shows on this card. connect_harness( ClaudeHarness, @@ -73,7 +73,7 @@ def test_harness_card_reports_identity(tmp_path: Path, db_session): assert claude["providerEmail"] == "seat@corp.com" -def test_disconnect_removes_only_the_signed_in_accounts_connection(tmp_path: Path, db_session): +def test_disconnect_removes_only_the_requesting_accounts_connection(tmp_path: Path, db_session): from conftest import connect_harness from druks.harnesses.claude import ClaudeHarness from druks.harnesses.models import HarnessConnection @@ -86,7 +86,7 @@ def test_disconnect_removes_only_the_signed_in_accounts_connection(tmp_path: Pat ) mine_id, other_id = mine.id, other.id with _build_client(tmp_path) as client: - response = client.delete("/api/settings/harnesses/claude/login") + response = client.delete("/api/harnesses/claude/connection") assert response.status_code == 200 assert response.json()["connected"] is False # The request deleted in its own task-scoped session; read past this @@ -97,7 +97,7 @@ def test_disconnect_removes_only_the_signed_in_accounts_connection(tmp_path: Pat def test_disconnect_without_a_connection_is_a_no_op(tmp_path: Path): with _build_client(tmp_path) as client: - response = client.delete("/api/settings/harnesses/claude/login") + response = client.delete("/api/harnesses/claude/connection") assert response.status_code == 200 assert response.json()["connected"] is False diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py deleted file mode 100644 index 82a8233..0000000 --- a/backend/tests/test_auth.py +++ /dev/null @@ -1,253 +0,0 @@ -import base64 -import json -from pathlib import Path - -import druks.redis -import httpx -import pytest -from conftest import configure_app_for_test, connect_harness, make_settings -from druks import database -from druks.accounts.constants import SESSION_PREFIX -from druks.accounts.models import Account -from druks.accounts.sessions import SESSION_COOKIE -from druks.harnesses import base as hbase -from druks.harnesses.claude import ClaudeHarness -from druks.harnesses.models import HarnessConnection -from druks.user_settings.models import UserSettings -from fastapi.testclient import TestClient -from sqlalchemy import select - - -@pytest.fixture(autouse=True) -def _clear_redis(): - druks.redis.get_client()._data.clear() - yield - - -def _client(tmp_path: Path, **settings_overrides) -> TestClient: - app = configure_app_for_test( - settings=make_settings(tmp_path, **settings_overrides), authenticated=False - ) - return TestClient(app) - - -def _grant(email: str = "me@example.com") -> dict: - return { - "access_token": "AT", - "refresh_token": "RT", - "expires_in": 28800, - "scope": "user:profile", - "account": {"email_address": email}, - } - - -def _mock_exchange(monkeypatch, grant: dict): - async def fake_post(self, url, *, json=None, data=None, **_kwargs): - return httpx.Response(200, text=_dumps(grant), request=httpx.Request("POST", url)) - - monkeypatch.setattr(hbase.httpx.AsyncClient, "post", fake_post) - - -def _dumps(value: dict) -> str: - return json.dumps(value) - - -def _login(client: TestClient, monkeypatch, *, email="me@example.com") -> dict: - start = client.post("/api/auth/harnesses/claude/login/start") - assert start.status_code == 200 - _mock_exchange(monkeypatch, _grant(email)) - return client.post( - "/api/auth/harnesses/claude/login/complete", - json={"code": "thecode", "loginId": start.json()["loginId"]}, - ) - - -def test_a_sessionless_request_gets_401(tmp_path): - # One behavioral canary; the boundary enumeration proves the full surface. - with _client(tmp_path) as client: - assert client.get("/api/auth/session").status_code == 401 - - -def test_login_flow_mints_session_and_account(tmp_path, monkeypatch, db_session): - with _client(tmp_path) as client: - start = client.post("/api/auth/harnesses/claude/login/start") - assert start.json()["authorizeUrl"].startswith("https://claude.ai/oauth/authorize?") - - response = _login(client, monkeypatch) - assert response.status_code == 200 - assert response.json()["username"] == "me@example.com" - cookie = response.headers["set-cookie"] - assert SESSION_COOKIE in cookie - assert "HttpOnly" in cookie - assert "SameSite=lax" in cookie - assert "Path=/" in cookie - - session = client.get("/api/auth/session") - assert session.status_code == 200 - assert session.json()["username"] == "me@example.com" - - -def test_the_session_read_slides_the_cookie(tmp_path, monkeypatch, db_session): - with _client(tmp_path) as client: - _login(client, monkeypatch) - # Every app load hits /session; that re-issue keeps the cookie sliding. - response = client.get("/api/auth/session") - assert SESSION_COOKIE in response.headers.get("set-cookie", "") - assert "Max-Age" in response.headers["set-cookie"] - - -def test_logout_drops_the_session_and_clears_the_cookie(tmp_path, monkeypatch, db_session): - with _client(tmp_path) as client: - _login(client, monkeypatch) - logout = client.post("/api/auth/logout") - assert logout.status_code == 204 - assert "Max-Age=0" in logout.headers["set-cookie"] - assert client.get("/api/auth/session").status_code == 401 - - -def test_login_rotates_any_prior_session_token(tmp_path, monkeypatch, db_session): - with _client(tmp_path) as client: - first = _login(client, monkeypatch) - first_token = client.cookies[SESSION_COOKIE] - second = _login(client, monkeypatch) - second_token = client.cookies[SESSION_COOKIE] - assert first.status_code == second.status_code == 200 - assert first_token != second_token - # The old token no longer resolves anywhere. - client.cookies.set(SESSION_COOKIE, first_token) - assert client.get("/api/auth/session").status_code == 401 - - -def test_redis_eviction_signs_out_but_keeps_credentials(tmp_path, monkeypatch, db_session): - with _client(tmp_path) as client: - _login(client, monkeypatch) - druks.redis.get_client()._data.clear() # Redis loss - assert client.get("/api/auth/session").status_code == 401 - # The durable credential is untouched — only the session died. - assert HarnessConnection.get_for_account( - "claude", Account.get_for_username("me@example.com").id - ) - - -def test_missing_account_does_not_drop_the_session(tmp_path, monkeypatch, db_session): - # A None from Account.get is a transient/anomalous read (nothing deletes - # accounts), so it 401s the request but must never destroy the session — - # else a blip becomes a forced re-login. - with _client(tmp_path) as client: - _login(client, monkeypatch) - key = f"{SESSION_PREFIX}{client.cookies[SESSION_COOKIE]}" - druks.redis.get_client()._data[key] = b"no-such-account" - assert client.get("/api/auth/session").status_code == 401 - assert key in druks.redis.get_client()._data - - -def test_session_keeps_its_account_across_reconnects(tmp_path, monkeypatch, db_session): - with _client(tmp_path) as client: - _login(client, monkeypatch, email="me@example.com") - # The session account wins; the login records the provider identity. - start = client.post("/api/auth/harnesses/codex/login/start") - _mock_exchange_codex(monkeypatch, email="corp-seat@corp.com") - complete = client.post( - "/api/auth/harnesses/codex/login/complete", - json={"code": "thecode", "loginId": start.json()["loginId"]}, - ) - assert complete.status_code == 200 - assert complete.json()["username"] == "me@example.com" - account = Account.get_for_username("me@example.com") - codex = HarnessConnection.get_for_account("codex", account.id) - assert codex.provider_email == "corp-seat@corp.com" - - -def test_bound_reconnect_requires_its_session_at_complete(tmp_path, monkeypatch, db_session): - with _client(tmp_path) as client: - _login(client, monkeypatch, email="me@example.com") - # A reconnect binds its flow to the session account… - start = client.post("/api/auth/harnesses/codex/login/start") - # …which dies before paste-back. - druks.redis.get_client()._data = { - key: value - for key, value in druks.redis.get_client()._data.items() - if not key.startswith("druks:session:") - } - _mock_exchange_codex(monkeypatch, email="corp-seat@corp.com") - response = client.post( - "/api/auth/harnesses/codex/login/complete", - json={"code": "thecode", "loginId": start.json()["loginId"]}, - ) - # The flow must never rebind the login by email fallback. - assert response.status_code == 422 - assert "different session" in response.json()["detail"] - assert not Account.get_for_username("corp-seat@corp.com") - assert not any(row.harness == "codex" for row in HarnessConnection.list_all()) - - -def test_two_accounts_may_connect_the_same_provider_login(tmp_path, monkeypatch, db_session): - # Deliberately unpoliced: the provider email is renameable upstream, so - # any uniqueness constraint on it would lapse silently. - connect_harness( - ClaudeHarness, - {"claudeAiOauth": {"accessToken": "x"}}, - provider_email="shared@corp.com", - ) - with _client(tmp_path) as client: - start = client.post("/api/auth/harnesses/codex/login/start") - _mock_exchange_codex(monkeypatch, email="me@example.com") - client.post( - "/api/auth/harnesses/codex/login/complete", - json={"code": "thecode", "loginId": start.json()["loginId"]}, - ) - assert _login(client, monkeypatch, email="shared@corp.com").status_code == 200 - - claude = [login for login in HarnessConnection.list_all() if login.harness == "claude"] - assert len(claude) == 2 # one row per account, same provider email - assert len({login.account_id for login in claude}) == 2 - - -def test_new_identity_cannot_acquire_legacy_logins(tmp_path, monkeypatch, db_session): - # The migration shape: the dashboard account owns the legacy login. - legacy = connect_harness( - ClaudeHarness, {"claudeAiOauth": {"accessToken": "x"}}, provider_email="op@example.com" - ) - with _client(tmp_path) as client: - response = _login(client, monkeypatch, email="newcomer@example.com") - assert response.status_code == 200 - accounts = {account.username: account for account in _all_accounts()} - assert set(accounts) == {"op@example.com", "newcomer@example.com", "system"} - # The legacy login stays put; the fallback account stays the operator's. - assert HarnessConnection.reload(legacy.id).account_id == accounts["op@example.com"].id - assert UserSettings.get().fallback_account_id == accounts["op@example.com"].id - - -def test_dashboard_identity_resolves_the_migrated_account(tmp_path, monkeypatch, db_session): - legacy = connect_harness( - ClaudeHarness, {"claudeAiOauth": {"accessToken": "x"}}, provider_email="op@example.com" - ) - legacy_id = legacy.id - with _client(tmp_path) as client: - response = _login(client, monkeypatch, email="op@example.com") - assert response.status_code == 200 - # No second account minted, the legacy login updated in place; reload() - # reads past this task's identity map. - assert {account.username for account in _all_accounts()} == {"op@example.com", "system"} - updated = HarnessConnection.reload(legacy_id) - assert dict(updated.payload)["claudeAiOauth"]["accessToken"] == "AT" - - -def _all_accounts() -> list[Account]: - return list(database.db_session().scalars(select(Account))) - - -def _mock_exchange_codex(monkeypatch, *, email: str): - claims = { - "https://api.openai.com/auth": {"chatgpt_account_id": "acc-1"}, - "https://api.openai.com/profile": {"email": email}, - "exp": 4102444800, - } - header = base64.urlsafe_b64encode(b'{"alg":"none"}').rstrip(b"=").decode() - payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b"=").decode() - grant = {"access_token": f"{header}.{payload}.sig", "refresh_token": "RT", "id_token": "ID"} - - async def fake_post(self, url, *, json=None, data=None, **_kwargs): - return httpx.Response(200, text=_dumps(grant), request=httpx.Request("POST", url)) - - monkeypatch.setattr(hbase.httpx.AsyncClient, "post", fake_post) diff --git a/backend/tests/test_auth_boundary.py b/backend/tests/test_auth_boundary.py index 0a036bf..2fde576 100644 --- a/backend/tests/test_auth_boundary.py +++ b/backend/tests/test_auth_boundary.py @@ -1,24 +1,36 @@ import pytest -from druks.accounts.dependencies import current_account, current_session_account +from druks.accounts.dependencies import ( + current_account, + current_account_or_setup, + current_operator_account, + current_operator_or_setup, +) from druks.api.app import app from fastapi.routing import APIRoute, _IncludedRouter -# Every /api path allowed to skip the session gate; additions are deliberate. +# Every /api path allowed to skip the identity gate; additions are deliberate. EXEMPT_API_PATHS = { "/api/system/health", - "/api/auth/harnesses/{name}/login/start", - "/api/auth/harnesses/{name}/login/complete", - "/api/auth/logout", + "/api/auth/me", + "/api/harnesses/{name}/connection/start", + "/api/harnesses/{name}/connection/complete", "/api/{path:path}", # the JSON-404 catch-all } -# PAT management admits the session cookie only — never a PAT, so a token -# cannot mint or revoke tokens. -SESSION_ONLY_API_PATHS = { +# PAT management admits the edge/none operator identity only — never a PAT, so +# a token cannot mint or revoke tokens. +OPERATOR_ONLY_API_PATHS = { "/api/auth/personal-tokens", "/api/auth/personal-tokens/{pat_id}", } +# The connection flow must answer during none/zero setup, before any account +# exists. +SETUP_CAPABLE_API_PATHS = { + "/api/harnesses/{name}/connection/start", + "/api/harnesses/{name}/connection/complete", +} + def _walk(routes): # FastAPI 0.139 defers include_router into _IncludedRouter nodes; only @@ -43,13 +55,13 @@ def _gated_by(route, gate) -> bool: return any(dependency.call is gate for dependency in route.dependant.dependencies) -def test_every_internal_api_route_sits_behind_the_session_gate(api_routes): +def test_every_internal_api_route_sits_behind_the_identity_gate(api_routes): unguarded = [ route.path for route in api_routes if route.path.startswith("/api/") and route.path not in EXEMPT_API_PATHS - and route.path not in SESSION_ONLY_API_PATHS + and route.path not in OPERATOR_ONLY_API_PATHS and not _gated_by(route, current_account) ] assert unguarded == [] @@ -67,13 +79,34 @@ def test_the_exemptions_are_exactly_the_enumerated_ones(api_routes): assert any(route.path.startswith("/_external/") for route in api_routes) -def test_pat_management_is_session_only(api_routes): - listed = [route for route in api_routes if route.path in SESSION_ONLY_API_PATHS] - assert {route.path for route in listed} == SESSION_ONLY_API_PATHS +def test_identity_bootstrap_is_the_only_setup_tolerant_read(api_routes): + listed = [route for route in api_routes if route.path == "/api/auth/me"] + assert listed + for route in listed: + assert _gated_by(route, current_account_or_setup), route.path + for route in api_routes: + if route.path != "/api/auth/me": + assert not _gated_by(route, current_account_or_setup), route.path + + +def test_connection_setup_uses_only_the_operator_or_setup_resolver(api_routes): + listed = [route for route in api_routes if route.path in SETUP_CAPABLE_API_PATHS] + assert {route.path for route in listed} == SETUP_CAPABLE_API_PATHS + for route in listed: + assert _gated_by(route, current_operator_or_setup), route.path + assert not _gated_by(route, current_account), route.path + for route in api_routes: + if route.path not in SETUP_CAPABLE_API_PATHS: + assert not _gated_by(route, current_operator_or_setup), route.path + + +def test_pat_management_is_operator_only(api_routes): + listed = [route for route in api_routes if route.path in OPERATOR_ONLY_API_PATHS] + assert {route.path for route in listed} == OPERATOR_ONLY_API_PATHS for route in listed: - assert _gated_by(route, current_session_account), route.path + assert _gated_by(route, current_operator_account), route.path assert not _gated_by(route, current_account), route.path - # And nothing else carries the session-only gate. + # And nothing else carries the operator-only gate. for route in api_routes: - if route.path not in SESSION_ONLY_API_PATHS: - assert not _gated_by(route, current_session_account), route.path + if route.path not in OPERATOR_ONLY_API_PATHS: + assert not _gated_by(route, current_operator_account), route.path diff --git a/backend/tests/test_auth_pats.py b/backend/tests/test_auth_pats.py index 87753c4..ce71ff8 100644 --- a/backend/tests/test_auth_pats.py +++ b/backend/tests/test_auth_pats.py @@ -6,14 +6,16 @@ import druks.redis import pytest from conftest import configure_app_for_test, make_settings -from druks.accounts.constants import PAT_TOKEN_TAG, SESSION_PREFIX +from druks.accounts.constants import PAT_TOKEN_TAG from druks.accounts.exceptions import InvalidPatError from druks.accounts.models import Account, PersonalAccessToken -from druks.accounts.sessions import SESSION_COOKIE from druks.database import db_session as session_registry from druks.models import Base from fastapi.testclient import TestClient +HEADER = "X-ExeDev-Email" +OPERATOR = {HEADER: "op@example.com"} + @pytest.fixture(autouse=True) def _clear_redis(): @@ -21,19 +23,14 @@ def _clear_redis(): yield -def _client(tmp_path: Path) -> TestClient: - app = configure_app_for_test(settings=make_settings(tmp_path), authenticated=False) +def _client(tmp_path: Path, **settings_overrides) -> TestClient: + settings_overrides.setdefault("auth_mode", "header") + app = configure_app_for_test( + settings=make_settings(tmp_path, **settings_overrides), authenticated=False + ) return TestClient(app) -def _sign_in(client: TestClient, username: str = "op@example.com") -> Account: - account = Account.get_or_create(username) - token = f"session-{username}" - druks.redis.get_client()._data[f"{SESSION_PREFIX}{token}"] = account.id.encode() - client.cookies.set(SESSION_COOKIE, token) - return account - - def _bearer(token: str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} @@ -108,20 +105,19 @@ def test_last_used_advances_at_most_hourly(db_session): def test_a_bearer_pat_authenticates_gated_routes(tmp_path, db_session): with _client(tmp_path) as client: _, token = _mint() - response = client.get("/api/auth/session", headers=_bearer(token)) + response = client.get("/api/auth/me", headers=_bearer(token)) assert response.status_code == 200 - assert response.json()["username"] == "agent@example.com" - # No cookie to slide on a Bearer request. + assert response.json()["account"]["username"] == "agent@example.com" assert "set-cookie" not in response.headers assert client.get("/api/settings", headers=_bearer(token)).status_code == 200 -def test_an_authorization_header_never_falls_back_to_the_cookie(tmp_path, db_session): +def test_an_unknown_token_never_falls_through_to_the_assertion(tmp_path, db_session): with _client(tmp_path) as client: - _sign_in(client) - assert client.get("/api/auth/session").status_code == 200 + assert client.get("/api/auth/me", headers=OPERATOR).status_code == 200 response = client.get( - "/api/auth/session", headers=_bearer(f"{PAT_TOKEN_TAG}_unknownpref1_nosecret") + "/api/auth/me", + headers={**OPERATOR, **_bearer(f"{PAT_TOKEN_TAG}_unknownpref1_nosecret")}, ) assert response.status_code == 401 assert response.headers["WWW-Authenticate"] == 'Bearer realm="druks", error="invalid_token"' @@ -132,15 +128,14 @@ def test_an_authorization_header_never_falls_back_to_the_cookie(tmp_path, db_ses ) def test_a_malformed_authorization_header_is_challenged(tmp_path, db_session, header): with _client(tmp_path) as client: - response = client.get("/api/auth/session", headers={"Authorization": header}) + response = client.get("/api/auth/me", headers={"Authorization": header}) assert response.status_code == 401 assert response.headers["WWW-Authenticate"] == 'Bearer realm="druks"' -def test_an_empty_authorization_header_never_slides_to_the_cookie(tmp_path, db_session): +def test_an_empty_authorization_header_never_slides_to_the_assertion(tmp_path, db_session): with _client(tmp_path) as client: - _sign_in(client) - response = client.get("/api/auth/session", headers={"Authorization": ""}) + response = client.get("/api/auth/me", headers={**OPERATOR, "Authorization": ""}) assert response.status_code == 401 @@ -148,7 +143,7 @@ def test_a_dead_token_401s_with_its_prefix_only(tmp_path, db_session): with _client(tmp_path) as client: pat, token = _mint() pat.revoke() - response = client.get("/api/auth/session", headers=_bearer(token)) + response = client.get("/api/auth/me", headers=_bearer(token)) assert response.status_code == 401 assert response.headers["WWW-Authenticate"] == 'Bearer realm="druks", error="invalid_token"' assert pat.token_prefix in response.json()["detail"] @@ -166,12 +161,17 @@ def test_a_pat_cannot_manage_pats(tmp_path, db_session): assert create.status_code == 401 revoke = client.delete(f"/api/auth/personal-tokens/{pat.id}", headers=_bearer(token)) assert revoke.status_code == 401 + # Even riding beside a valid identity assertion, a bearer is refused — + # management admits the operator identity alone. + both = client.get("/api/auth/personal-tokens", headers={**OPERATOR, **_bearer(token)}) + assert both.status_code == 401 -def test_the_session_manages_the_token_lifecycle(tmp_path, db_session): +def test_the_operator_manages_the_token_lifecycle(tmp_path, db_session): with _client(tmp_path) as client: - _sign_in(client) - created = client.post("/api/auth/personal-tokens", json={"name": "ci bot"}) + created = client.post( + "/api/auth/personal-tokens", json={"name": "ci bot"}, headers=OPERATOR + ) assert created.status_code == 200 # Create answers only the plaintext, exactly once; the row surfaces # through the list. @@ -179,37 +179,50 @@ def test_the_session_manages_the_token_lifecycle(tmp_path, db_session): token = created.json()["token"] assert token.startswith(f"{PAT_TOKEN_TAG}_") - listed = client.get("/api/auth/personal-tokens").json() + listed = client.get("/api/auth/personal-tokens", headers=OPERATOR).json() assert [item["name"] for item in listed] == ["ci bot"] assert token.split("_")[2] == listed[0]["prefix"] assert "token" not in listed[0] row_id = listed[0]["id"] - revoked = client.delete(f"/api/auth/personal-tokens/{row_id}").json() + revoked = client.delete(f"/api/auth/personal-tokens/{row_id}", headers=OPERATOR).json() assert revoked["status"] == "revoked" # A repeat revoke answers the same state, same instant. - again = client.delete(f"/api/auth/personal-tokens/{row_id}").json() + again = client.delete(f"/api/auth/personal-tokens/{row_id}", headers=OPERATOR).json() assert again["revokedAt"] == revoked["revokedAt"] -def test_the_list_is_scoped_to_the_signed_in_account(tmp_path, db_session): +def test_the_none_mode_operator_manages_tokens_too(tmp_path, db_session): + Account.get_or_create("op@example.com") + with _client(tmp_path, auth_mode="none") as client: + created = client.post("/api/auth/personal-tokens", json={"name": "local"}) + assert created.status_code == 200 + listed = client.get("/api/auth/personal-tokens").json() + assert [item["name"] for item in listed] == ["local"] + + +def test_the_list_is_scoped_to_the_operator(tmp_path, db_session): with _client(tmp_path) as client: _mint("other@example.com") - _sign_in(client) - assert client.get("/api/auth/personal-tokens").json() == [] + assert client.get("/api/auth/personal-tokens", headers=OPERATOR).json() == [] def test_revoking_anothers_token_is_a_404(tmp_path, db_session): with _client(tmp_path) as client: pat, _ = _mint("other@example.com") - _sign_in(client) - assert client.delete(f"/api/auth/personal-tokens/{pat.id}").status_code == 404 + assert ( + client.delete(f"/api/auth/personal-tokens/{pat.id}", headers=OPERATOR).status_code + == 404 + ) session_registry().expire_all() assert not pat.revoked_at def test_a_token_needs_a_name_that_fits(tmp_path, db_session): with _client(tmp_path) as client: - _sign_in(client) - assert client.post("/api/auth/personal-tokens", json={"name": " "}).status_code == 422 - assert client.post("/api/auth/personal-tokens", json={"name": "x" * 81}).status_code == 422 + no_name = client.post("/api/auth/personal-tokens", json={"name": " "}, headers=OPERATOR) + assert no_name.status_code == 422 + too_long = client.post( + "/api/auth/personal-tokens", json={"name": "x" * 81}, headers=OPERATOR + ) + assert too_long.status_code == 422 diff --git a/backend/tests/test_durable_sdk.py b/backend/tests/test_durable_sdk.py index 7e45bec..f504e54 100644 --- a/backend/tests/test_durable_sdk.py +++ b/backend/tests/test_durable_sdk.py @@ -161,7 +161,7 @@ def rt(): # An agent run checks the resolved harness is connected before any VM work; # AgentFlow's decider resolves to claude, so connect it for the module — # and mark the account as the execution fallback, the way the first - # login would. + # harness connection would. from druks.accounts.models import Account from druks.harnesses.models import HarnessConnection from druks.user_settings.models import UserSettings diff --git a/backend/tests/test_extensions.py b/backend/tests/test_extensions.py index 23fbe86..8eb6f94 100644 --- a/backend/tests/test_extensions.py +++ b/backend/tests/test_extensions.py @@ -81,7 +81,7 @@ def discover(cls) -> list[ModuleType]: monkeypatch.setattr(loader, "import_extension_models", lambda: None) app = FastAPI() load(app) - # Mounting is under test, not the session gate. + # Mounting is under test, not the identity gate. from druks.accounts.dependencies import current_account app.dependency_overrides[current_account] = lambda: None diff --git a/backend/tests/test_harness_login.py b/backend/tests/test_harness_connect.py similarity index 62% rename from backend/tests/test_harness_login.py rename to backend/tests/test_harness_connect.py index abfb5ba..2f9da50 100644 --- a/backend/tests/test_harness_login.py +++ b/backend/tests/test_harness_connect.py @@ -8,13 +8,13 @@ from druks.harnesses import base as hbase from druks.harnesses.claude import ClaudeHarness from druks.harnesses.codex import CodexHarness -from druks.harnesses.exceptions import LoginError +from druks.harnesses.exceptions import ConnectError @pytest.fixture(autouse=True) def _clear_pending(): - # The suite shares one in-memory fake Redis; clear the login pending keys so - # one test's stash never leaks into another. + # The suite shares one in-memory fake Redis; clear the connect pending keys + # so one test's stash never leaks into another. druks.redis.get_client()._data.clear() yield @@ -42,7 +42,7 @@ async def fake_post(self, url, *, json=None, data=None, **_kwargs): async def _pending(flow_id: str) -> dict | None: - raw = await druks.redis.get_client().get(f"druks:login:pending:{flow_id}") + raw = await druks.redis.get_client().get(f"druks:harness:connect:pending:{flow_id}") return json.loads(raw) if raw else None @@ -55,8 +55,8 @@ async def _pending(flow_id: str) -> dict | None: } -async def test_claude_login_start_builds_url_and_stashes_pending(db_session): - url, flow_id = await ClaudeHarness.login_start() +async def test_claude_connect_start_builds_url_and_stashes_pending(db_session): + url, flow_id = await ClaudeHarness.connect_start() assert url.startswith("https://claude.ai/oauth/authorize?") assert "code=true" in url assert "code_challenge_method=S256" in url @@ -64,20 +64,20 @@ async def test_claude_login_start_builds_url_and_stashes_pending(db_session): pending = await _pending(flow_id) assert pending["state"] == pending["verifier"] # claude echoes the verifier as state - # An initial login binds to nothing until account resolution. + # An unbound setup flow binds to nothing until account resolution. assert not pending["account_id"] -async def test_login_start_binds_the_session_account(db_session): - _, flow_id = await ClaudeHarness.login_start(account_id="acct-1") +async def test_connect_start_binds_the_operator_account(db_session): + _, flow_id = await ClaudeHarness.connect_start(account_id="acct-1") pending = await _pending(flow_id) assert pending["account_id"] == "acct-1" -async def test_claude_login_complete_returns_the_exchange(monkeypatch, db_session): - _, flow_id = await ClaudeHarness.login_start(account_id="acct-1") +async def test_claude_connect_complete_returns_the_exchange(monkeypatch, db_session): + _, flow_id = await ClaudeHarness.connect_start(account_id="acct-1") calls = _mock_post(monkeypatch, _resp(200, _CLAUDE_GRANT)) - completed = await ClaudeHarness.login_complete(flow_id=flow_id, pasted="thecode") + completed = await ClaudeHarness.connect_complete(flow_id=flow_id, pasted="thecode") block = completed.payload["claudeAiOauth"] assert block["accessToken"] == "AT" @@ -94,35 +94,35 @@ async def test_claude_login_complete_returns_the_exchange(monkeypatch, db_sessio assert not await _pending(flow_id) -async def test_concurrent_login_flows_do_not_clobber_each_other(monkeypatch, db_session): +async def test_concurrent_connect_flows_do_not_clobber_each_other(monkeypatch, db_session): # Two people connect the same harness at once: distinct flow ids, both # pendings live, and completing one leaves the other completable. - _, first_flow = await ClaudeHarness.login_start() - _, second_flow = await ClaudeHarness.login_start() + _, first_flow = await ClaudeHarness.connect_start() + _, second_flow = await ClaudeHarness.connect_start() assert first_flow != second_flow _mock_post(monkeypatch, _resp(200, _CLAUDE_GRANT)) - first = await ClaudeHarness.login_complete(flow_id=first_flow, pasted="code-1") + first = await ClaudeHarness.connect_complete(flow_id=first_flow, pasted="code-1") assert await _pending(second_flow) second_grant = dict(_CLAUDE_GRANT, account={"email_address": "other@example.com"}) _mock_post(monkeypatch, _resp(200, second_grant)) - second = await ClaudeHarness.login_complete(flow_id=second_flow, pasted="code-2") + second = await ClaudeHarness.connect_complete(flow_id=second_flow, pasted="code-2") assert first.provider_email == "me@example.com" assert second.provider_email == "other@example.com" -async def test_login_complete_without_provider_email_raises(monkeypatch, db_session): - _, flow_id = await ClaudeHarness.login_start() +async def test_connect_complete_without_provider_email_raises(monkeypatch, db_session): + _, flow_id = await ClaudeHarness.connect_start() grant = dict(_CLAUDE_GRANT, account={}) _mock_post(monkeypatch, _resp(200, grant)) - with pytest.raises(LoginError, match="no account email"): - await ClaudeHarness.login_complete(flow_id=flow_id, pasted="thecode") + with pytest.raises(ConnectError, match="no account email"): + await ClaudeHarness.connect_complete(flow_id=flow_id, pasted="thecode") -async def test_codex_login_complete_is_form_encoded_and_reads_jwt(monkeypatch, db_session): - _, flow_id = await CodexHarness.login_start() +async def test_codex_connect_complete_is_form_encoded_and_reads_jwt(monkeypatch, db_session): + _, flow_id = await CodexHarness.connect_start() pending = await _pending(flow_id) access = _jwt( { @@ -134,7 +134,7 @@ async def test_codex_login_complete_is_form_encoded_and_reads_jwt(monkeypatch, d calls = _mock_post( monkeypatch, _resp(200, {"access_token": access, "refresh_token": "RT", "id_token": "ID"}) ) - completed = await CodexHarness.login_complete( + completed = await CodexHarness.connect_complete( flow_id=flow_id, pasted=f"http://localhost:1455/auth/callback?code=thecode&state={pending['state']}", ) @@ -147,32 +147,34 @@ async def test_codex_login_complete_is_form_encoded_and_reads_jwt(monkeypatch, d assert "state" not in calls[0]["data"] -async def test_login_complete_unreadable_provider_json_raises_login_error(monkeypatch, db_session): - _, flow_id = await ClaudeHarness.login_start() +async def test_connect_complete_unreadable_provider_json_raises_connect_error( + monkeypatch, db_session +): + _, flow_id = await ClaudeHarness.connect_start() _mock_post(monkeypatch, _resp(200, "not json")) - with pytest.raises(LoginError) as error: - await ClaudeHarness.login_complete(flow_id=flow_id, pasted="code") + with pytest.raises(ConnectError) as error: + await ClaudeHarness.connect_complete(flow_id=flow_id, pasted="code") assert "unreadable response" in str(error.value) -async def test_login_complete_without_pending_raises(db_session): - with pytest.raises(LoginError): - await ClaudeHarness.login_complete(flow_id="not-a-flow", pasted="code") +async def test_connect_complete_without_pending_raises(db_session): + with pytest.raises(ConnectError): + await ClaudeHarness.connect_complete(flow_id="not-a-flow", pasted="code") -async def test_login_complete_state_mismatch_raises(db_session): - _, flow_id = await ClaudeHarness.login_start() - with pytest.raises(LoginError): - await ClaudeHarness.login_complete(flow_id=flow_id, pasted="code#not-the-state") +async def test_connect_complete_state_mismatch_raises(db_session): + _, flow_id = await ClaudeHarness.connect_start() + with pytest.raises(ConnectError): + await ClaudeHarness.connect_complete(flow_id=flow_id, pasted="code#not-the-state") -async def test_login_complete_provider_error_clears_pending(monkeypatch, db_session): - _, flow_id = await ClaudeHarness.login_start() +async def test_connect_complete_provider_error_clears_pending(monkeypatch, db_session): + _, flow_id = await ClaudeHarness.connect_start() _mock_post(monkeypatch, _resp(400, "invalid_grant: code expired")) - with pytest.raises(LoginError) as error: - await ClaudeHarness.login_complete(flow_id=flow_id, pasted="code") + with pytest.raises(ConnectError) as error: + await ClaudeHarness.connect_complete(flow_id=flow_id, pasted="code") assert "invalid_grant" in str(error.value) # Failure is single-use too — a retry must re-start. assert not await _pending(flow_id) diff --git a/backend/tests/test_identity.py b/backend/tests/test_identity.py new file mode 100644 index 0000000..353b1eb --- /dev/null +++ b/backend/tests/test_identity.py @@ -0,0 +1,342 @@ +import base64 +import json +from pathlib import Path + +import druks.redis +import httpx +import pytest +from conftest import configure_app_for_test, connect_harness, make_settings +from druks import database +from druks.accounts.dependencies import resolve_none_operator +from druks.accounts.exceptions import AuthConfigurationError +from druks.accounts.models import Account, PersonalAccessToken +from druks.harnesses import base as hbase +from druks.harnesses.claude import ClaudeHarness +from druks.harnesses.models import HarnessConnection +from druks.user_settings.models import UserSettings +from fastapi.testclient import TestClient +from sqlalchemy import select + +HEADER = "X-ExeDev-Email" + + +@pytest.fixture(autouse=True) +def _clear_redis(): + druks.redis.get_client()._data.clear() + yield + + +def _client(tmp_path: Path, **settings_overrides) -> TestClient: + app = configure_app_for_test( + settings=make_settings(tmp_path, **settings_overrides), authenticated=False + ) + return TestClient(app) + + +def _header_client(tmp_path: Path) -> TestClient: + return _client(tmp_path, auth_mode="header") + + +def _grant(email: str = "me@example.com") -> dict: + return { + "access_token": "AT", + "refresh_token": "RT", + "expires_in": 28800, + "scope": "user:profile", + "account": {"email_address": email}, + } + + +def _mock_exchange(monkeypatch, grant: dict): + async def fake_post(self, url, *, json=None, data=None, **_kwargs): + return httpx.Response(200, text=_dumps(grant), request=httpx.Request("POST", url)) + + monkeypatch.setattr(hbase.httpx.AsyncClient, "post", fake_post) + + +def _dumps(value: dict) -> str: + return json.dumps(value) + + +def _connect( + client: TestClient, + monkeypatch, + *, + harness: str = "claude", + email: str = "me@example.com", + headers: dict[str, str] | None = None, +): + start = client.post(f"/api/harnesses/{harness}/connection/start", headers=headers) + assert start.status_code == 200 + if harness == "claude": + _mock_exchange(monkeypatch, _grant(email)) + else: + _mock_exchange_codex(monkeypatch, email=email) + return client.post( + f"/api/harnesses/{harness}/connection/complete", + json={"code": "thecode", "connectionId": start.json()["connectionId"]}, + headers=headers, + ) + + +def _all_accounts() -> list[Account]: + return list(database.db_session().scalars(select(Account))) + + +def _mock_exchange_codex(monkeypatch, *, email: str): + claims = { + "https://api.openai.com/auth": {"chatgpt_account_id": "acc-1"}, + "https://api.openai.com/profile": {"email": email}, + "exp": 4102444800, + } + header = base64.urlsafe_b64encode(b'{"alg":"none"}').rstrip(b"=").decode() + payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b"=").decode() + grant = {"access_token": f"{header}.{payload}.sig", "refresh_token": "RT", "id_token": "ID"} + + async def fake_post(self, url, *, json=None, data=None, **_kwargs): + return httpx.Response(200, text=_dumps(grant), request=httpx.Request("POST", url)) + + monkeypatch.setattr(hbase.httpx.AsyncClient, "post", fake_post) + + +# --- header mode ----------------------------------------------------------- + + +def test_header_mode_requires_exactly_one_nonblank_assertion(tmp_path, db_session): + with _header_client(tmp_path) as client: + assert client.get("/api/auth/me").status_code == 401 + assert client.get("/api/settings").status_code == 401 + assert client.get("/api/auth/me", headers={HEADER: " "}).status_code == 401 + two = client.get("/api/auth/me", headers=[(HEADER, "a@x.com"), (HEADER, "b@x.com")]) + assert two.status_code == 401 + # Rejection never enrolls anyone. + assert {account.username for account in _all_accounts()} == {"system"} + + +def test_an_asserted_email_open_enrolls_once_across_case_variants(tmp_path, db_session): + with _header_client(tmp_path) as client: + first = client.get("/api/auth/me", headers={HEADER: " Op@Example.com "}) + assert first.status_code == 200 + body = first.json() + assert body["authMode"] == "header" + assert body["account"]["username"] == "Op@Example.com" + assert body["onboardingRequired"] is True + + again = client.get("/api/auth/me", headers={HEADER: "op@example.COM"}) + assert again.json()["account"]["id"] == body["account"]["id"] + assert len(Account.list_non_system()) == 1 + + +def test_get_or_create_losing_the_insert_race_still_converges(db_session, monkeypatch): + existing = Account.get_or_create("race@example.com") + # Simulate losing the read-then-insert race: the pre-read misses, the + # INSERT hits ON CONFLICT DO NOTHING, the canonical lookup converges. + monkeypatch.setattr(Account, "get_for_username", classmethod(lambda cls, username: None)) + assert Account.get_or_create("Race@example.com").id == existing.id + assert Account.count_non_system() == 1 + + +def test_a_valid_pat_wins_over_a_conflicting_header(tmp_path, db_session): + agent = Account.get_or_create("agent@example.com") + _, token = PersonalAccessToken.create(account_id=agent.id, name="agent") + with _header_client(tmp_path) as client: + response = client.get( + "/api/auth/me", + headers={"Authorization": f"Bearer {token}", HEADER: "op@example.com"}, + ) + assert response.status_code == 200 + assert response.json()["account"]["username"] == "agent@example.com" + # The losing assertion never enrolled. + assert not Account.get_for_username("op@example.com") + + +@pytest.mark.parametrize("header", ["", "Token abc", "Bearer", "Bearer a b", "Bearer garbage"]) +def test_a_bad_authorization_never_falls_through_to_the_assertion(tmp_path, db_session, header): + with _header_client(tmp_path) as client: + response = client.get( + "/api/auth/me", headers={"Authorization": header, HEADER: "op@example.com"} + ) + assert response.status_code == 401 + assert response.headers["WWW-Authenticate"].startswith('Bearer realm="druks"') + assert not Account.get_for_username("op@example.com") + + +def test_onboarding_clears_once_the_account_has_a_connection(tmp_path, db_session): + connect_harness(ClaudeHarness, {"claudeAiOauth": {"accessToken": "x"}}) + with _header_client(tmp_path) as client: + body = client.get("/api/auth/me", headers={HEADER: "op@example.com"}).json() + assert body["onboardingRequired"] is False + + +# --- none mode ------------------------------------------------------------- + + +def test_none_mode_ignores_a_present_identity_header(tmp_path, db_session): + connect_harness(ClaudeHarness, {"claudeAiOauth": {"accessToken": "x"}}) + with _client(tmp_path) as client: + body = client.get("/api/auth/me", headers={HEADER: "intruder@example.com"}).json() + assert body["authMode"] == "none" + assert body["account"]["username"] == "op@example.com" + # Never open-enrolls in none mode. + assert not Account.get_for_username("intruder@example.com") + + +def test_none_zero_reads_as_setup(tmp_path, db_session): + with _client(tmp_path) as client: + me = client.get("/api/auth/me") + assert me.status_code == 200 + assert me.json() == {"authMode": "none", "account": None, "onboardingRequired": True} + assert client.get("/api/settings").status_code == 409 + + +def test_none_one_resolves_the_operator(tmp_path, db_session): + connect_harness(ClaudeHarness, {"claudeAiOauth": {"accessToken": "x"}}) + with _client(tmp_path) as client: + body = client.get("/api/auth/me").json() + assert body["account"]["username"] == "op@example.com" + assert body["onboardingRequired"] is False + assert client.get("/api/settings").status_code == 200 + + +def test_none_multi_refuses_requests_and_startup(tmp_path, db_session): + Account.get_or_create("one@example.com") + Account.get_or_create("two@example.com") + with _client(tmp_path) as client: + assert client.get("/api/settings").status_code == 503 + # The startup validator runs the same check and refuses boot. + with pytest.raises(AuthConfigurationError): + resolve_none_operator() + + +# --- connection flow ------------------------------------------------------- + + +def test_none_zero_setup_flow_creates_the_operator(tmp_path, monkeypatch, db_session): + with _client(tmp_path) as client: + response = _connect(client, monkeypatch, email="me@example.com") + assert response.status_code == 200 + assert response.json()["username"] == "me@example.com" + assert "set-cookie" not in response.headers + # The created operator now resolves and is past onboarding. + body = client.get("/api/auth/me").json() + assert body["account"]["username"] == "me@example.com" + assert body["onboardingRequired"] is False + account = Account.get_for_username("me@example.com") + assert UserSettings.get().fallback_account_id == account.id + assert HarnessConnection.get_for_account("claude", account.id) + + +def test_concurrent_setup_completions_with_one_email_converge(tmp_path, monkeypatch, db_session): + with _client(tmp_path) as client: + # Both flows start while zero accounts exist — both unbound. + first = client.post("/api/harnesses/claude/connection/start") + second = client.post("/api/harnesses/codex/connection/start") + _mock_exchange(monkeypatch, _grant("me@example.com")) + assert ( + client.post( + "/api/harnesses/claude/connection/complete", + json={"code": "c1", "connectionId": first.json()["connectionId"]}, + ).status_code + == 200 + ) + _mock_exchange_codex(monkeypatch, email="me@example.com") + assert ( + client.post( + "/api/harnesses/codex/connection/complete", + json={"code": "c2", "connectionId": second.json()["connectionId"]}, + ).status_code + == 200 + ) + assert len(Account.list_non_system()) == 1 + assert len(HarnessConnection.list_all()) == 2 + + +def test_setup_completions_with_different_emails_surface_the_drift( + tmp_path, monkeypatch, db_session +): + with _client(tmp_path) as client: + first = client.post("/api/harnesses/claude/connection/start") + second = client.post("/api/harnesses/codex/connection/start") + _mock_exchange(monkeypatch, _grant("a@example.com")) + client.post( + "/api/harnesses/claude/connection/complete", + json={"code": "c1", "connectionId": first.json()["connectionId"]}, + ) + _mock_exchange_codex(monkeypatch, email="b@example.com") + client.post( + "/api/harnesses/codex/connection/complete", + json={"code": "c2", "connectionId": second.json()["connectionId"]}, + ) + # Two operators under auth mode none is drift — every ordinary request + # now refuses loudly instead of guessing. + assert client.get("/api/settings").status_code == 503 + + +def test_a_bound_connect_cannot_complete_under_another_operator(tmp_path, monkeypatch, db_session): + with _header_client(tmp_path) as client: + start = client.post( + "/api/harnesses/claude/connection/start", headers={HEADER: "alice@example.com"} + ) + _mock_exchange(monkeypatch, _grant("seat@corp.com")) + response = client.post( + "/api/harnesses/claude/connection/complete", + json={"code": "thecode", "connectionId": start.json()["connectionId"]}, + headers={HEADER: "bob@example.com"}, + ) + assert response.status_code == 422 + assert "different operator" in response.json()["detail"] + assert not any(row.harness == "claude" for row in HarnessConnection.list_all()) + + +def test_first_connection_claims_the_fallback_slot_once(tmp_path, monkeypatch, db_session): + with _header_client(tmp_path) as client: + _connect(client, monkeypatch, email="seat@corp.com", headers={HEADER: "first@example.com"}) + first = Account.get_for_username("first@example.com") + assert UserSettings.get().fallback_account_id == first.id + _connect( + client, + monkeypatch, + harness="codex", + email="other-seat@corp.com", + headers={HEADER: "second@example.com"}, + ) + # The fallback stays with the first operator. + assert UserSettings.get().fallback_account_id == first.id + + +def test_reconnect_records_provider_email_but_keeps_the_operator(tmp_path, monkeypatch, db_session): + with _header_client(tmp_path) as client: + response = _connect( + client, + monkeypatch, + harness="codex", + email="corp-seat@corp.com", + headers={HEADER: "me@example.com"}, + ) + assert response.status_code == 200 + assert response.json()["username"] == "me@example.com" + account = Account.get_for_username("me@example.com") + codex = HarnessConnection.get_for_account("codex", account.id) + assert codex.provider_email == "corp-seat@corp.com" + + +def test_connection_flow_rejects_a_bearer(tmp_path, db_session): + agent = Account.get_or_create("agent@example.com") + _, token = PersonalAccessToken.create(account_id=agent.id, name="agent") + with _client(tmp_path) as client: + response = client.post( + "/api/harnesses/claude/connection/start", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 401 + + +# --- the old browser-session surface is gone ------------------------------- + + +def test_the_session_era_routes_are_gone(tmp_path, db_session): + with _client(tmp_path) as client: + assert client.get("/api/auth/session").status_code == 404 + assert client.post("/api/auth/logout").status_code == 404 + assert client.post("/api/auth/harnesses/claude/login/start").status_code == 404 + assert client.post("/api/auth/harnesses/claude/login/complete").status_code == 404 diff --git a/backend/tests/test_scaffolding.py b/backend/tests/test_scaffolding.py index 469dc5d..0cc732e 100644 --- a/backend/tests/test_scaffolding.py +++ b/backend/tests/test_scaffolding.py @@ -51,7 +51,7 @@ def test_create_extension_scaffolds_a_loadable_package(tmp_path): app = FastAPI() extension.load(app) - # Scaffolding is under test, not the session gate. + # Scaffolding is under test, not the identity gate. from druks.accounts.dependencies import current_account app.dependency_overrides[current_account] = lambda: None diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index 7b471cf..c81d320 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -1,4 +1,21 @@ +import pytest from druks.settings import Settings, ensure_data_dirs, load_settings +from pydantic import ValidationError + + +def test_auth_mode_defaults_to_none_with_the_exe_header(tmp_path, monkeypatch): + monkeypatch.setenv("DRUKS_DATA_DIR", str(tmp_path)) + monkeypatch.delenv("DRUKS_AUTH_MODE", raising=False) + monkeypatch.delenv("DRUKS_AUTH_HEADER", raising=False) + settings = load_settings() + assert settings.auth_mode == "none" + assert settings.auth_header == "X-ExeDev-Email" + + +def test_header_mode_requires_a_nonblank_header_name(tmp_path, monkeypatch): + monkeypatch.setenv("DRUKS_DATA_DIR", str(tmp_path)) + with pytest.raises(ValidationError): + Settings(auth_mode="header", auth_header=" ") # type: ignore[call-arg] def test_ensure_data_dirs_provisions_skills_dir(tmp_path, monkeypatch): diff --git a/backend/tests/test_setup_env.py b/backend/tests/test_setup_env.py index f0253c1..2be01d2 100644 --- a/backend/tests/test_setup_env.py +++ b/backend/tests/test_setup_env.py @@ -31,6 +31,9 @@ def test_fresh_run_writes_template_with_secrets_and_reports_gaps(tmp_path): # Provider block present. assert values["DEFAULT_HOST_PROVIDER"] == "exe" assert values["TAILSCALE_ENABLED"] == "true" + # exe.dev is the identity edge; druks maps its asserted email header. + assert values["DRUKS_AUTH_MODE"] == "header" + assert values["DRUKS_AUTH_HEADER"] == "X-ExeDev-Email" # The secrets dir is created alongside. assert (tmp_path / "secrets").is_dir() @@ -47,6 +50,10 @@ def test_aws_provider_block(tmp_path): assert values["DRUKS_ENDPOINT"] == "" assert values["TAILSCALE_ENABLED"] == "false" assert "AWS_REGION" in values + # Bring-your-own-edge: header mode, and the operator must name the header + # their proxy injects — a required gap, never a guessed default. + assert values["DRUKS_AUTH_MODE"] == "header" + assert values["DRUKS_AUTH_HEADER"] == "" # The sandbox-VM instance profile stays a documented, commented knob. assert "# AWS_INSTANCE_PROFILE=" in env_path.read_text() @@ -69,6 +76,9 @@ def test_docker_provider_wires_drukbox_on_host(tmp_path): # The local dashboard's URL is fixed, so the OAuth-MCP callback base is # defaulted — connecting an OAuth MCP server works without a manual edit. assert values["DRUKS_ENDPOINT"] == "http://localhost:8001" + # Loopback dashboard, no edge: identity mode none. + assert values["DRUKS_AUTH_MODE"] == "none" + assert "DRUKS_AUTH_HEADER" not in values def test_rerun_preserves_values_secrets_and_operator_additions(tmp_path): @@ -89,6 +99,20 @@ def test_rerun_preserves_values_secrets_and_operator_additions(tmp_path): assert values["MY_CUSTOM_FLAG"] == "on" # hand edits survive +def test_rerun_preserves_the_identity_header_choice(tmp_path): + env_path = tmp_path / ".env" + _run(env_path) + env_path.write_text( + env_path.read_text().replace( + "DRUKS_AUTH_HEADER=X-ExeDev-Email", "DRUKS_AUTH_HEADER=X-Custom-Email" + ) + ) + + _run(env_path) + + assert read_env(env_path)["DRUKS_AUTH_HEADER"] == "X-Custom-Email" + + def test_rerun_keeps_the_provider_the_env_was_written_with(tmp_path): env_path = tmp_path / ".env" _run(env_path, provider="aws") diff --git a/deploy/README.md b/deploy/README.md index 469a0f1..136b85b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -3,7 +3,7 @@ The whole stack runs in Docker Compose: Druks (`web`, which embeds the DBOS durable engine and serves the dashboard SPA), Postgres, and Redis. A **remote** install (`DRUKS_PROVIDER=exe`/`aws`) also brings up stock Caddy -(auth gate + proxy, Caddyfile bind-mounted) and the Drukbox sandbox control +(identity edge + proxy, Caddyfile bind-mounted) and the Drukbox sandbox control plane (`sandbox-service`, `sandbox-janitor`). Those three live in the compose `remote` profile, which `install.sh` selects by writing `COMPOSE_PROFILES` to `.env`, so plain `docker compose` commands in the install dir pick it up. @@ -108,7 +108,8 @@ ssh exe.dev share set-public druks Public URLs: `https:///_external/{github,linear,jira}/events/` (HMAC-gated webhooks), `https:///mcp` (PAT-authenticated MCP endpoint — [Connect your agent](../docs/connect-your-agent.md)), and `https:///` -(exe.dev login at the edge; harness login in the app mints the session). +(exe.dev authenticates at the edge; druks maps its asserted email to your +account). **Elsewhere (e.g. AWS + Teleport)**, the dashboard goes through your identity proxy (set `DRUKS_AUTH_HEADER` to the header it injects), but @@ -151,41 +152,14 @@ code until they finish. Recovery does not preserve a live agent process inside a sandbox; it follows the operation boundary described in [Concepts](../docs/concepts.md#durability-and-recovery). -### One-time: upgrading across the accounts migration - -The accounts release re-keys `harness_logins` per account and encrypts the -stored credentials in place — a destructive schema change. Upgrade it as a -maintenance cutover: stop the stack (`docker compose down`), wait for active -calls to end, **back up Postgres**, run the migration with the new image -(`docker compose run --rm web druks init-db`), then `docker compose up -d`. -Never run old and new processes across this migration; parked workflows may -stay parked. Rolling back afterwards means restoring the pre-migration -database backup and the old image — there is no Alembic downgrade. - -With existing harness connections, the migration requires -`DRUKS_DASHBOARD_EMAIL` in the migration run's environment and attaches every -existing seat to one account with that email. A remote install needs no extra -step — `.env` already holds the value the old Caddy gate matched. **Warning:** -the one-run value must match the provider email you will actually sign in with -once account login lands; a mismatch strands the migrated seats on an account -you cannot enter (automation keeps working, and reconnecting after an eventual -`invalid_grant` re-creates the seat under your real account). Fresh installs -and installs with no connected harness skip all of this. - -Once the account-login release is deployed, finish the cutover — deploys never -refresh the host-copied `compose.yaml`/`Caddyfile`, so this is an explicit -step (re-running `install.sh` performs the copy for you): - -1. Replace the host copies of `deploy/compose.yaml` and - `deploy/caddy/Caddyfile` with this release's versions (the Caddy gate now - admits any nonempty trusted identity; the app enforces its own session). -2. Delete the now-unused `DRUKS_DASHBOARD_EMAIL` line from `~/druks/.env`. -3. Recreate the affected services: `docker compose up -d --force-recreate web - caddy`. - -Until those land, the new release still runs behind the old single-email gate -— that transition window is supported. Afterwards, each browser performs one -harness login to mint its session cookie. +### One-time: upgrading across the edge-identity release + +The edge-identity release replaces browser sessions with per-request +edge-asserted identity (`DRUKS_AUTH_MODE`/`DRUKS_AUTH_HEADER`). The cutover +runbook for existing installs ships with the deploy ticket (ENG-731); until +you run it, upgrade an existing box only alongside that runbook. Remember +that deploys never refresh the host-copied `compose.yaml`/`Caddyfile` — +re-running `install.sh` performs the copy. ### One-time: upgrading a box that ran the backend as root @@ -247,5 +221,6 @@ Caddyfile fetched by the installer) enforces path-level access: the app; proxied unbuffered so its SSE frames stream. - Everything else — a nonempty trusted identity header (exe.dev login provides one) required, then proxied to `web` (`127.0.0.1:8001`), which - serves the API, the SPA, and extension frontends alike; the app itself - requires its session cookie, minted by harness login. + serves the API, the SPA, and extension frontends alike; the app maps that + asserted email to your account per request + ([access control](../docs/configuration.md#public-urls-and-access-control)). diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 89373aa..93b7017 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -42,12 +42,13 @@ http://:8000 { } } - # Admission is an upstream-injected trusted header: any nonempty value - # passes the edge. Identity inside the app is its own (harness login - # mints the session cookie); the header is never read by the backend. - # DRUKS_AUTH_HEADER defaults to exe.dev's; point it at another + # The edge authenticates; this proxy forwards its identity assertion and + # the backend maps the asserted email to an account (DRUKS_AUTH_MODE=header + # reads the same DRUKS_AUTH_HEADER). The upstream proxy must strip any + # client-supplied copy of the header before inserting its authenticated + # value. DRUKS_AUTH_HEADER defaults to exe.dev's; point it at another # proxy's header (Teleport: X-Forwarded-Email, Cloudflare Access: - # Cf-Access-Authenticated-User-Email, …) to swap the auth edge with no + # Cf-Access-Authenticated-User-Email, …) to swap the identity edge with no # code change. # The backend serves everything behind the gate — the API, the SPA at /, # extension frontends at /app/. Cache policy for the served @@ -69,9 +70,10 @@ http://:8000 { # A-record at this box and Caddy auto-provisions TLS for it. Serves the # HMAC-verified /_external/* paths and the PAT-authenticated /mcp endpoint # (this host is the canonical MCP address) and nothing else: no dashboard -# routes, no identity header, so the trusted-header gate above stays -# unforgeable. Unset → an inert loopback listener; deployments whose edge -# already carries webhooks (exe.dev port-share) need nothing here. +# routes, and nothing served here resolves the DRUKS_AUTH_HEADER assertion, +# so edge identity stays unforgeable from the public side. Unset → an inert +# loopback listener; deployments whose edge already carries webhooks (exe.dev +# port-share) need nothing here. {$DRUKS_WEBHOOK_HOST:http://127.0.0.1:8081} { header { X-Content-Type-Options "nosniff" diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 8533128..f8755c2 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -117,16 +117,18 @@ services: disable: true caddy: - # Stock Caddy — the external auth gate + webhook TLS. A local install - # skips it (dashboard reached directly on 127.0.0.1:8001), so it lives - # in the ``remote`` profile. Caddyfile is fetched next to this compose - # file by install.sh. + # Stock Caddy — the identity edge + webhook TLS. A local install skips it + # (dashboard reached directly on 127.0.0.1:8001, DRUKS_AUTH_MODE=none), so + # it lives in the ``remote`` profile. Caddyfile is fetched next to this + # compose file by install.sh. image: caddy:2.10-alpine profiles: ["remote"] network_mode: host restart: unless-stopped environment: DRUKS_UPSTREAM: ${DRUKS_UPSTREAM:-127.0.0.1:8001} + # Both Caddy and druks read DRUKS_AUTH_HEADER (web gets it from env_file): + # Caddy requires the edge's assertion, druks maps it to an account. DRUKS_AUTH_HEADER: ${DRUKS_AUTH_HEADER:-X-ExeDev-Email} # ``:-`` so an empty .env value still collapses to the inert loopback # default — Caddy treats set-but-empty as a real (broken) site address. diff --git a/docs/concepts.md b/docs/concepts.md index 577b281..08b80aa 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -203,14 +203,18 @@ coordination such as webhook deduplication, OAuth state/token caches, and the sandbox provisioning gate. Drukbox provisions sandbox hosts; Druks then reaches them over SSH. -Harness login is the application's login: connecting Codex or Claude resolves -an account and mints the `druks_session` cookie (30-day sliding TTL in Redis) -that every internal API and SSE stream requires. In the shipped remote stack, -Caddy additionally requires an identity header inserted by exe.dev or another -upstream proxy — a pure admission check; the app never reads it. Public `/_external` -routes — webhooks and the token-authenticated notification respond — bypass -the session gate but keep their own authentication. A local dashboard bound directly to -`127.0.0.1:8001` still requires the session but has no edge in front and must -not be published as-is. The upstream proxy must remove any client-supplied -copy of the trusted identity header before inserting its authenticated value; -merely forwarding that header makes identity resolution forgeable. +Druks does not authenticate browsers; identity resolves per request. A +`Authorization: Bearer` personal access token always resolves first — present +means it must authenticate. Otherwise the configured `DRUKS_AUTH_MODE` +decides: in `header` mode the edge (exe.dev, Teleport, Cloudflare Access, …) +authenticates and asserts the operator's email in the trusted identity +header, and Druks maps it to an account — open enrollment, since the edge +gates who reaches Druks at all; in `none` mode there is no authentication and +exactly one operator account, created by the first completed harness +connection. Public `/_external` routes — webhooks and the token-authenticated +notification respond — and the PAT-authenticated `/mcp` endpoint sit outside +that identity gate but keep their own authentication. Connecting Codex or +Claude is a capability connect for the current account, not a login. See +[configuration](configuration.md#public-urls-and-access-control) for the +trust requirements, including why the edge must strip client-supplied copies +of the identity header. diff --git a/docs/configuration.md b/docs/configuration.md index 879b1b9..bc29538 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,25 +35,51 @@ caches, and the sandbox provisioning gate. | `DRUKS_ENDPOINT` | Browser-visible dashboard base URL used to build MCP OAuth callbacks | | `DRUKS_WEBHOOK_HOST` | Public webhook hostname used by `druks doctor` for its ingress probe | | `DRUKS_WEBHOOK_SECRET` | Shared HMAC secret used by bundled webhook integrations | -| `DRUKS_AUTH_HEADER` | Identity header the edge (Caddy) requires; the app never reads it | +| `DRUKS_AUTH_MODE` | `none` (default; no authentication, single operator) or `header` (edge-asserted identity) | +| `DRUKS_AUTH_HEADER` | The trusted identity header; read by both the shipped Caddy edge and Druks | `DRUKS_ENDPOINT` and `DRUKS_WEBHOOK_HOST` are different. The first is where an operator's browser reaches Druks; the second is the public ingress webhook senders reach. They may share a hostname on exe.dev. -Harness login is the account door: signing in to Codex or Claude from the -dashboard resolves your account and mints the `druks_session` cookie -(HttpOnly, 30-day sliding TTL in Redis) that every internal API and SSE -stream requires. The shipped remote Caddy admits only requests carrying a -nonempty `DRUKS_AUTH_HEADER` identity — pure admission; the app never reads -the header. Public `POST /_external/*` routes bypass the -edge identity check and carry their own authentication — webhook signature -verification, and the notification respond route's correlation token. Do not -publish the local `127.0.0.1:8001` listener directly. Configure -the identity proxy to strip every client-supplied copy of -`DRUKS_AUTH_HEADER` — a client that can inject it walks past the edge. Terminate -TLS and set HSTS at that public proxy; the shipped Caddy listener is loopback -HTTP behind the TLS edge. +Druks does not authenticate browsers. Identity resolves per request, in this +order: + +1. **Personal access token.** When an `Authorization` header is present it + must authenticate — a malformed or dead bearer is a 401, never a fall + through to the modes below. +2. **`header` mode.** The edge (exe.dev, Teleport, Cloudflare Access, …) + authenticates and asserts the operator's email as exactly one nonblank + `DRUKS_AUTH_HEADER` value; Druks trims outer whitespace and maps it to an + account, creating one on first sight (open enrollment — the edge decides + who reaches Druks at all; the account column is case-insensitive). +3. **`none` mode.** No authentication and no identity edge: Druks resolves + the only non-system account. Zero accounts is the setup state — the + dashboard onboards by connecting a harness, and the first completed + connection creates the operator account from the provider-verified email. + More than one non-system account is configuration drift: Druks refuses + requests (and startup) loudly rather than guess. + +Trust requirements for `header` mode: the edge must authenticate every +dashboard request, must strip any client-supplied copy of +`DRUKS_AUTH_HEADER` before inserting its authenticated value — a client that +can inject the header can be anyone — and must terminate TLS and set HSTS. +The shipped Caddy listener is loopback HTTP behind that edge, and the Druks +web listener itself binds loopback by default. In `none` mode there is no +authentication at all, so the listener must stay loopback-only — never +publish it. + +Any public listener that bypasses the identity edge must never forward +`DRUKS_AUTH_HEADER` upstream. The shipped webhook listener already serves +only HMAC-verified `/_external/*` and the PAT-authenticated `/mcp` — nothing +that resolves the header — and any future public listener (for example the +planned MCP integrations listener) must keep that same isolation. + +Public `POST /_external/*` routes bypass the identity gate and carry their +own authentication — webhook signature verification, and the notification +respond route's correlation token. `GET /api/auth/me` answers without a +resolved account so the dashboard can render onboarding in the `none`-mode +setup state. ## Personal access tokens @@ -63,12 +89,13 @@ personal access tokens minted in Settings → Agent access, sent as `druks_pat__`; Druks stores only the SHA-256 of the full token, shows the plaintext exactly once at mint, and expires it 365 days after creation. When the header is present it must authenticate — a bad -token is a 401, never a fall back to the session cookie — and token -management itself accepts the dashboard session only, so a leaked token -cannot mint or revoke tokens. On compromise, revoke the token in Settings → -Agent access (immediate; the list shows each token's prefix and last use, -tracked hourly, to identify it) and mint a replacement — rotation is mint -first, revoke second. Agents consume the API through the MCP endpoint; see +token is a 401, never a fall back to edge identity — and token management +itself accepts the edge-asserted or none-mode operator identity only and +refuses any `Authorization` header, so a leaked token cannot mint or revoke +tokens. On compromise, revoke the token in Settings → Agent access +(immediate; the list shows each token's prefix and last use, tracked hourly, +to identify it) and mint a replacement — rotation is mint first, revoke +second. Agents consume the API through the MCP endpoint; see [Connect your agent](connect-your-agent.md). ## GitHub Apps @@ -160,9 +187,12 @@ extension and edited in the dashboard, not environment variables. ## Harnesses Claude and Codex subscription credentials are connected from **Settings → -Harnesses**. The login flow stores each credential in Postgres; Druks refreshes -it on a schedule and synthesizes the CLI credential file inside each sandbox. -It does not copy a host login. +Harnesses**. The connect flow stores each credential in Postgres; Druks +refreshes it on a schedule and synthesizes the CLI credential file inside each +sandbox. It does not copy a host login. Connecting is a capability connect for +the requesting account — in a fresh `none`-mode install the first completed +connection also creates the operator account (see +[access control](#public-urls-and-access-control)). Process settings such as `DRUKS_CLAUDE_CONFIG_DIR` and `DRUKS_CODEX_CONFIG_DIR` point at optional non-auth CLI configuration to carry diff --git a/docs/development.md b/docs/development.md index d7e7037..248d9a8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -15,10 +15,12 @@ python3 -c 'import base64, os; print("DRUKS_SECRETS_KEY=" + base64.b64encode(os. uv run druks init-db ``` -Upgrading a dev database that already holds connected harnesses across the -accounts migration needs `DRUKS_DASHBOARD_EMAIL` exported for that one -`druks init-db` run — set it to the provider email you will sign in with once -account login lands. Fresh databases don't need it. +Local development runs `DRUKS_AUTH_MODE=none` (the `.env.example` default): +the loopback dashboard has no authentication and exactly one operator +account, created by your first harness connection. To exercise `header` mode +against the dev server, set `DRUKS_AUTH_MODE=header` and send the +`DRUKS_AUTH_HEADER` header yourself (for example with a browser header +extension or `curl -H 'X-ExeDev-Email: you@example.com'`). The dev Compose project creates two databases: diff --git a/docs/full-local.md b/docs/full-local.md index 78e5d1e..ffdc161 100644 --- a/docs/full-local.md +++ b/docs/full-local.md @@ -86,12 +86,12 @@ Success means the Compose services are up and the health endpoint returns Open **Settings → Harnesses** in the dashboard and connect Claude and Codex. Druks stores those subscription credentials in Postgres and writes a fresh credential file into each sandbox. It does not use host CLI login files. -That first connect is also the dashboard sign-in: harness login mints the -`druks_session` cookie every internal API requires — a local install has no -identity proxy in front, so the provider email is the account identity. -Protect database access and backups as credential-bearing data; unlike MCP -tokens and OAuth grants, harness payloads do not use the -`DRUKS_SECRETS_KEY` envelope. +The local profile runs `DRUKS_AUTH_MODE=none` — no browser authentication and +exactly one operator account. A fresh install shows onboarding until the +first harness connection completes; that connection creates the sole operator +account from the provider-verified email. Protect database access and backups +as credential-bearing data; unlike MCP tokens and OAuth grants, harness +payloads do not use the `DRUKS_SECRETS_KEY` envelope. Agent calls refuse before provisioning if their selected harness is not connected. `druks doctor` reports the connection and token expiry for every diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c81509a..663f2cc 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -66,24 +66,38 @@ The installer performs these steps in this order. ## The dashboard is inaccessible -The shipped remote edge admits any request whose configured -`DRUKS_AUTH_HEADER` carries a nonempty trusted identity; from there the app -itself asks you to connect a harness, which mints the session cookie. A -redirect to `__exe.dev/login` means no trusted identity header reached Caddy. -A dashboard that loads but immediately shows the connect screen means the -session cookie is missing or expired — sign in with Codex or Claude again -(Redis loss signs everyone out but never touches stored credentials). +The edge authenticates; Druks maps its asserted identity to an account. +Distinguish the failure by what you see: + +- **A redirect to `__exe.dev/login`** — no trusted identity header reached + Caddy; sign in at the edge. +- **A "couldn't resolve your identity" page (`header` mode 401)** — the + request reached Druks without exactly one nonblank `DRUKS_AUTH_HEADER` + value. Confirm the proxy injects the header Druks expects, and that + nothing between them drops or duplicates it. +- **Onboarding ("connect a harness to finish setup")** — identity resolved + but that account has no harness connection yet; in a fresh `none`-mode + install the first completed connection creates the operator account. + Ordinary API calls answer 409 until then. +- **A 503 (or refused startup) in `none` mode** — more than one non-system + account exists. Druks refuses to guess which is the operator; remove the + extras or switch to `header` mode. +- **Runs refusing to start** — identity is fine; the selected harness is not + connected (see below). + +Redis loss does not sign browsers out — there are no sessions. It only drops +in-flight connect attempts and other transient coordination. Check: ```bash -grep -E '^(DRUKS_AUTH_HEADER|DRUKS_UPSTREAM)=' ~/druks/.env +grep -E '^(DRUKS_AUTH_MODE|DRUKS_AUTH_HEADER|DRUKS_UPSTREAM)=' ~/druks/.env docker compose logs --tail=200 caddy web ``` The local `docker` profile intentionally skips Caddy; use -. It has no application login and should remain -loopback-only. +. It runs `DRUKS_AUTH_MODE=none` — no authentication — +and must remain loopback-only. ## Webhooks are not arriving diff --git a/docs/writing-an-extension.md b/docs/writing-an-extension.md index 5005093..a0b9033 100644 --- a/docs/writing-an-extension.md +++ b/docs/writing-an-extension.md @@ -136,8 +136,8 @@ attribution never changes that (two accounts starting the same subject share the one run). Wrap `start()` in a domain `dispatch()` method when the extension needs lookup, snapshot, or routing policy before launch. -A browser-origin start attributes itself: the session gate stamps the -request's signed-in account, and `start()` inherits it — a route that starts a +A browser-origin start attributes itself: the request identity gate stamps +the resolved account, and `start()` inherits it — a route that starts a workflow needs no ceremony. Pass `account_id` only when the dispatcher knows better (a webhook dispatch resolving the ticket assignee). Each agent call executes with the run's account's own connection, else the install's fallback diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index aa734fa..b8c7b78 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -1,6 +1,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { AUTH_EXPIRED_EVENT, UnauthorizedError, api, authApi, getJSON, postJSON } from './client' +import { + IDENTITY_INVALIDATED_EVENT, + UnauthorizedError, + api, + getJSON, + identityApi, + postJSON, +} from './client' function failWith(status: number, statusText: string, body: string) { vi.stubGlobal( @@ -45,21 +52,39 @@ describe('personal access tokens', () => { }) }) -describe('session identity', () => { - it('types a 401 and broadcasts the expiry', async () => { - failWith(401, 'Unauthorized', JSON.stringify({ error: 'HTTP_401', detail: 'Sign in.' })) - const expired = vi.fn() - window.addEventListener(AUTH_EXPIRED_EVENT, expired) +describe('request identity', () => { + it('reads /api/auth/me for the nested identity', async () => { + const identity = { + authMode: 'header', + account: { id: 'a1', username: 'me@example.com' }, + onboardingRequired: false, + } + const fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise>( + async () => new Response(JSON.stringify(identity), { status: 200 }), + ) + vi.stubGlobal('fetch', fetchMock) + + await expect(identityApi.me()).resolves.toEqual(identity) + expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/auth/me') + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ credentials: 'same-origin' }) + }) + + it('types a 401 and broadcasts the invalidation', async () => { + failWith(401, 'Unauthorized', JSON.stringify({ error: 'HTTP_401', detail: 'No identity.' })) + const invalidated = vi.fn() + window.addEventListener(IDENTITY_INVALIDATED_EVENT, invalidated) try { await expect(getJSON('/api/x')).rejects.toBeInstanceOf(UnauthorizedError) - expect(expired).toHaveBeenCalledTimes(1) + expect(invalidated).toHaveBeenCalledTimes(1) } finally { - window.removeEventListener(AUTH_EXPIRED_EVENT, expired) + window.removeEventListener(IDENTITY_INVALIDATED_EVENT, invalidated) } }) - it('reads a dead session as null without broadcasting noise elsewhere', async () => { - failWith(401, 'Unauthorized', JSON.stringify({ error: 'HTTP_401', detail: 'Sign in.' })) - await expect(authApi.session()).resolves.toBeNull() + it('an unresolved identity rejects instead of reading as a signed-out null', async () => { + // The bootstrap must see the failure and show an edge error — a 401 is + // never converted into onboarding. + failWith(401, 'Unauthorized', JSON.stringify({ error: 'HTTP_401', detail: 'No identity.' })) + await expect(identityApi.me()).rejects.toBeInstanceOf(UnauthorizedError) }) }) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 88852fd..7c714c3 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2,11 +2,12 @@ import type { Account, AgentCallFiles, ArtifactContent, + ConnectChallenge, DashboardHealth, FeedResponse, ExtensionsSettingsResponse, Harness, - LoginChallenge, + Identity, Pat, SubjectResponse, SubjectSummary, @@ -23,11 +24,12 @@ import type { UserSettings, } from './types' -// A 401 means the session is gone: typed to branch on, broadcast so the -// AuthProvider unmounts the app. +// A 401 means the request's identity did not resolve: typed to branch on, +// broadcast so the IdentityBootstrap rechecks /api/auth/me — never converted +// into onboarding. export class UnauthorizedError extends Error {} -export const AUTH_EXPIRED_EVENT = 'druks:auth-expired' +export const IDENTITY_INVALIDATED_EVENT = 'druks:identity-invalidated' // FastAPI puts the human-readable message in ``detail``; throw that as the // Error message so consumers display it as-is. Non-JSON bodies (proxy pages, @@ -45,7 +47,7 @@ async function throwApiError(response: Response, path: string): Promise { ? detail : `${response.status} ${response.statusText}: ${body || path}` if (response.status === 401) { - window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT)) + window.dispatchEvent(new Event(IDENTITY_INVALIDATED_EVENT)) throw new UnauthorizedError(message) } throw new Error(message) @@ -122,21 +124,11 @@ export async function postNoContent(path: string, body: unknown): Promise } } -// Harness login mints the session; landing and Settings reconnect share it. -export const authApi = { - session: (): Promise => - getJSON('/api/auth/session').catch((error) => { - if (error instanceof UnauthorizedError) return null - throw error - }), - startLogin: (name: string) => - postJSON(`/api/auth/harnesses/${encodeURIComponent(name)}/login/start`, {}), - completeLogin: (name: string, code: string, loginId: string) => - postJSON(`/api/auth/harnesses/${encodeURIComponent(name)}/login/complete`, { - code, - loginId, - }), - logout: () => postNoContent('/api/auth/logout', {}), +// The edge (or none-mode locality) asserts identity; /api/auth/me is the one +// bootstrap read. A 401 here rejects — the bootstrap shows an identity error, +// never onboarding. +export const identityApi = { + me: () => getJSON('/api/auth/me'), } // The generic subject read-side every extension gets for free at @@ -181,8 +173,17 @@ export const api = { harnesses: () => getJSON('/api/settings/harnesses'), updateHarness: (name: string, body: UpdateHarnessRequest) => patchJSON(`/api/settings/harnesses/${encodeURIComponent(name)}`, body), + // The harness connection flow — the capability connect (and, during setup, + // what creates the operator account). + startHarnessConnect: (name: string) => + postJSON(`/api/harnesses/${encodeURIComponent(name)}/connection/start`, {}), + completeHarnessConnect: (name: string, code: string, connectionId: string) => + postJSON(`/api/harnesses/${encodeURIComponent(name)}/connection/complete`, { + code, + connectionId, + }), disconnectHarness: (name: string) => - deleteJSON(`/api/settings/harnesses/${encodeURIComponent(name)}/login`), + deleteJSON(`/api/harnesses/${encodeURIComponent(name)}/connection`), getExtensionSettings: () => getJSON('/api/settings/extensions'), updateExtensionSettings: (body: UpdateExtensionsSettingsRequest) => patchJSON('/api/settings/extensions', body), @@ -204,8 +205,8 @@ export const api = { ), // Personal access tokens — the agent door to this same API. The plaintext - // comes back once, on mint; list rows carry the prefix only. Management is - // session-only, so these calls always ride the cookie. + // comes back once, on mint; list rows carry the prefix only. Management + // admits the edge/none operator identity alone — never a bearer token. pats: () => getJSON('/api/auth/personal-tokens'), createPat: (name: string) => postJSON<{ token: string }>('/api/auth/personal-tokens', { name }), revokePat: (id: string) => diff --git a/frontend/src/api/sse.test.tsx b/frontend/src/api/sse.test.tsx index 0bfd05c..bab2f25 100644 --- a/frontend/src/api/sse.test.tsx +++ b/frontend/src/api/sse.test.tsx @@ -44,7 +44,13 @@ class FakeEventSource implements EventTarget { } } -function stubSession(body: string, status: number) { +const LIVE_IDENTITY = { + authMode: 'none', + account: { id: 'a1', username: 'me@example.com' }, + onboardingRequired: false, +} + +function stubIdentity(body: string, status: number) { const fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise>( async () => new Response(body, { status }), ) @@ -55,8 +61,8 @@ function stubSession(body: string, status: number) { beforeEach(() => { FakeEventSource.instances = [] vi.stubGlobal('EventSource', FakeEventSource) - // Every SSE error rechecks the session; default to a live one. - stubSession(JSON.stringify({ id: 'a1', username: 'me@example.com' }), 200) + // Every SSE error rechecks identity; default to a live one. + stubIdentity(JSON.stringify(LIVE_IDENTITY), 200) }) afterEach(() => { @@ -217,8 +223,8 @@ describe('useSSE', () => { expect(onError).toHaveBeenCalledTimes(1) }) - it('rechecks the session on error and stays open while it is live', async () => { - const fetchMock = stubSession(JSON.stringify({ id: 'a1', username: 'me@example.com' }), 200) + it('rechecks identity on error and stays open while the account remains valid', async () => { + const fetchMock = stubIdentity(JSON.stringify(LIVE_IDENTITY), 200) render() act(() => { @@ -227,14 +233,30 @@ describe('useSSE', () => { await flushMicrotasks() expect(fetchMock).toHaveBeenCalledWith( - '/api/auth/session', + '/api/auth/me', expect.objectContaining({ credentials: 'same-origin' }), ) + // The EventSource keeps its automatic reconnect. expect(FakeEventSource.instances[0]?.closed).toBe(false) }) - it('closes the source when the session died — no blind reconnects', async () => { - stubSession(JSON.stringify({ error: 'HTTP_401', detail: 'Sign in.' }), 401) + it('closes the source when identity cannot be resolved — no blind reconnects', async () => { + stubIdentity(JSON.stringify({ error: 'HTTP_401', detail: 'No identity.' }), 401) + render() + + act(() => { + FakeEventSource.instances[0]?.emitError() + }) + await flushMicrotasks() + + expect(FakeEventSource.instances[0]?.closed).toBe(true) + }) + + it('closes the source when identity resolves to the setup state', async () => { + stubIdentity( + JSON.stringify({ authMode: 'none', account: null, onboardingRequired: true }), + 200, + ) render() act(() => { diff --git a/frontend/src/api/sse.ts b/frontend/src/api/sse.ts index 57a5d94..9522492 100644 --- a/frontend/src/api/sse.ts +++ b/frontend/src/api/sse.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' -import { authApi } from './client' +import { identityApi } from './client' type Handler = (data: unknown) => void @@ -64,15 +64,16 @@ export function useSSE(url: string, { handlers, onError, enabled = true }: UseSS const errorListener: EventListener = (event) => { onErrorRef.current?.(event) - // An SSE error may be an expired session: recheck instead of letting - // EventSource reconnect forever; the recheck's 401 signals the - // AuthProvider. - void authApi - .session() - .then((account) => { - if (!account) source.close() + // An SSE error may be a dead identity: recheck /api/auth/me and close + // only when identity cannot be resolved (the recheck's 401 broadcasts + // to the IdentityBootstrap). While the same account remains valid the + // EventSource keeps its automatic reconnect. + void identityApi + .me() + .then((identity) => { + if (!identity.account) source.close() }) - .catch(() => undefined) + .catch(() => source.close()) } source.addEventListener('error', errorListener) diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 14516fe..1c52999 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -221,11 +221,20 @@ export interface Account { username: string } -export interface LoginChallenge { +/** What /api/auth/me answers: how this deployment authenticates, who the + * request resolved to (null in the none/zero setup state), and whether that + * identity still needs its first harness connection. */ +export interface Identity { + authMode: 'none' | 'header' + account: Account | null + onboardingRequired: boolean +} + +export interface ConnectChallenge { authorizeUrl: string /** Opaque id of this connect attempt; passed back on complete so - * concurrent sign-ins never clobber each other's pending state. */ - loginId: string + * concurrent connects never clobber each other's pending state. */ + connectionId: string } export interface UpdateHarnessRequest { diff --git a/frontend/src/components/AuthProvider.test.tsx b/frontend/src/components/AuthProvider.test.tsx deleted file mode 100644 index ba28d29..0000000 --- a/frontend/src/components/AuthProvider.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { act, cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { AUTH_EXPIRED_EVENT } from '../api/client' -import { AuthProvider } from './AuthProvider' - -function stubFetch(status: number, body: unknown) { - const fetchMock = vi.fn(async () => new Response(JSON.stringify(body), { status })) - vi.stubGlobal('fetch', fetchMock) - return fetchMock -} - -afterEach(() => { - cleanup() - vi.unstubAllGlobals() -}) - -async function flush() { - await act(async () => { - await Promise.resolve() - }) -} - -describe('AuthProvider', () => { - it('mounts the app only once the session resolves', async () => { - stubFetch(200, { id: 'a1', username: 'me@example.com' }) - render( - -
- , - ) - // Loading: neither the app nor the landing shows yet. - expect(screen.queryByTestId('app')).toBeNull() - await flush() - expect(screen.getByTestId('app')).toBeTruthy() - }) - - it('shows the landing when there is no session', async () => { - stubFetch(401, { error: 'HTTP_401', detail: 'Sign in.' }) - render( - -
- , - ) - await flush() - expect(screen.queryByTestId('app')).toBeNull() - expect(screen.getByText('Connect Codex')).toBeTruthy() - expect(screen.getByText('Connect Claude')).toBeTruthy() - }) - - it('a broadcast 401 unmounts the app back to the landing', async () => { - stubFetch(200, { id: 'a1', username: 'me@example.com' }) - render( - -
- , - ) - await flush() - expect(screen.getByTestId('app')).toBeTruthy() - - act(() => { - window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT)) - }) - - expect(screen.queryByTestId('app')).toBeNull() - expect(screen.getByText('Connect Codex')).toBeTruthy() - }) -}) diff --git a/frontend/src/components/AuthProvider.tsx b/frontend/src/components/AuthProvider.tsx deleted file mode 100644 index 1cfe018..0000000 --- a/frontend/src/components/AuthProvider.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useState, - type ReactNode, -} from 'react' - -import { AUTH_EXPIRED_EVENT, authApi } from '../api/client' -import type { Account } from '../api/types' -import { Landing } from './Landing' - -interface AuthContextValue { - account: Account - signOut: () => Promise -} - -const AuthContext = createContext(null) - -// eslint-disable-next-line react-refresh/only-export-components -- hook co-located with its context -export function useAuth(): AuthContextValue { - const value = useContext(AuthContext) - if (!value) throw new Error('useAuth must be used inside AuthProvider') - return value -} - -// Only an authenticated state mounts the app's queries and EventSources; -// any 401 unmounts them, closing every stream. -export function AuthProvider({ children }: { children: ReactNode }) { - const [account, setAccount] = useState(null) - const [loading, setLoading] = useState(true) - - useEffect(() => { - let cancelled = false - void authApi - .session() - .then((current) => { - if (!cancelled) setAccount(current) - }) - .catch(() => { - // Network trouble reads as signed out; the landing retries via login. - if (!cancelled) setAccount(null) - }) - .finally(() => { - if (!cancelled) setLoading(false) - }) - return () => { - cancelled = true - } - }, []) - - useEffect(() => { - const expire = () => setAccount(null) - window.addEventListener(AUTH_EXPIRED_EVENT, expire) - return () => window.removeEventListener(AUTH_EXPIRED_EVENT, expire) - }, []) - - const signOut = useCallback(async () => { - await authApi.logout().catch(() => undefined) - setAccount(null) - }, []) - - if (loading) return null - if (!account) return - return {children} -} diff --git a/frontend/src/components/AuthedApp.tsx b/frontend/src/components/AuthedApp.tsx index 8b83213..f3734c9 100644 --- a/frontend/src/components/AuthedApp.tsx +++ b/frontend/src/components/AuthedApp.tsx @@ -2,11 +2,16 @@ import { useState } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { App } from '../App' +import type { Account } from '../api/types' import { UserPreferencesProvider } from '../lib/preferences' -// The query cache is scoped to this mount: logout/expiry unmounts it, so +// The query cache is keyed to the account: an identity change remounts it, so // nothing cached for one account can render for the next. -export function AuthedApp() { +export function AuthedApp({ account }: { account: Account }) { + return +} + +function QueryCacheMount() { const [queryClient] = useState( () => new QueryClient({ diff --git a/frontend/src/components/HarnessConnect.test.tsx b/frontend/src/components/HarnessConnect.test.tsx index cd8371d..43ade7c 100644 --- a/frontend/src/components/HarnessConnect.test.tsx +++ b/frontend/src/components/HarnessConnect.test.tsx @@ -25,11 +25,13 @@ function harness(overrides: Partial = {}): Harness { function renderCard(value: Harness) { const queryClient = new QueryClient() - return render( + const invalidate = vi.spyOn(queryClient, 'invalidateQueries') + const view = render( , ) + return { view, invalidate } } afterEach(() => { @@ -44,16 +46,19 @@ async function flush() { } describe('HarnessConnect', () => { - it('shows the signed-in identity', () => { + it('shows the connected account identity', () => { renderCard(harness({ connected: true, account: 'ops@corp.com' })) expect(screen.getByText('connected · ops@corp.com')).toBeTruthy() expect(screen.getByText('Reconnect')).toBeTruthy() }) - it('drives the /api/auth connect flow end to end', async () => { + it('drives the connection flow end to end and refreshes only the harness query', async () => { const responses: Record = { - '/api/auth/harnesses/claude/login/start': { authorizeUrl: 'https://x/auth', loginId: 'L1' }, - '/api/auth/harnesses/claude/login/complete': { id: 'a1', username: 'me@example.com' }, + '/api/harnesses/claude/connection/start': { + authorizeUrl: 'https://x/auth', + connectionId: 'C1', + }, + '/api/harnesses/claude/connection/complete': { id: 'a1', username: 'me@example.com' }, } const fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise>( async (url) => { @@ -63,11 +68,11 @@ describe('HarnessConnect', () => { ) vi.stubGlobal('fetch', fetchMock) - renderCard(harness()) + const { invalidate } = renderCard(harness()) fireEvent.click(screen.getByText('Connect')) await flush() - expect(screen.getByText('Open the sign-in page')).toBeTruthy() + expect(screen.getByText('Open the authorization page')).toBeTruthy() fireEvent.change(screen.getByPlaceholderText('Paste the code or redirect URL'), { target: { value: 'the-code' }, }) @@ -75,12 +80,15 @@ describe('HarnessConnect', () => { await flush() const completeCall = fetchMock.mock.calls.find( - ([url]) => url === '/api/auth/harnesses/claude/login/complete', + ([url]) => url === '/api/harnesses/claude/connection/complete', ) expect(JSON.parse(String(completeCall?.[1]?.body))).toEqual({ code: 'the-code', - loginId: 'L1', + connectionId: 'C1', }) + // Completion refreshes the harness card — and only that: the browser's + // own identity is untouched, so /api/auth/me is never rechecked. + expect(invalidate).toHaveBeenCalledWith({ queryKey: ['harnesses'] }) + expect(fetchMock.mock.calls.some(([url]) => String(url) === '/api/auth/me')).toBe(false) }) - }) diff --git a/frontend/src/components/HarnessLogin.tsx b/frontend/src/components/HarnessConnectFlow.tsx similarity index 74% rename from frontend/src/components/HarnessLogin.tsx rename to frontend/src/components/HarnessConnectFlow.tsx index 4585291..06fc9be 100644 --- a/frontend/src/components/HarnessLogin.tsx +++ b/frontend/src/components/HarnessConnectFlow.tsx @@ -1,12 +1,15 @@ import { useState } from 'react' -import { authApi } from '../api/client' -import type { Account, LoginChallenge } from '../api/types' +import { api } from '../api/client' +import type { Account, ConnectChallenge } from '../api/types' -// The one PKCE paste-back flow, shared by the landing screen and Settings. +// The one PKCE paste-back connect flow, shared by onboarding and Settings. // eslint-disable-next-line react-refresh/only-export-components -- hook co-located with its steps UI -export function useHarnessLogin(name: string, onDone: (account: Account) => void | Promise) { - const [challenge, setChallenge] = useState(null) +export function useHarnessConnect( + name: string, + onDone: (account: Account) => void | Promise, +) { + const [challenge, setChallenge] = useState(null) const [code, setCode] = useState('') const [busy, setBusy] = useState(false) const [error, setError] = useState(null) @@ -23,12 +26,12 @@ export function useHarnessLogin(name: string, onDone: (account: Account) => void } } - const start = () => run(async () => setChallenge(await authApi.startLogin(name))) + const start = () => run(async () => setChallenge(await api.startHarnessConnect(name))) const finish = () => run(async () => { if (!challenge) return - const account = await authApi.completeLogin(name, code.trim(), challenge.loginId) + const account = await api.completeHarnessConnect(name, code.trim(), challenge.connectionId) setChallenge(null) setCode('') await onDone(account) @@ -43,10 +46,10 @@ export function useHarnessLogin(name: string, onDone: (account: Account) => void return { challenge, code, setCode, busy, error, start, finish, cancel } } -export function LoginSteps({ +export function ConnectSteps({ flow, }: { - flow: ReturnType + flow: ReturnType }) { if (!flow.challenge) return null return ( @@ -54,7 +57,7 @@ export function LoginSteps({
1 - Open the sign-in page + Open the authorization page , approve, then copy the code it shows (or the redirect URL).
diff --git a/frontend/src/components/IdentityBootstrap.test.tsx b/frontend/src/components/IdentityBootstrap.test.tsx new file mode 100644 index 0000000..607e2c6 --- /dev/null +++ b/frontend/src/components/IdentityBootstrap.test.tsx @@ -0,0 +1,136 @@ +import { useQueryClient } from '@tanstack/react-query' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { Identity } from '../api/types' +import { AuthedApp } from './AuthedApp' +import { IdentityBootstrap } from './IdentityBootstrap' + +// AuthedApp mounts the whole dashboard; a probe recording its query client is +// enough to observe the cache mount being replaced. +const queryClients: unknown[] = [] +vi.mock('../App', () => ({ + App: () => { + queryClients.push(useQueryClient()) + return
+ }, +})) + +function stubRoutes(routes: Record { status: number; body: unknown }>) { + const fetchMock = vi.fn(async (url: string | URL | Request) => { + const route = routes[String(url)] + if (!route) return new Response('{}', { status: 404 }) + const { status, body } = route() + return new Response(JSON.stringify(body), { status }) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +function identity(overrides: Partial = {}): Identity { + return { + authMode: 'none', + account: { id: 'a1', username: 'me@example.com' }, + onboardingRequired: false, + ...overrides, + } +} + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() + queryClients.length = 0 +}) + +async function flush() { + await act(async () => { + await Promise.resolve() + }) +} + +function renderBootstrap() { + return render( + + {(account) =>
} + , + ) +} + +describe('IdentityBootstrap', () => { + it('mounts the app only once identity resolves', async () => { + stubRoutes({ '/api/auth/me': () => ({ status: 200, body: identity() }) }) + renderBootstrap() + // Loading: neither the app nor onboarding shows yet. + expect(screen.queryByTestId('app')).toBeNull() + await flush() + expect(screen.getByTestId('app').dataset.account).toBe('me@example.com') + }) + + it('a setup-state identity renders onboarding', async () => { + stubRoutes({ + '/api/auth/me': () => ({ + status: 200, + body: identity({ account: null, onboardingRequired: true }), + }), + }) + renderBootstrap() + await flush() + expect(screen.queryByTestId('app')).toBeNull() + expect(screen.getByText('Connect a harness to finish setup')).toBeTruthy() + }) + + it('completing onboarding remounts with the created account', async () => { + let me = identity({ account: null, onboardingRequired: true }) + stubRoutes({ + '/api/auth/me': () => ({ status: 200, body: me }), + '/api/harnesses/claude/connection/start': () => ({ + status: 200, + body: { authorizeUrl: 'https://x/auth', connectionId: 'C1' }, + }), + '/api/harnesses/claude/connection/complete': () => { + // The completed connection created the operator; /me now resolves it. + me = identity() + return { status: 200, body: { id: 'a1', username: 'me@example.com' } } + }, + }) + renderBootstrap() + await flush() + + fireEvent.click(screen.getByText('Connect Claude')) + await flush() + fireEvent.change(screen.getByPlaceholderText('Paste the code or redirect URL'), { + target: { value: 'the-code' }, + }) + fireEvent.click(screen.getByText('Finish')) + await flush() + + expect(screen.getByTestId('app').dataset.account).toBe('me@example.com') + }) + + it('a failed identity resolution shows the error panel, never onboarding', async () => { + stubRoutes({ + '/api/auth/me': () => ({ + status: 401, + body: { error: 'HTTP_401', detail: 'The edge asserted no identity.' }, + }), + }) + renderBootstrap() + await flush() + expect(screen.queryByTestId('app')).toBeNull() + expect(screen.queryByText('Connect a harness to finish setup')).toBeNull() + expect(screen.getByText("Couldn't resolve your identity")).toBeTruthy() + expect(screen.getByText('The edge asserted no identity.')).toBeTruthy() + }) + + it('an account-id change replaces the query-cache mount', async () => { + stubRoutes({}) + const first = { id: 'a1', username: 'one@example.com' } + const second = { id: 'a2', username: 'two@example.com' } + const { rerender } = render() + rerender() + // Same account: the mount (and its QueryClient) survives re-renders. + expect(new Set(queryClients).size).toBe(1) + rerender() + expect(new Set(queryClients).size).toBe(2) + }) +}) diff --git a/frontend/src/components/IdentityBootstrap.tsx b/frontend/src/components/IdentityBootstrap.tsx new file mode 100644 index 0000000..6f94f45 --- /dev/null +++ b/frontend/src/components/IdentityBootstrap.tsx @@ -0,0 +1,89 @@ +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' + +import { IDENTITY_INVALIDATED_EVENT, identityApi } from '../api/client' +import type { Account, Identity } from '../api/types' +import { Onboarding } from './Onboarding' + +type Phase = + | { kind: 'loading' } + | { kind: 'ready'; identity: Identity } + | { kind: 'error'; message: string } + +// Resolves who this browser is — the edge (or none-mode locality) asserts +// identity; druks maps it — then mounts the app, or onboarding while the +// identity has no harness connection yet. A failed resolution is an edge, +// configuration, or network problem, never an invitation to onboard. +export function IdentityBootstrap({ + children, +}: { + children: (account: Account) => ReactNode +}) { + const [phase, setPhase] = useState({ kind: 'loading' }) + // One probe at a time: the probe's own 401 broadcasts the invalidation + // event like any API call, and an un-deduped listener would loop on it. + const checking = useRef(false) + + const check = useCallback(() => { + if (checking.current) return + checking.current = true + identityApi + .me() + .then( + (identity) => setPhase({ kind: 'ready', identity }), + (e: unknown) => + setPhase({ kind: 'error', message: e instanceof Error ? e.message : String(e) }), + ) + .finally(() => { + checking.current = false + }) + }, []) + + useEffect(() => { + check() + }, [check]) + + useEffect(() => { + // Any API 401 broadcasts here: recheck instead of tearing down — a live + // identity keeps the app; a dead one lands on the error panel. + window.addEventListener(IDENTITY_INVALIDATED_EVENT, check) + return () => window.removeEventListener(IDENTITY_INVALIDATED_EVENT, check) + }, [check]) + + if (phase.kind === 'loading') return null + if (phase.kind === 'error') return + const { account, onboardingRequired } = phase.identity + if (onboardingRequired || !account) { + // Completion rechecks /me: the connection (and in none/zero the account + // itself) now exists, so the app mounts under the resolved identity. + return void check()} /> + } + return children(account) +} + +function IdentityError({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( +
+
+
+ druks. +
+
+

Couldn't resolve your identity

+

+ druks expects the edge in front of it (or a local none-mode install) to say who you + are, and that answer didn't arrive. Check the identity proxy, the + DRUKS_AUTH_MODE/DRUKS_AUTH_HEADER configuration, or your + network, then retry. +

+
+
+ ! + {message} +
+ +
+
+ ) +} diff --git a/frontend/src/components/Landing.test.tsx b/frontend/src/components/Landing.test.tsx deleted file mode 100644 index 00ce92d..0000000 --- a/frontend/src/components/Landing.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { Landing } from './Landing' - -afterEach(() => { - cleanup() - vi.unstubAllGlobals() -}) - -async function flush() { - await act(async () => { - await Promise.resolve() - }) -} - -describe('Landing', () => { - it('an active flow takes over the stage; cancel restores the harness cards', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => - new Response(JSON.stringify({ authorizeUrl: 'https://x/auth', loginId: 'L1' }), { - status: 200, - }), - ), - ) - - render( undefined} />) - fireEvent.click(screen.getByText('Connect Codex')) - await flush() - - // The challenge panel replaces both cards, not just its own. - expect(screen.getByText('Open the sign-in page')).toBeTruthy() - expect(screen.queryByText('Connect Claude')).toBeNull() - - fireEvent.click(screen.getByText('Cancel')) - expect(screen.queryByText('Open the sign-in page')).toBeNull() - expect(screen.getByText('Connect Claude')).toBeTruthy() - }) -}) diff --git a/frontend/src/components/Onboarding.test.tsx b/frontend/src/components/Onboarding.test.tsx new file mode 100644 index 0000000..be1e805 --- /dev/null +++ b/frontend/src/components/Onboarding.test.tsx @@ -0,0 +1,48 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { Onboarding } from './Onboarding' + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +async function flush() { + await act(async () => { + await Promise.resolve() + }) +} + +describe('Onboarding', () => { + it('frames the door as finishing setup, never as signing in', () => { + render( undefined} />) + expect(screen.getByText('Connect a harness to finish setup')).toBeTruthy() + expect(screen.queryByText(/sign in/i)).toBeNull() + }) + + it('an active flow takes over the stage; cancel restores the harness cards', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string | URL | Request) => { + expect(String(url)).toBe('/api/harnesses/codex/connection/start') + return new Response( + JSON.stringify({ authorizeUrl: 'https://x/auth', connectionId: 'C1' }), + { status: 200 }, + ) + }), + ) + + render( undefined} />) + fireEvent.click(screen.getByText('Connect Codex')) + await flush() + + // The challenge panel replaces both cards, not just its own. + expect(screen.getByText('Open the authorization page')).toBeTruthy() + expect(screen.queryByText('Connect Claude')).toBeNull() + + fireEvent.click(screen.getByText('Cancel')) + expect(screen.queryByText('Open the authorization page')).toBeNull() + expect(screen.getByText('Connect Claude')).toBeTruthy() + }) +}) diff --git a/frontend/src/components/Landing.tsx b/frontend/src/components/Onboarding.tsx similarity index 68% rename from frontend/src/components/Landing.tsx rename to frontend/src/components/Onboarding.tsx index 6aa21c5..71c1369 100644 --- a/frontend/src/components/Landing.tsx +++ b/frontend/src/components/Onboarding.tsx @@ -1,24 +1,26 @@ import type { CSSProperties } from 'react' -import { LoginSteps, useHarnessLogin } from './HarnessLogin' +import { ConnectSteps, useHarnessConnect } from './HarnessConnectFlow' import { harnessColors } from '../lib/harnessColors' import type { Account } from '../api/types' -type LandingEntry = { +type OnboardingEntry = { title: string mark: string fam: string - flow: ReturnType + flow: ReturnType } -// The unauthenticated door: connect a harness, get a session. -export function Landing({ onSignedIn }: { onSignedIn: (account: Account) => void }) { - const codex = useHarnessLogin('codex', onSignedIn) - const claude = useHarnessLogin('claude', onSignedIn) +// The setup door: the edge (or none-mode locality) already decided who you +// are — druks just needs its first harness connection. Works before any +// account exists (fresh none mode) and for a newly enrolled header identity. +export function Onboarding({ onConnected }: { onConnected: (account: Account) => void }) { + const codex = useHarnessConnect('codex', onConnected) + const claude = useHarnessConnect('claude', onConnected) // Accent slots follow registry enrolment order (claude, codex) so each - // harness keeps the colour it has everywhere in the signed-in app. + // harness keeps the colour it has everywhere in the app. const color = harnessColors(['claude', 'codex']) - const entries: LandingEntry[] = [ + const entries: OnboardingEntry[] = [ { title: 'Codex', mark: 'Cx', fam: color.codex!, flow: codex }, { title: 'Claude', mark: 'Cl', fam: color.claude!, flow: claude }, ] @@ -32,9 +34,9 @@ export function Landing({ onSignedIn }: { onSignedIn: (account: Account) => void
home for durable agent apps
-

Connect a harness to sign in

+

Connect a harness to finish setup

- druks runs agents on your own coding subscription. Connecting one signs you in. + druks runs agents on your own coding subscription. Connecting one finishes setup.

@@ -49,23 +51,23 @@ export function Landing({ onSignedIn }: { onSignedIn: (account: Account) => void ) } -function HarnessCard({ entry }: { entry: LandingEntry }) { +function HarnessCard({ entry }: { entry: OnboardingEntry }) { return (
- {entry.flow.error && } + {entry.flow.error && }
) } -function ConnectPanel({ entry }: { entry: LandingEntry }) { +function ConnectPanel({ entry }: { entry: OnboardingEntry }) { const { flow } = entry return (
@@ -79,8 +81,8 @@ function ConnectPanel({ entry }: { entry: LandingEntry }) {
{flow.challenge ? ( <> - - {flow.error && } + + {flow.error && } @@ -95,7 +97,7 @@ function ConnectPanel({ entry }: { entry: LandingEntry }) { ) } -function LandingError({ message }: { message: string }) { +function OnboardingError({ message }: { message: string }) { return (
! diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index b5931a1..3ab8955 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -3,7 +3,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { api } from '../api/client' import { ExtensionGlyph } from './ExtensionGlyph' -import { LoginSteps, useHarnessLogin } from './HarnessLogin' +import { ConnectSteps, useHarnessConnect } from './HarnessConnectFlow' import { type Harness, type ExtensionSettings, @@ -767,12 +767,13 @@ export function HarnessConnect({ harness }: { harness: Harness }) { const [error, setError] = useState(null) const refresh = () => queryClient.invalidateQueries({ queryKey: ['harnesses'] }) - const flow = useHarnessLogin(harness.name, async () => { + const flow = useHarnessConnect(harness.name, async () => { await refresh() }) const disconnect = () => { - if (!window.confirm(`Disconnect ${harness.name}? You'll need to sign in again to run it.`)) return + if (!window.confirm(`Disconnect ${harness.name}? Reconnect it before agents can run on it.`)) + return setBusy(true) setError(null) void api @@ -808,7 +809,7 @@ export function HarnessConnect({ harness }: { harness: Harness }) { )}
- + {(error ?? flow.error) &&
{error ?? flow.error}
}
) @@ -1462,8 +1463,8 @@ export function AgentAccessPane() {
Mint a personal access token for an agent to call this druks — sent as{' '} - Authorization: Bearer …, same account and authority as your session. Revoking a - token cuts its access immediately. + Authorization: Bearer …, same account and authority as your browser identity. + Revoking a token cuts its access immediately.
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index fcee3a1..ce5df06 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,7 +2,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { AuthedApp } from './components/AuthedApp' -import { AuthProvider } from './components/AuthProvider' +import { IdentityBootstrap } from './components/IdentityBootstrap' import './styles.css' const rootElement = document.getElementById('root') @@ -10,8 +10,6 @@ if (!rootElement) throw new Error('Root element #root not found') createRoot(rootElement).render( - - - + {(account) => } , ) diff --git a/frontend/src/styles.css b/frontend/src/styles.css index c8ba3b7..c7c5650 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2253,7 +2253,8 @@ input.set-select { background-image: none; padding-right: 12px; } .ins .wic-op-sub { display: inline-flex; align-items: center; gap: 7px; } .ins .wic-op-sub-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--text-faint); box-shadow: none; flex-shrink: 0; } -/* Landing — the unauthenticated door: connect a harness, get a session. */ +/* Landing shell — onboarding (connect a harness to finish setup) and the + identity-error panel share it. */ .landing { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 40px 20px; position: relative; overflow: hidden; } .landing::before { content: ""; position: absolute; inset: 0; background-image: linear-gradient(var(--border) 1px, transparent 1px), linear-gradient(90deg, var(--border) 1px, transparent 1px); background-size: 56px 56px; opacity: 0.28; mask-image: radial-gradient(120% 100% at 50% 42%, #000 0%, transparent 70%); } .landing::after { content: ""; position: absolute; top: -20%; left: 50%; width: 70%; height: 70%; background: radial-gradient(circle, color-mix(in oklch, var(--accent-violet) 12%, transparent), transparent 66%); filter: blur(24px); animation: landing-drift 16s ease-in-out infinite alternate; } From 1bae3b394843f77f0a3db9114605cbef3b54c05e Mon Sep 17 00:00:00 2001 From: Paulo Date: Tue, 21 Jul 2026 19:38:23 +0200 Subject: [PATCH 03/11] Keep SSE reconnect alive through transient identity rechecks Close the stream only when identity actually fails to resolve; drop the count helper the identity test can derive; pin that the session-era 404s and /me set no cookie. --- backend/druks/accounts/models.py | 7 +------ backend/tests/test_identity.py | 17 ++++++++++++----- frontend/src/api/sse.ts | 8 ++++++-- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/backend/druks/accounts/models.py b/backend/druks/accounts/models.py index ac7c57c..3ba8806 100644 --- a/backend/druks/accounts/models.py +++ b/backend/druks/accounts/models.py @@ -4,7 +4,7 @@ import secrets from datetime import datetime -from sqlalchemy import ForeignKey, Index, LargeBinary, String, func, select +from sqlalchemy import ForeignKey, Index, LargeBinary, String, select from sqlalchemy.dialects.postgresql import CITEXT, insert from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -66,11 +66,6 @@ def list_non_system(cls) -> list["Account"]: stmt = select(cls).where(cls.username != SYSTEM_ACCOUNT_ID).order_by(cls.created_at) return list(db_session().scalars(stmt)) - @classmethod - def count_non_system(cls) -> int: - stmt = select(func.count()).select_from(cls).where(cls.username != SYSTEM_ACCOUNT_ID) - return db_session().scalars(stmt).one() - def _hash_token(token: str) -> bytes: return hashlib.sha256(token.encode()).digest() diff --git a/backend/tests/test_identity.py b/backend/tests/test_identity.py index 353b1eb..20ad256 100644 --- a/backend/tests/test_identity.py +++ b/backend/tests/test_identity.py @@ -133,7 +133,7 @@ def test_get_or_create_losing_the_insert_race_still_converges(db_session, monkey # INSERT hits ON CONFLICT DO NOTHING, the canonical lookup converges. monkeypatch.setattr(Account, "get_for_username", classmethod(lambda cls, username: None)) assert Account.get_or_create("Race@example.com").id == existing.id - assert Account.count_non_system() == 1 + assert len(Account.list_non_system()) == 1 def test_a_valid_pat_wins_over_a_conflicting_header(tmp_path, db_session): @@ -336,7 +336,14 @@ def test_connection_flow_rejects_a_bearer(tmp_path, db_session): def test_the_session_era_routes_are_gone(tmp_path, db_session): with _client(tmp_path) as client: - assert client.get("/api/auth/session").status_code == 404 - assert client.post("/api/auth/logout").status_code == 404 - assert client.post("/api/auth/harnesses/claude/login/start").status_code == 404 - assert client.post("/api/auth/harnesses/claude/login/complete").status_code == 404 + gone = [ + client.get("/api/auth/session"), + client.post("/api/auth/logout"), + client.post("/api/auth/harnesses/claude/login/start"), + client.post("/api/auth/harnesses/claude/login/complete"), + ] + for response in gone: + assert response.status_code == 404 + assert "set-cookie" not in response.headers + # The identity read never mints a cookie either. + assert "set-cookie" not in client.get("/api/auth/me").headers diff --git a/frontend/src/api/sse.ts b/frontend/src/api/sse.ts index 9522492..d832061 100644 --- a/frontend/src/api/sse.ts +++ b/frontend/src/api/sse.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' -import { identityApi } from './client' +import { UnauthorizedError, identityApi } from './client' type Handler = (data: unknown) => void @@ -73,7 +73,11 @@ export function useSSE(url: string, { handlers, onError, enabled = true }: UseSS .then((identity) => { if (!identity.account) source.close() }) - .catch(() => source.close()) + .catch((error: unknown) => { + // Only a dead identity ends the stream; a transient recheck failure + // leaves EventSource's automatic reconnect running. + if (error instanceof UnauthorizedError) source.close() + }) } source.addEventListener('error', errorListener) From 1c17e10ccbea7f64d8c2b5a7aeccd709cf5f47cd Mon Sep 17 00:00:00 2001 From: Paulo Date: Tue, 21 Jul 2026 19:59:47 +0200 Subject: [PATCH 04/11] Close the adversarial-review findings on the connect flow A stale unbound completion attaches to the operator the request resolved instead of minting a rival none-mode account; the credential transaction commits before the provider await so flushed row locks never span network I/O; an identity recheck that resolves a different account broadcasts the invalidation so account-scoped UI remounts. --- backend/druks/harnesses/routes.py | 16 +++++++++++----- backend/tests/test_identity.py | 21 ++++++++++++++------- frontend/src/api/client.test.ts | 28 ++++++++++++++++++++++++++++ frontend/src/api/client.ts | 16 +++++++++++++++- 4 files changed, 68 insertions(+), 13 deletions(-) diff --git a/backend/druks/harnesses/routes.py b/backend/druks/harnesses/routes.py index 6758380..fb94099 100644 --- a/backend/druks/harnesses/routes.py +++ b/backend/druks/harnesses/routes.py @@ -3,6 +3,7 @@ from druks.accounts.dependencies import current_account, current_operator_or_setup from druks.accounts.models import Account from druks.accounts.schemas import AccountResponse +from druks.database import db_session from druks.harnesses.base import Harness from druks.harnesses.exceptions import ConnectError from druks.harnesses.models import HarnessConnection @@ -57,11 +58,13 @@ async def complete_connection( ) resolved = account else: - # The unbound none/zero setup flow: the provider-verified email becomes - # the operator. get_or_create is atomic, so concurrent completions of - # the same email converge on one account; different emails surface as - # the none-mode multi-operator refusal. - resolved = Account.get_or_create(completed.provider_email) + # The unbound setup flow attaches to the operator this request resolved + # — a flow started before the account existed still lands on it. Only a + # still-account-less request creates the operator from the + # provider-verified email; get_or_create is atomic, so concurrent + # completions of the same email converge, and a true different-email + # race surfaces as the none-mode multi-operator refusal. + resolved = account or Account.get_or_create(completed.provider_email) # Runs with no actor execute as the fallback account; claim the slot when # none is set yet. settings = UserSettings.get() @@ -74,6 +77,9 @@ async def complete_connection( expires_at=completed.expires_at, provider_email=completed.provider_email, ) + # Land the credential before any provider I/O — an await while flushed rows + # still hold their locks can stall every other writer on this event loop. + db_session().commit() # Fresh picker right after connect; failures are tagged inside, never raised. await HarnessSettings.require(harness.name).refresh_models(connection) return resolved diff --git a/backend/tests/test_identity.py b/backend/tests/test_identity.py index 20ad256..ff0f689 100644 --- a/backend/tests/test_identity.py +++ b/backend/tests/test_identity.py @@ -251,10 +251,12 @@ def test_concurrent_setup_completions_with_one_email_converge(tmp_path, monkeypa assert len(HarnessConnection.list_all()) == 2 -def test_setup_completions_with_different_emails_surface_the_drift( - tmp_path, monkeypatch, db_session -): +def test_a_stale_unbound_completion_attaches_to_the_operator(tmp_path, monkeypatch, db_session): with _client(tmp_path) as client: + # Both flows start while zero accounts exist; the first completion + # creates the operator, so the second — a different provider email — + # must attach to that operator instead of minting a rival account and + # bricking none mode. first = client.post("/api/harnesses/claude/connection/start") second = client.post("/api/harnesses/codex/connection/start") _mock_exchange(monkeypatch, _grant("a@example.com")) @@ -263,13 +265,18 @@ def test_setup_completions_with_different_emails_surface_the_drift( json={"code": "c1", "connectionId": first.json()["connectionId"]}, ) _mock_exchange_codex(monkeypatch, email="b@example.com") - client.post( + completed = client.post( "/api/harnesses/codex/connection/complete", json={"code": "c2", "connectionId": second.json()["connectionId"]}, ) - # Two operators under auth mode none is drift — every ordinary request - # now refuses loudly instead of guessing. - assert client.get("/api/settings").status_code == 503 + assert completed.status_code == 200 + assert completed.json()["username"] == "a@example.com" + assert client.get("/api/settings").status_code == 200 + operator = Account.get_for_username("a@example.com") + assert len(Account.list_non_system()) == 1 + codex_connection = HarnessConnection.get_for_account("codex", operator.id) + # The capability keeps its own provider identity; it never rekeys the account. + assert codex_connection.provider_email == "b@example.com" def test_a_bound_connect_cannot_complete_under_another_operator(tmp_path, monkeypatch, db_session): diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index b8c7b78..2bd6d2d 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -87,4 +87,32 @@ describe('request identity', () => { failWith(401, 'Unauthorized', JSON.stringify({ error: 'HTTP_401', detail: 'No identity.' })) await expect(identityApi.me()).rejects.toBeInstanceOf(UnauthorizedError) }) + + it('broadcasts when a recheck resolves a different account', async () => { + // The edge can switch who it asserts without any 401; the changed answer + // must remount account-scoped state, not stream on under the old mount. + const identityFor = (id: string) => ({ + authMode: 'header', + account: { id, username: `${id}@example.com` }, + onboardingRequired: false, + }) + let accountId = 'a1' + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify(identityFor(accountId)), { status: 200 })), + ) + await identityApi.me() + const invalidated = vi.fn() + window.addEventListener(IDENTITY_INVALIDATED_EVENT, invalidated) + try { + accountId = 'b2' + await identityApi.me() + expect(invalidated).toHaveBeenCalledTimes(1) + // Settled on b2: rechecking the same account broadcasts nothing. + await identityApi.me() + expect(invalidated).toHaveBeenCalledTimes(1) + } finally { + window.removeEventListener(IDENTITY_INVALIDATED_EVENT, invalidated) + } + }) }) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 7c714c3..70a33b7 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -127,8 +127,22 @@ export async function postNoContent(path: string, body: unknown): Promise // The edge (or none-mode locality) asserts identity; /api/auth/me is the one // bootstrap read. A 401 here rejects — the bootstrap shows an identity error, // never onboarding. +let lastAccountId: string | null = null + export const identityApi = { - me: () => getJSON('/api/auth/me'), + me: async (): Promise => { + const identity = await getJSON('/api/auth/me') + const accountId = identity.account?.id ?? null + // The edge can switch who it asserts without any 401 — a recheck that + // resolves a different account broadcasts so the bootstrap remounts every + // account-scoped surface instead of streaming as one identity while + // rendering another. + if (lastAccountId && accountId && accountId !== lastAccountId) { + window.dispatchEvent(new Event(IDENTITY_INVALIDATED_EVENT)) + } + lastAccountId = accountId + return identity + }, } // The generic subject read-side every extension gets for free at From 01384712887c3c7e571072121947bed3520fc40d Mon Sep 17 00:00:00 2001 From: Paulo Date: Tue, 21 Jul 2026 20:08:25 +0200 Subject: [PATCH 05/11] Make the post-commit model refresh best-effort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing past the credential commit may turn a durable connect into a client-visible failure — the single-use flow is already spent. Refresh trouble logs and rolls back its own transaction; the connect returns 200. --- backend/druks/harnesses/routes.py | 13 +++++++++++-- backend/tests/test_identity.py | 16 +++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/backend/druks/harnesses/routes.py b/backend/druks/harnesses/routes.py index fb94099..8dc9cc6 100644 --- a/backend/druks/harnesses/routes.py +++ b/backend/druks/harnesses/routes.py @@ -1,3 +1,5 @@ +import logging + from fastapi import APIRouter, Body, Depends, HTTPException from druks.accounts.dependencies import current_account, current_operator_or_setup @@ -80,8 +82,15 @@ async def complete_connection( # Land the credential before any provider I/O — an await while flushed rows # still hold their locks can stall every other writer on this event loop. db_session().commit() - # Fresh picker right after connect; failures are tagged inside, never raised. - await HarnessSettings.require(harness.name).refresh_models(connection) + try: + # Fresh picker right after connect; fetch failures are tagged inside. + # Nothing past the commit may turn the durable connect into a + # client-visible failure — the single-use flow is already spent — so + # persistence trouble here only logs. + await HarnessSettings.require(harness.name).refresh_models(connection) + except Exception: + logging.getLogger(__name__).exception("Model refresh after connect failed") + db_session().rollback() return resolved diff --git a/backend/tests/test_identity.py b/backend/tests/test_identity.py index ff0f689..65e4732 100644 --- a/backend/tests/test_identity.py +++ b/backend/tests/test_identity.py @@ -13,7 +13,7 @@ from druks.harnesses import base as hbase from druks.harnesses.claude import ClaudeHarness from druks.harnesses.models import HarnessConnection -from druks.user_settings.models import UserSettings +from druks.user_settings.models import HarnessSettings, UserSettings from fastapi.testclient import TestClient from sqlalchemy import select @@ -279,6 +279,20 @@ def test_a_stale_unbound_completion_attaches_to_the_operator(tmp_path, monkeypat assert codex_connection.provider_email == "b@example.com" +def test_a_connect_survives_a_failed_model_refresh(tmp_path, monkeypatch, db_session): + async def _refresh_boom(self, connection): + raise RuntimeError("picker flush failed") + + # The credential commits before the refresh runs; a refresh failure past + # that point must not turn the durable connect into a client-visible error. + monkeypatch.setattr(HarnessSettings, "refresh_models", _refresh_boom) + with _client(tmp_path) as client: + response = _connect(client, monkeypatch, email="me@example.com") + assert response.status_code == 200 + account = Account.get_for_username("me@example.com") + assert HarnessConnection.get_for_account("claude", account.id) + + def test_a_bound_connect_cannot_complete_under_another_operator(tmp_path, monkeypatch, db_session): with _header_client(tmp_path) as client: start = client.post( From 771d6aa4e25b28401fae53f57feb9b75f887b4f0 Mon Sep 17 00:00:00 2001 From: Paulo Date: Tue, 21 Jul 2026 20:13:45 +0200 Subject: [PATCH 06/11] Detach the connect reply from post-commit database reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Materialize the response DTO before the commit expires the row and keep rollback failures from escaping — after the point of durability the reply depends on nothing but data already in hand. --- backend/druks/harnesses/routes.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/backend/druks/harnesses/routes.py b/backend/druks/harnesses/routes.py index 8dc9cc6..766b2c9 100644 --- a/backend/druks/harnesses/routes.py +++ b/backend/druks/harnesses/routes.py @@ -1,4 +1,5 @@ import logging +from contextlib import suppress from fastapi import APIRouter, Body, Depends, HTTPException @@ -44,7 +45,7 @@ async def complete_connection( account: Account | None = Depends(current_operator_or_setup), code: str = Body(..., embed=True), connection_id: str = Body(..., embed=True, alias="connectionId"), -) -> Account: +) -> AccountResponse: harness = _resolve_harness(name) try: completed = await harness.connect_complete(flow_id=connection_id, pasted=code) @@ -79,19 +80,22 @@ async def complete_connection( expires_at=completed.expires_at, provider_email=completed.provider_email, ) - # Land the credential before any provider I/O — an await while flushed rows - # still hold their locks can stall every other writer on this event loop. + # Materialize the reply, then land the credential before any provider I/O — + # an await while flushed rows still hold their locks can stall every other + # writer on this event loop, and nothing past the point of durability may + # depend on another database read. + response = AccountResponse.model_validate(resolved) db_session().commit() try: # Fresh picker right after connect; fetch failures are tagged inside. - # Nothing past the commit may turn the durable connect into a - # client-visible failure — the single-use flow is already spent — so - # persistence trouble here only logs. + # The single-use flow is already spent, so trouble here — including a + # database that vanished under the refresh — only logs. await HarnessSettings.require(harness.name).refresh_models(connection) except Exception: logging.getLogger(__name__).exception("Model refresh after connect failed") - db_session().rollback() - return resolved + with suppress(Exception): + db_session().rollback() + return response @router.delete("/{name}/connection", response_model=HarnessResponse, response_model_by_alias=True) From 23c61defcbded1fd10ca066cc2d4efa62d8170cf Mon Sep 17 00:00:00 2001 From: Paulo Date: Tue, 21 Jul 2026 20:26:08 +0200 Subject: [PATCH 07/11] Add verified JWT edge assertions via JWKS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third auth mode: the edge signs its assertion and druks verifies it — RS256 against the published JWKS (five-minute key cache, refetch on rotation), pinned iss/aud/exp, identity claim mapped through the same open enrollment. The PAT slot is untouched; PyJWT[crypto] is the one new dependency. --- .env.example | 8 ++ backend/druks/accounts/dependencies.py | 25 ++-- backend/druks/accounts/exceptions.py | 6 + backend/druks/accounts/jwt.py | 44 +++++++ backend/druks/settings.py | 27 +++- backend/tests/test_identity_jwt.py | 166 +++++++++++++++++++++++++ backend/tests/test_settings.py | 14 +++ docs/configuration.md | 21 +++- docs/troubleshooting.md | 6 + pyproject.toml | 1 + uv.lock | 2 + 11 files changed, 306 insertions(+), 14 deletions(-) create mode 100644 backend/druks/accounts/jwt.py create mode 100644 backend/tests/test_identity_jwt.py diff --git a/.env.example b/.env.example index 17c8e32..acb33c3 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,14 @@ DRUKS_SECRETS_KEY= # identity edge, switch to header mode: # DRUKS_AUTH_MODE=header # DRUKS_AUTH_HEADER=X-ExeDev-Email +# Or, for an edge that signs its assertions (Teleport, Cloudflare Access), +# jwt mode verifies the token in DRUKS_AUTH_HEADER against the edge's JWKS: +# DRUKS_AUTH_MODE=jwt +# DRUKS_AUTH_HEADER=Teleport-Jwt-Assertion +# DRUKS_AUTH_JWKS_URL=https://teleport.example.com/.well-known/jwks.json +# DRUKS_AUTH_JWT_ISSUER=https://teleport.example.com +# DRUKS_AUTH_JWT_AUDIENCE=https://druks.example.com +# DRUKS_AUTH_JWT_IDENTITY_CLAIM=email DRUKS_AUTH_MODE=none # Webhook authentication and public ingress. DRUKS_WEBHOOK_HOST is used only diff --git a/backend/druks/accounts/dependencies.py b/backend/druks/accounts/dependencies.py index 9865a35..55a6c82 100644 --- a/backend/druks/accounts/dependencies.py +++ b/backend/druks/accounts/dependencies.py @@ -3,7 +3,12 @@ from fastapi import HTTPException, Request from druks.accounts.context import current_account_id -from druks.accounts.exceptions import AuthConfigurationError, InvalidPatError +from druks.accounts.exceptions import ( + AuthConfigurationError, + InvalidAssertionError, + InvalidPatError, +) +from druks.accounts.jwt import verify_assertion from druks.accounts.models import Account, PersonalAccessToken _BEARER_CHALLENGE = 'Bearer realm="druks"' @@ -46,9 +51,10 @@ def resolve_none_operator() -> Account | None: def _resolve_operator(request: Request) -> Account | None: """Edge/none operator identity for this request; None only in none/zero - (the setup state). Header mode is open enrollment — exactly one nonblank - asserted value, trimmed of outer whitespace, lookup-or-created as the - account (the edge gates who reaches druks at all; CITEXT handles case). + (the setup state). Both edge modes are open enrollment on exactly one + nonblank asserted value, trimmed of outer whitespace (the edge gates who + reaches druks at all; CITEXT handles case): header mode maps the value + itself, jwt mode maps the verified identity claim of the asserted token. ``none`` mode ignores any asserted header — there is no edge to trust.""" settings = request.app.state.settings if settings.auth_mode == "none": @@ -59,13 +65,18 @@ def _resolve_operator(request: Request) -> Account | None: status_code=401, detail=f"The edge must assert exactly one {settings.auth_header} identity.", ) - email = values[0].strip() - if not email: + asserted = values[0].strip() + if not asserted: raise HTTPException( status_code=401, detail=f"The edge asserted a blank {settings.auth_header} identity.", ) - return Account.get_or_create(email) + if settings.auth_mode == "header": + return Account.get_or_create(asserted) + try: + return Account.get_or_create(verify_assertion(asserted, settings)) + except InvalidAssertionError as error: + raise HTTPException(status_code=401, detail=str(error)) from error def _require_no_bearer(request: Request) -> None: diff --git a/backend/druks/accounts/exceptions.py b/backend/druks/accounts/exceptions.py index 798764f..7b852f1 100644 --- a/backend/druks/accounts/exceptions.py +++ b/backend/druks/accounts/exceptions.py @@ -7,3 +7,9 @@ class AuthConfigurationError(Exception): """The configured auth mode cannot resolve a single operator identity — e.g. ``none`` mode with more than one non-system account. Refuses the request (and startup) instead of guessing which account is the operator.""" + + +class InvalidAssertionError(Exception): + """An edge-minted JWT assertion that fails verification — bad signature, + wrong issuer or audience, expired, unknown signing key, or a missing + identity claim. The raw token never appears in the message.""" diff --git a/backend/druks/accounts/jwt.py b/backend/druks/accounts/jwt.py new file mode 100644 index 0000000..96a54c3 --- /dev/null +++ b/backend/druks/accounts/jwt.py @@ -0,0 +1,44 @@ +from functools import lru_cache + +import jwt + +from druks.accounts.exceptions import InvalidAssertionError +from druks.settings import Settings + +# The edge signs with an asymmetric key it publishes over JWKS; RS256 is the +# pinned profile — an untrusted token never chooses its own algorithm. +_ALGORITHMS = ["RS256"] +_JWKS_TIMEOUT_SECONDS = 5 + + +@lru_cache(maxsize=1) +def _jwks_client(url: str) -> jwt.PyJWKClient: + # One client per process: it caches the fetched key set (five-minute + # lifespan) and refetches on an unknown kid, which is the rotation story. + return jwt.PyJWKClient(url, cache_keys=True, timeout=_JWKS_TIMEOUT_SECONDS) + + +def verify_assertion(token: str, settings: Settings) -> str: + """The verified identity claim of an edge-minted assertion, else + InvalidAssertionError. Fails closed on every fetch, signature, claim, or + shape problem; only the failure class name reaches the message.""" + try: + signing_key = _jwks_client(settings.auth_jwks_url).get_signing_key_from_jwt(token) + claims = jwt.decode( + token, + signing_key.key, + algorithms=_ALGORITHMS, + issuer=settings.auth_jwt_issuer, + audience=settings.auth_jwt_audience, + options={"require": ["exp", "iss", "aud"]}, + ) + except jwt.PyJWTError as error: + raise InvalidAssertionError(f"Assertion rejected: {type(error).__name__}.") from error + claim = claims.get(settings.auth_jwt_identity_claim) + # Claims are external data: the identity must be a nonblank string, not + # whatever shape the token happened to carry. + if isinstance(claim, str) and claim.strip(): + return claim.strip() + raise InvalidAssertionError( + f"Assertion carries no usable {settings.auth_jwt_identity_claim} claim." + ) diff --git a/backend/druks/settings.py b/backend/druks/settings.py index ea83ee6..6fceb94 100644 --- a/backend/druks/settings.py +++ b/backend/druks/settings.py @@ -93,9 +93,15 @@ class Settings(BaseSettings): # loopback-only deployments with a single operator account. ``header``: the # edge (exe.dev, Teleport, Cloudflare Access, …) authenticates and asserts # the operator's email in ``auth_header``; druks maps it to an account. - # Bearer personal access tokens resolve first in either mode. - auth_mode: Literal["none", "header"] = Field(default="none", alias="DRUKS_AUTH_MODE") + # ``jwt``: the edge asserts a signed JWT in ``auth_header`` instead; druks + # verifies it against the JWKS below and maps its identity claim. Bearer + # personal access tokens resolve first in every mode. + auth_mode: Literal["none", "header", "jwt"] = Field(default="none", alias="DRUKS_AUTH_MODE") auth_header: str = Field(default="X-ExeDev-Email", alias="DRUKS_AUTH_HEADER") + auth_jwks_url: str = Field(default="", alias="DRUKS_AUTH_JWKS_URL") + auth_jwt_issuer: str = Field(default="", alias="DRUKS_AUTH_JWT_ISSUER") + auth_jwt_audience: str = Field(default="", alias="DRUKS_AUTH_JWT_AUDIENCE") + auth_jwt_identity_claim: str = Field(default="email", alias="DRUKS_AUTH_JWT_IDENTITY_CLAIM") webhook_secret: str = Field(default="", alias="DRUKS_WEBHOOK_SECRET") # Public hostname webhook senders POST to (Caddy serves it; see @@ -206,11 +212,22 @@ class Settings(BaseSettings): log_level: str = Field(default="INFO", alias="DRUKS_LOG_LEVEL") @model_validator(mode="after") - def _header_mode_names_its_header(self) -> "Settings": - if self.auth_mode == "header" and not self.auth_header.strip(): + def _auth_mode_is_fully_configured(self) -> "Settings": + if self.auth_mode != "none" and not self.auth_header.strip(): raise ValueError( - "DRUKS_AUTH_HEADER must name the edge's identity header when DRUKS_AUTH_MODE=header" + "DRUKS_AUTH_HEADER must name the edge's identity header " + f"when DRUKS_AUTH_MODE={self.auth_mode}" ) + if self.auth_mode == "jwt": + required = { + "DRUKS_AUTH_JWKS_URL": self.auth_jwks_url, + "DRUKS_AUTH_JWT_ISSUER": self.auth_jwt_issuer, + "DRUKS_AUTH_JWT_AUDIENCE": self.auth_jwt_audience, + "DRUKS_AUTH_JWT_IDENTITY_CLAIM": self.auth_jwt_identity_claim, + } + missing = [name for name, value in required.items() if not value.strip()] + if missing: + raise ValueError(f"DRUKS_AUTH_MODE=jwt requires {', '.join(missing)}") return self @property diff --git a/backend/tests/test_identity_jwt.py b/backend/tests/test_identity_jwt.py new file mode 100644 index 0000000..b2effc8 --- /dev/null +++ b/backend/tests/test_identity_jwt.py @@ -0,0 +1,166 @@ +import time +from pathlib import Path + +import druks.redis +import jwt as pyjwt +import pytest +from conftest import configure_app_for_test, make_settings +from cryptography.hazmat.primitives.asymmetric import rsa +from druks.accounts import jwt as assertion +from druks.accounts.models import Account, PersonalAccessToken +from fastapi.testclient import TestClient + +HEADER = "X-ExeDev-Email" +KID = "edge-key-1" +ROTATED_KID = "edge-key-2" +ISSUER = "https://edge.example.com" +AUDIENCE = "druks" + +_PRIVATE_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) +_ROTATED_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) +_FOREIGN_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +@pytest.fixture(autouse=True) +def _fresh_state(): + druks.redis.get_client()._data.clear() + assertion._jwks_client.cache_clear() + yield + assertion._jwks_client.cache_clear() + + +def _jwk(key, kid: str) -> dict: + public = pyjwt.algorithms.RSAAlgorithm.to_jwk(key.public_key(), as_dict=True) + return {**public, "kid": kid, "alg": "RS256", "use": "sig"} + + +def _serve_jwks(monkeypatch, *keys, fetches: list[int] | None = None): + document = {"keys": [_jwk(key, kid) for key, kid in keys]} + + def fetch_data(self): + if fetches is not None: + fetches.append(1) + return document + + monkeypatch.setattr(pyjwt.PyJWKClient, "fetch_data", fetch_data) + + +def _break_jwks(monkeypatch): + def fetch_data(self): + raise RuntimeError("jwks endpoint is down") + + monkeypatch.setattr(pyjwt.PyJWKClient, "fetch_data", fetch_data) + + +def _token(key=_PRIVATE_KEY, kid: str = KID, **claim_overrides) -> str: + claims = { + "iss": ISSUER, + "aud": AUDIENCE, + "exp": int(time.time()) + 600, + "email": "op@example.com", + **claim_overrides, + } + claims = {name: value for name, value in claims.items() if value is not None} + return pyjwt.encode(claims, key, algorithm="RS256", headers={"kid": kid}) + + +def _jwt_client(tmp_path: Path) -> TestClient: + app = configure_app_for_test( + settings=make_settings( + tmp_path, + auth_mode="jwt", + auth_jwks_url="https://edge.example.com/jwks.json", + auth_jwt_issuer=ISSUER, + auth_jwt_audience=AUDIENCE, + ), + authenticated=False, + ) + return TestClient(app) + + +def test_a_valid_assertion_open_enrolls_its_subject(tmp_path, db_session, monkeypatch): + _serve_jwks(monkeypatch, (_PRIVATE_KEY, KID)) + with _jwt_client(tmp_path) as client: + response = client.get("/api/auth/me", headers={HEADER: _token()}) + assert response.status_code == 200 + assert response.json()["account"]["username"] == "op@example.com" + other = client.get("/api/auth/me", headers={HEADER: _token(email="two@example.com")}) + assert other.status_code == 200 + usernames = {account.username for account in Account.list_non_system()} + assert usernames == {"op@example.com", "two@example.com"} + + +@pytest.mark.parametrize( + "token", + [ + _token(key=_FOREIGN_KEY), + _token(exp=int(time.time()) - 60), + _token(nbf=int(time.time()) + 600), + _token(iss="https://impostor.example.com"), + _token(aud="not-druks"), + _token(email=None), + _token(email={"nested": "never"}), + _token(kid="unknown-kid"), + ], +) +def test_a_bad_assertion_rejects_without_enrolling(tmp_path, db_session, monkeypatch, token): + _serve_jwks(monkeypatch, (_PRIVATE_KEY, KID)) + with _jwt_client(tmp_path) as client: + response = client.get("/api/auth/me", headers={HEADER: token}) + assert response.status_code == 401 + # Only the failure class reaches the caller — never token material. + assert token.split(".")[1] not in response.json()["detail"] + assert not Account.list_non_system() + + +def test_one_fetch_serves_every_kid_in_the_document(tmp_path, db_session, monkeypatch): + fetches: list[int] = [] + _serve_jwks(monkeypatch, (_PRIVATE_KEY, KID), (_ROTATED_KEY, ROTATED_KID), fetches=fetches) + with _jwt_client(tmp_path) as client: + assert client.get("/api/auth/me", headers={HEADER: _token()}).status_code == 200 + rotated = client.get( + "/api/auth/me", headers={HEADER: _token(key=_ROTATED_KEY, kid=ROTATED_KID)} + ) + assert rotated.status_code == 200 + assert len(fetches) == 1 + + +def test_cached_keys_serve_through_a_jwks_outage(tmp_path, db_session, monkeypatch): + _serve_jwks(monkeypatch, (_PRIVATE_KEY, KID)) + with _jwt_client(tmp_path) as client: + assert client.get("/api/auth/me", headers={HEADER: _token()}).status_code == 200 + _break_jwks(monkeypatch) + # The cached key set still verifies known kids; an unknown kid fails + # closed instead of enrolling anyone. + assert client.get("/api/auth/me", headers={HEADER: _token()}).status_code == 200 + unknown = client.get( + "/api/auth/me", headers={HEADER: _token(key=_ROTATED_KEY, kid=ROTATED_KID)} + ) + assert unknown.status_code == 401 + assert [account.username for account in Account.list_non_system()] == ["op@example.com"] + + +def test_bearer_precedence_survives_jwt_mode(tmp_path, db_session, monkeypatch): + _serve_jwks(monkeypatch, (_PRIVATE_KEY, KID)) + agent = Account.get_or_create("agent@example.com") + _, token = PersonalAccessToken.create(account_id=agent.id, name="agent") + with _jwt_client(tmp_path) as client: + # A valid bearer wins over any assertion, even a garbage one. + response = client.get( + "/api/auth/me", + headers={"Authorization": f"Bearer {token}", HEADER: "not.a.jwt"}, + ) + assert response.status_code == 200 + assert response.json()["account"]["username"] == "agent@example.com" + # An invalid bearer never falls through to a valid assertion. + bad = client.get("/api/auth/me", headers={"Authorization": "Bearer nope", HEADER: _token()}) + assert bad.status_code == 401 + # PAT management admits the verified assertion alone — never a bearer, + # not even alongside one. + allowed = client.get("/api/auth/personal-tokens", headers={HEADER: _token()}) + assert allowed.status_code == 200 + managed = client.get( + "/api/auth/personal-tokens", + headers={"Authorization": f"Bearer {token}", HEADER: _token()}, + ) + assert managed.status_code == 401 diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index c81d320..e9af5a0 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -1,4 +1,5 @@ import pytest +from conftest import make_settings from druks.settings import Settings, ensure_data_dirs, load_settings from pydantic import ValidationError @@ -18,6 +19,19 @@ def test_header_mode_requires_a_nonblank_header_name(tmp_path, monkeypatch): Settings(auth_mode="header", auth_header=" ") # type: ignore[call-arg] +def test_jwt_mode_requires_its_verification_targets(tmp_path): + complete = { + "auth_jwks_url": "https://edge.example.com/jwks.json", + "auth_jwt_issuer": "https://edge.example.com", + "auth_jwt_audience": "druks", + } + settings = make_settings(tmp_path, auth_mode="jwt", **complete) + assert settings.auth_jwt_identity_claim == "email" + for name in complete: + with pytest.raises(ValidationError): + make_settings(tmp_path, auth_mode="jwt", **{**complete, name: " "}) + + def test_ensure_data_dirs_provisions_skills_dir(tmp_path, monkeypatch): # The settings UI installs skill collections into skills_dir; if startup # doesn't create it, the first install's write raises OSError → opaque 500. diff --git a/docs/configuration.md b/docs/configuration.md index bc29538..b75856e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,8 +35,12 @@ caches, and the sandbox provisioning gate. | `DRUKS_ENDPOINT` | Browser-visible dashboard base URL used to build MCP OAuth callbacks | | `DRUKS_WEBHOOK_HOST` | Public webhook hostname used by `druks doctor` for its ingress probe | | `DRUKS_WEBHOOK_SECRET` | Shared HMAC secret used by bundled webhook integrations | -| `DRUKS_AUTH_MODE` | `none` (default; no authentication, single operator) or `header` (edge-asserted identity) | +| `DRUKS_AUTH_MODE` | `none` (default; no authentication, single operator), `header` (edge-asserted identity), or `jwt` (edge-signed assertion, verified) | | `DRUKS_AUTH_HEADER` | The trusted identity header; read by both the shipped Caddy edge and Druks | +| `DRUKS_AUTH_JWKS_URL` | `jwt` mode: where the edge publishes its signing keys | +| `DRUKS_AUTH_JWT_ISSUER` | `jwt` mode: required `iss` claim value | +| `DRUKS_AUTH_JWT_AUDIENCE` | `jwt` mode: required `aud` claim value | +| `DRUKS_AUTH_JWT_IDENTITY_CLAIM` | `jwt` mode: the claim mapped to the account (default `email`) | `DRUKS_ENDPOINT` and `DRUKS_WEBHOOK_HOST` are different. The first is where an operator's browser reaches Druks; the second is the public ingress webhook @@ -53,7 +57,16 @@ order: `DRUKS_AUTH_HEADER` value; Druks trims outer whitespace and maps it to an account, creating one on first sight (open enrollment — the edge decides who reaches Druks at all; the account column is case-insensitive). -3. **`none` mode.** No authentication and no identity edge: Druks resolves +3. **`jwt` mode.** The same assertion channel as `header` mode, but the value + is a signed JWT: Druks verifies the RS256 signature against + `DRUKS_AUTH_JWKS_URL` (keys cached for five minutes and refetched on + rotation), requires `exp`, `iss`, and `aud` to match the configured + issuer and audience, and maps the verified + `DRUKS_AUTH_JWT_IDENTITY_CLAIM` through the same open enrollment. A + failed verification is a 401 naming only the failure class — never the + token. Confirm the real edge's header name, claims, and rotation story + before enabling the mode; the RS256 profile is pinned, not negotiated. +4. **`none` mode.** No authentication and no identity edge: Druks resolves the only non-system account. Zero accounts is the setup state — the dashboard onboards by connecting a harness, and the first completed connection creates the operator account from the provider-verified email. @@ -64,6 +77,10 @@ Trust requirements for `header` mode: the edge must authenticate every dashboard request, must strip any client-supplied copy of `DRUKS_AUTH_HEADER` before inserting its authenticated value — a client that can inject the header can be anyone — and must terminate TLS and set HSTS. +`jwt` mode keeps the same strip requirement but adds cryptographic +provenance: a forged header value fails signature verification instead of +becoming an identity, so a misconfigured proxy degrades to a 401 rather +than an impersonation. The shipped Caddy listener is loopback HTTP behind that edge, and the Druks web listener itself binds loopback by default. In `none` mode there is no authentication at all, so the listener must stay loopback-only — never diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 663f2cc..cd53c68 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -75,6 +75,12 @@ Distinguish the failure by what you see: request reached Druks without exactly one nonblank `DRUKS_AUTH_HEADER` value. Confirm the proxy injects the header Druks expects, and that nothing between them drops or duplicates it. +- **An "Assertion rejected: …" 401 (`jwt` mode)** — the edge asserted a token + Druks could not verify. The named failure class says which check failed: + signature or key problems point at `DRUKS_AUTH_JWKS_URL` (is it reachable + from the container? did the edge rotate keys?), issuer/audience mismatches + at the `DRUKS_AUTH_JWT_*` values, and expiry classes at clock skew between + edge and host. - **Onboarding ("connect a harness to finish setup")** — identity resolved but that account has no harness connection yet; in a fresh `none`-mode install the first completed connection creates the operator account. diff --git a/pyproject.toml b/pyproject.toml index e3d69ff..e305306 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "jinja2>=3.1", "pydantic>=2.12.0", "pydantic-settings>=2.14.2", + "pyjwt[crypto]>=2.10", "pyyaml>=6.0", "sqlalchemy>=2.0", "uvicorn>=0.38.0", diff --git a/uv.lock b/uv.lock index ea63d69..a38c0ed 100644 --- a/uv.lock +++ b/uv.lock @@ -553,6 +553,7 @@ dependencies = [ { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "pyyaml" }, { name = "redis" }, { name = "sqlalchemy" }, @@ -586,6 +587,7 @@ requires-dist = [ { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "pydantic", specifier = ">=2.12.0" }, { name = "pydantic-settings", specifier = ">=2.14.2" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "redis", specifier = ">=8" }, { name = "sqlalchemy", specifier = ">=2.0" }, From c5efee5ea0a998d591863d529621caae426e40e9 Mon Sep 17 00:00:00 2001 From: Paulo Date: Tue, 21 Jul 2026 20:37:04 +0200 Subject: [PATCH 08/11] Close the adversarial-review findings on the JWKS verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set-level key caching only (a per-kid cache trusts rotated-away keys until restart); unknown kids get at most one forced refetch per trust window; every verification failure — including transport surprises — lands as the typed rejection; Authorization is refused as the identity header; the test fakes stub the HTTP layer so the real cache runs. --- backend/druks/accounts/jwt.py | 45 ++++++++++++--- backend/druks/settings.py | 4 ++ backend/tests/test_identity_jwt.py | 88 ++++++++++++++++++++++++------ backend/tests/test_settings.py | 15 +++++ 4 files changed, 127 insertions(+), 25 deletions(-) diff --git a/backend/druks/accounts/jwt.py b/backend/druks/accounts/jwt.py index 96a54c3..ca65170 100644 --- a/backend/druks/accounts/jwt.py +++ b/backend/druks/accounts/jwt.py @@ -1,3 +1,4 @@ +import time from functools import lru_cache import jwt @@ -9,13 +10,37 @@ # pinned profile — an untrusted token never chooses its own algorithm. _ALGORITHMS = ["RS256"] _JWKS_TIMEOUT_SECONDS = 5 +# One trust window for both directions: the cached key set expires after it, +# and an unknown kid can force at most one early refetch inside it — forged +# kids must not turn key fetching into a request-rate amplifier. +_JWKS_LIFESPAN_SECONDS = 300 @lru_cache(maxsize=1) -def _jwks_client(url: str) -> jwt.PyJWKClient: - # One client per process: it caches the fetched key set (five-minute - # lifespan) and refetches on an unknown kid, which is the rotation story. - return jwt.PyJWKClient(url, cache_keys=True, timeout=_JWKS_TIMEOUT_SECONDS) +def _jwks_state(url: str) -> dict: + # Set-level caching only: a per-key cache would trust a rotated-away or + # compromised key until process restart. + client = jwt.PyJWKClient( + url, cache_keys=False, lifespan=_JWKS_LIFESPAN_SECONDS, timeout=_JWKS_TIMEOUT_SECONDS + ) + return {"client": client, "forced_refresh_at": 0.0} + + +def _find_key(jwk_set, kid: str): + return next((key for key in jwk_set.keys if key.key_id == kid), None) + + +def _signing_key(url: str, kid: str): + state = _jwks_state(url) + key = _find_key(state["client"].get_jwk_set(), kid) + if key: + return key.key + if time.monotonic() - state["forced_refresh_at"] >= _JWKS_LIFESPAN_SECONDS: + state["forced_refresh_at"] = time.monotonic() + key = _find_key(state["client"].get_jwk_set(refresh=True), kid) + if key: + return key.key + raise InvalidAssertionError("Assertion rejected: unknown signing key.") def verify_assertion(token: str, settings: Settings) -> str: @@ -23,16 +48,22 @@ def verify_assertion(token: str, settings: Settings) -> str: InvalidAssertionError. Fails closed on every fetch, signature, claim, or shape problem; only the failure class name reaches the message.""" try: - signing_key = _jwks_client(settings.auth_jwks_url).get_signing_key_from_jwt(token) + kid = jwt.get_unverified_header(token).get("kid") + if not kid: + raise InvalidAssertionError("Assertion rejected: no signing key named.") claims = jwt.decode( token, - signing_key.key, + _signing_key(settings.auth_jwks_url, kid), algorithms=_ALGORITHMS, issuer=settings.auth_jwt_issuer, audience=settings.auth_jwt_audience, options={"require": ["exp", "iss", "aud"]}, ) - except jwt.PyJWTError as error: + except InvalidAssertionError: + raise + except Exception as error: + # The whole path fails closed — a broken JWKS endpoint or a transport + # surprise is a rejected assertion, never a 500. raise InvalidAssertionError(f"Assertion rejected: {type(error).__name__}.") from error claim = claims.get(settings.auth_jwt_identity_claim) # Claims are external data: the identity must be a nonblank string, not diff --git a/backend/druks/settings.py b/backend/druks/settings.py index 6fceb94..ae92fe5 100644 --- a/backend/druks/settings.py +++ b/backend/druks/settings.py @@ -218,6 +218,10 @@ def _auth_mode_is_fully_configured(self) -> "Settings": "DRUKS_AUTH_HEADER must name the edge's identity header " f"when DRUKS_AUTH_MODE={self.auth_mode}" ) + if self.auth_mode != "none" and self.auth_header.strip().lower() == "authorization": + # Authorization is the PAT slot and always parses bearer-first — an + # assertion configured there could never be read, locking everyone out. + raise ValueError("DRUKS_AUTH_HEADER cannot be Authorization — that slot is PAT-only") if self.auth_mode == "jwt": required = { "DRUKS_AUTH_JWKS_URL": self.auth_jwks_url, diff --git a/backend/tests/test_identity_jwt.py b/backend/tests/test_identity_jwt.py index b2effc8..93a5c5b 100644 --- a/backend/tests/test_identity_jwt.py +++ b/backend/tests/test_identity_jwt.py @@ -1,3 +1,4 @@ +import json import time from pathlib import Path @@ -24,9 +25,9 @@ @pytest.fixture(autouse=True) def _fresh_state(): druks.redis.get_client()._data.clear() - assertion._jwks_client.cache_clear() + assertion._jwks_state.cache_clear() yield - assertion._jwks_client.cache_clear() + assertion._jwks_state.cache_clear() def _jwk(key, kid: str) -> dict: @@ -34,25 +35,42 @@ def _jwk(key, kid: str) -> dict: return {**public, "kid": kid, "alg": "RS256", "use": "sig"} -def _serve_jwks(monkeypatch, *keys, fetches: list[int] | None = None): - document = {"keys": [_jwk(key, kid) for key, kid in keys]} +def _document(*keys) -> dict: + return {"keys": [_jwk(key, kid) for key, kid in keys]} - def fetch_data(self): + +class _JwksResponse: + def __init__(self, payload: bytes): + self._payload = payload + + def read(self): + return self._payload + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +def _serve_jwks(monkeypatch, document: dict, fetches: list[int] | None = None): + # Stub at the HTTP layer so PyJWKClient's own set cache runs for real. + def fake_urlopen(request, timeout=None): if fetches is not None: fetches.append(1) - return document + return _JwksResponse(json.dumps(document).encode()) - monkeypatch.setattr(pyjwt.PyJWKClient, "fetch_data", fetch_data) + monkeypatch.setattr("jwt.jwks_client.urlopen", fake_urlopen) def _break_jwks(monkeypatch): - def fetch_data(self): + def fake_urlopen(request, timeout=None): raise RuntimeError("jwks endpoint is down") - monkeypatch.setattr(pyjwt.PyJWKClient, "fetch_data", fetch_data) + monkeypatch.setattr("jwt.jwks_client.urlopen", fake_urlopen) -def _token(key=_PRIVATE_KEY, kid: str = KID, **claim_overrides) -> str: +def _token(key=_PRIVATE_KEY, kid: str | None = KID, **claim_overrides) -> str: claims = { "iss": ISSUER, "aud": AUDIENCE, @@ -61,7 +79,8 @@ def _token(key=_PRIVATE_KEY, kid: str = KID, **claim_overrides) -> str: **claim_overrides, } claims = {name: value for name, value in claims.items() if value is not None} - return pyjwt.encode(claims, key, algorithm="RS256", headers={"kid": kid}) + headers = {"kid": kid} if kid else {} + return pyjwt.encode(claims, key, algorithm="RS256", headers=headers) def _jwt_client(tmp_path: Path) -> TestClient: @@ -79,7 +98,7 @@ def _jwt_client(tmp_path: Path) -> TestClient: def test_a_valid_assertion_open_enrolls_its_subject(tmp_path, db_session, monkeypatch): - _serve_jwks(monkeypatch, (_PRIVATE_KEY, KID)) + _serve_jwks(monkeypatch, _document((_PRIVATE_KEY, KID))) with _jwt_client(tmp_path) as client: response = client.get("/api/auth/me", headers={HEADER: _token()}) assert response.status_code == 200 @@ -101,10 +120,12 @@ def test_a_valid_assertion_open_enrolls_its_subject(tmp_path, db_session, monkey _token(email=None), _token(email={"nested": "never"}), _token(kid="unknown-kid"), + _token(kid=None), + "garbageheader." + "garbagepayload" * 3 + ".garbagesignature", ], ) def test_a_bad_assertion_rejects_without_enrolling(tmp_path, db_session, monkeypatch, token): - _serve_jwks(monkeypatch, (_PRIVATE_KEY, KID)) + _serve_jwks(monkeypatch, _document((_PRIVATE_KEY, KID))) with _jwt_client(tmp_path) as client: response = client.get("/api/auth/me", headers={HEADER: token}) assert response.status_code == 401 @@ -115,7 +136,8 @@ def test_a_bad_assertion_rejects_without_enrolling(tmp_path, db_session, monkeyp def test_one_fetch_serves_every_kid_in_the_document(tmp_path, db_session, monkeypatch): fetches: list[int] = [] - _serve_jwks(monkeypatch, (_PRIVATE_KEY, KID), (_ROTATED_KEY, ROTATED_KID), fetches=fetches) + document = _document((_PRIVATE_KEY, KID), (_ROTATED_KEY, ROTATED_KID)) + _serve_jwks(monkeypatch, document, fetches=fetches) with _jwt_client(tmp_path) as client: assert client.get("/api/auth/me", headers={HEADER: _token()}).status_code == 200 rotated = client.get( @@ -125,13 +147,43 @@ def test_one_fetch_serves_every_kid_in_the_document(tmp_path, db_session, monkey assert len(fetches) == 1 +def test_a_same_kid_rotation_lands_after_the_cache_window(tmp_path, db_session, monkeypatch): + document = _document((_PRIVATE_KEY, KID)) + _serve_jwks(monkeypatch, document) + with _jwt_client(tmp_path) as client: + assert client.get("/api/auth/me", headers={HEADER: _token()}).status_code == 200 + # The edge replaces the key behind the same kid. Inside the trust + # window the old set still answers; once it expires the next request + # picks up the replacement. + document["keys"][:] = _document((_ROTATED_KEY, KID))["keys"] + stale = client.get("/api/auth/me", headers={HEADER: _token(key=_ROTATED_KEY, kid=KID)}) + assert stale.status_code == 401 + state = assertion._jwks_state("https://edge.example.com/jwks.json") + state["client"].jwk_set_cache.lifespan = -1 + state["forced_refresh_at"] = 0.0 + landed = client.get("/api/auth/me", headers={HEADER: _token(key=_ROTATED_KEY, kid=KID)}) + assert landed.status_code == 200 + + +def test_unknown_kids_cannot_stampede_the_jwks_endpoint(tmp_path, db_session, monkeypatch): + fetches: list[int] = [] + _serve_jwks(monkeypatch, _document((_PRIVATE_KEY, KID)), fetches=fetches) + with _jwt_client(tmp_path) as client: + assert client.get("/api/auth/me", headers={HEADER: _token()}).status_code == 200 + for attempt in range(3): + forged = client.get("/api/auth/me", headers={HEADER: _token(kid=f"forged-{attempt}")}) + assert forged.status_code == 401 + # One forced refresh inside the trust window, however many kids are forged. + assert len(fetches) == 2 + + def test_cached_keys_serve_through_a_jwks_outage(tmp_path, db_session, monkeypatch): - _serve_jwks(monkeypatch, (_PRIVATE_KEY, KID)) + _serve_jwks(monkeypatch, _document((_PRIVATE_KEY, KID))) with _jwt_client(tmp_path) as client: assert client.get("/api/auth/me", headers={HEADER: _token()}).status_code == 200 _break_jwks(monkeypatch) - # The cached key set still verifies known kids; an unknown kid fails - # closed instead of enrolling anyone. + # The cached key set still verifies known kids; an unknown kid rides + # the broken refetch into a clean 401, never a 500. assert client.get("/api/auth/me", headers={HEADER: _token()}).status_code == 200 unknown = client.get( "/api/auth/me", headers={HEADER: _token(key=_ROTATED_KEY, kid=ROTATED_KID)} @@ -141,7 +193,7 @@ def test_cached_keys_serve_through_a_jwks_outage(tmp_path, db_session, monkeypat def test_bearer_precedence_survives_jwt_mode(tmp_path, db_session, monkeypatch): - _serve_jwks(monkeypatch, (_PRIVATE_KEY, KID)) + _serve_jwks(monkeypatch, _document((_PRIVATE_KEY, KID))) agent = Account.get_or_create("agent@example.com") _, token = PersonalAccessToken.create(account_id=agent.id, name="agent") with _jwt_client(tmp_path) as client: diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index e9af5a0..66f7f6f 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -32,6 +32,21 @@ def test_jwt_mode_requires_its_verification_targets(tmp_path): make_settings(tmp_path, auth_mode="jwt", **{**complete, name: " "}) +@pytest.mark.parametrize("auth_mode", ["header", "jwt"]) +def test_the_pat_slot_cannot_be_the_identity_header(tmp_path, auth_mode): + # Authorization always parses bearer-first, so an assertion configured + # there could never be read — a total lockout, refused at startup. + with pytest.raises(ValidationError): + make_settings( + tmp_path, + auth_mode=auth_mode, + auth_header="authorization", + auth_jwks_url="https://edge.example.com/jwks.json", + auth_jwt_issuer="https://edge.example.com", + auth_jwt_audience="druks", + ) + + def test_ensure_data_dirs_provisions_skills_dir(tmp_path, monkeypatch): # The settings UI installs skill collections into skills_dir; if startup # doesn't create it, the first install's write raises OSError → opaque 500. From bfefcadcfc27d15c8cc4b926b68ed825b7151325 Mon Sep 17 00:00:00 2001 From: Paulo Date: Tue, 21 Jul 2026 20:44:45 +0200 Subject: [PATCH 09/11] Close the round-2 findings on key selection and the test harness Signature verification only trusts keys published for signing; the forced-refresh stamp starts at -inf so a young monotonic clock cannot suppress the first refetch; the fakes patch urllib.request.urlopen (the symbol PyJWT actually calls) and the rotation test expires the real cache instead of disabling its expiry. --- backend/druks/accounts/jwt.py | 11 +++++-- backend/tests/test_identity_jwt.py | 48 +++++++++++++++++++++++------- 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/backend/druks/accounts/jwt.py b/backend/druks/accounts/jwt.py index ca65170..1358ab6 100644 --- a/backend/druks/accounts/jwt.py +++ b/backend/druks/accounts/jwt.py @@ -23,11 +23,18 @@ def _jwks_state(url: str) -> dict: client = jwt.PyJWKClient( url, cache_keys=False, lifespan=_JWKS_LIFESPAN_SECONDS, timeout=_JWKS_TIMEOUT_SECONDS ) - return {"client": client, "forced_refresh_at": 0.0} + # -inf: the first unknown kid is always eligible to force a refetch — a + # zero stamp would suppress it while the monotonic clock is still young. + return {"client": client, "forced_refresh_at": float("-inf")} def _find_key(jwk_set, kid: str): - return next((key for key in jwk_set.keys if key.key_id == kid), None) + # Only keys published for signing (or unrestricted) may authenticate — a + # mixed-purpose JWKS must not let an encryption key mint identities. + return next( + (key for key in jwk_set.keys if key.key_id == kid and key.public_key_use in ("sig", None)), + None, + ) def _signing_key(url: str, kid: str): diff --git a/backend/tests/test_identity_jwt.py b/backend/tests/test_identity_jwt.py index 93a5c5b..4033d01 100644 --- a/backend/tests/test_identity_jwt.py +++ b/backend/tests/test_identity_jwt.py @@ -1,6 +1,8 @@ import json import time from pathlib import Path +from types import SimpleNamespace +from urllib.error import URLError import druks.redis import jwt as pyjwt @@ -30,9 +32,9 @@ def _fresh_state(): assertion._jwks_state.cache_clear() -def _jwk(key, kid: str) -> dict: +def _jwk(key, kid: str, use: str = "sig") -> dict: public = pyjwt.algorithms.RSAAlgorithm.to_jwk(key.public_key(), as_dict=True) - return {**public, "kid": kid, "alg": "RS256", "use": "sig"} + return {**public, "kid": kid, "alg": "RS256", "use": use} def _document(*keys) -> dict: @@ -55,19 +57,19 @@ def __exit__(self, *exc): def _serve_jwks(monkeypatch, document: dict, fetches: list[int] | None = None): # Stub at the HTTP layer so PyJWKClient's own set cache runs for real. - def fake_urlopen(request, timeout=None): + def fake_urlopen(request, timeout=None, context=None): if fetches is not None: fetches.append(1) return _JwksResponse(json.dumps(document).encode()) - monkeypatch.setattr("jwt.jwks_client.urlopen", fake_urlopen) + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) def _break_jwks(monkeypatch): - def fake_urlopen(request, timeout=None): - raise RuntimeError("jwks endpoint is down") + def fake_urlopen(request, timeout=None, context=None): + raise URLError("jwks endpoint is down") - monkeypatch.setattr("jwt.jwks_client.urlopen", fake_urlopen) + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) def _token(key=_PRIVATE_KEY, kid: str | None = KID, **claim_overrides) -> str: @@ -148,8 +150,9 @@ def test_one_fetch_serves_every_kid_in_the_document(tmp_path, db_session, monkey def test_a_same_kid_rotation_lands_after_the_cache_window(tmp_path, db_session, monkeypatch): + fetches: list[int] = [] document = _document((_PRIVATE_KEY, KID)) - _serve_jwks(monkeypatch, document) + _serve_jwks(monkeypatch, document, fetches=fetches) with _jwt_client(tmp_path) as client: assert client.get("/api/auth/me", headers={HEADER: _token()}).status_code == 200 # The edge replaces the key behind the same kid. Inside the trust @@ -158,11 +161,13 @@ def test_a_same_kid_rotation_lands_after_the_cache_window(tmp_path, db_session, document["keys"][:] = _document((_ROTATED_KEY, KID))["keys"] stale = client.get("/api/auth/me", headers={HEADER: _token(key=_ROTATED_KEY, kid=KID)}) assert stale.status_code == 401 + assert len(fetches) == 1 state = assertion._jwks_state("https://edge.example.com/jwks.json") - state["client"].jwk_set_cache.lifespan = -1 - state["forced_refresh_at"] = 0.0 + cached = state["client"].jwk_set_cache.jwk_set_with_timestamp + cached.timestamp -= assertion._JWKS_LIFESPAN_SECONDS + 1 landed = client.get("/api/auth/me", headers={HEADER: _token(key=_ROTATED_KEY, kid=KID)}) assert landed.status_code == 200 + assert len(fetches) == 2 def test_unknown_kids_cannot_stampede_the_jwks_endpoint(tmp_path, db_session, monkeypatch): @@ -177,6 +182,29 @@ def test_unknown_kids_cannot_stampede_the_jwks_endpoint(tmp_path, db_session, mo assert len(fetches) == 2 +def test_an_encryption_key_cannot_authenticate(tmp_path, db_session, monkeypatch): + # A mixed-purpose JWKS: the matching kid is published for encryption only, + # so it must never verify an identity. + document = {"keys": [_jwk(_PRIVATE_KEY, KID, use="enc")]} + _serve_jwks(monkeypatch, document) + with _jwt_client(tmp_path) as client: + assert client.get("/api/auth/me", headers={HEADER: _token()}).status_code == 401 + assert not Account.list_non_system() + + +def test_the_first_unknown_kid_refreshes_even_near_clock_origin(tmp_path, db_session, monkeypatch): + # A young monotonic clock (fresh host) must not suppress the first forced + # refetch; only druks' own module sees the stubbed clock. + monkeypatch.setattr(assertion, "time", SimpleNamespace(monotonic=lambda: 100.0)) + fetches: list[int] = [] + _serve_jwks(monkeypatch, _document((_PRIVATE_KEY, KID)), fetches=fetches) + with _jwt_client(tmp_path) as client: + assert client.get("/api/auth/me", headers={HEADER: _token()}).status_code == 200 + forged = client.get("/api/auth/me", headers={HEADER: _token(kid="forged")}) + assert forged.status_code == 401 + assert len(fetches) == 2 + + def test_cached_keys_serve_through_a_jwks_outage(tmp_path, db_session, monkeypatch): _serve_jwks(monkeypatch, _document((_PRIVATE_KEY, KID))) with _jwt_client(tmp_path) as client: From 710d1913b93d68125bc8b911401270f4b4c086de Mon Sep 17 00:00:00 2001 From: Paulo Date: Tue, 21 Jul 2026 20:52:06 +0200 Subject: [PATCH 10/11] Close the round-3 findings on key restriction and loop blocking Signing keys must pass both JWK restriction channels (use and key_ops); assertion verification runs in the threadpool so a slow or dead JWKS endpoint never stalls the event loop. --- backend/druks/accounts/dependencies.py | 19 +++++++++++++------ backend/druks/accounts/jwt.py | 16 ++++++++++++---- backend/tests/test_identity_jwt.py | 21 +++++++++++++++++---- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/backend/druks/accounts/dependencies.py b/backend/druks/accounts/dependencies.py index 55a6c82..52c2a0d 100644 --- a/backend/druks/accounts/dependencies.py +++ b/backend/druks/accounts/dependencies.py @@ -1,6 +1,7 @@ from collections.abc import AsyncIterator from fastapi import HTTPException, Request +from fastapi.concurrency import run_in_threadpool from druks.accounts.context import current_account_id from druks.accounts.exceptions import ( @@ -49,7 +50,7 @@ def resolve_none_operator() -> Account | None: return operators[0] if operators else None -def _resolve_operator(request: Request) -> Account | None: +async def _resolve_operator(request: Request) -> Account | None: """Edge/none operator identity for this request; None only in none/zero (the setup state). Both edge modes are open enrollment on exactly one nonblank asserted value, trimmed of outer whitespace (the edge gates who @@ -74,9 +75,12 @@ def _resolve_operator(request: Request) -> Account | None: if settings.auth_mode == "header": return Account.get_or_create(asserted) try: - return Account.get_or_create(verify_assertion(asserted, settings)) + # Verification can fetch JWKS behind a sync client — run it off the + # loop so a slow or dead key endpoint never stalls unrelated requests. + email = await run_in_threadpool(verify_assertion, asserted, settings) except InvalidAssertionError as error: raise HTTPException(status_code=401, detail=str(error)) from error + return Account.get_or_create(email) def _require_no_bearer(request: Request) -> None: @@ -103,7 +107,7 @@ async def current_account(request: Request) -> AsyncIterator[Account]: if header is not None: account = resolve_pat_account(header) else: - account = _resolve_operator(request) + account = await _resolve_operator(request) if not account: raise _setup_required() token = current_account_id.set(account.id) @@ -118,7 +122,7 @@ async def current_operator_account(request: Request) -> AsyncIterator[Account]: """The edge/none operator, and only that — the door for PAT management, so a token can never manage tokens.""" _require_no_bearer(request) - account = _resolve_operator(request) + account = await _resolve_operator(request) if not account: raise _setup_required() token = current_account_id.set(account.id) @@ -132,7 +136,7 @@ async def current_operator_or_setup(request: Request) -> AsyncIterator[Account | """The edge/none operator for the harness connection flow, where none/zero is a legal state: the first completed connection creates the operator.""" _require_no_bearer(request) - account = _resolve_operator(request) + account = await _resolve_operator(request) token = current_account_id.set(account.id if account else None) try: yield account @@ -144,7 +148,10 @@ async def current_account_or_setup(request: Request) -> AsyncIterator[Account | """PAT-first identity that reads none/zero as the setup state instead of refusing — only ``/api/auth/me``, which must answer during onboarding.""" header = request.headers.get("Authorization") - account = resolve_pat_account(header) if header is not None else _resolve_operator(request) + if header is not None: + account = resolve_pat_account(header) + else: + account = await _resolve_operator(request) token = current_account_id.set(account.id if account else None) try: yield account diff --git a/backend/druks/accounts/jwt.py b/backend/druks/accounts/jwt.py index 1358ab6..38e6f9b 100644 --- a/backend/druks/accounts/jwt.py +++ b/backend/druks/accounts/jwt.py @@ -28,12 +28,20 @@ def _jwks_state(url: str) -> dict: return {"client": client, "forced_refresh_at": float("-inf")} +def _usable_for_signing(key) -> bool: + # Both JWK restriction channels must permit verification — ``use`` (sig or + # unrestricted) and ``key_ops`` (verify or unrestricted) — so a + # mixed-purpose JWKS can never let an encryption key mint identities. + # key_ops has no public accessor on PyJWK. + if key.public_key_use not in ("sig", None): + return False + operations = key._jwk_data.get("key_ops") + return not operations or "verify" in operations + + def _find_key(jwk_set, kid: str): - # Only keys published for signing (or unrestricted) may authenticate — a - # mixed-purpose JWKS must not let an encryption key mint identities. return next( - (key for key in jwk_set.keys if key.key_id == kid and key.public_key_use in ("sig", None)), - None, + (key for key in jwk_set.keys if key.key_id == kid and _usable_for_signing(key)), None ) diff --git a/backend/tests/test_identity_jwt.py b/backend/tests/test_identity_jwt.py index 4033d01..1036bbc 100644 --- a/backend/tests/test_identity_jwt.py +++ b/backend/tests/test_identity_jwt.py @@ -182,16 +182,29 @@ def test_unknown_kids_cannot_stampede_the_jwks_endpoint(tmp_path, db_session, mo assert len(fetches) == 2 -def test_an_encryption_key_cannot_authenticate(tmp_path, db_session, monkeypatch): - # A mixed-purpose JWKS: the matching kid is published for encryption only, - # so it must never verify an identity. - document = {"keys": [_jwk(_PRIVATE_KEY, KID, use="enc")]} +@pytest.mark.parametrize( + "restriction", + [{"use": "enc"}, {"key_ops": ["encrypt"]}, {"use": "enc", "key_ops": ["encrypt", "decrypt"]}], +) +def test_a_non_signing_key_cannot_authenticate(tmp_path, db_session, monkeypatch, restriction): + # A mixed-purpose JWKS: whichever restriction channel marks the matching + # kid as non-signing, it must never verify an identity. + public = pyjwt.algorithms.RSAAlgorithm.to_jwk(_PRIVATE_KEY.public_key(), as_dict=True) + document = {"keys": [{**public, "kid": KID, "alg": "RS256", **restriction}]} _serve_jwks(monkeypatch, document) with _jwt_client(tmp_path) as client: assert client.get("/api/auth/me", headers={HEADER: _token()}).status_code == 401 assert not Account.list_non_system() +def test_a_verify_restricted_key_authenticates(tmp_path, db_session, monkeypatch): + public = pyjwt.algorithms.RSAAlgorithm.to_jwk(_PRIVATE_KEY.public_key(), as_dict=True) + document = {"keys": [{**public, "kid": KID, "alg": "RS256", "key_ops": ["verify"]}]} + _serve_jwks(monkeypatch, document) + with _jwt_client(tmp_path) as client: + assert client.get("/api/auth/me", headers={HEADER: _token()}).status_code == 200 + + def test_the_first_unknown_kid_refreshes_even_near_clock_origin(tmp_path, db_session, monkeypatch): # A young monotonic clock (fresh host) must not suppress the first forced # refetch; only druks' own module sees the stubbed clock. From 520dd9a7b3f2daeb5921b0b80fa7495265fd9e15 Mon Sep 17 00:00:00 2001 From: Paulo Date: Tue, 21 Jul 2026 20:57:31 +0200 Subject: [PATCH 11/11] Treat an empty key_ops list as permitting nothing Absence is unrestricted; a present list permits exactly what it names (RFC 7517), and malformed values permit nothing. --- backend/druks/accounts/jwt.py | 6 +++++- backend/tests/test_identity_jwt.py | 8 +++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/backend/druks/accounts/jwt.py b/backend/druks/accounts/jwt.py index 38e6f9b..3fc4239 100644 --- a/backend/druks/accounts/jwt.py +++ b/backend/druks/accounts/jwt.py @@ -36,7 +36,11 @@ def _usable_for_signing(key) -> bool: if key.public_key_use not in ("sig", None): return False operations = key._jwk_data.get("key_ops") - return not operations or "verify" in operations + # Absent means unrestricted; a present list — even an empty one — permits + # exactly what it names, and anything malformed permits nothing. + if operations is None: + return True + return isinstance(operations, list) and "verify" in operations def _find_key(jwk_set, kid: str): diff --git a/backend/tests/test_identity_jwt.py b/backend/tests/test_identity_jwt.py index 1036bbc..5dd2e00 100644 --- a/backend/tests/test_identity_jwt.py +++ b/backend/tests/test_identity_jwt.py @@ -184,7 +184,13 @@ def test_unknown_kids_cannot_stampede_the_jwks_endpoint(tmp_path, db_session, mo @pytest.mark.parametrize( "restriction", - [{"use": "enc"}, {"key_ops": ["encrypt"]}, {"use": "enc", "key_ops": ["encrypt", "decrypt"]}], + [ + {"use": "enc"}, + {"key_ops": ["encrypt"]}, + {"use": "enc", "key_ops": ["encrypt", "decrypt"]}, + {"key_ops": []}, + {"key_ops": "verify"}, + ], ) def test_a_non_signing_key_cannot_authenticate(tmp_path, db_session, monkeypatch, restriction): # A mixed-purpose JWKS: whichever restriction channel marks the matching