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 sessionSecret: "${TRIGGERSHELL_SESSION_SECRET}" # >= 32 chars
users: users:
- username: admin - username: admin
passwordHash: "$argon2id$..." # from `triggershell users add` passwordHash: "${TRIGGERSHELL_USER_ADMIN_PASSWORD_HASH}" # from `triggershell users add`
tokens: [] tokens: []
database: 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 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 start [-c CONFIG] [--port] [--host] [--no-browser] [--skip-build]` | Build (if stale) and run in production mode |
| `triggershell doctor` | Print environment/config diagnostics | | `triggershell doctor` | Print environment/config diagnostics |
| `triggershell users add <username>` | Hash a password and print a config snippet for `auth.users` | | `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>` | Generate an API token and print a config snippet for `auth.tokens` | | `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 ## 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 - Route Handlers check auth themselves (`requireAuth()`); `proxy.ts` is only a fast, optimistic
redirect layer, not the security boundary. redirect layer, not the security boundary.
- `auth.sessionSecret` should live in `.env`, not the config file — `triggershell init` sets this - `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) up for you. `passwordHash`/`tokenHash` are one-way hashes (not the secrets themselves), so
belong in the config. 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 ## 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 | | `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 | | `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 | | `sessionTtlHours` | number | `12` | Session cookie lifetime |
| `users` | array | `[]` | `{username, passwordHash}`hash via `triggershell users add` | | `users` | array | `[]` | `{username, passwordHash}`generate via `triggershell users add` |
| `tokens` | array | `[]` | `{name, tokenHash}`hash via `triggershell users add-token` | | `tokens` | array | `[]` | `{name, tokenHash}`generate via `triggershell users add-token` |
If `enabled: true`, at least one user or token must be configured. 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` ## `database`
| Field | Type | Default | | 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}" sessionSecret: "${TRIGGERSHELL_SESSION_SECRET:-dev-only-insecure-session-secret-change-me-before-deploying}"
sessionTtlHours: 12 sessionTtlHours: 12
users: users:
# Generate with `triggershell users add`. This demo hash is for the password "admin" - # Generate with `triggershell users add` (add --inline for this raw-hash-in-config style;
# change it before using this example anywhere but your own machine. # 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 - username: admin
passwordHash: "$argon2id$v=19$m=65536,t=3,p=4$zQavMjKmTLUlSSy8e7doRQ$s6uQeh9rc4Jl+l0GHepH4S8zhcqGfIAbvrxk9F3rT4U" passwordHash: "$argon2id$v=19$m=65536,t=3,p=4$zQavMjKmTLUlSSy8e7doRQ$s6uQeh9rc4Jl+l0GHepH4S8zhcqGfIAbvrxk9F3rT4U"
tokens: 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 - name: ci-bot
tokenHash: "sha256:131709125e3c04e7c1dd59bc840a997a772ba54ff7448184c558ebc550d9d245" 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 hashlib
import os import os
import re
import secrets import secrets
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -9,6 +10,7 @@ from typing import Optional
import typer import typer
import yaml import yaml
from argon2 import PasswordHasher from argon2 import PasswordHasher
from dotenv import set_key
from rich.console import Console from rich.console import Console
from rich.table import Table from rich.table import Table
@@ -267,35 +269,85 @@ def doctor() -> None:
console.print(table) 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") @users_app.command("add")
def users_add( def users_add(
username: str = typer.Argument(..., help="Username to create."), 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: ) -> 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 = typer.prompt("Password", hide_input=True, confirmation_prompt=True)
password_hash = PasswordHasher().hash(password) password_hash = PasswordHasher().hash(password)
snippet = yaml.safe_dump([{"username": username, "passwordHash": password_hash}], sort_keys=False, width=1000) if inline:
console.print("\n[green]Add this under `auth.users:` in your config file:[/green]\n") _print_snippet(
console.print(snippet, markup=False, highlight=False, soft_wrap=True) "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") @users_app.command("add-token")
def users_add_token( def users_add_token(
name: str = typer.Argument(..., help="A label for this token, e.g. 'ci-bot'."), 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: ) -> 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 = secrets.token_hex(32)
token_hash = f"sha256:{hashlib.sha256(token.encode()).hexdigest()}" 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("\n[yellow]Save this token now - it will not be shown again:[/yellow]")
console.print(f" [bold]{token}[/bold]\n") console.print(f" [bold]{token}[/bold]")
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"Use it as: [dim]Authorization: Bearer {token}[/dim]") 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__": if __name__ == "__main__":
app() app()