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:
parent
5a3b598cb4
commit
f75b336a3c
20
README.md
20
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue