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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ 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 and name the header your edge injects —
# there is no default (exe.dev: X-ExeDev-Email, Teleport: X-Forwarded-Email,
# Cloudflare Access: Cf-Access-Authenticated-User-Email):
# DRUKS_AUTH_MODE=header
# DRUKS_AUTH_HEADER=<your edge's identity header>
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
Expand Down
3 changes: 0 additions & 3 deletions backend/druks/accounts/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_<prefix>_<secret>. 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.
Expand Down
173 changes: 127 additions & 46 deletions backend/druks/accounts/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,56 +1,137 @@
from fastapi import HTTPException, Request
from collections.abc import AsyncIterator

from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

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"'
# auto_error=False: absence and malformed both come back None — presence is
# checked separately so a malformed header hard-fails instead of sliding to
# the session identity. Registers the bearer scheme in the OpenAPI schema.
_bearer_scheme = HTTPBearer(auto_error=False, scheme_name="personalAccessToken")


def resolve_pat_account(credentials: HTTPAuthorizationCredentials | None) -> Account:
"""A present Authorization must authenticate — never a fall-through."""
if credentials:
try:
return PersonalAccessToken.authenticate(credentials.credentials).account
except InvalidPatError as error:
raise HTTPException(
status_code=401,
detail=str(error),
headers={"WWW-Authenticate": f'{_BEARER_CHALLENGE}, error="invalid_token"'},
) from error
raise HTTPException(
status_code=401,
detail="Authorization must be: Bearer <token>.",
headers={"WWW-Authenticate": _BEARER_CHALLENGE},
)


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)


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.")


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_single_operator() -> Account | None:
"""None while zero accounts exist (setup); more than one refuses rather
than guesses."""
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


def _resolve_operator(request: Request) -> Account | None:
"""None only during none/zero setup. Header mode open-enrolls the edge's
asserted email; none mode ignores the header entirely."""
settings = request.app.state.settings
if settings.auth_mode == "none":
return resolve_single_operator()
values = request.headers.getlist(settings.auth_header)
if len(values) == 1 and (email := values[0].strip()):
return Account.get_or_create(email)
raise HTTPException(
status_code=401,
detail=f"The edge must assert exactly one nonblank {settings.auth_header} identity.",
)


def _require_no_bearer(request: Request) -> None:
if "Authorization" in request.headers:
raise HTTPException(
status_code=401,
detail="Authorization must be exactly: Bearer <token>.",
headers={"WWW-Authenticate": _BEARER_CHALLENGE},
detail="This API accepts your edge or local operator identity only, "
"never a bearer token.",
)


async def current_account(
request: Request,
bearer: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
) -> AsyncIterator[Account]:
"""The Bearer PAT when Authorization is present — present-but-empty still
challenges — else the session identity."""
if "Authorization" in request.headers:
account = resolve_pat_account(bearer)
else:
account = _resolve_operator(request)
if not account:
raise HTTPException(
status_code=409,
detail="No operator account exists yet — connect a harness to finish setup.",
)
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_session_account(request: Request) -> AsyncIterator[Account]:
"""The signed-in human, never a bearer — a token cannot manage
capabilities. Identity re-asserts per request; no session state."""
_require_no_bearer(request)
account = _resolve_operator(request)
if not account:
raise HTTPException(
status_code=409,
detail="No operator account exists yet — connect a harness to finish setup.",
)
return await current_session_account(request)
token = current_account_id.set(account.id)
try:
yield account
finally:
current_account_id.reset(token)


async def current_session_or_setup(request: Request) -> AsyncIterator[Account | None]:
"""The signed-in human; None during none/zero setup, where 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,
bearer: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
) -> AsyncIterator[Account | None]:
"""PAT-first identity that reads none/zero setup as None instead of
refusing — ``/api/auth/me`` only."""
if "Authorization" in request.headers:
account = resolve_pat_account(bearer)
else:
account = _resolve_operator(request)
token = current_account_id.set(account.id if account else None)
try:
yield account
finally:
current_account_id.reset(token)
6 changes: 6 additions & 0 deletions backend/druks/accounts/exceptions.py
Original file line number Diff line number Diff line change
@@ -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."""
21 changes: 16 additions & 5 deletions backend/druks/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from datetime import datetime

from sqlalchemy import ForeignKey, Index, LargeBinary, String, select
from sqlalchemy.dialects.postgresql import CITEXT
from sqlalchemy.dialects.postgresql import CITEXT, insert
from sqlalchemy.orm import Mapped, mapped_column, relationship

from druks.accounts.constants import (
Expand All @@ -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
Expand Down Expand Up @@ -46,14 +47,24 @@ 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))


def _hash_token(token: str) -> bytes:
Expand Down
115 changes: 13 additions & 102 deletions backend/druks/accounts/routes.py
Original file line number Diff line number Diff line change
@@ -1,116 +1,27 @@
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_session_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),
Expand Down
Loading