From f75b336a3ced32b5de57702ae1b843a36f01668a Mon Sep 17 00:00:00 2001 From: Anthony Yuan <1127391+hyuan@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:49:55 -0400 Subject: [PATCH] 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 --- README.md | 20 ++++ scripts/generate_jwt.py | 148 ++++++++++++++++++++++++++++++ tests/conftest.py | 1 + tests/test_generate_jwt_script.py | 23 +++++ 4 files changed, 192 insertions(+) create mode 100644 scripts/generate_jwt.py create mode 100644 tests/test_generate_jwt_script.py diff --git a/README.md b/README.md index acf20123..e62bce37 100644 --- a/README.md +++ b/README.md @@ -394,6 +394,26 @@ the `AUTH_JWT_SECRET` environment variable. This is required for `AUTH_USE_AUTH` AUTH_JWT_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 diff --git a/scripts/generate_jwt.py b/scripts/generate_jwt.py new file mode 100644 index 00000000..8d96cd67 --- /dev/null +++ b/scripts/generate_jwt.py @@ -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() diff --git a/tests/conftest.py b/tests/conftest.py index 06a31ac8..90fc269b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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" diff --git a/tests/test_generate_jwt_script.py b/tests/test_generate_jwt_script.py new file mode 100644 index 00000000..04011974 --- /dev/null +++ b/tests/test_generate_jwt_script.py @@ -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