diff --git a/docs/testing-plan.md b/docs/testing-plan.md index 79096a2..2324394 100644 --- a/docs/testing-plan.md +++ b/docs/testing-plan.md @@ -89,10 +89,12 @@ three files moves here, alongside: cast: one admin, two users, one agent, one daemon, one swarm. Parametrized over both backends (`memory` and `sqlite`) via `backend_kind`; tests never touch backend internals — they seed/assert through the public API or the - backend-agnostic `seed_trash` / `seed_list` / `list_members` fixtures + backend-agnostic `seed_trash` / `seed_list` / `seed_refresh_token` / + `list_members` fixtures - `app_client` — `TestClient` over the **real** `mail_server.server.app` - (env vars `MAIL_HOST`, `MAIL_JWT_SECRET_KEY`, `MAIL_JWT_ALGORITHM` set - before import), wired to `backend`. A module may override `backend_kind` to + (env vars `MAIL_HOST`, `MAIL_JWT_SECRET_KEY`, `MAIL_JWT_ALGORITHM`, + `MAIL_REFRESH_TOKEN_EXPIRE_DAYS` set before import), wired to `backend`. A + module may override `backend_kind` to pin one backend (e.g. `test_stubs.py` → memory, `test_gap_fill.py` → sqlite) - `token_for(address)` — factory issuing real JWTs via `POST /auth/token`, so integration tests exercise real auth instead of monkeypatching it diff --git a/llms.txt b/llms.txt index 99e0fb1..2f0a6f2 100644 --- a/llms.txt +++ b/llms.txt @@ -187,8 +187,8 @@ three files moves here, alongside: - `backend` — started `MemoryBackend` seeded with a standard cast: one admin, two users, one agent, one daemon, one swarm - `app_client` — `TestClient` over the **real** `mail_server.server.app` - (env vars `MAIL_HOST`, `MAIL_JWT_SECRET_KEY`, `MAIL_JWT_ALGORITHM` set - before import), wired to `backend` + (env vars `MAIL_HOST`, `MAIL_JWT_SECRET_KEY`, `MAIL_JWT_ALGORITHM`, + `MAIL_REFRESH_TOKEN_EXPIRE_DAYS` set before import), wired to `backend` - `token_for(address)` — factory issuing real JWTs via `POST /auth/token`, so integration tests exercise real auth instead of monkeypatching it - `webhook_receiver` — in-process ASGI app that records deliveries and can be diff --git a/spec/openapi.yaml b/spec/openapi.yaml index 8305ec2..4677c33 100644 --- a/spec/openapi.yaml +++ b/spec/openapi.yaml @@ -30,6 +30,33 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /auth/refresh: + post: + tags: + - authentication + summary: Exchange a refresh token for a new access token (rotates the refresh + token) + operationId: post_auth_refresh_auth_refresh_post + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AuthRefreshPostResponse' + /auth/logout: + post: + tags: + - authentication + summary: Revoke the presented refresh token's family and clear the cookie + operationId: post_auth_logout_auth_logout_post + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AuthLogoutPostResponse' /auth/whoami: get: tags: @@ -1461,6 +1488,19 @@ components: description: 'Corresponds to `POST /admin/webhooks`. Contains information on the newly-created webhook.' + AuthLogoutPostResponse: + properties: + status: + type: string + const: success + title: Status + type: object + required: + - status + title: AuthLogoutPostResponse + description: 'Corresponds to `POST /auth/logout`. + + Contains a message indicating operation success.' AuthPasswordResetResponse: properties: status: @@ -1474,6 +1514,44 @@ components: description: 'Corresponds to `POST /auth/password/reset`. Contains a message indicating operation success.' + AuthRefreshPostResponse: + properties: + access_token: + type: string + title: Access Token + token_type: + type: string + const: bearer + title: Token Type + refresh_token: + anyOf: + - type: string + - type: 'null' + title: Refresh Token + expires_in: + type: integer + title: Expires In + metadata: + additionalProperties: true + type: object + title: Metadata + type: object + required: + - access_token + - token_type + - expires_in + - metadata + title: AuthRefreshPostResponse + description: 'Corresponds to `POST /auth/refresh`. + + Contains a freshly-minted access token and a rotated refresh token. + + + Mirrors `AuthTokenPostResponse`. The previous refresh token is invalidated + + on every successful refresh; ``refresh_token`` carries its replacement (also + + rotated in the ``httpOnly`` cookie for browser clients).' AuthTokenPostResponse: properties: access_token: @@ -1483,6 +1561,14 @@ components: type: string const: bearer title: Token Type + refresh_token: + anyOf: + - type: string + - type: 'null' + title: Refresh Token + expires_in: + type: integer + title: Expires In metadata: additionalProperties: true type: object @@ -1491,11 +1577,23 @@ components: required: - access_token - token_type + - expires_in - metadata title: AuthTokenPostResponse description: 'Corresponds to `POST /auth/token`. - Contains a temporary JWT and associated metadata.' + Contains a temporary JWT and associated metadata. + + + ``refresh_token`` is populated only for interactive principals (users and + + admins); agents and daemons re-authenticate with their credentials and + + receive ``None``. When present, the server also sets it as an ``httpOnly`` + + cookie for browser clients. ``expires_in`` is the access-token lifetime in + + seconds.' AuthWhoamiGetResponse: properties: user_agent: diff --git a/src/mail/client/docs/reference/cli.md b/src/mail/client/docs/reference/cli.md index dcb210e..97fb8bb 100644 --- a/src/mail/client/docs/reference/cli.md +++ b/src/mail/client/docs/reference/cli.md @@ -41,7 +41,8 @@ mail [option]... [argument]... ### Utility Commands - `ping`: Attempt to ping the MAIL server at the URL provided. -- `login`: Log into a MAIL server with valid credentials to obtain a temporary access token. +- `login`: Log into a MAIL server with valid credentials to obtain a temporary access token (and, for users/admins, a refresh token). +- `refresh`: Renew your access token from a refresh token (set `MAIL_REFRESH_TOKEN`), without logging in again. The refresh token is rotated, so update `MAIL_REFRESH_TOKEN` with the returned value. - `whoami`: View information on the logged-in MAIL user-agent. ## Top-level Options diff --git a/src/mail/client/docs/tutorials/quickstart.md b/src/mail/client/docs/tutorials/quickstart.md index ae57bc8..a1bb386 100644 --- a/src/mail/client/docs/tutorials/quickstart.md +++ b/src/mail/client/docs/tutorials/quickstart.md @@ -23,6 +23,26 @@ uv run mail login If your credentials are valid, you should see a JWT printed to the console. Copy this and use it as the value for environment variable `MAIL_TOKEN` in subsequent commands. +If you logged in as a user or admin, a **refresh token** is printed as well. +Save it as `MAIL_REFRESH_TOKEN` — it lets you renew your access token without +re-entering your password. (Agents and daemons don't get one; they simply log in +again.) + +## Renew Your Access Token + +Access tokens are short-lived. When yours expires, exchange your refresh token +for a new one with the `refresh` command instead of logging in again: + +```bash +MAIL_SERVER=... \ +MAIL_REFRESH_TOKEN=... \ +uv run mail refresh +``` + +A new access token is printed, along with a **rotated** refresh token — the old +refresh token is now invalid, so update `MAIL_REFRESH_TOKEN` with the new value +for next time. + ## Validate Client Identity To ensure your credentials are valid and as expected, use the `whoami` command: diff --git a/src/mail/client/src/mail_client/cli.py b/src/mail/client/src/mail_client/cli.py index f4c390c..2b4f0c9 100644 --- a/src/mail/client/src/mail_client/cli.py +++ b/src/mail/client/src/mail_client/cli.py @@ -24,6 +24,7 @@ cmd_outbox, cmd_outbox_open, cmd_ping, + cmd_refresh, cmd_reply, cmd_send, cmd_swarm_get, @@ -68,6 +69,7 @@ [ ("ping (p)", "Ping a MAIL server."), ("login (l)", "Log into a MAIL server."), + ("refresh (rt)", "Renew your access token with a refresh token."), ("whoami (me, id)", "Show authenticated user-agent info."), ], ), @@ -219,6 +221,17 @@ def build_parser() -> argparse.ArgumentParser: ) login_p.set_defaults(func=cmd_login, cmd="login") + # command `refresh` + refresh_d = "renew your access token using a refresh token" + refresh_p = subparsers.add_parser( + "refresh", + aliases=["rt"], + prog="mail refresh", + help=refresh_d, + description=refresh_d, + ) + refresh_p.set_defaults(func=cmd_refresh, cmd="refresh") + # command `whoami` whoami_d = "get authenticated user-agent info from a MAIL server" whoami_p = subparsers.add_parser( diff --git a/src/mail/client/src/mail_client/commands/__init__.py b/src/mail/client/src/mail_client/commands/__init__.py index f5f5a1d..fccf88f 100644 --- a/src/mail/client/src/mail_client/commands/__init__.py +++ b/src/mail/client/src/mail_client/commands/__init__.py @@ -28,6 +28,7 @@ from .outbox import cmd_outbox from .outbox_open import cmd_outbox_open from .ping import cmd_ping +from .refresh import cmd_refresh from .reply import cmd_reply from .send import cmd_send from .swarm_delete import cmd_swarm_delete @@ -78,6 +79,7 @@ "cmd_outbox", "cmd_outbox_open", "cmd_ping", + "cmd_refresh", "cmd_reply", "cmd_send", "cmd_swarm_delete", diff --git a/src/mail/client/src/mail_client/commands/login.py b/src/mail/client/src/mail_client/commands/login.py index 4b4d633..cfecbfe 100644 --- a/src/mail/client/src/mail_client/commands/login.py +++ b/src/mail/client/src/mail_client/commands/login.py @@ -73,3 +73,14 @@ def _print_text(response_obj: AuthTokenPostResponse) -> None: print(response_obj.access_token) print() print("Run subsequent commands with `MAIL_TOKEN={token}`") + # Interactive principals (users/admins) also receive a refresh token; agents + # and daemons do not. + if response_obj.refresh_token is not None: + print() + print("Got refresh token:") + print(response_obj.refresh_token) + print() + print( + "Renew your access token without logging in again by setting " + "`MAIL_REFRESH_TOKEN={refresh_token}` and running `mail refresh`" + ) diff --git a/src/mail/client/src/mail_client/commands/refresh.py b/src/mail/client/src/mail_client/commands/refresh.py new file mode 100644 index 0000000..58e0e71 --- /dev/null +++ b/src/mail/client/src/mail_client/commands/refresh.py @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Addison Kline + +import os +from argparse import Namespace + +import httpx +from mail_protocol.network.responses import AuthRefreshPostResponse +from pydantic import ValidationError + + +def cmd_refresh(args: Namespace) -> None: + """ + Exchange a refresh token for a renewed access token. + + The refresh token is rotated server-side: the value in ``MAIL_REFRESH_TOKEN`` + is invalidated and a replacement is returned, which the caller should store + for the next refresh. + """ + + # 1. check that required env vars are provided + MAIL_SERVER = os.getenv("MAIL_SERVER") + if MAIL_SERVER is None: + raise ValueError("environment variable MAIL_SERVER is required") + MAIL_REFRESH_TOKEN = os.getenv("MAIL_REFRESH_TOKEN") + if MAIL_REFRESH_TOKEN is None: + raise ValueError("environment variable MAIL_REFRESH_TOKEN is required") + + # 2. hit the server endpoint `POST /auth/refresh`. The CLI can't use the + # httpOnly cookie browsers rely on, so the token is sent in the body. + response = httpx.post( + url=f"{MAIL_SERVER}/auth/refresh", + headers={ + "accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "Multi-Agent-Interface-Layer-CLI-Client/2.0.0 (github.com/charonlabs/mail)", + }, + json={"refresh_token": MAIL_REFRESH_TOKEN}, + ) + + # 3. parse and validate server response + if response.status_code != 200: + raise RuntimeError( + f"refresh request to {MAIL_SERVER} failed with status code {response.status_code}" + ) + + response_json = response.json() + try: + response_obj = AuthRefreshPostResponse.model_validate(response_json) + except ValidationError as e: + raise RuntimeError(f"response validation failed: {e}") + + # 4. print the renewed token(s) + match args.output: + case "json": + _print_json(response_obj) + case "text": + _print_text(response_obj) + + +def _print_json(response_obj: AuthRefreshPostResponse) -> None: + print(response_obj.model_dump_json()) + + +def _print_text(response_obj: AuthRefreshPostResponse) -> None: + print("Got token:") + print(response_obj.access_token) + print() + print("Run subsequent commands with `MAIL_TOKEN={token}`") + if response_obj.refresh_token is not None: + print() + print("Got rotated refresh token:") + print(response_obj.refresh_token) + print() + print( + "Your previous refresh token is now invalid. " + "Update `MAIL_REFRESH_TOKEN` with this value." + ) diff --git a/src/mail/protocol/src/mail_protocol/core/auth.py b/src/mail/protocol/src/mail_protocol/core/auth.py new file mode 100644 index 0000000..00b648a --- /dev/null +++ b/src/mail/protocol/src/mail_protocol/core/auth.py @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Addison Kline + +from datetime import datetime +from typing import Annotated + +from pydantic import AfterValidator, BaseModel + +from mail_protocol.core.validators import validate_mail_address + + +class RefreshTokenRecord(BaseModel): + """ + A stored refresh token, as persisted by a MAIL server backend. + + Backend-internal — this never crosses the wire. The plaintext token is + returned to the client exactly once at issuance; only its SHA-256 hash is + stored, so this record carries the hash rather than the token itself. + + Refresh tokens are grouped into *families*: the token minted at login starts + a family, and every rotation keeps the same ``family_id``. ``expires_at`` is + an absolute cap set at login and carried forward unchanged on rotation (it + does not slide). A token is unusable once ``revoked`` is ``True`` or + ``rotated_at`` is set — presenting such a token is treated as reuse and + revokes the whole family. + """ + + token_hash: str + family_id: str + owner_address: Annotated[str, AfterValidator(validate_mail_address)] + issued_at: datetime + expires_at: datetime + revoked: bool = False + rotated_at: datetime | None = None diff --git a/src/mail/protocol/src/mail_protocol/network/requests.py b/src/mail/protocol/src/mail_protocol/network/requests.py index c679ea0..dad39ef 100644 --- a/src/mail/protocol/src/mail_protocol/network/requests.py +++ b/src/mail/protocol/src/mail_protocol/network/requests.py @@ -39,6 +39,17 @@ class AuthTokenPostRequest(BaseModel): pass +class AuthRefreshPostRequest(BaseModel): + """ + Corresponds to `POST /auth/refresh`. + Body fallback carrying the refresh token for clients that cannot use the + ``httpOnly`` cookie (e.g. the CLI). Browsers send the token via cookie and + may omit the body entirely. + """ + + refresh_token: str | None = None + + class AuthPasswordResetRequest(BaseModel): """ Corresponds to `POST /auth/password/reset`. diff --git a/src/mail/protocol/src/mail_protocol/network/responses.py b/src/mail/protocol/src/mail_protocol/network/responses.py index ae3e194..f60ece0 100644 --- a/src/mail/protocol/src/mail_protocol/network/responses.py +++ b/src/mail/protocol/src/mail_protocol/network/responses.py @@ -56,13 +56,47 @@ class AuthTokenPostResponse(BaseModel): """ Corresponds to `POST /auth/token`. Contains a temporary JWT and associated metadata. + + ``refresh_token`` is populated only for interactive principals (users and + admins); agents and daemons re-authenticate with their credentials and + receive ``None``. When present, the server also sets it as an ``httpOnly`` + cookie for browser clients. ``expires_in`` is the access-token lifetime in + seconds. """ access_token: str token_type: Literal["bearer"] + refresh_token: str | None = None + expires_in: int metadata: dict[str, Any] +class AuthRefreshPostResponse(BaseModel): + """ + Corresponds to `POST /auth/refresh`. + Contains a freshly-minted access token and a rotated refresh token. + + Mirrors `AuthTokenPostResponse`. The previous refresh token is invalidated + on every successful refresh; ``refresh_token`` carries its replacement (also + rotated in the ``httpOnly`` cookie for browser clients). + """ + + access_token: str + token_type: Literal["bearer"] + refresh_token: str | None = None + expires_in: int + metadata: dict[str, Any] + + +class AuthLogoutPostResponse(BaseModel): + """ + Corresponds to `POST /auth/logout`. + Contains a message indicating operation success. + """ + + status: Literal["success"] + + class AuthWhoamiGetResponse(BaseModel): """ Corresponds to `GET /auth/whoami`. diff --git a/src/mail/server/.env.example b/src/mail/server/.env.example index ae88234..30a280a 100644 --- a/src/mail/server/.env.example +++ b/src/mail/server/.env.example @@ -5,3 +5,16 @@ MAIL_HOST="swarms.example.com" MAIL_JWT_SECRET_KEY="0d67b4cce591d1ff298fdbc8781f721b811d0483d9892c66dd7f430d15c42492" MAIL_JWT_ALGORITHM="HS256" MAIL_JWT_EXPIRE_MINUTES=30 + +# Refresh token configuration +# Absolute lifetime (in days) of a refresh-token family; carried forward +# unchanged across rotations (the window does not slide). Required. +MAIL_REFRESH_TOKEN_EXPIRE_DAYS=30 + +# Refresh-token cookie configuration (optional) +# MAIL_COOKIE_SECURE: send the refresh cookie only over HTTPS. Defaults to +# "true"; set "false" for local http:// development. +MAIL_COOKIE_SECURE="true" +# MAIL_COOKIE_DOMAIN: optional cookie Domain for cross-subdomain deployments. +# Leave unset for a host-only cookie. +# MAIL_COOKIE_DOMAIN="example.com" diff --git a/src/mail/server/docs/reference/http.md b/src/mail/server/docs/reference/http.md index 17c8262..f96c4c8 100644 --- a/src/mail/server/docs/reference/http.md +++ b/src/mail/server/docs/reference/http.md @@ -11,7 +11,9 @@ This document serves as a reference for the MAIL (Mult-Agent Interface Layer) HT ### Authentication -- `POST /auth/token`: Log in with a valid MAIL address and password to obtain a temporary access token. +- `POST /auth/token`: Log in with a valid MAIL address and password to obtain a temporary access token (and, for users/admins, a refresh token). +- `POST /auth/refresh`: Exchange a refresh token for a new access token, rotating the refresh token. +- `POST /auth/logout`: Revoke the presented refresh token's family and clear the refresh cookie. - `GET /auth/whoami`: Obtain information on the logged-in MAIL server user-agent. - `POST /auth/password/reset`: Reset the logged-in user-agent's password. @@ -75,3 +77,36 @@ This document serves as a reference for the MAIL (Mult-Agent Interface Layer) HT - `GET /admin/webhooks/{webhook_id}`: Get an existing server webhook by ID. - `DELETE /admin/webhooks/{webhook_id}`: Delete an existing server webhook by ID. - `PATCH /admin/webhooks/{webhook_id}`: Update an existing server webhook by ID. + +## Refresh tokens + +Access tokens are short-lived JWTs sent as `Authorization: Bearer `. +Interactive principals (users and admins) additionally receive a **refresh +token** at login, which renews the access token without re-entering a password. +Agents and daemons do not receive refresh tokens — they re-authenticate with +their credentials. + +- **Delivery.** `POST /auth/token` and `POST /auth/refresh` return the refresh + token in the response body **and** set it as an `httpOnly`, `Secure`, + `SameSite=Strict` cookie scoped to `/auth`. Browsers rely on the cookie (it is + not readable by JavaScript, which mitigates XSS token theft); non-browser + clients (e.g. the CLI) send the token back in the `POST /auth/refresh` body. + The cookie takes precedence over the body when both are present. +- **Rotation & reuse detection.** Every successful refresh invalidates the + presented token and issues a replacement in the same *family*. Presenting an + already-rotated (or revoked) token is treated as theft and revokes the entire + family. +- **Expiry.** A family has an absolute lifetime + (`MAIL_REFRESH_TOKEN_EXPIRE_DAYS`) that is carried forward unchanged across + rotations — the window does not slide. +- **Revocation.** `POST /auth/logout` revokes the family; a successful + `POST /auth/password/reset` revokes **all** of the principal's families. + +### Browser silent-refresh pattern + +Keep the access token in memory and let the browser hold the refresh cookie. On +a `401` from any API call, `POST /auth/refresh` once (no body needed — the +cookie is sent automatically) and retry the original request; optionally refresh +proactively shortly before `expires_in` elapses. To avoid two tabs racing and +tripping reuse detection, coalesce concurrent refreshes into a single in-flight +request (single-flight). diff --git a/src/mail/server/docs/tutorials/quickstart.md b/src/mail/server/docs/tutorials/quickstart.md index 8b06c88..9020d64 100644 --- a/src/mail/server/docs/tutorials/quickstart.md +++ b/src/mail/server/docs/tutorials/quickstart.md @@ -18,6 +18,14 @@ The following environment variables are required for `mail-server` to run: - **Example**: `0d67b4cce591d1ff298fdbc8781f721b811d0483d9892c66dd7f430d15c42492` - `MAIL_JWT_EXPIRE_MINUTES`: The lifetime in minutes of JWTs issues by the MAIL server. - **Example**: `30` +- `MAIL_REFRESH_TOKEN_EXPIRE_DAYS`: The absolute lifetime in days of a refresh-token family. Carried forward unchanged across rotations (the window does not slide). + - **Example**: `30` + +The following environment variables are optional: +- `MAIL_COOKIE_SECURE`: Whether the refresh-token cookie is marked `Secure` (HTTPS-only). Defaults to `true`; set `false` for local `http://` development. + - **Example**: `false` +- `MAIL_COOKIE_DOMAIN`: An optional cookie `Domain` for cross-subdomain deployments. Leave unset for a host-only cookie. + - **Example**: `example.com` > [!NOTE] > Refer to `.env.example` in the `mail-server` root for an example environment variable configuration to test with. diff --git a/src/mail/server/src/mail_server/auth.py b/src/mail/server/src/mail_server/auth.py index 39a02da..e31d04a 100644 --- a/src/mail/server/src/mail_server/auth.py +++ b/src/mail/server/src/mail_server/auth.py @@ -1,14 +1,21 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2025-26 Addison Kline +import hashlib import os +import secrets from datetime import UTC, datetime, timedelta import jwt -from fastapi import HTTPException, Request +from fastapi import HTTPException, Request, Response from fastapi.security import OAuth2PasswordBearer from jwt.exceptions import InvalidTokenError -from mail_protocol.core.user_agents import MAILAdmin, MAILDaemon, MAILUserAgent +from mail_protocol.core.user_agents import ( + MAILAdmin, + MAILDaemon, + MAILUser, + MAILUserAgent, +) from pwdlib import PasswordHash from pydantic import BaseModel @@ -21,6 +28,27 @@ if ALGORITHM is None: raise RuntimeError("env var MAIL_JWT_ALGORITHM must be set") +_REFRESH_TOKEN_EXPIRE_DAYS = os.getenv("MAIL_REFRESH_TOKEN_EXPIRE_DAYS") +if _REFRESH_TOKEN_EXPIRE_DAYS is None: + raise RuntimeError("env var MAIL_REFRESH_TOKEN_EXPIRE_DAYS must be set") +REFRESH_TOKEN_EXPIRE_DAYS = int(_REFRESH_TOKEN_EXPIRE_DAYS) + +# Refresh-token cookie configuration. +# +# The cookie is scoped to ``/auth`` so it is sent to ``/auth/refresh`` and +# ``/auth/logout`` (and only those auth endpoints) — never to the wider API, +# which authenticates exclusively via the ``Authorization`` header and so stays +# CSRF-immune. ``Secure`` defaults on; set ``MAIL_COOKIE_SECURE=false`` for +# local ``http://`` development. +REFRESH_COOKIE_NAME = "mail_refresh_token" +REFRESH_COOKIE_PATH = "/auth" +COOKIE_SECURE = os.getenv("MAIL_COOKIE_SECURE", "true").lower() != "false" +COOKIE_DOMAIN = os.getenv("MAIL_COOKIE_DOMAIN") + +# High-entropy opaque refresh tokens; the ``rt_`` prefix aids on-the-wire +# identification. Stored hashed (sha256) — never in plaintext. +REFRESH_TOKEN_PREFIX = "rt_" + class Token(BaseModel): access_token: str @@ -75,6 +103,78 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None): return encoded_jwt +# +# Refresh token helpers +# +def is_interactive_principal(user_agent: MAILUserAgent) -> bool: + """ + Return True for principals that get a refresh token (users and admins). + + Agents and daemons run unattended and re-authenticate with their + credentials, so they are deliberately excluded — issuing them refresh + tokens would only widen the attack surface. + """ + + return isinstance(user_agent.user_agent, MAILUser | MAILAdmin) + + +def generate_refresh_token() -> str: + """ + Generate a new high-entropy opaque refresh token (the plaintext returned to + the client). At least 256 bits of entropy. + """ + + return f"{REFRESH_TOKEN_PREFIX}{secrets.token_urlsafe(32)}" + + +def hash_refresh_token(token: str) -> str: + """ + Hash a refresh token for storage/lookup. SHA-256 is appropriate (and fast) + because the token is already high-entropy — unlike passwords, it needs no + slow KDF. + """ + + return hashlib.sha256(token.encode()).hexdigest() + + +def refresh_token_expiry() -> datetime: + """ + The absolute expiry for a refresh-token family minted now. Carried forward + unchanged on rotation (the window does not slide). + """ + + return datetime.now(UTC) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) + + +def set_refresh_cookie(response: Response, token: str) -> None: + """ + Set the ``httpOnly`` refresh-token cookie for browser clients. + """ + + response.set_cookie( + key=REFRESH_COOKIE_NAME, + value=token, + max_age=REFRESH_TOKEN_EXPIRE_DAYS * 24 * 3600, + path=REFRESH_COOKIE_PATH, + domain=COOKIE_DOMAIN, + secure=COOKIE_SECURE, + httponly=True, + samesite="strict", + ) + + +def clear_refresh_cookie(response: Response) -> None: + """ + Clear the refresh-token cookie (logout). + """ + + response.delete_cookie( + key=REFRESH_COOKIE_NAME, + path=REFRESH_COOKIE_PATH, + domain=COOKIE_DOMAIN, + ) + + async def validate_user_agent( backend: MAILServerBackend, request: Request ) -> MAILUserAgent: diff --git a/src/mail/server/src/mail_server/backends/base.py b/src/mail/server/src/mail_server/backends/base.py index 2e6a090..a56c0f9 100644 --- a/src/mail/server/src/mail_server/backends/base.py +++ b/src/mail/server/src/mail_server/backends/base.py @@ -12,6 +12,7 @@ from uuid import uuid4 import httpx +from mail_protocol.core.auth import RefreshTokenRecord from mail_protocol.core.drafts import MAILDraftsEntry, MAILDraftsEntrySummary from mail_protocol.core.inbox import MAILInboxEntry, MAILInboxEntrySummary from mail_protocol.core.lists import MAILListInBackend @@ -113,6 +114,75 @@ async def reset_password( pass + # + # Refresh token handlers + # + # Refresh tokens are stored hashed (never plaintext). They are grouped into + # *families*: the token minted at login starts a family, and every rotation + # keeps the same ``family_id`` while carrying the family's original + # ``expires_at`` forward (absolute cap — rotation never extends it). A token + # is unusable once ``revoked`` is True or ``rotated_at`` is set; presenting + # such a token is reuse and the caller revokes the whole family. + # + @abstractmethod + async def create_refresh_token( + self, + owner_address: str, + token_hash: str, + family_id: str, + expires_at: datetime, + ) -> None: + """ + Persist a newly-issued refresh token (stamped ``issued_at`` = now, + ``revoked`` = False, ``rotated_at`` = None). + """ + + pass + + @abstractmethod + async def get_refresh_token(self, token_hash: str) -> RefreshTokenRecord | None: + """ + Get a stored refresh token by its hash, or None if it does not exist. + """ + + pass + + @abstractmethod + async def rotate_refresh_token(self, old_hash: str, new_hash: str) -> None: + """ + Rotate a refresh token atomically: mark ``old_hash`` revoked + rotated, + and insert ``new_hash`` into the same family carrying the old token's + ``expires_at`` forward unchanged. Raises ``ValueError`` if ``old_hash`` + is not found. + """ + + pass + + @abstractmethod + async def revoke_refresh_family(self, family_id: str) -> None: + """ + Revoke every refresh token in a family (logout, or reuse detection). + """ + + pass + + @abstractmethod + async def revoke_all_refresh_tokens(self, owner_address: str) -> None: + """ + Revoke every refresh token owned by an address (e.g. on password reset). + """ + + pass + + @abstractmethod + async def purge_expired_refresh_tokens(self) -> int: + """ + Delete every refresh token whose ``expires_at`` is in the past. + Returns the number of tokens removed. + """ + + pass + # # Swarm endpoint handlers # diff --git a/src/mail/server/src/mail_server/backends/memory/api.py b/src/mail/server/src/mail_server/backends/memory/api.py index 3c441cd..c258fc6 100644 --- a/src/mail/server/src/mail_server/backends/memory/api.py +++ b/src/mail/server/src/mail_server/backends/memory/api.py @@ -10,6 +10,7 @@ from datetime import UTC, datetime from typing import Any +from mail_protocol.core.auth import RefreshTokenRecord from mail_protocol.core.constants import LIST_ADDRESS_PREFIX from mail_protocol.core.drafts import MAILDraft, MAILDraftsEntry, MAILDraftsEntrySummary from mail_protocol.core.inbox import MAILInboxEntry, MAILInboxEntrySummary @@ -57,6 +58,7 @@ load_messages, load_outbox_entries, load_outboxes, + load_refresh_tokens, load_swarms, load_trash_entries, load_trashes, @@ -71,6 +73,7 @@ save_messages, save_outbox_entries, save_outboxes, + save_refresh_tokens, save_swarms, save_trash_entries, save_trashes, @@ -150,6 +153,7 @@ def _snapshot_persistence_state(self) -> dict[str, Any]: "message_buffer": list(self.message_buffer), "webhooks": dict(self.webhooks), "lists": dict(self.lists), + "refresh_tokens": dict(self.refresh_tokens), } async def persist(self, *, reason: str = "manual") -> None: @@ -176,6 +180,7 @@ async def persist(self, *, reason: str = "manual") -> None: await save_message_buffer(snapshot["message_buffer"]) await save_webhooks(snapshot["webhooks"]) await save_lists(snapshot["lists"]) + await save_refresh_tokens(snapshot["refresh_tokens"]) elapsed = time.monotonic() - started_at logger.info( @@ -348,6 +353,15 @@ async def on_server_startup(self, **kwargs: Any) -> None: Values: MAILListInBackend instances """ + self.refresh_tokens: dict[str, RefreshTokenRecord] = ( + await load_refresh_tokens() + ) + """ + A dict of all stored refresh tokens on this server. + Keys: token hashes (sha256 hex) + Values: RefreshTokenRecord instances + """ + host = kwargs.get("host") if host is not None: if isinstance(host, str): @@ -411,6 +425,91 @@ async def reset_password( return "success" + # + # Refresh token handlers + # + async def create_refresh_token( + self, + owner_address: str, + token_hash: str, + family_id: str, + expires_at: datetime, + ) -> None: + """ + Persist a newly-issued refresh token. + """ + + self.refresh_tokens[token_hash] = RefreshTokenRecord( + token_hash=token_hash, + family_id=family_id, + owner_address=owner_address, + issued_at=datetime.now(UTC), + expires_at=expires_at, + ) + + async def get_refresh_token(self, token_hash: str) -> RefreshTokenRecord | None: + """ + Get a stored refresh token by its hash, or None if it does not exist. + """ + + return self.refresh_tokens.get(token_hash) + + async def rotate_refresh_token(self, old_hash: str, new_hash: str) -> None: + """ + Rotate a refresh token: revoke the old one and mint a replacement in the + same family carrying the old token's ``expires_at`` forward. + """ + + old = self.refresh_tokens.get(old_hash) + if old is None: + raise ValueError(f"refresh token {old_hash} not found") + + now = datetime.now(UTC) + old.revoked = True + old.rotated_at = now + self.refresh_tokens[old_hash] = old + + self.refresh_tokens[new_hash] = RefreshTokenRecord( + token_hash=new_hash, + family_id=old.family_id, + owner_address=old.owner_address, + issued_at=now, + expires_at=old.expires_at, + ) + + async def revoke_refresh_family(self, family_id: str) -> None: + """ + Revoke every refresh token in a family. + """ + + for record in self.refresh_tokens.values(): + if record.family_id == family_id: + record.revoked = True + + async def revoke_all_refresh_tokens(self, owner_address: str) -> None: + """ + Revoke every refresh token owned by an address. + """ + + for record in self.refresh_tokens.values(): + if record.owner_address == owner_address: + record.revoked = True + + async def purge_expired_refresh_tokens(self) -> int: + """ + Delete every refresh token whose ``expires_at`` is in the past. + """ + + now = datetime.now(UTC) + expired = [ + token_hash + for token_hash, record in self.refresh_tokens.items() + if record.expires_at < now + ] + for token_hash in expired: + del self.refresh_tokens[token_hash] + return len(expired) + # # Swarm endpoint handlers # diff --git a/src/mail/server/src/mail_server/backends/memory/fs.py b/src/mail/server/src/mail_server/backends/memory/fs.py index 3911892..b98ff49 100644 --- a/src/mail/server/src/mail_server/backends/memory/fs.py +++ b/src/mail/server/src/mail_server/backends/memory/fs.py @@ -7,6 +7,7 @@ from os import scandir from pathlib import Path +from mail_protocol.core.auth import RefreshTokenRecord from mail_protocol.core.drafts import MAILDraftsEntry from mail_protocol.core.inbox import MAILInboxEntrySummary from mail_protocol.core.lists import MAILListInBackend @@ -584,6 +585,37 @@ async def load_webhooks() -> dict[str, MAILWebhook]: return webhooks +async def load_refresh_tokens() -> dict[str, RefreshTokenRecord]: + """ + Load saved refresh tokens from the local filesystem. + + The directory is created if absent so memory deployments provisioned before + refresh-token support start cleanly. Each file is named by its token hash + (sha256 hex) and holds the serialized ``RefreshTokenRecord``. + """ + + refresh_tokens_path = DEPLOYMENT_PATH.joinpath("refresh_tokens") + refresh_tokens_path.mkdir(parents=True, exist_ok=True) + logger.info(f"loading refresh_tokens: {refresh_tokens_path}...") + refresh_tokens: dict[str, RefreshTokenRecord] = {} + with scandir(refresh_tokens_path) as entries: + for entry in entries: + if entry.is_file(): + with open(entry) as rt_file: + content = rt_file.read() + try: + rt_model = RefreshTokenRecord.model_validate_json(content) + except Exception as e: + logger.warning(f"RefreshTokenRecord validation failed: {e}") + continue + + refresh_tokens.update({rt_model.token_hash: rt_model}) + + logger.info(f"found {len(refresh_tokens)} refresh_tokens") + + return refresh_tokens + + # # Save memory backend to the local filesystem # (on server shutdown and periodic checkpoints) @@ -805,3 +837,22 @@ async def save_webhooks(webhooks: dict[str, MAILWebhook]) -> None: for webhook in webhooks.values() }, ) + + +async def save_refresh_tokens( + refresh_tokens: dict[str, RefreshTokenRecord], +) -> None: + """ + Save refresh tokens from memory to the local filesystem, one file per token + keyed by its hash. + """ + + logger.info(f"saving {len(refresh_tokens)} refresh_tokens...") + + _save_directory_snapshot( + DEPLOYMENT_PATH.joinpath("refresh_tokens"), + { + token_hash: record.model_dump_json() + for token_hash, record in refresh_tokens.items() + }, + ) diff --git a/src/mail/server/src/mail_server/backends/memory/init.py b/src/mail/server/src/mail_server/backends/memory/init.py index df25fc1..5b60dce 100644 --- a/src/mail/server/src/mail_server/backends/memory/init.py +++ b/src/mail/server/src/mail_server/backends/memory/init.py @@ -128,6 +128,11 @@ def init_memory_backend( LISTS_PATH.mkdir(exist_ok=True) print(f"ensured deployment lists: {LISTS_PATH}") + # ~/.mail-swarms/deployments/{deployment}/refresh_tokens + REFRESH_TOKENS_PATH = DEPLOYMENT_PATH.joinpath("refresh_tokens") + REFRESH_TOKENS_PATH.mkdir(exist_ok=True) + print(f"ensured deployment refresh_tokens: {REFRESH_TOKENS_PATH}") + # write swarm file SWARM_PATH = SWARMS_PATH.joinpath(swarm) with open(SWARM_PATH, "w") as swarm_file: diff --git a/src/mail/server/src/mail_server/backends/sqlite/api.py b/src/mail/server/src/mail_server/backends/sqlite/api.py index cefe3c3..88c0411 100644 --- a/src/mail/server/src/mail_server/backends/sqlite/api.py +++ b/src/mail/server/src/mail_server/backends/sqlite/api.py @@ -36,6 +36,7 @@ from datetime import UTC, datetime from typing import Any, NamedTuple +from mail_protocol.core.auth import RefreshTokenRecord from mail_protocol.core.constants import LIST_ADDRESS_PREFIX from mail_protocol.core.drafts import MAILDraft, MAILDraftsEntry, MAILDraftsEntrySummary from mail_protocol.core.inbox import MAILInboxEntry, MAILInboxEntrySummary @@ -169,6 +170,63 @@ async def reset_password( ) return "success" + # + # Refresh token handlers + # + async def create_refresh_token( + self, + owner_address: str, + token_hash: str, + family_id: str, + expires_at: datetime, + ) -> None: + record = RefreshTokenRecord( + token_hash=token_hash, + family_id=family_id, + owner_address=owner_address, + issued_at=datetime.now(UTC), + expires_at=expires_at, + ) + async with self._db.session() as session: + await MailStore(session).refresh_tokens.add(record) + + async def get_refresh_token(self, token_hash: str) -> RefreshTokenRecord | None: + async with self._db.session() as session: + return await MailStore(session).refresh_tokens.get(token_hash) + + async def rotate_refresh_token(self, old_hash: str, new_hash: str) -> None: + async with self._db.session() as session: + store = MailStore(session) + old = await store.refresh_tokens.get(old_hash) + if old is None: + raise ValueError(f"refresh token {old_hash} not found") + now = datetime.now(UTC) + # Carry the family's original ``expires_at`` forward unchanged — the + # absolute cap does not slide on rotation. + new_record = RefreshTokenRecord( + token_hash=new_hash, + family_id=old.family_id, + owner_address=old.owner_address, + issued_at=now, + expires_at=old.expires_at, + ) + await store.refresh_tokens.mark_rotated(old_hash, now) + await store.refresh_tokens.add(new_record) + + async def revoke_refresh_family(self, family_id: str) -> None: + async with self._db.session() as session: + await MailStore(session).refresh_tokens.revoke_family(family_id) + + async def revoke_all_refresh_tokens(self, owner_address: str) -> None: + async with self._db.session() as session: + await MailStore(session).refresh_tokens.revoke_for_owner(owner_address) + + async def purge_expired_refresh_tokens(self) -> int: + async with self._db.session() as session: + return await MailStore(session).refresh_tokens.purge_expired( + datetime.now(UTC) + ) + # # Swarm endpoint handlers # diff --git a/src/mail/server/src/mail_server/backends/sqlite/repositories.py b/src/mail/server/src/mail_server/backends/sqlite/repositories.py index e5d2df7..dc2e8cc 100644 --- a/src/mail/server/src/mail_server/backends/sqlite/repositories.py +++ b/src/mail/server/src/mail_server/backends/sqlite/repositories.py @@ -27,6 +27,7 @@ from datetime import datetime from typing import Any +from mail_protocol.core.auth import RefreshTokenRecord from mail_protocol.core.drafts import MAILDraftsEntry, MAILDraftsEntrySummary from mail_protocol.core.inbox import MAILInboxEntrySummary from mail_protocol.core.lists import MAILListInBackend @@ -37,7 +38,7 @@ from mail_protocol.core.user_agents import MAILUserAgentInBackend from mail_protocol.core.webhooks import MAILWebhook from mail_protocol.network.requests import BoxFilterParams -from sqlalchemy import asc, delete, func, select +from sqlalchemy import asc, delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from mail_server.backends.sqlite import serializers as ser @@ -49,6 +50,7 @@ MessageBufferRow, MessageRow, OutboxEntryRow, + RefreshTokenRow, SwarmRow, TrashEntryRow, UserAgentRow, @@ -96,6 +98,10 @@ def webhooks(self) -> WebhookRepository: def lists(self) -> ListRepository: return ListRepository(self.session) + @property + def refresh_tokens(self) -> RefreshTokenRepository: + return RefreshTokenRepository(self.session) + # --------------------------------------------------------------------------- # # user_agents @@ -629,3 +635,57 @@ async def delete(self, address: str) -> MAILListInBackend | None: await self.session.delete(row) await self.session.flush() return model + + +# --------------------------------------------------------------------------- # +# refresh_tokens (keyed by hash; no JSON body) +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True) +class RefreshTokenRepository: + session: AsyncSession + + async def get(self, token_hash: str) -> RefreshTokenRecord | None: + row = await self.session.get(RefreshTokenRow, token_hash) + if row is None: + return None + return ser.refresh_token_from_row(row) + + async def add(self, model: RefreshTokenRecord) -> RefreshTokenRecord: + self.session.add(RefreshTokenRow(**ser.refresh_token_to_columns(model))) + await self.session.flush() + return model + + async def mark_rotated(self, token_hash: str, rotated_at: datetime) -> None: + """Mark a token as revoked + rotated (the old half of a rotation).""" + + await self.session.execute( + update(RefreshTokenRow) + .where(RefreshTokenRow.token_hash == token_hash) + .values(revoked=True, rotated_at=rotated_at) + ) + await self.session.flush() + + async def revoke_family(self, family_id: str) -> None: + await self.session.execute( + update(RefreshTokenRow) + .where(RefreshTokenRow.family_id == family_id) + .values(revoked=True) + ) + await self.session.flush() + + async def revoke_for_owner(self, owner_address: str) -> None: + await self.session.execute( + update(RefreshTokenRow) + .where(RefreshTokenRow.owner_address == owner_address) + .values(revoked=True) + ) + await self.session.flush() + + async def purge_expired(self, now: datetime) -> int: + result = await self.session.execute( + delete(RefreshTokenRow).where(RefreshTokenRow.expires_at < now) + ) + await self.session.flush() + return result.rowcount or 0 diff --git a/src/mail/server/src/mail_server/backends/sqlite/schema.py b/src/mail/server/src/mail_server/backends/sqlite/schema.py index 9c469fc..5d7212f 100644 --- a/src/mail/server/src/mail_server/backends/sqlite/schema.py +++ b/src/mail/server/src/mail_server/backends/sqlite/schema.py @@ -230,6 +230,37 @@ class WebhookRow(Base): ) +class RefreshTokenRow(Base): + """ + A stored refresh token, keyed by its hash. + + Unlike the entity rows, this table has **no** ``body`` JSON column: the + ``RefreshTokenRecord`` model is tiny and every field is queried directly, so + all columns are typed (mirroring ``mailbox_items`` / ``message_buffer``). + ``owner_address`` cascades on user-agent deletion, so removing a principal + drops their refresh tokens for free. + """ + + __tablename__ = "refresh_tokens" + + # sha256 hex of the plaintext token. + token_hash: Mapped[str] = mapped_column(String(64), primary_key=True) + family_id: Mapped[str] = mapped_column(String(64), index=True) + owner_address: Mapped[str] = mapped_column( + String(512), + ForeignKey("user_agents.address", ondelete="CASCADE"), + index=True, + ) + issued_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utc_now + ) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + revoked: Mapped[bool] = mapped_column(default=False) + rotated_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + class ListRow(Base): """A MAIL list. Members live inside ``body``, mirroring the memory backend.""" diff --git a/src/mail/server/src/mail_server/backends/sqlite/serializers.py b/src/mail/server/src/mail_server/backends/sqlite/serializers.py index 30bffea..18e5d19 100644 --- a/src/mail/server/src/mail_server/backends/sqlite/serializers.py +++ b/src/mail/server/src/mail_server/backends/sqlite/serializers.py @@ -28,8 +28,10 @@ from __future__ import annotations +from datetime import UTC, datetime from typing import Any +from mail_protocol.core.auth import RefreshTokenRecord from mail_protocol.core.drafts import MAILDraftsEntry from mail_protocol.core.inbox import MAILInboxEntrySummary from mail_protocol.core.lists import MAILListInBackend @@ -46,6 +48,7 @@ ListRow, MessageRow, OutboxEntryRow, + RefreshTokenRow, SwarmRow, TrashEntryRow, UserAgentRow, @@ -221,3 +224,48 @@ def list_to_columns(model: MAILListInBackend) -> dict[str, Any]: def list_from_row(row: ListRow) -> MAILListInBackend: return MAILListInBackend.model_validate(row.body) + + +# --------------------------------------------------------------------------- # +# refresh_tokens (no body column — every field is typed and queried) +# --------------------------------------------------------------------------- # + + +def refresh_token_to_columns(model: RefreshTokenRecord) -> dict[str, Any]: + return { + "token_hash": model.token_hash, + "family_id": model.family_id, + "owner_address": model.owner_address, + "issued_at": model.issued_at, + "expires_at": model.expires_at, + "revoked": model.revoked, + "rotated_at": model.rotated_at, + } + + +def _as_utc(value: datetime | None) -> datetime | None: + """ + Re-attach UTC to a datetime read back from SQLite. + + SQLite has no native datetime type, so ``DateTime(timezone=True)`` round-trips + as a tz-*naive* value even though we always persist UTC. The entity tables + avoid this by rehydrating from the JSON ``body`` (ISO strings keep the + offset); ``refresh_tokens`` has no body, so we normalize here to keep the + backend contract (tz-aware UTC) identical to the memory backend. + """ + + if value is not None and value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value + + +def refresh_token_from_row(row: RefreshTokenRow) -> RefreshTokenRecord: + return RefreshTokenRecord( + token_hash=row.token_hash, + family_id=row.family_id, + owner_address=row.owner_address, + issued_at=_as_utc(row.issued_at), # type: ignore[arg-type] + expires_at=_as_utc(row.expires_at), # type: ignore[arg-type] + revoked=row.revoked, + rotated_at=_as_utc(row.rotated_at), + ) diff --git a/src/mail/server/src/mail_server/routers/auth.py b/src/mail/server/src/mail_server/routers/auth.py index 3c13828..e77ce98 100644 --- a/src/mail/server/src/mail_server/routers/auth.py +++ b/src/mail/server/src/mail_server/routers/auth.py @@ -2,23 +2,36 @@ # Copyright (c) 2026 Addison Kline import os -from datetime import timedelta +from datetime import UTC, datetime, timedelta from typing import Annotated +from uuid import uuid4 -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.security.oauth2 import OAuth2PasswordRequestForm from mail_protocol.network.responses import ( + AuthLogoutPostResponse, AuthPasswordResetResponse, + AuthRefreshPostResponse, AuthTokenPostResponse, AuthWhoamiGetResponse, ) from mail_server.auth import ( + REFRESH_COOKIE_NAME, authenticate_user_agent, + clear_refresh_cookie, create_access_token, + generate_refresh_token, + hash_refresh_token, + is_interactive_principal, + refresh_token_expiry, + set_refresh_cookie, validate_user_agent, ) -from mail_server.validators import validate_auth_password_reset_request +from mail_server.validators import ( + validate_auth_password_reset_request, + validate_auth_refresh_request, +) ACCESS_TOKEN_EXPIRE_MINUTES = os.getenv("MAIL_JWT_EXPIRE_MINUTES") if ACCESS_TOKEN_EXPIRE_MINUTES is None: @@ -35,6 +48,7 @@ ) async def create_auth_token( request: Request, + response: Response, form_data: Annotated[OAuth2PasswordRequestForm, Depends()], ) -> AuthTokenPostResponse: backend = request.app.state.backend @@ -51,13 +65,126 @@ async def create_auth_token( access_token = create_access_token( data={"sub": user_agent.get_address()}, expires_delta=access_token_expires ) + + # Interactive principals (users/admins) also get a refresh token, set as an + # httpOnly cookie for browsers and returned in the body for the CLI. Agents + # and daemons re-authenticate with their credentials and get none. + refresh_token: str | None = None + if is_interactive_principal(user_agent): + refresh_token = generate_refresh_token() + await backend.create_refresh_token( + owner_address=user_agent.get_address(), + token_hash=hash_refresh_token(refresh_token), + family_id=f"fam_{uuid4()}", + expires_at=refresh_token_expiry(), + ) + set_refresh_cookie(response, refresh_token) + return AuthTokenPostResponse( access_token=access_token, token_type="bearer", + refresh_token=refresh_token, + expires_in=default_token_limit * 60, metadata={}, ) +async def _read_refresh_token(request: Request) -> str | None: + """ + Extract the presented refresh token: the cookie (browsers) takes precedence, + falling back to the request body (CLI / non-cookie clients). + """ + + token = request.cookies.get(REFRESH_COOKIE_NAME) + if token is not None: + return token + payload = await validate_auth_refresh_request(request=request) + return payload.refresh_token + + +@router.post( + "/refresh", + summary="Exchange a refresh token for a new access token (rotates the refresh token)", + response_model=AuthRefreshPostResponse, +) +async def post_auth_refresh( + request: Request, + response: Response, +) -> AuthRefreshPostResponse: + backend = request.app.state.backend + credentials_exception = HTTPException( + status_code=401, + detail="could not validate refresh token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + token = await _read_refresh_token(request) + if token is None: + raise credentials_exception + + record = await backend.get_refresh_token(hash_refresh_token(token)) + if record is None: + raise credentials_exception + + # Absolute cap: a family expires at a fixed time, carried forward unchanged + # across rotations. + if record.expires_at <= datetime.now(UTC): + raise credentials_exception + + # Reuse detection: a revoked or already-rotated token being presented means + # the token was stolen (or replayed) — revoke the whole family. + if record.revoked or record.rotated_at is not None: + await backend.revoke_refresh_family(record.family_id) + raise credentials_exception + + # Fail closed if the owner no longer exists (e.g. deleted by an admin). + if not await backend.user_agent_exists(record.owner_address): + await backend.revoke_refresh_family(record.family_id) + raise credentials_exception + + new_refresh_token = generate_refresh_token() + await backend.rotate_refresh_token( + hash_refresh_token(token), hash_refresh_token(new_refresh_token) + ) + + access_token = create_access_token( + data={"sub": record.owner_address}, + expires_delta=timedelta(minutes=default_token_limit), # type: ignore + ) + set_refresh_cookie(response, new_refresh_token) + + return AuthRefreshPostResponse( + access_token=access_token, + token_type="bearer", + refresh_token=new_refresh_token, + expires_in=default_token_limit * 60, + metadata={}, + ) + + +@router.post( + "/logout", + summary="Revoke the presented refresh token's family and clear the cookie", + response_model=AuthLogoutPostResponse, +) +async def post_auth_logout( + request: Request, + response: Response, +) -> AuthLogoutPostResponse: + backend = request.app.state.backend + + # Idempotent: revoke the family if the token resolves, but always succeed and + # clear the cookie so a stale/absent token still logs the client out. + token = await _read_refresh_token(request) + if token is not None: + record = await backend.get_refresh_token(hash_refresh_token(token)) + if record is not None: + await backend.revoke_refresh_family(record.family_id) + + clear_refresh_cookie(response) + return AuthLogoutPostResponse(status="success") + + @router.get( "/whoami", summary="Get MAIL user-agent info", response_model=AuthWhoamiGetResponse ) @@ -89,6 +216,11 @@ async def post_password_reset(request: Request) -> AuthPasswordResetResponse: ) if result != "success": raise HTTPException(status_code=400, detail="could not reset password") + + # A password change invalidates every existing session for this principal — + # revoke all of their refresh-token families, forcing re-login everywhere. + await backend.revoke_all_refresh_tokens(user_agent.get_address()) + return AuthPasswordResetResponse( status=result, ) diff --git a/src/mail/server/src/mail_server/validators.py b/src/mail/server/src/mail_server/validators.py index bbaafea..9a7c6e6 100644 --- a/src/mail/server/src/mail_server/validators.py +++ b/src/mail/server/src/mail_server/validators.py @@ -20,6 +20,7 @@ AdminWebhooksPatchRequest, AdminWebhooksPostRequest, AuthPasswordResetRequest, + AuthRefreshPostRequest, BoxFilterParams, DaemonDeliverLocalRequest, DaemonDeliverRemoteRequest, @@ -390,3 +391,25 @@ async def validate_auth_password_reset_request( raise HTTPException( status_code=422, detail=f"request body validation failed: {e}" ) + + +async def validate_auth_refresh_request( + request: Request, +) -> AuthRefreshPostRequest: + """ + Ensure that the request payload is valid for `POST /auth/refresh`. + + An empty body is allowed: browsers carry the refresh token in the + ``httpOnly`` cookie and may send no body at all. A non-empty body must still + be valid JSON for the model, otherwise 422. + """ + + raw = await request.body() + if not raw: + return AuthRefreshPostRequest() + try: + return AuthRefreshPostRequest.model_validate_json(raw) + except ValueError as e: + raise HTTPException( + status_code=422, detail=f"request body validation failed: {e}" + ) diff --git a/tests/conftest.py b/tests/conftest.py index ab76519..c445925 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,11 @@ # mail_server.* imports succeed everywhere. os.environ.setdefault("MAIL_JWT_SECRET_KEY", "test-secret-not-used") os.environ.setdefault("MAIL_JWT_ALGORITHM", "HS256") +os.environ.setdefault("MAIL_REFRESH_TOKEN_EXPIRE_DAYS", "30") +# The TestClient speaks http://testserver; a ``Secure`` cookie would never be +# sent back over http, so disable the flag for the in-process suites. The +# secure-by-default behavior is covered by a dedicated unit test. +os.environ.setdefault("MAIL_COOKIE_SECURE", "false") import pytest # noqa: E402 from mail_server.backends.memory import fs as memory_fs # noqa: E402 @@ -61,6 +66,7 @@ def deployment_dir( "trashes", "webhooks", "lists", + "refresh_tokens", ): (deployment / subdir).mkdir(parents=True, exist_ok=True) (deployment / "message_buffer.lock").touch() diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 2d6d573..0ba0aff 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -52,6 +52,7 @@ def __init__(self, home: Path) -> None: "MAIL_JWT_SECRET_KEY": "e2e-secret", "MAIL_JWT_ALGORITHM": "HS256", "MAIL_JWT_EXPIRE_MINUTES": "15", + "MAIL_REFRESH_TOKEN_EXPIRE_DAYS": "30", } self.server: subprocess.Popen | None = None self.credentials: dict[str, str] = {} diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 37baa57..85dcb88 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -4,7 +4,7 @@ import asyncio import os from collections.abc import Awaitable, Callable, Iterator -from datetime import datetime +from datetime import UTC, datetime from pathlib import Path # mail_server.server reads MAIL_HOST and mail_server.routers.auth reads @@ -15,6 +15,7 @@ import pytest # noqa: E402 from fastapi.testclient import TestClient # noqa: E402 +from mail_protocol.core.auth import RefreshTokenRecord # noqa: E402 from mail_protocol.core.lists import MAILListInBackend # noqa: E402 from mail_protocol.core.messages import MAILMessage # noqa: E402 from mail_protocol.core.swarms import MAILSwarm # noqa: E402 @@ -27,7 +28,7 @@ MAILUserAgentInBackend, ) from mail_server import server as mail_server_module # noqa: E402 -from mail_server.auth import get_password_hash # noqa: E402 +from mail_server.auth import get_password_hash, hash_refresh_token # noqa: E402 from mail_server.backends.base import MAILServerBackend # noqa: E402 from mail_server.backends.memory.api import MemoryBackend # noqa: E402 from mail_server.backends.sqlite.api import SQLiteBackend # noqa: E402 @@ -232,6 +233,42 @@ async def mutate(store: MailStore) -> None: return _seed +@pytest.fixture +def seed_refresh_token(backend: MAILServerBackend) -> Callable[..., str]: + """ + Backend-agnostic: persist a refresh token directly so a test can pin its + ``expires_at`` / ``family_id`` (impossible through the login API, which + always stamps the configured absolute cap). Returns the plaintext token. + """ + + def _seed( + owner: str, + token: str, + *, + expires_at: datetime, + family_id: str = "fam_seed", + ) -> str: + record = RefreshTokenRecord( + token_hash=hash_refresh_token(token), + family_id=family_id, + owner_address=owner, + issued_at=datetime.now(UTC), + expires_at=expires_at, + ) + if isinstance(backend, MemoryBackend): + backend.refresh_tokens[record.token_hash] = record + else: + assert isinstance(backend, SQLiteBackend) + + async def mutate(store: MailStore) -> None: + await store.refresh_tokens.add(record) + + _run_sqlite_write(backend._db.url, mutate) + return token + + return _seed + + @pytest.fixture def list_members( app_client: TestClient, headers_for: Callable[..., dict[str, str]] diff --git a/tests/integration/test_refresh_flow.py b/tests/integration/test_refresh_flow.py new file mode 100644 index 0000000..bdd293a --- /dev/null +++ b/tests/integration/test_refresh_flow.py @@ -0,0 +1,184 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Addison Kline + +""" +HTTP-level tests for the refresh-token flow (``POST /auth/token`` issuance, +``POST /auth/refresh`` rotation + reuse detection, ``POST /auth/logout``, and +the password-reset cascade). Every test runs against both backends via the +parametrized ``app_client`` fixture. + +The ``Secure`` cookie flag is disabled for the suite (see ``tests/conftest.py``) +so the TestClient replays the cookie over http; ``test_login_cookie_attributes`` +asserts the remaining hardening attributes. +""" + +from collections.abc import Callable +from datetime import UTC, datetime, timedelta + +import pytest +from fastapi.testclient import TestClient + +ADMIN = "admin:ryan@localhost" +USER = "user:alice@localhost" +AGENT = "sage@chorus@localhost" +DAEMON = "daemon:dummy@localhost" +PASSWORD = "correct-horse-battery-staple" + + +def _login(client: TestClient, address: str, password: str = PASSWORD): + return client.post( + "/auth/token", data={"username": address, "password": password} + ) + + +def _refresh_body(client: TestClient, token: str): + """Refresh via the body path — cookies cleared so the cookie can't win.""" + + client.cookies.clear() + return client.post("/auth/refresh", json={"refresh_token": token}) + + +# ─── issuance ────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("address", [USER, ADMIN]) +def test_login_issues_refresh_token_for_interactive( + app_client: TestClient, address: str +) -> None: + app_client.cookies.clear() + resp = _login(app_client, address) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["refresh_token"] is not None + assert body["refresh_token"].startswith("rt_") + assert body["expires_in"] > 0 + assert "mail_refresh_token=" in (resp.headers.get("set-cookie") or "") + + +@pytest.mark.parametrize("address", [AGENT, DAEMON]) +def test_login_no_refresh_token_for_non_interactive( + app_client: TestClient, address: str +) -> None: + app_client.cookies.clear() + resp = _login(app_client, address) + assert resp.status_code == 200, resp.text + assert resp.json()["refresh_token"] is None + assert resp.headers.get("set-cookie") is None + + +def test_login_cookie_attributes(app_client: TestClient) -> None: + app_client.cookies.clear() + resp = _login(app_client, USER) + set_cookie = (resp.headers.get("set-cookie") or "").lower() + assert "httponly" in set_cookie + assert "samesite=strict" in set_cookie + assert "path=/auth" in set_cookie + + +# ─── refresh / rotation ──────────────────────────────────────────── + + +def test_refresh_via_cookie_rotates(app_client: TestClient) -> None: + app_client.cookies.clear() + old = _login(app_client, USER).json()["refresh_token"] + + # cookie from login is replayed automatically + resp = app_client.post("/auth/refresh") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["access_token"] + assert body["refresh_token"] and body["refresh_token"] != old + + # the consumed token is now dead (reuse → 401) + assert _refresh_body(app_client, old).status_code == 401 + + +def test_refresh_via_body(app_client: TestClient) -> None: + app_client.cookies.clear() + token = _login(app_client, USER).json()["refresh_token"] + + resp = _refresh_body(app_client, token) + assert resp.status_code == 200, resp.text + assert resp.json()["refresh_token"] != token + + +def test_reuse_revokes_whole_family(app_client: TestClient) -> None: + app_client.cookies.clear() + t0 = _login(app_client, USER).json()["refresh_token"] + + t1 = _refresh_body(app_client, t0).json()["refresh_token"] + + # replaying the already-rotated t0 is reuse → 401 and nukes the family + assert _refresh_body(app_client, t0).status_code == 401 + # the live sibling t1 is now collateral-revoked + assert _refresh_body(app_client, t1).status_code == 401 + + +def test_refresh_unknown_token_401(app_client: TestClient) -> None: + assert _refresh_body(app_client, "rt_does-not-exist").status_code == 401 + + +def test_refresh_without_token_401(app_client: TestClient) -> None: + app_client.cookies.clear() + assert app_client.post("/auth/refresh").status_code == 401 + + +def test_refresh_expired_token_401( + app_client: TestClient, seed_refresh_token: Callable[..., str] +) -> None: + token = seed_refresh_token( + USER, "rt_expired", expires_at=datetime.now(UTC) - timedelta(seconds=1) + ) + assert _refresh_body(app_client, token).status_code == 401 + + +def test_refresh_after_owner_deleted_401( + app_client: TestClient, headers_for: Callable[..., dict[str, str]] +) -> None: + app_client.cookies.clear() + token = _login(app_client, USER).json()["refresh_token"] + + deleted = app_client.delete("/admin/users/alice", headers=headers_for(ADMIN)) + assert deleted.status_code == 200, deleted.text + + assert _refresh_body(app_client, token).status_code == 401 + + +# ─── logout ──────────────────────────────────────────────────────── + + +def test_logout_revokes_family_and_succeeds(app_client: TestClient) -> None: + app_client.cookies.clear() + token = _login(app_client, USER).json()["refresh_token"] + + out = app_client.post("/auth/logout") + assert out.status_code == 200, out.text + assert out.json()["status"] == "success" + + assert _refresh_body(app_client, token).status_code == 401 + + +def test_logout_without_token_is_idempotent(app_client: TestClient) -> None: + app_client.cookies.clear() + out = app_client.post("/auth/logout") + assert out.status_code == 200 + assert out.json()["status"] == "success" + + +# ─── password reset cascade ──────────────────────────────────────── + + +def test_password_reset_revokes_refresh_tokens( + app_client: TestClient, headers_for: Callable[..., dict[str, str]] +) -> None: + app_client.cookies.clear() + token = _login(app_client, USER).json()["refresh_token"] + + reset = app_client.post( + "/auth/password/reset", + json={"current_password": PASSWORD, "new_password": "new-passw0rd-here"}, + headers=headers_for(USER), + ) + assert reset.status_code == 200, reset.text + + assert _refresh_body(app_client, token).status_code == 401 diff --git a/tests/integration/test_refresh_tokens_backend.py b/tests/integration/test_refresh_tokens_backend.py new file mode 100644 index 0000000..4459a23 --- /dev/null +++ b/tests/integration/test_refresh_tokens_backend.py @@ -0,0 +1,187 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Addison Kline + +""" +Conformance tests for the refresh-token backend protocol methods. + +Each test runs against **both** the memory and sqlite backends via the +``rt_backend`` fixture, so the two implementations are held to identical +behavior (create / get / rotate / revoke-family / revoke-for-owner / purge). +Unlike the rest of the integration suite these exercise the backend protocol +directly rather than the FastAPI app — the HTTP-level refresh flow is covered in +the auth router tests. +""" + +import hashlib +from collections.abc import AsyncIterator +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from mail_protocol.core.user_agents import MAILUser, MAILUserAgentInBackend +from mail_server.auth import get_password_hash +from mail_server.backends.base import MAILServerBackend +from mail_server.backends.memory.api import MemoryBackend +from mail_server.backends.sqlite.api import SQLiteBackend +from mail_server.backends.sqlite.repositories import MailStore + +HOST = "localhost" +OWNER = f"user:alice@{HOST}" +OTHER = f"user:bob@{HOST}" + +# Argon2 is deliberately slow; hash the throwaway password once. +_PWHASH = get_password_hash("pw") + + +def _user(user_id: str) -> MAILUserAgentInBackend: + return MAILUserAgentInBackend( + user_agent=MAILUser(ua_type="user", user_id=user_id, host=HOST), + hashed_password=_PWHASH, + ) + + +def _h(label: str) -> str: + """A deterministic, validly-shaped (64 hex) stand-in token hash.""" + + return hashlib.sha256(label.encode()).hexdigest() + + +def _exp(*, days: int = 30) -> datetime: + return datetime.now(UTC) + timedelta(days=days) + + +@pytest.fixture(params=["memory", "sqlite"]) +async def rt_backend( + request: pytest.FixtureRequest, + deployment_dir: Path, + tmp_path: Path, +) -> AsyncIterator[MAILServerBackend]: + """A started backend seeded with two users (FK owners for refresh tokens).""" + + if request.param == "memory": + backend: MAILServerBackend = MemoryBackend() + await backend.on_server_startup(host=HOST) + backend.user_agents[OWNER] = _user("alice") # type: ignore[attr-defined] + backend.user_agents[OTHER] = _user("bob") # type: ignore[attr-defined] + try: + yield backend + finally: + await backend.on_server_shutdown() + return + + db_url = f"sqlite:///{tmp_path / 'mail.db'}" + sqlite_backend = SQLiteBackend(url=db_url) + await sqlite_backend.on_server_startup(host=HOST) + async with sqlite_backend._db.session() as session: + store = MailStore(session) + await store.user_agents.add(_user("alice")) + await store.user_agents.add(_user("bob")) + try: + yield sqlite_backend + finally: + await sqlite_backend.on_server_shutdown() + + +async def test_create_and_get(rt_backend: MAILServerBackend) -> None: + exp = _exp() + await rt_backend.create_refresh_token(OWNER, _h("t1"), "fam1", exp) + + rec = await rt_backend.get_refresh_token(_h("t1")) + assert rec is not None + assert rec.token_hash == _h("t1") + assert rec.owner_address == OWNER + assert rec.family_id == "fam1" + assert rec.revoked is False + assert rec.rotated_at is None + assert abs((rec.expires_at - exp).total_seconds()) < 1 + assert rec.issued_at <= datetime.now(UTC) + + +async def test_get_missing_returns_none(rt_backend: MAILServerBackend) -> None: + assert await rt_backend.get_refresh_token(_h("nope")) is None + + +async def test_rotate_revokes_old_and_carries_expiry_forward( + rt_backend: MAILServerBackend, +) -> None: + exp = _exp() + await rt_backend.create_refresh_token(OWNER, _h("old"), "fam", exp) + + await rt_backend.rotate_refresh_token(_h("old"), _h("new")) + + old = await rt_backend.get_refresh_token(_h("old")) + new = await rt_backend.get_refresh_token(_h("new")) + assert old is not None and new is not None + # old half: revoked + rotated + assert old.revoked is True + assert old.rotated_at is not None + # new half: live, same family, absolute cap unchanged + assert new.revoked is False + assert new.rotated_at is None + assert new.family_id == "fam" + assert new.owner_address == OWNER + assert abs((new.expires_at - old.expires_at).total_seconds()) < 1 + + +async def test_rotate_missing_raises(rt_backend: MAILServerBackend) -> None: + with pytest.raises(ValueError): + await rt_backend.rotate_refresh_token(_h("ghost"), _h("x")) + + +async def test_revoke_family_only_targets_that_family( + rt_backend: MAILServerBackend, +) -> None: + await rt_backend.create_refresh_token(OWNER, _h("a1"), "A", _exp()) + await rt_backend.create_refresh_token(OWNER, _h("a2"), "A", _exp()) + await rt_backend.create_refresh_token(OWNER, _h("b1"), "B", _exp()) + + await rt_backend.revoke_refresh_family("A") + + a1 = await rt_backend.get_refresh_token(_h("a1")) + a2 = await rt_backend.get_refresh_token(_h("a2")) + b1 = await rt_backend.get_refresh_token(_h("b1")) + assert a1 is not None and a1.revoked is True + assert a2 is not None and a2.revoked is True + assert b1 is not None and b1.revoked is False + + +async def test_revoke_all_for_owner_spares_other_owners( + rt_backend: MAILServerBackend, +) -> None: + await rt_backend.create_refresh_token(OWNER, _h("o1"), "X", _exp()) + await rt_backend.create_refresh_token(OTHER, _h("p1"), "Y", _exp()) + + await rt_backend.revoke_all_refresh_tokens(OWNER) + + o1 = await rt_backend.get_refresh_token(_h("o1")) + p1 = await rt_backend.get_refresh_token(_h("p1")) + assert o1 is not None and o1.revoked is True + assert p1 is not None and p1.revoked is False + + +async def test_purge_expired_removes_only_expired( + rt_backend: MAILServerBackend, +) -> None: + await rt_backend.create_refresh_token( + OWNER, _h("dead"), "fam", datetime.now(UTC) - timedelta(seconds=1) + ) + await rt_backend.create_refresh_token(OWNER, _h("live"), "fam", _exp()) + + removed = await rt_backend.purge_expired_refresh_tokens() + + assert removed == 1 + assert await rt_backend.get_refresh_token(_h("dead")) is None + assert await rt_backend.get_refresh_token(_h("live")) is not None + + +async def test_sqlite_user_delete_cascades_refresh_tokens( + rt_backend: MAILServerBackend, +) -> None: + if not isinstance(rt_backend, SQLiteBackend): + pytest.skip("FK cascade-on-delete is a sqlite-specific guarantee") + + await rt_backend.create_refresh_token(OWNER, _h("c1"), "fam", _exp()) + async with rt_backend._db.session() as session: + await MailStore(session).user_agents.delete(OWNER) + + assert await rt_backend.get_refresh_token(_h("c1")) is None diff --git a/tests/unit/test_client_commands.py b/tests/unit/test_client_commands.py index bb5f309..533a959 100644 --- a/tests/unit/test_client_commands.py +++ b/tests/unit/test_client_commands.py @@ -23,6 +23,7 @@ cmd_inbox, cmd_login, cmd_ping, + cmd_refresh, cmd_reply, cmd_send, ) @@ -95,7 +96,12 @@ def test_login_posts_credentials_as_form_data( route = respx.post(f"{SERVER}/auth/token").mock( return_value=httpx.Response( 200, - json={"access_token": "issued-jwt", "token_type": "bearer", "metadata": {}}, + json={ + "access_token": "issued-jwt", + "token_type": "bearer", + "expires_in": 900, + "metadata": {}, + }, ) ) cmd_login(Namespace(output="text")) @@ -113,6 +119,53 @@ def test_login_requires_credentials_env(monkeypatch: pytest.MonkeyPatch) -> None cmd_login(Namespace(output="text")) +# ─── refresh ─────────────────────────────────────────────────────── + + +@respx.mock +def test_refresh_posts_token_in_body_and_prints_rotated( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture +) -> None: + monkeypatch.setenv("MAIL_SERVER", SERVER) + monkeypatch.setenv("MAIL_REFRESH_TOKEN", "rt_old") + + route = respx.post(f"{SERVER}/auth/refresh").mock( + return_value=httpx.Response( + 200, + json={ + "access_token": "fresh-jwt", + "token_type": "bearer", + "refresh_token": "rt_new", + "expires_in": 900, + "metadata": {}, + }, + ) + ) + cmd_refresh(Namespace(output="text")) + + sent = json.loads(route.calls[0].request.content.decode()) + assert sent == {"refresh_token": "rt_old"} + out = capsys.readouterr().out + assert "fresh-jwt" in out + assert "rt_new" in out + + +def test_refresh_requires_refresh_token_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MAIL_SERVER", SERVER) + monkeypatch.delenv("MAIL_REFRESH_TOKEN", raising=False) + with pytest.raises(ValueError, match="MAIL_REFRESH_TOKEN"): + cmd_refresh(Namespace(output="text")) + + +@respx.mock +def test_refresh_raises_on_401(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MAIL_SERVER", SERVER) + monkeypatch.setenv("MAIL_REFRESH_TOKEN", "rt_dead") + respx.post(f"{SERVER}/auth/refresh").mock(return_value=httpx.Response(401)) + with pytest.raises(RuntimeError, match="401"): + cmd_refresh(Namespace(output="text")) + + # ─── inbox ───────────────────────────────────────────────────────── diff --git a/tests/unit/test_daemon_api.py b/tests/unit/test_daemon_api.py index c4a64c8..d9e3fee 100644 --- a/tests/unit/test_daemon_api.py +++ b/tests/unit/test_daemon_api.py @@ -20,7 +20,12 @@ TOKEN = "daemon-jwt" ROOT_RESPONSE = {"protocol_name": "mail", "protocol_version": "2.0", "uptime": 1.5} -TOKEN_RESPONSE = {"access_token": TOKEN, "token_type": "bearer", "metadata": {}} +TOKEN_RESPONSE = { + "access_token": TOKEN, + "token_type": "bearer", + "expires_in": 900, + "metadata": {}, +} def _whoami_response(ua_type: str = "daemon") -> dict: diff --git a/tests/unit/test_refresh_auth_helpers.py b/tests/unit/test_refresh_auth_helpers.py new file mode 100644 index 0000000..e6889b9 --- /dev/null +++ b/tests/unit/test_refresh_auth_helpers.py @@ -0,0 +1,98 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Addison Kline + +""" +Unit tests for the refresh-token helpers in ``mail_server.auth``: token +generation/hashing, the interactive-principal check, and cookie attributes +(including the ``Secure`` flag, which the integration suite disables so the +TestClient can replay cookies over http). +""" + +import hashlib + +import mail_server.auth as auth +import pytest +from fastapi import Response +from mail_protocol.core.user_agents import ( + MAILAdmin, + MAILAgent, + MAILDaemon, + MAILUser, + MAILUserAgent, + MAILUserAgentInBackend, +) + +HOST = "example.com" + + +def _set_cookie(resp: Response) -> str: + return resp.headers.get("set-cookie") or "" + + +def _wrap(ua: MAILUserAgent) -> MAILUserAgentInBackend: + return MAILUserAgentInBackend(user_agent=ua, hashed_password="x") + + +def test_generate_refresh_token_prefixed_and_unique() -> None: + a = auth.generate_refresh_token() + b = auth.generate_refresh_token() + assert a.startswith(auth.REFRESH_TOKEN_PREFIX) + assert b.startswith(auth.REFRESH_TOKEN_PREFIX) + assert a != b + + +def test_hash_refresh_token_is_sha256_hex() -> None: + token = "rt_example" + assert auth.hash_refresh_token(token) == hashlib.sha256(token.encode()).hexdigest() + + +def test_is_interactive_principal() -> None: + assert auth.is_interactive_principal( + _wrap(MAILUser(ua_type="user", user_id="alice", host=HOST)) + ) + assert auth.is_interactive_principal( + _wrap(MAILAdmin(ua_type="admin", admin_id="ryan", host=HOST)) + ) + assert not auth.is_interactive_principal( + _wrap(MAILAgent(ua_type="agent", name="sage", swarm="chorus", host=HOST)) + ) + assert not auth.is_interactive_principal( + _wrap(MAILDaemon(ua_type="daemon", worker_name="dummy", host=HOST)) + ) + + +def test_set_refresh_cookie_secure_when_configured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(auth, "COOKIE_SECURE", True) + resp = Response() + auth.set_refresh_cookie(resp, "rt_value") + + header = _set_cookie(resp) + lowered = header.lower() + assert f"{auth.REFRESH_COOKIE_NAME}=rt_value" in header + assert "secure" in lowered + assert "httponly" in lowered + assert "samesite=strict" in lowered + assert "path=/auth" in lowered + + +def test_set_refresh_cookie_omits_secure_when_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(auth, "COOKIE_SECURE", False) + resp = Response() + auth.set_refresh_cookie(resp, "rt_value") + + assert "secure" not in _set_cookie(resp).lower() + + +def test_clear_refresh_cookie_emits_deletion() -> None: + resp = Response() + auth.clear_refresh_cookie(resp) + + lowered = _set_cookie(resp).lower() + assert f"{auth.REFRESH_COOKIE_NAME}=" in lowered + assert "path=/auth" in lowered + # deletion is expressed as an immediate expiry + assert "max-age=0" in lowered or 'expires=thu, 01 jan 1970' in lowered