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
+47 -36
View File
@@ -1,60 +1,71 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Generates a fresh OAuth access token from the local Stalwart dev container
and writes it to .env.development.local (gitignored).
Generates a fresh access token from the local Stalwart dev container and
writes it to .env.development.local (gitignored).
.DESCRIPTION
Local development only. Requires the dev container from docker-compose.yml
(`docker compose up -d`) to be running. 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.
See DEVELOPMENT.md for the full workflow. Non-Windows shells (and AI
agents without PowerShell) can use scripts/dev-token.sh instead.
Local development only. Requires scripts/dev-server-init.ps1 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). See DEVELOPMENT.md for the full workflow. Non-Windows
shells (and AI agents without PowerShell) can use scripts/dev-token.sh
instead.
.PARAMETER DurationSeconds
How long the token should stay valid, in seconds. Defaults to 3 hours
(10800), matching the server default set by dev-server-init.ps1.
.EXAMPLE
./dev-token.ps1 # 3 hour token
.EXAMPLE
./dev-token.ps1 -DurationSeconds 1800 # 30 minute token
#>
param(
[string]$ApiBaseUrl = "http://localhost:8080",
[string]$AccountName = "admin@example.org",
[string]$AccountSecret = "c8321iEscHDy0GWV"
[int]$DurationSeconds = 10800,
[string]$ApiBaseUrl = "http://localhost:8080"
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
# PKCE pair (S256)
$chars = (48..57) + (65..90) + (97..122)
$verifier = -join ($chars | Get-Random -Count 64 | ForEach-Object { [char]$_ })
$sha = [System.Security.Cryptography.SHA256]::Create()
$challenge = [Convert]::ToBase64String($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($verifier))).Replace('+', '-').Replace('/', '_').TrimEnd('=')
$DevAdminAccount = "devadmin@example.org"
$DevAdminSecret = "DevAdminPass123!"
$redirectUri = "http://localhost:3005/oauth/callback"
$creds = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("$($DevAdminAccount):$($DevAdminSecret)"))
$authHeader = @{ Authorization = "Basic $creds" }
$authPayload = @{
type = "authCode"
accountName = $AccountName
accountSecret = $AccountSecret
clientId = "stalwart-webui"
redirectUri = $redirectUri
scope = "openid email profile offline_access"
state = [guid]::NewGuid().ToString("N")
codeChallenge = $challenge
codeChallengeMethod = "S256"
} | ConvertTo-Json -Compress
$auth = Invoke-RestMethod -Uri "$ApiBaseUrl/api/auth" -Method Post -ContentType "application/json" -Body $authPayload -TimeoutSec 15
if ($auth.type -ne "authenticated" -or -not $auth.client_code) {
throw "Unexpected /api/auth response: $($auth | ConvertTo-Json -Compress)"
try {
$session = Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/session" -Headers $authHeader -TimeoutSec 15
} catch {
throw "Could not reach $ApiBaseUrl as $DevAdminAccount. Is the server running ('npm run dev:server') and initialized ('scripts/dev-server-init.ps1')?"
}
$accountId = $session.primaryAccounts.'urn:stalwart:jmap'
$tokenBody = "grant_type=authorization_code&code=$($auth.client_code)&code_verifier=$verifier&client_id=stalwart-webui&redirect_uri=$([uri]::EscapeDataString($redirectUri))"
$token = Invoke-RestMethod -Uri "$ApiBaseUrl/auth/token" -Method Post -ContentType "application/x-www-form-urlencoded" -Body $tokenBody -TimeoutSec 15
$expiresAt = [DateTime]::UtcNow.AddSeconds($DurationSeconds).ToString("yyyy-MM-ddTHH:mm:ssZ")
$request = @{
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
methodCalls = @(, @("x:ApiKey/set", @{
accountId = $accountId
create = @{ k1 = @{ description = "dev-token.ps1"; expiresAt = $expiresAt } }
}, "0"))
} | ConvertTo-Json -Depth 10 -Compress
$response = Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/" -Method Post -ContentType "application/json" -Headers $authHeader -Body $request -TimeoutSec 15
$secret = $response.methodResponses[0][1].created.k1.secret
if (-not $secret) {
throw "Unexpected x:ApiKey/set response: $($response | ConvertTo-Json -Depth 10 -Compress)"
}
$envPath = Join-Path $root ".env.development.local"
@"
# Generated by scripts/dev-token.ps1 - 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=$($token.access_token)
VITE_ACCESS_TOKEN=$secret
"@ | Set-Content -Path $envPath -Encoding ascii
Write-Host "Token written to $envPath (expires in $($token.expires_in)s). Restart 'npm run dev' to pick it up."
Write-Host "Token written to $envPath (expires $expiresAt, in ${DurationSeconds}s). Restart 'npm run dev' to pick it up."