feat(dev): switch dev tokens to a real admin account with configurable duration

Requested: dev-token.sh/.ps1 tokens should last 3h by default, with an
argument to override the duration.

Server-verified finding: STALWART_RECOVERY_ADMIN (the break-glass
account docker-compose.yml and the scripts used) always issues OAuth
tokens with a fixed 1h expiry, regardless of the server's configured
accessTokenExpiry — confirmed against the live container, including
after changing the setting and restarting. Confirmed x:ApiKey objects,
by contrast, support an arbitrary expiresAt set per request, and their
secret works directly as a bearer token.

Add scripts/dev-server-init.sh (+ .ps1): a one-time, idempotent setup
step that completes the server's bootstrap wizard (default domain, no
TLS certificate request), creates a real "devadmin" admin account, and
sets the server's default OAuth token lifetime to 3h.

Rework dev-token.sh/.ps1 to authenticate as devadmin and create an
x:ApiKey with a caller-supplied expiry (`dev-token.sh 1800` for 30
minutes, defaults to 10800s/3h) instead of running the OAuth PKCE flow
against the recovery account. Verified end-to-end against a fresh
container, including a real browser session against the running WebUI.

Also includes an incidental package-lock.json sync (was still pinned to
v1.0.8 / stale dependency ranges from before the upstream merge).
This commit is contained in:
Steven RYDELL
2026-08-01 18:32:13 +02:00
parent f4c8f8f21c
commit adca6d8730
9 changed files with 725 additions and 191 deletions
+32 -38
View File
@@ -1,54 +1,48 @@
#!/usr/bin/env bash
# Local development only. Generates a fresh OAuth access token from the local
# Local development only. Generates a fresh access token from the local
# Stalwart dev container (see docker-compose.yml) and writes it to
# .env.development.local (gitignored). Bash equivalent of dev-token.ps1, for
# non-Windows shells (and AI agents without PowerShell).
#
# Tokens expire after 1 hour; re-run this script and restart "npm run dev"
# when the UI starts returning 401s.
# The credentials below belong to the disposable local Stalwart container.
# Requires scripts/dev-server-init.sh to have been run once first (creates
# the "devadmin" account this script authenticates as — the
# STALWART_RECOVERY_ADMIN account is break-glass only and always issues
# fixed 1h tokens regardless of server config, so it can't honor a custom
# duration).
#
# Usage: dev-token.sh [duration_seconds] [api_base_url]
# dev-token.sh # 3 hour token (server default, see dev-server-init.sh)
# dev-token.sh 1800 # 30 minute token
set -euo pipefail
API_BASE_URL="${1:-http://localhost:8080}"
ACCOUNT_NAME="${2:-admin@example.org}"
ACCOUNT_SECRET="${3:-c8321iEscHDy0GWV}"
REDIRECT_URI="http://localhost:3005/oauth/callback"
DURATION_SECONDS="${1:-10800}"
API_BASE_URL="${2:-http://localhost:8080}"
DEVADMIN_ACCOUNT="devadmin@example.org"
DEVADMIN_SECRET="DevAdminPass123!"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
b64url() {
base64 | tr '+/' '-_' | tr -d '=\n'
if ! date -u -d "+1 minute" +"%Y-%m-%dT%H:%M:%SZ" >/dev/null 2>&1; then
EXPIRES_AT=$(date -u -v+"${DURATION_SECONDS}"S +"%Y-%m-%dT%H:%M:%SZ") # BSD/macOS date
else
EXPIRES_AT=$(date -u -d "+${DURATION_SECONDS} seconds" +"%Y-%m-%dT%H:%M:%SZ") # GNU date
fi
SESSION=$(curl -sf --compressed -u "$DEVADMIN_ACCOUNT:$DEVADMIN_SECRET" "$API_BASE_URL/jmap/session") || {
echo "Could not reach $API_BASE_URL as $DEVADMIN_ACCOUNT. Is the server running ('npm run dev:server') and initialized ('scripts/dev-server-init.sh')?" >&2
exit 1
}
ACCOUNT_ID=$(printf '%s' "$SESSION" | grep -o '"urn:stalwart:jmap":"[^"]*"' | cut -d'"' -f4)
VERIFIER="$(head -c 48 /dev/urandom | b64url | head -c 64)"
CHALLENGE="$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | b64url)"
STATE="$(head -c 16 /dev/urandom | xxd -p)"
AUTH_PAYLOAD=$(cat <<JSON
{"type":"authCode","accountName":"$ACCOUNT_NAME","accountSecret":"$ACCOUNT_SECRET","clientId":"stalwart-webui","redirectUri":"$REDIRECT_URI","scope":"openid email profile offline_access","state":"$STATE","codeChallenge":"$CHALLENGE","codeChallengeMethod":"S256"}
REQ=$(cat <<JSON
{"using":["urn:ietf:params:jmap:core","urn:stalwart:jmap"],"methodCalls":[["x:ApiKey/set",{"accountId":"$ACCOUNT_ID","create":{"k1":{"description":"dev-token.sh","expiresAt":"$EXPIRES_AT"}}},"0"]]}
JSON
)
AUTH_RESPONSE=$(curl -sf "$API_BASE_URL/api/auth" -X POST -H "Content-Type: application/json" -d "$AUTH_PAYLOAD")
CLIENT_CODE=$(printf '%s' "$AUTH_RESPONSE" | grep -o '"client_code":"[^"]*"' | cut -d'"' -f4)
RESPONSE=$(curl -sf --compressed -u "$DEVADMIN_ACCOUNT:$DEVADMIN_SECRET" -X POST -H "Content-Type: application/json" -d "$REQ" "$API_BASE_URL/jmap/")
TOKEN=$(printf '%s' "$RESPONSE" | grep -o '"secret":"[^"]*"' | cut -d'"' -f4)
if [ -z "$CLIENT_CODE" ]; then
echo "Unexpected /api/auth response: $AUTH_RESPONSE" >&2
exit 1
fi
TOKEN_RESPONSE=$(curl -sf "$API_BASE_URL/auth/token" -X POST \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=$CLIENT_CODE" \
--data-urlencode "code_verifier=$VERIFIER" \
--data-urlencode "client_id=stalwart-webui" \
--data-urlencode "redirect_uri=$REDIRECT_URI")
ACCESS_TOKEN=$(printf '%s' "$TOKEN_RESPONSE" | grep -o '"access_token":"[^"]*"' | cut -d'"' -f4)
EXPIRES_IN=$(printf '%s' "$TOKEN_RESPONSE" | grep -o '"expires_in":[0-9]*' | cut -d':' -f2)
if [ -z "$ACCESS_TOKEN" ]; then
echo "Unexpected /auth/token response: $TOKEN_RESPONSE" >&2
if [ -z "$TOKEN" ]; then
echo "Unexpected x:ApiKey/set response: $RESPONSE" >&2
exit 1
fi
@@ -57,7 +51,7 @@ cat > "$ENV_PATH" <<EOF
# Generated by scripts/dev-token.sh - gitignored, do not commit.
# Empty base URL: API calls stay same-origin and go through the Vite proxy.
VITE_API_BASE_URL=
VITE_ACCESS_TOKEN=$ACCESS_TOKEN
VITE_ACCESS_TOKEN=$TOKEN
EOF
echo "Token written to $ENV_PATH (expires in ${EXPIRES_IN}s). Restart 'npm run dev' to pick it up."
echo "Token written to $ENV_PATH (expires $EXPIRES_AT, in ${DURATION_SECONDS}s). Restart 'npm run dev' to pick it up."