feat: add generate_jwt.py script for creating scoped JWTs (#757)

* feat: add generate_jwt.py script for creating scoped JWTs

Adds a CLI utility script for generating Honcho JWTs without needing
to call the /v1/keys API endpoint. Useful for local development and
bootstrapping admin tokens.

Features:
- --admin flag for full-access tokens
- --workspace / --peer / --session flags for scoped tokens
- --expires flag with human-friendly duration syntax (e.g. 5h, 30d, 1y)
- --print-only flag for scripting (outputs bare token)

Examples:
  uv run python scripts/generate_jwt.py --admin
  uv run python scripts/generate_jwt.py --admin --expires 24h
  uv run python scripts/generate_jwt.py --workspace my-ws --expires 30d
  uv run python scripts/generate_jwt.py --workspace my-ws --peer my-peer --expires 1y

* docs: document generate_jwt.py in README auth setup section

* fix: remove t='' override to preserve utc_now_iso default in JWTParams

Per CodeRabbit review: explicitly setting t="" bypasses JWTParams's
default utc_now_iso timestamp, causing tokens for the same scope to
become byte-identical. Omitting t lets the default apply, ensuring
each generated token is unique.

* fix: address JWT script review feedback

* fix: type, lint

---------

Co-authored-by: Rajat Ahuja <rahuja445@gmail.com>
This commit is contained in:
Anthony Yuan 2026-06-09 13:49:55 -04:00 committed by GitHub
parent 5a3b598cb4
commit f75b336a3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 192 additions and 0 deletions

View File

@ -394,6 +394,26 @@ the `AUTH_JWT_SECRET` environment variable. This is required for `AUTH_USE_AUTH`
AUTH_JWT_SECRET=<generated_secret>
```
Once auth is enabled, use `scripts/generate_jwt.py` to mint tokens for local
development and scripting:
```bash
# Admin token (full access, no expiry)
uv run python scripts/generate_jwt.py --admin
# Admin token expiring in 24 hours
uv run python scripts/generate_jwt.py --admin --expires 24h
# Workspace-scoped token
uv run python scripts/generate_jwt.py --workspace my-workspace --expires 30d
# Capture a token for use in curl/scripts
TOKEN=$(uv run python scripts/generate_jwt.py --admin --print-only)
curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/v3/workspaces
```
Duration units: `s` (seconds), `m` (minutes), `h` (hours), `d` (days), `w` (weeks), `y` (years).
5. **Run database migrations**
With the database set up and environment variables configured, run the migrations

148
scripts/generate_jwt.py Normal file
View File

@ -0,0 +1,148 @@
#!/usr/bin/env uv run python
"""
Utility script to generate scoped JWTs for Honcho.
Examples:
# Admin JWT (no expiry)
uv run python scripts/generate_jwt.py --admin
# Admin JWT expiring in 24 hours
uv run python scripts/generate_jwt.py --admin --expires 24h
# Workspace-scoped JWT expiring in 30 days
uv run python scripts/generate_jwt.py --workspace my-workspace --expires 30d
# Peer-scoped JWT expiring in 1 year
uv run python scripts/generate_jwt.py --workspace my-workspace --peer my-peer --expires 1y
# Session-scoped JWT
uv run python scripts/generate_jwt.py --workspace my-workspace --session my-session --expires 8h
"""
import argparse
import datetime
import os
import re
import sys
# Allow running from repo root without installing
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.security import JWTParams, create_jwt
from src.utils.formatting import format_datetime_utc
DURATION_UNITS = {
"s": datetime.timedelta(seconds=1),
"m": datetime.timedelta(minutes=1),
"h": datetime.timedelta(hours=1),
"d": datetime.timedelta(days=1),
"w": datetime.timedelta(weeks=1),
"y": datetime.timedelta(days=365),
}
def parse_duration(value: str) -> datetime.timedelta:
"""Parse a duration string like '5h', '1d', '2w', '1y' into a timedelta."""
match = re.fullmatch(r"(\d+)([smhdwy])", value.strip().lower())
if not match:
raise argparse.ArgumentTypeError(
f"Invalid duration '{value}'. Use format like: 30s, 5m, 2h, 7d, 2w, 1y"
)
amount, unit = int(match.group(1)), match.group(2)
return DURATION_UNITS[unit] * amount
def main():
parser = argparse.ArgumentParser(
description="Generate a scoped JWT for Honcho authentication.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--admin",
action="store_true",
help="Generate an admin JWT (full access)",
)
parser.add_argument(
"--workspace",
"-w",
metavar="NAME",
help="Scope the JWT to a workspace",
)
parser.add_argument(
"--peer",
"-p",
metavar="NAME",
help="Scope the JWT to a peer (requires --workspace)",
)
parser.add_argument(
"--session",
"-s",
metavar="NAME",
help="Scope the JWT to a session (requires --workspace)",
)
parser.add_argument(
"--expires",
"-e",
metavar="DURATION",
type=parse_duration,
help="Token expiry duration. Units: s=seconds, m=minutes, h=hours, d=days, w=weeks, y=years. E.g. 5h, 30d, 1y",
)
parser.add_argument(
"--print-only",
action="store_true",
help="Only print the token, no labels",
)
args = parser.parse_args()
if not args.admin and not any([args.workspace, args.peer, args.session]):
parser.error(
"Specify --admin or at least one of --workspace, --peer, --session"
)
if args.admin and any([args.workspace, args.peer, args.session]):
parser.error(
"--admin cannot be combined with --workspace, --peer, or --session"
)
if (args.peer or args.session) and not args.workspace:
parser.error("--peer and --session require --workspace")
exp_str: str | None = None
if args.expires:
expiry = datetime.datetime.now(datetime.timezone.utc) + args.expires
exp_str = format_datetime_utc(expiry)
params = JWTParams(
ad=True if args.admin else None,
w=args.workspace,
p=args.peer,
s=args.session,
exp=exp_str,
)
token = create_jwt(params)
if args.print_only:
print(token)
else:
scope_parts: list[str] = []
if args.admin:
scope_parts.append("admin")
if args.workspace:
scope_parts.append(f"workspace={args.workspace}")
if args.peer:
scope_parts.append(f"peer={args.peer}")
if args.session:
scope_parts.append(f"session={args.session}")
print(f"Scope: {', '.join(scope_parts)}")
if exp_str:
print(f"Expires: {exp_str}")
else:
print("Expires: never")
print(f"Token: {token}")
if __name__ == "__main__":
main()

View File

@ -78,6 +78,7 @@ _RUNTIME_MOCK_TEST_BLOCKLIST_PREFIXES = (
# LLM transport tests mock providers directly and don't need database/runtime setup.
"tests/utils/test_length_finish_reason.py",
"tests/utils/test_clients.py",
"tests/test_generate_jwt_script.py",
)
_LIVE_LLM_MARKER = "live_llm"

View File

@ -0,0 +1,23 @@
import sys
import pytest
from scripts import generate_jwt
def test_admin_cannot_be_combined_with_scoped_flags(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
sys,
"argv",
[
"generate_jwt.py",
"--admin",
"--workspace",
"my-workspace",
],
)
with pytest.raises(SystemExit) as exc_info:
generate_jwt.main()
assert exc_info.value.code == 2