Store password/token hashes in .env by default too

triggershell users add/add-token now generate a TRIGGERSHELL_USER_<name>_
PASSWORD_HASH / TRIGGERSHELL_TOKEN_<name>_HASH variable in .env (creating
or updating it idempotently) and print a ${VAR} snippet to paste into
auth.users/auth.tokens, instead of printing the raw hash. Pass --inline to
get the old behavior, since a hash - unlike sessionSecret - is safe to
store directly in the config (same trust model as /etc/shadow); this just
gives people who don't want it there at all an easy option.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 19:31:41 +02:00
co-authored by Claude Sonnet 5
parent 80c11d3bd3
commit ac0e645be3
5 changed files with 143 additions and 20 deletions
+8 -5
View File
@@ -56,7 +56,7 @@ auth:
sessionSecret: "${TRIGGERSHELL_SESSION_SECRET}" # >= 32 chars
users:
- username: admin
passwordHash: "$argon2id$..." # from `triggershell users add`
passwordHash: "${TRIGGERSHELL_USER_ADMIN_PASSWORD_HASH}" # from `triggershell users add`
tokens: []
database:
@@ -104,8 +104,8 @@ the script — always as a discrete argv element or env var, never interpolated
| `triggershell dev [-c CONFIG] [--port] [--host] [--no-browser]` | Run in development mode (hot reload) |
| `triggershell start [-c CONFIG] [--port] [--host] [--no-browser] [--skip-build]` | Build (if stale) and run in production mode |
| `triggershell doctor` | Print environment/config diagnostics |
| `triggershell users add <username>` | Hash a password and print a config snippet for `auth.users` |
| `triggershell users add-token <name>` | Generate an API token and print a config snippet for `auth.tokens` |
| `triggershell users add <username> [-c CONFIG] [--inline]` | Hash a password, store it in `.env`, and print a `${VAR}` snippet for `auth.users` (`--inline` prints the raw hash instead) |
| `triggershell users add-token <name> [-c CONFIG] [--inline]` | Generate an API token, store its hash in `.env`, and print a `${VAR}` snippet for `auth.tokens` (`--inline` prints the raw hash instead) |
## Web App Guide
@@ -177,8 +177,11 @@ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the pieces fit togeth
- Route Handlers check auth themselves (`requireAuth()`); `proxy.ts` is only a fast, optimistic
redirect layer, not the security boundary.
- `auth.sessionSecret` should live in `.env`, not the config file — `triggershell init` sets this
up for you. Only `passwordHash`/`tokenHash` values (one-way hashes, not the secrets themselves)
belong in the config.
up for you. `passwordHash`/`tokenHash` are one-way hashes (not the secrets themselves), so
storing them directly in the config is reasonably safe — the same trust model as `/etc/shadow`
or `.htpasswd` — but `triggershell users add`/`add-token` store them in `.env` via `${VAR}` by
default too, for cases where you don't want them readable by anyone with config access at all.
Pass `--inline` to get the old behavior of printing the raw hash to paste into the config.
## License
+8 -2
View File
@@ -28,11 +28,17 @@ it. `triggershell validate` runs the Python pre-flight checks below, then that f
| `enabled` | boolean | `true` | `false` disables login entirely |
| `sessionSecret` | string | — | Required, >= 32 chars, if `enabled`. Reference it via `${TRIGGERSHELL_SESSION_SECRET}` and set the real value in `.env`, not here |
| `sessionTtlHours` | number | `12` | Session cookie lifetime |
| `users` | array | `[]` | `{username, passwordHash}`hash via `triggershell users add` |
| `tokens` | array | `[]` | `{name, tokenHash}`hash via `triggershell users add-token` |
| `users` | array | `[]` | `{username, passwordHash}`generate via `triggershell users add` |
| `tokens` | array | `[]` | `{name, tokenHash}`generate via `triggershell users add-token` |
If `enabled: true`, at least one user or token must be configured.
`passwordHash`/`tokenHash` are one-way hashes, so storing them directly in the config is
reasonably safe (same trust model as `/etc/shadow`). By default `triggershell users add`/
`add-token` instead store the hash in `.env` and give you a `${VAR}` reference to put in the
config, named `TRIGGERSHELL_USER_<USERNAME>_PASSWORD_HASH` / `TRIGGERSHELL_TOKEN_<NAME>_HASH`
pass `--inline` to those commands to get the raw hash printed for pasting into the config instead.
## `database`
| Field | Type | Default |
+5 -3
View File
@@ -13,12 +13,14 @@ auth:
sessionSecret: "${TRIGGERSHELL_SESSION_SECRET:-dev-only-insecure-session-secret-change-me-before-deploying}"
sessionTtlHours: 12
users:
# Generate with `triggershell users add`. This demo hash is for the password "admin" -
# change it before using this example anywhere but your own machine.
# Generate with `triggershell users add` (add --inline for this raw-hash-in-config style;
# the default instead stores the hash in .env and gives you a ${VAR} reference). This demo
# hash is for the password "admin" - change it before using this example anywhere else.
- username: admin
passwordHash: "$argon2id$v=19$m=65536,t=3,p=4$zQavMjKmTLUlSSy8e7doRQ$s6uQeh9rc4Jl+l0GHepH4S8zhcqGfIAbvrxk9F3rT4U"
tokens:
# Generate with `triggershell users add-token`. This demo token is "ci-bot-demo-token".
# Generate with `triggershell users add-token` (see the --inline note above). This demo
# token is "ci-bot-demo-token".
- name: ci-bot
tokenHash: "sha256:131709125e3c04e7c1dd59bc840a997a772ba54ff7448184c558ebc550d9d245"
+60
View File
@@ -0,0 +1,60 @@
from pathlib import Path
from dotenv import dotenv_values
from typer.testing import CliRunner
from triggershell.cli import _slug, app
runner = CliRunner()
def test_slug_normalizes_to_env_var_style() -> None:
assert _slug("ci-bot") == "CI_BOT"
assert _slug("Admin User") == "ADMIN_USER"
assert _slug("__weird--name__") == "WEIRD_NAME"
def test_users_add_stores_hash_in_dotenv(tmp_path: Path) -> None:
config_path = tmp_path / "triggershell.yml"
config_path.write_text("scripts: []\n")
result = runner.invoke(app, ["users", "add", "admin", "--config", str(config_path)], input="s3cret\ns3cret\n")
assert result.exit_code == 0, result.output
env_path = tmp_path / ".env"
assert env_path.exists()
values = dotenv_values(env_path)
assert values["TRIGGERSHELL_USER_ADMIN_PASSWORD_HASH"].startswith("$argon2id$")
assert "${TRIGGERSHELL_USER_ADMIN_PASSWORD_HASH}" in result.output
assert "$argon2id$" not in result.output.split("Stored")[0] # raw hash not printed pre-storage
def test_users_add_inline_does_not_touch_dotenv(tmp_path: Path) -> None:
config_path = tmp_path / "triggershell.yml"
config_path.write_text("scripts: []\n")
result = runner.invoke(
app, ["users", "add", "admin", "--config", str(config_path), "--inline"], input="s3cret\ns3cret\n"
)
assert result.exit_code == 0, result.output
assert not (tmp_path / ".env").exists()
assert "$argon2id$" in result.output
def test_users_add_token_overwrites_existing_var(tmp_path: Path) -> None:
config_path = tmp_path / "triggershell.yml"
config_path.write_text("scripts: []\n")
first = runner.invoke(app, ["users", "add-token", "ci-bot", "--config", str(config_path)])
assert first.exit_code == 0, first.output
first_hash = dotenv_values(tmp_path / ".env")["TRIGGERSHELL_TOKEN_CI_BOT_HASH"]
second = runner.invoke(app, ["users", "add-token", "ci-bot", "--config", str(config_path)])
assert second.exit_code == 0, second.output
values = dotenv_values(tmp_path / ".env")
second_hash = values["TRIGGERSHELL_TOKEN_CI_BOT_HASH"]
assert first_hash != second_hash
env_content = (tmp_path / ".env").read_text()
assert env_content.count("TRIGGERSHELL_TOKEN_CI_BOT_HASH") == 1
+62 -10
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import hashlib
import os
import re
import secrets
from pathlib import Path
from typing import Optional
@@ -9,6 +10,7 @@ from typing import Optional
import typer
import yaml
from argon2 import PasswordHasher
from dotenv import set_key
from rich.console import Console
from rich.table import Table
@@ -267,35 +269,85 @@ def doctor() -> None:
console.print(table)
def _slug(value: str) -> str:
return re.sub(r"[^A-Za-z0-9]+", "_", value).strip("_").upper()
def _print_snippet(heading: str, entry: dict) -> None:
snippet = yaml.safe_dump([entry], sort_keys=False, width=1000)
console.print(f"\n[green]{heading}[/green]\n")
console.print(snippet, markup=False, highlight=False, soft_wrap=True)
@users_app.command("add")
def users_add(
username: str = typer.Argument(..., help="Username to create."),
config: Optional[Path] = typer.Option(None, "-c", "--config", help="Path to the config file (for display only)."),
config: Optional[Path] = typer.Option(
None, "-c", "--config", help="Path to the config file (used to locate .env)."
),
inline: bool = typer.Option(
False, "--inline", help="Print the raw hash to paste into the config instead of storing it in .env."
),
) -> None:
"""Hash a password with argon2id and print a config snippet to paste under `auth.users`."""
"""Hash a password with argon2id and wire it up for `auth.users`."""
password = typer.prompt("Password", hide_input=True, confirmation_prompt=True)
password_hash = PasswordHasher().hash(password)
snippet = yaml.safe_dump([{"username": username, "passwordHash": password_hash}], sort_keys=False, width=1000)
console.print("\n[green]Add this under `auth.users:` in your config file:[/green]\n")
console.print(snippet, markup=False, highlight=False, soft_wrap=True)
if inline:
_print_snippet(
"Add this under `auth.users:` in your config file:",
{"username": username, "passwordHash": password_hash},
)
return
config_path = _resolve_config_path(config)
env_path = config_path.parent / ".env"
var_name = f"TRIGGERSHELL_USER_{_slug(username)}_PASSWORD_HASH"
set_key(str(env_path), var_name, password_hash)
console.print(f"[green]Stored[/green] {var_name} in {env_path}")
_print_snippet(
"Add this under `auth.users:` in your config file:",
{"username": username, "passwordHash": f"${{{var_name}}}"},
)
@users_app.command("add-token")
def users_add_token(
name: str = typer.Argument(..., help="A label for this token, e.g. 'ci-bot'."),
config: Optional[Path] = typer.Option(
None, "-c", "--config", help="Path to the config file (used to locate .env)."
),
inline: bool = typer.Option(
False, "--inline", help="Print the raw hash to paste into the config instead of storing it in .env."
),
) -> None:
"""Generate an API token and print a config snippet to paste under `auth.tokens`."""
"""Generate an API token and wire its hash up for `auth.tokens`."""
token = secrets.token_hex(32)
token_hash = f"sha256:{hashlib.sha256(token.encode()).hexdigest()}"
snippet = yaml.safe_dump([{"name": name, "tokenHash": token_hash}], sort_keys=False, width=1000)
console.print("\n[yellow]Save this token now - it will not be shown again:[/yellow]")
console.print(f" [bold]{token}[/bold]\n")
console.print("[green]Add this under `auth.tokens:` in your config file:[/green]\n")
console.print(snippet, markup=False, highlight=False, soft_wrap=True)
console.print(f" [bold]{token}[/bold]")
console.print(f"Use it as: [dim]Authorization: Bearer {token}[/dim]")
if inline:
_print_snippet(
"Add this under `auth.tokens:` in your config file:",
{"name": name, "tokenHash": token_hash},
)
return
config_path = _resolve_config_path(config)
env_path = config_path.parent / ".env"
var_name = f"TRIGGERSHELL_TOKEN_{_slug(name)}_HASH"
set_key(str(env_path), var_name, token_hash)
console.print(f"[green]Stored[/green] {var_name} in {env_path}")
_print_snippet(
"Add this under `auth.tokens:` in your config file:",
{"name": name, "tokenHash": f"${{{var_name}}}"},
)
if __name__ == "__main__":
app()