sign-in: in-app password reset (emailed code) on Mac/Linux and Windows; v0.3.4

- auth.PasswordForgot / auth.PasswordReset (+ httptest tests); ErrResetUnavailable
  on 404/401/405/5xx so dialogs offer https://customer.verae.ai/portal/
- keyflow_gui: 'Forgot password?' on the password step and after a failed sign-in
- Windows: Forgot password? link, forgot/reset/unavailable forms
- bump HelperVersion, deb and pkg default VERSION to 0.3.4
This commit is contained in:
George Lambert 2026-10-04 14:14:55 -04:00
commit 179c974b60
7 changed files with 468 additions and 7 deletions

View file

@ -45,6 +45,8 @@ function Btn([string]$text, [int]$x, [int]$w, [string]$res) {
switch ($kind) {
'login' {
$e = Field 'Email' $false $pre; $p = Field 'Password' $true ''; $t = Field '2FA code (if enabled)' $false ''
$fl = New-Object Windows.Forms.LinkLabel; $fl.Text = 'Forgot password?'; $fl.Left = 170; $fl.Top = $script:y; $fl.Width = 200
$fl.Add_LinkClicked({ $script:result = 'forgot'; $f.Close() }); $f.Controls.Add($fl); $script:y += 24
$script:y += 8
$ok = Btn 'Sign in' 15 100 'login'; $f.AcceptButton = $ok
Btn 'Create account' 125 120 'create' | Out-Null
@ -53,7 +55,35 @@ switch ($kind) {
$f.Height = $script:y + 75
if ($pre) { $f.Add_Shown({ $p.Focus() }) }
[void]$f.ShowDialog()
if ($script:result -eq 'login') { "login|$(B $e.Text)|$(B $p.Text)|$(B $t.Text)" } else { $script:result }
if ($script:result -eq 'login') { "login|$(B $e.Text)|$(B $p.Text)|$(B $t.Text)" }
elseif ($script:result -eq 'forgot') { "forgot|$(B $e.Text)" } else { $script:result }
}
'forgot' {
$e = Field 'Email' $false $pre
$script:y += 8
$ok = Btn 'Send code' 15 100 'send'; $f.AcceptButton = $ok
$c = Btn 'Back' 410 100 'cancel'; $f.CancelButton = $c
$f.Height = $script:y + 75
[void]$f.ShowDialog()
if ($script:result -eq 'send') { "send|$(B $e.Text)" } else { 'cancel' }
}
'reset' {
$k = Field 'Code from the email' $false ''; $p = Field 'New password' $true ''; $p2 = Field 'Confirm new password' $true ''
$script:y += 8
$ok = Btn 'Reset password' 15 130 'reset'; $f.AcceptButton = $ok
Btn 'Send a new code' 155 130 'resend' | Out-Null
$c = Btn 'Cancel' 410 100 'cancel'; $f.CancelButton = $c
$f.Height = $script:y + 75
[void]$f.ShowDialog()
if ($script:result -eq 'reset') { "reset|$(B $k.Text)|$(B $p.Text)|$(B $p2.Text)" } else { $script:result }
}
'unavail' {
$ok = Btn 'Open customer.verae.ai' 15 200 'open'; $f.AcceptButton = $ok
$c = Btn 'Back' 410 100 'cancel'; $f.CancelButton = $c
$f.Height = $script:y + 75
[void]$f.ShowDialog()
if ($script:result -eq 'open') { Start-Process 'https://customer.verae.ai/portal/' }
'cancel'
}
'create' {
$n = Field 'Your name' $false ''; $e = Field 'Email' $false $pre; $p = Field 'Password' $true ''; $p2 = Field 'Confirm password' $true ''
@ -143,9 +173,21 @@ func acquireKeyInteractive(reason string) bool {
}
if err != nil {
vlog.Errorf("sign-in: login failed for %s: %v", auth.MaskEmail(email), err)
msg = "Sign-in failed: " + err.Error()
msg = "Sign-in failed: " + err.Error() + "\r\nForgot your password? Click \"Forgot password?\" below."
continue
}
case "forgot":
if e := strings.TrimSpace(f[0]); e != "" {
email = e
}
k, ok, m := winForgotFlow(caps, email)
if m != "" {
msg = m
}
if !ok {
continue
}
key = k
case "create":
k, ok := winCreateFlow(caps, email)
if !ok {
@ -231,6 +273,95 @@ func winCreateFlow(caps auth.Capabilities, email string) (string, bool) {
return "", false
}
// winResetUnavailable: in-app reset not supported; offer the web portal.
func winResetUnavailable() {
_, _, _ = winForm("unavail", "Password reset isn't available in the app yet.\r\n\r\nYou can reset your password on the Verae customer portal in your browser ("+auth.PortalResetURL+").", "")
}
// winForgotFlow: emailed-code password reset. Returns (key, ok, message for the sign-in form).
func winForgotFlow(caps auth.Capabilities, email string) (string, bool, string) {
msg := "Reset your Verae password.\r\nWe'll email a 6-digit code to this address."
for i := 0; i < 10; i++ {
act, f, err := winForm("forgot", msg, email)
if err != nil || act != "send" {
return "", false, ""
}
email = strings.TrimSpace(f[0])
if !auth.ValidEmail(email) {
msg = "Please enter a valid email address."
continue
}
vlog.Printf("sign-in: password reset requested for %s", auth.MaskEmail(email))
if ferr := auth.PasswordForgot(email); ferr != nil {
vlog.Errorf("sign-in: password forgot %s: %v", auth.MaskEmail(email), ferr)
if errors.Is(ferr, auth.ErrResetUnavailable) {
winResetUnavailable()
return "", false, ""
}
msg = "Could not send a reset code: " + ferr.Error()
continue
}
cmsg := "If " + email + " has a Verae account, we emailed it a 6-digit code (valid 15 minutes). Check your spam folder.\r\n" + caps.Policy.Describe()
for j := 0; j < 15; j++ {
act, cf, err := winForm("reset", cmsg, "")
if err != nil || act == "cancel" || act == "" {
return "", false, ""
}
if act == "resend" {
if rerr := auth.PasswordForgot(email); rerr != nil {
if errors.Is(rerr, auth.ErrResetUnavailable) {
winResetUnavailable()
return "", false, ""
}
cmsg = "Could not send a new code: " + rerr.Error()
} else {
cmsg = "A new code was sent to " + email + ".\r\n" + caps.Policy.Describe()
}
continue
}
code, pw := strings.TrimSpace(cf[0]), cf[1]
switch {
case len(code) != 6 || strings.Trim(code, "0123456789") != "":
cmsg = "Enter the 6-digit code from the email.\r\n" + caps.Policy.Describe()
continue
case pw != cf[2]:
cmsg = "The passwords do not match.\r\n" + caps.Policy.Describe()
continue
}
if perr := caps.Policy.Check(pw, email); perr != nil {
cmsg = perr.Error() + "\r\n" + caps.Policy.Describe()
continue
}
key, rerr := auth.PasswordReset(email, code, pw, tokenName())
cf[1], cf[2] = "", ""
if rerr != nil {
vlog.Errorf("sign-in: password reset %s: %v", auth.MaskEmail(email), rerr)
if errors.Is(rerr, auth.ErrResetUnavailable) {
winResetUnavailable()
return "", false, ""
}
cmsg = "Could not reset the password: " + rerr.Error()
continue
}
vlog.Printf("sign-in: password reset ok for %s (key returned=%v)", auth.MaskEmail(email), key != "")
if key != "" {
return key, true, ""
}
key, lerr := auth.InstallLogin(caps, email, pw, "", tokenName())
pw = ""
if lerr != nil {
if errors.Is(lerr, auth.ErrTOTPRequired) {
return "", false, "Your password was changed. Sign in with your new password and your 6-digit authenticator code."
}
return "", false, "Your password was changed, but signing in failed: " + lerr.Error() + "\r\nSign in with your new password."
}
return key, true, ""
}
return "", false, ""
}
return "", false, ""
}
// pasteFlow: masked key field + portal button; validated before saving.
func pasteFlow(reason string) bool {
msg := "Open customer.verae.ai, sign in, open Security and create an xVerae- token (shown once). Paste it below."

View file

@ -102,6 +102,8 @@ func keyFlow(reason string) (string, bool) {
}
}
const btnForgot = "Forgot password?"
func macLogin(caps auth.Capabilities) (string, error) {
email := ""
for {
@ -114,10 +116,17 @@ func macLogin(caps auth.Capabilities) (string, error) {
say("Please enter a valid email address.")
continue
}
btn, pw, err := ask("Password for "+email+":", "", true, true, []string{"Back", "Sign in"}, "Sign in")
btn, pw, err := ask("Password for "+email+":", "", true, true, []string{"Back", btnForgot, "Sign in"}, "Sign in")
if err != nil || btn == "Back" {
continue
}
if btn == btnForgot {
key, ferr := macForgot(caps, email)
if errors.Is(ferr, errCancel) {
continue
}
return key, ferr
}
vlog.Printf("mac-key-setup: login %s", auth.MaskEmail(email))
key, err := auth.InstallLogin(caps, email, pw, "", tokenName())
if errors.Is(err, auth.ErrTOTPRequired) {
@ -130,13 +139,128 @@ func macLogin(caps auth.Capabilities) (string, error) {
pw = ""
if err != nil {
vlog.Errorf("mac-key-setup: login failed for %s: %v", auth.MaskEmail(email), err)
say("Sign-in failed: " + err.Error())
b, _, _ := ask("Sign-in failed: "+err.Error(), "", false, false, []string{btnForgot, "Try again"}, "Try again")
if b == btnForgot {
key, ferr := macForgot(caps, email)
if errors.Is(ferr, errCancel) {
continue
}
return key, ferr
}
continue
}
return key, nil
}
}
// offerPortalReset tells the user in-app reset is unavailable and offers the web portal.
func offerPortalReset() {
b, _, _ := ask("Password reset isn't available in the app yet.\n\nYou can reset your password on the Verae customer portal in your browser ("+auth.PortalResetURL+").", "", false, false, []string{"Back", "Open portal"}, "Open portal")
if b == "Open portal" {
openURL(auth.PortalResetURL)
}
}
// macForgot: emailed-code password reset. Returns a minted key, or errCancel.
func macForgot(caps auth.Capabilities, email string) (string, error) {
for {
btn, e, err := ask("Reset your Verae password\n\nWe'll email a 6-digit code to this address:", email, false, true, []string{"Back", "Send code"}, "Send code")
if err != nil || btn == "Back" {
return "", errCancel
}
email = strings.TrimSpace(e)
if !auth.ValidEmail(email) {
say("Please enter a valid email address.")
continue
}
vlog.Printf("mac-key-setup: password reset requested for %s", auth.MaskEmail(email))
if ferr := auth.PasswordForgot(email); ferr != nil {
vlog.Errorf("mac-key-setup: password forgot %s: %v", auth.MaskEmail(email), ferr)
if errors.Is(ferr, auth.ErrResetUnavailable) {
offerPortalReset()
return "", errCancel
}
say("Could not send a reset code: " + ferr.Error())
continue
}
msg := "If " + email + " has a Verae account, we emailed it a 6-digit code (valid 15 minutes).\nIt may take a minute; check spam.\n\nCode:"
codeLoop:
for {
btn, code, err := ask(msg, "", false, true, []string{"Cancel", "Send a new code", "Next"}, "Next")
if err != nil || btn == "Cancel" {
return "", errCancel
}
if btn == "Send a new code" {
if rerr := auth.PasswordForgot(email); rerr != nil {
if errors.Is(rerr, auth.ErrResetUnavailable) {
offerPortalReset()
return "", errCancel
}
say("Could not send a new code: " + rerr.Error())
} else {
say("A new code is on its way.")
}
continue
}
code = strings.TrimSpace(code)
if len(code) != 6 || strings.Trim(code, "0123456789") != "" {
say("Enter the 6-digit code from the email.")
continue
}
for {
btn, pw, err := ask("Choose a new password.\n"+caps.Policy.Describe(), "", true, true, []string{"Back", "Next"}, "Next")
if err != nil || btn == "Back" {
continue codeLoop
}
if perr := caps.Policy.Check(pw, email); perr != nil {
say(perr.Error())
continue
}
btn, pw2, err := ask("Confirm the new password:", "", true, true, []string{"Back", "Reset password"}, "Reset password")
if err != nil || btn == "Back" {
continue
}
if pw != pw2 {
say("The passwords do not match.")
continue
}
pw2 = ""
key, rerr := auth.PasswordReset(email, code, pw, tokenName())
if rerr != nil {
pw = ""
vlog.Errorf("mac-key-setup: password reset %s: %v", auth.MaskEmail(email), rerr)
if errors.Is(rerr, auth.ErrResetUnavailable) {
offerPortalReset()
return "", errCancel
}
say("Could not reset the password: " + rerr.Error())
continue codeLoop
}
vlog.Printf("mac-key-setup: password reset ok for %s (key returned=%v)", auth.MaskEmail(email), key != "")
if key != "" {
return key, nil
}
// No key minted (e.g. 2FA account): sign in with the new password.
key, lerr := auth.InstallLogin(caps, email, pw, "", tokenName())
if errors.Is(lerr, auth.ErrTOTPRequired) {
b, totp, cerr := ask("Your password was changed.\n\nThis account uses two-factor authentication. Enter the 6-digit code from your authenticator app:", "", false, true, []string{"Cancel", "Verify"}, "Verify")
if cerr != nil || b == "Cancel" {
pw = ""
return "", errCancel
}
key, lerr = auth.InstallLogin(caps, email, pw, strings.TrimSpace(totp), tokenName())
}
pw = ""
if lerr != nil {
say("Your password was changed, but signing in failed: " + lerr.Error() + "\n\nChoose \"Sign in\" and use your new password.")
return "", errCancel
}
return key, nil
}
}
}
}
func macCreate(caps auth.Capabilities) (string, error) {
var name, email string
for {

View file

@ -0,0 +1,92 @@
package auth
import (
"errors"
"strings"
)
// Password reset by emailed 6-digit code (customer.verae.ai):
// POST /portal/api/password/forgot {email} → 202 {ok:true} (always; no enumeration)
// POST /portal/api/password/reset {email, code, password, tokenName?}
// → 200 {ok:true, token?: "xVerae-…"}
// A server without these endpoints (404/401/405/5xx, or 503 email_unavailable)
// yields ErrResetUnavailable so the UI can send the user to the web portal.
// PortalResetURL is where users can reset their password in a browser.
const PortalResetURL = "https://customer.verae.ai/portal/"
var (
// ErrResetUnavailable: the server cannot do in-app password reset right now.
ErrResetUnavailable = errors.New("password reset isn't available in the app yet")
// ErrResetInvalidCode: wrong code (attempts remain).
ErrResetInvalidCode = errors.New("that code is not right — check the email and try again")
// ErrResetCodeExpired: the code expired; request a new one.
ErrResetCodeExpired = errors.New("that code has expired — send a new code")
// ErrResetTooManyAttempts: the code was locked after too many tries.
ErrResetTooManyAttempts = errors.New("too many wrong codes — send a new code")
)
func resetUnavailableStatus(code int) bool {
return code == 404 || code == 401 || code == 405 || code >= 500
}
// PasswordForgot asks the server to email a reset code to email.
func PasswordForgot(email string) error {
out, code, err := portalPost("/password/forgot", "", map[string]any{"email": strings.TrimSpace(email)})
if err != nil {
if code == 0 && !strings.HasPrefix(err.Error(), "HTTP") {
return err // network / TLS / insecure-base error
}
if resetUnavailableStatus(code) {
return ErrResetUnavailable
}
if code == 429 {
return errors.New("too many requests — wait a few minutes and try again")
}
if msg, _ := out["error"].(string); msg != "" {
return errors.New(msg)
}
return err
}
return nil
}
// PasswordReset submits the emailed code and a new password. When tokenName is
// non-empty the server may mint and return an xVerae- key; key is "" when it
// did not (e.g. the account has 2FA) — callers then sign in with the new password.
func PasswordReset(email, code, newPassword, tokenName string) (string, error) {
body := map[string]any{"email": strings.TrimSpace(email), "code": strings.TrimSpace(code), "password": newPassword}
if tokenName != "" {
body["tokenName"] = tokenName
}
out, status, err := portalPost("/password/reset", "", body)
if err != nil {
if status == 0 && !strings.HasPrefix(err.Error(), "HTTP") {
return "", err
}
if resetUnavailableStatus(status) {
return "", ErrResetUnavailable
}
msg, _ := out["error"].(string)
switch msg {
case "invalid_code":
return "", ErrResetInvalidCode
case "code_expired":
return "", ErrResetCodeExpired
case "too_many_attempts":
return "", ErrResetTooManyAttempts
case "":
return "", err
default:
if status == 429 {
return "", errors.New("too many attempts — wait a few minutes and try again")
}
return "", errors.New(msg)
}
}
k, _ := out["token"].(string)
if strings.HasPrefix(k, "xVerae-") || strings.HasPrefix(k, "key-") {
return k, nil
}
return "", nil
}

View file

@ -0,0 +1,114 @@
package auth
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func fakeResetPortal(t *testing.T, forgotStatus int, withToken bool) *[]map[string]any {
var calls []map[string]any
mux := http.NewServeMux()
js := func(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
mux.HandleFunc("/portal/api/password/forgot", func(w http.ResponseWriter, r *http.Request) {
m := map[string]any{}
_ = json.NewDecoder(r.Body).Decode(&m)
calls = append(calls, m)
if forgotStatus == 202 {
js(w, 202, map[string]any{"ok": true})
return
}
js(w, forgotStatus, map[string]any{"error": "email_unavailable"})
})
mux.HandleFunc("/portal/api/password/reset", func(w http.ResponseWriter, r *http.Request) {
m := map[string]any{}
_ = json.NewDecoder(r.Body).Decode(&m)
calls = append(calls, m)
switch m["code"] {
case "123456":
out := map[string]any{"ok": true}
if withToken && m["tokenName"] != nil {
out["token"] = "xVerae-reset-abcdefgh"
}
js(w, 200, out)
case "999999":
js(w, 400, map[string]any{"error": "code_expired"})
case "888888":
js(w, 400, map[string]any{"error": "too_many_attempts"})
default:
js(w, 400, map[string]any{"error": "invalid_code"})
}
})
s := httptest.NewServer(mux)
t.Cleanup(s.Close)
t.Setenv("VERAE_PORTAL_BASE", s.URL)
t.Setenv("VERAE_ALLOW_INSECURE_PORTAL", "1")
return &calls
}
func TestPasswordForgotAndReset(t *testing.T) {
calls := fakeResetPortal(t, 202, true)
if err := PasswordForgot(" a@b.co "); err != nil {
t.Fatal(err)
}
if (*calls)[0]["email"] != "a@b.co" {
t.Fatalf("email not trimmed: %v", (*calls)[0])
}
k, err := PasswordReset("a@b.co", " 123456 ", "new-password-1", "Print Stamp")
if err != nil || k != "xVerae-reset-abcdefgh" {
t.Fatal(k, err)
}
if (*calls)[1]["password"] != "new-password-1" || (*calls)[1]["code"] != "123456" {
t.Fatalf("reset body %v", (*calls)[1])
}
if _, err := PasswordReset("a@b.co", "000000", "x", "n"); !errors.Is(err, ErrResetInvalidCode) {
t.Fatal(err)
}
if _, err := PasswordReset("a@b.co", "999999", "x", "n"); !errors.Is(err, ErrResetCodeExpired) {
t.Fatal(err)
}
if _, err := PasswordReset("a@b.co", "888888", "x", "n"); !errors.Is(err, ErrResetTooManyAttempts) {
t.Fatal(err)
}
}
func TestPasswordResetNoToken(t *testing.T) {
fakeResetPortal(t, 202, false)
k, err := PasswordReset("a@b.co", "123456", "new-password-1", "n")
if err != nil || k != "" {
t.Fatal(k, err)
}
}
func TestPasswordResetUnavailable(t *testing.T) {
for _, st := range []int{503, 500} {
fakeResetPortal(t, st, true)
if err := PasswordForgot("a@b.co"); !errors.Is(err, ErrResetUnavailable) {
t.Fatalf("status %d: %v", st, err)
}
}
// Server without the endpoints at all (404) and one requiring auth (401).
for _, st := range []int{404, 401} {
code := st
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_, _ = w.Write([]byte(`{"error":"nope"}`))
}))
t.Setenv("VERAE_PORTAL_BASE", s.URL)
t.Setenv("VERAE_ALLOW_INSECURE_PORTAL", "1")
if err := PasswordForgot("a@b.co"); !errors.Is(err, ErrResetUnavailable) {
t.Fatalf("forgot %d: %v", code, err)
}
if _, err := PasswordReset("a@b.co", "123456", "pw-12345678", "n"); !errors.Is(err, ErrResetUnavailable) {
t.Fatalf("reset %d: %v", code, err)
}
s.Close()
}
}

View file

@ -15,7 +15,7 @@ import (
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
)
const HelperVersion = "0.3.3"
const HelperVersion = "0.3.4"
// Result paths written for one print job.
type Result struct {

View file

@ -11,7 +11,7 @@
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
VERSION="${VERSION:-0.3.3}"
VERSION="${VERSION:-0.3.4}"
OUT="${OUT:-$ROOT/build/deb}"
ARCHS="${1:-all}"
PKG_NAME="verae-print"

View file

@ -9,7 +9,7 @@ SIGNING_ENV="${SIGNING_ENV:-$HOME/fie/installers/macos/signing.env}"
# shellcheck source=/dev/null
source "$SIGNING_ENV"
VERSION="${VERSION:-0.3.3}"
VERSION="${VERSION:-0.3.4}"
BUILD_ROOT="$ROOT/build/macos-pkg"
STAGE="$BUILD_ROOT/stage"
SCRIPTS="$BUILD_ROOT/scripts"