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
8 changes: 5 additions & 3 deletions docs/testing-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
100 changes: 99 additions & 1 deletion spec/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion src/mail/client/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ mail [option]... <command> [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
Expand Down
20 changes: 20 additions & 0 deletions src/mail/client/docs/tutorials/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions src/mail/client/src/mail_client/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
cmd_outbox,
cmd_outbox_open,
cmd_ping,
cmd_refresh,
cmd_reply,
cmd_send,
cmd_swarm_get,
Expand Down Expand Up @@ -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."),
],
),
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions src/mail/client/src/mail_client/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -78,6 +79,7 @@
"cmd_outbox",
"cmd_outbox_open",
"cmd_ping",
"cmd_refresh",
"cmd_reply",
"cmd_send",
"cmd_swarm_delete",
Expand Down
11 changes: 11 additions & 0 deletions src/mail/client/src/mail_client/commands/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`"
)
78 changes: 78 additions & 0 deletions src/mail/client/src/mail_client/commands/refresh.py
Original file line number Diff line number Diff line change
@@ -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."
)
34 changes: 34 additions & 0 deletions src/mail/protocol/src/mail_protocol/core/auth.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions src/mail/protocol/src/mail_protocol/network/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Loading
Loading