feat(auth): store Subsonic API keys hashed; a new key is shown once (M462 #4983)
test-web / test (push) Successful in 2m4s
test-go / test (push) Successful in 2m23s
test-go / integration (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 6m29s
release / Build + push container image (push) Successful in 29s
release / Verify release artifacts (tag releases only) (push) Skipped

users.api_token held each user's apiKey in plaintext and was looked up by
equality, so a leaked row or backup handed out working keys. Migration
0063 replaces it with api_token_hash (sha256, hex), computed in place
from the existing keys so every Subsonic client keeps working.

The key can no longer be read back: GET /api/me/api-token is gone, and
POST returns the new key once. Settings shows it right after Regenerate
with a copy button and a "won't be shown again" note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 09:42:35 -04:00
co-authored by Claude Opus 5.5
parent 2f3fbccab6
commit 327d49428f
33 changed files with 161 additions and 179 deletions
+1 -1
View File
@@ -169,7 +169,7 @@ func (h *handlers) handleAdminCreateUser(w http.ResponseWriter, r *http.Request)
user, err := q.CreateUserAdmin(r.Context(), dbq.CreateUserAdminParams{
Username: req.Username,
PasswordHash: string(hash),
ApiToken: apiToken,
ApiTokenHash: auth.HashAPIToken(apiToken),
IsAdmin: req.IsAdmin,
DisplayName: req.DisplayName,
})
-1
View File
@@ -108,7 +108,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Put("/me/password", h.handleChangePassword)
authed.Put("/me/profile", h.handleUpdateMyProfile)
authed.Put("/me/timezone", h.handlePutTimezone)
authed.Get("/me/api-token", h.handleGetMyAPIToken)
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
authed.Get("/me/sessions", h.handleListMySessions)
authed.Delete("/me/sessions/{id}", h.handleRevokeMySession)
+1 -1
View File
@@ -161,7 +161,7 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
user, err := q.CreateUserFirstAdminRace(r.Context(), dbq.CreateUserFirstAdminRaceParams{
Username: req.Username,
PasswordHash: string(hash),
ApiToken: apiToken,
ApiTokenHash: auth.HashAPIToken(apiToken),
DisplayName: req.DisplayName,
})
if err != nil {
+1 -1
View File
@@ -91,7 +91,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, username, password string, isAdm
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: prefixed,
PasswordHash: string(hash),
ApiToken: "test-api-token-" + prefixed,
ApiTokenHash: "test-api-token-" + prefixed,
IsAdmin: isAdmin,
})
if err != nil {
+9 -22
View File
@@ -13,23 +13,11 @@ type apiTokenResp struct {
APIToken string `json:"api_token"`
}
// handleGetMyAPIToken implements GET /api/me/api-token.
func (h *handlers) handleGetMyAPIToken(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
q := dbq.New(h.pool)
current, err := q.GetUserByID(r.Context(), user.ID)
if err != nil {
h.logger.Error("get api token: lookup failed", "err", err)
writeErr(w, apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, apiTokenResp{APIToken: current.ApiToken})
}
// handleRegenerateMyAPIToken implements POST /api/me/api-token.
//
// Only the key's hash is stored, so this response is the one time the key
// can be read. There is deliberately no GET: a key that has been shown and
// lost is replaced, not looked up.
func (h *handlers) handleRegenerateMyAPIToken(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
@@ -42,11 +30,10 @@ func (h *handlers) handleRegenerateMyAPIToken(w http.ResponseWriter, r *http.Req
return
}
q := dbq.New(h.pool)
updated, err := q.RegenerateApiToken(r.Context(), dbq.RegenerateApiTokenParams{
ID: user.ID,
ApiToken: newToken,
})
if err != nil {
if err := q.RegenerateApiToken(r.Context(), dbq.RegenerateApiTokenParams{
ID: user.ID,
ApiTokenHash: auth.HashAPIToken(newToken),
}); err != nil {
h.logger.Error("regenerate api token: update failed", "err", err)
writeErr(w, apierror.Internal(err))
return
@@ -54,5 +41,5 @@ func (h *handlers) handleRegenerateMyAPIToken(w http.ResponseWriter, r *http.Req
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionTokenRegenerate, nil)
writeJSON(w, http.StatusOK, apiTokenResp{APIToken: updated.ApiToken})
writeJSON(w, http.StatusOK, apiTokenResp{APIToken: newToken})
}
+14 -38
View File
@@ -8,47 +8,23 @@ import (
"os"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"github.com/go-chi/chi/v5"
)
func newMeTokenRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Get("/api/me/api-token", h.handleGetMyAPIToken)
r.Post("/api/me/api-token", h.handleRegenerateMyAPIToken)
return r
}
func TestGetAPIToken_ReturnsCurrentToken(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
user := seedUser(t, pool, "tok1", "pw", false)
req := httptest.NewRequest(http.MethodGet, "/api/me/api-token", nil)
req = withUser(req, user)
rec := httptest.NewRecorder()
newMeTokenRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp apiTokenResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.APIToken != user.ApiToken {
t.Errorf("api_token = %q, want %q", resp.APIToken, user.ApiToken)
}
}
func TestRegenerateAPIToken_IssuesNewToken(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
user := seedUser(t, pool, "tok2", "pw", false)
oldToken := user.ApiToken
oldHash := user.ApiTokenHash
req := httptest.NewRequest(http.MethodPost, "/api/me/api-token", nil)
req = withUser(req, user)
@@ -65,20 +41,20 @@ func TestRegenerateAPIToken_IssuesNewToken(t *testing.T) {
if resp.APIToken == "" {
t.Fatalf("api_token is empty")
}
if resp.APIToken == oldToken {
t.Errorf("api_token unchanged after regenerate")
}
// Verify DB row has the new token and old token no longer matches.
var dbToken string
// The DB holds the new key's hash, never the key, and the old key no
// longer matches.
var dbHash string
if err := pool.QueryRow(context.Background(),
"SELECT api_token FROM users WHERE id = $1", user.ID).Scan(&dbToken); err != nil {
t.Fatalf("read token: %v", err)
"SELECT api_token_hash FROM users WHERE id = $1", user.ID).Scan(&dbHash); err != nil {
t.Fatalf("read token hash: %v", err)
}
if dbToken != resp.APIToken {
t.Errorf("DB api_token = %q, want %q", dbToken, resp.APIToken)
if dbHash != auth.HashAPIToken(resp.APIToken) {
t.Errorf("DB api_token_hash = %q, want hash of the returned key", dbHash)
}
if dbToken == oldToken {
t.Errorf("old token still in DB after regenerate")
if dbHash == oldHash {
t.Errorf("old key hash still in DB after regenerate")
}
if dbHash == resp.APIToken {
t.Errorf("DB holds the raw key")
}
}
+10
View File
@@ -5,6 +5,7 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"log/slog"
"net/http"
@@ -41,6 +42,15 @@ func HashSessionToken(token string) []byte {
return sum[:]
}
// HashAPIToken maps a raw API key (the OpenSubsonic apiKey) to the
// `users.api_token_hash` column: sha256, hex. The key is minted with
// MintSessionToken, so it carries the same 256 bits and the same reasoning
// as HashSessionToken applies. Hex rather than bytes so the column stays
// text and a migration can compute it in SQL from the old plaintext.
func HashAPIToken(token string) string {
return hex.EncodeToString(HashSessionToken(token))
}
// VerifyPassword is the canonical bcrypt comparison. Returns false on a
// malformed hash so callers don't need to distinguish "hash invalid" from
// "password wrong" — both are auth failures from the client's perspective.
+1 -1
View File
@@ -42,7 +42,7 @@ func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Disca
func seedUser(t *testing.T, q *dbq.Queries, name string) pgtype.UUID {
t.Helper()
u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiToken: name + "-tok", IsAdmin: false,
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiTokenHash: name + "-tok", IsAdmin: false,
})
if err != nil {
t.Fatalf("seed user %s: %v", name, err)
+1 -1
View File
@@ -731,7 +731,6 @@ type User struct {
ID pgtype.UUID
Username string
PasswordHash string
ApiToken string
IsAdmin bool
CreatedAt pgtype.Timestamptz
SubsonicPassword *string
@@ -743,6 +742,7 @@ type User struct {
Timezone string
TimezoneUpdatedAt pgtype.Timestamptz
DebugModeEnabled bool
ApiTokenHash string
}
type UserInvite struct {
+45 -63
View File
@@ -52,15 +52,15 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) {
}
const createUser = `-- name: CreateUser :one
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash
`
type CreateUserParams struct {
Username string
PasswordHash string
ApiToken string
ApiTokenHash string
IsAdmin bool
DisplayName *string
}
@@ -69,7 +69,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
row := q.db.QueryRow(ctx, createUser,
arg.Username,
arg.PasswordHash,
arg.ApiToken,
arg.ApiTokenHash,
arg.IsAdmin,
arg.DisplayName,
)
@@ -78,7 +78,6 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.ID,
&i.Username,
&i.PasswordHash,
&i.ApiToken,
&i.IsAdmin,
&i.CreatedAt,
&i.SubsonicPassword,
@@ -90,20 +89,21 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
&i.ApiTokenHash,
)
return i, err
}
const createUserAdmin = `-- name: CreateUserAdmin :one
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash
`
type CreateUserAdminParams struct {
Username string
PasswordHash string
ApiToken string
ApiTokenHash string
IsAdmin bool
DisplayName *string
}
@@ -115,7 +115,7 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
row := q.db.QueryRow(ctx, createUserAdmin,
arg.Username,
arg.PasswordHash,
arg.ApiToken,
arg.ApiTokenHash,
arg.IsAdmin,
arg.DisplayName,
)
@@ -124,7 +124,6 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
&i.ID,
&i.Username,
&i.PasswordHash,
&i.ApiToken,
&i.IsAdmin,
&i.CreatedAt,
&i.SubsonicPassword,
@@ -136,24 +135,25 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
&i.ApiTokenHash,
)
return i, err
}
const createUserFirstAdminRace = `-- name: CreateUserFirstAdminRace :one
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
VALUES (
$1, $2, $3,
(SELECT NOT EXISTS (SELECT 1 FROM users)),
$4
)
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash
`
type CreateUserFirstAdminRaceParams struct {
Username string
PasswordHash string
ApiToken string
ApiTokenHash string
DisplayName *string
}
@@ -174,7 +174,7 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
row := q.db.QueryRow(ctx, createUserFirstAdminRace,
arg.Username,
arg.PasswordHash,
arg.ApiToken,
arg.ApiTokenHash,
arg.DisplayName,
)
var i User
@@ -182,7 +182,6 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
&i.ID,
&i.Username,
&i.PasswordHash,
&i.ApiToken,
&i.IsAdmin,
&i.CreatedAt,
&i.SubsonicPassword,
@@ -194,6 +193,7 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
&i.ApiTokenHash,
)
return i, err
}
@@ -240,7 +240,7 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge
}
const getOldestAdmin = `-- name: GetOldestAdmin :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users
SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users
WHERE is_admin = true
ORDER BY created_at, id
LIMIT 1
@@ -260,7 +260,6 @@ func (q *Queries) GetOldestAdmin(ctx context.Context) (User, error) {
&i.ID,
&i.Username,
&i.PasswordHash,
&i.ApiToken,
&i.IsAdmin,
&i.CreatedAt,
&i.SubsonicPassword,
@@ -272,22 +271,22 @@ func (q *Queries) GetOldestAdmin(ctx context.Context) (User, error) {
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
&i.ApiTokenHash,
)
return i, err
}
const getUserByAPIToken = `-- name: GetUserByAPIToken :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE api_token = $1
const getUserByAPITokenHash = `-- name: GetUserByAPITokenHash :one
SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users WHERE api_token_hash = $1
`
func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, error) {
row := q.db.QueryRow(ctx, getUserByAPIToken, apiToken)
func (q *Queries) GetUserByAPITokenHash(ctx context.Context, apiTokenHash string) (User, error) {
row := q.db.QueryRow(ctx, getUserByAPITokenHash, apiTokenHash)
var i User
err := row.Scan(
&i.ID,
&i.Username,
&i.PasswordHash,
&i.ApiToken,
&i.IsAdmin,
&i.CreatedAt,
&i.SubsonicPassword,
@@ -299,12 +298,13 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
&i.ApiTokenHash,
)
return i, err
}
const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE lower(email) = lower($1)
SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users WHERE lower(email) = lower($1)
`
// Used by forgot-password lookup. Lowercase comparison both sides
@@ -317,7 +317,6 @@ func (q *Queries) GetUserByEmail(ctx context.Context, lower string) (User, error
&i.ID,
&i.Username,
&i.PasswordHash,
&i.ApiToken,
&i.IsAdmin,
&i.CreatedAt,
&i.SubsonicPassword,
@@ -329,12 +328,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, lower string) (User, error
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
&i.ApiTokenHash,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE id = $1
SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users WHERE id = $1
`
func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) {
@@ -344,7 +344,6 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error)
&i.ID,
&i.Username,
&i.PasswordHash,
&i.ApiToken,
&i.IsAdmin,
&i.CreatedAt,
&i.SubsonicPassword,
@@ -356,12 +355,13 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error)
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
&i.ApiTokenHash,
)
return i, err
}
const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE username = $1
SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users WHERE username = $1
`
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
@@ -371,7 +371,6 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
&i.ID,
&i.Username,
&i.PasswordHash,
&i.ApiToken,
&i.IsAdmin,
&i.CreatedAt,
&i.SubsonicPassword,
@@ -383,6 +382,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
&i.ApiTokenHash,
)
return i, err
}
@@ -471,39 +471,21 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
return items, nil
}
const regenerateApiToken = `-- name: RegenerateApiToken :one
UPDATE users SET api_token = $2 WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
const regenerateApiToken = `-- name: RegenerateApiToken :exec
UPDATE users SET api_token_hash = $2 WHERE id = $1
`
type RegenerateApiTokenParams struct {
ID pgtype.UUID
ApiToken string
ID pgtype.UUID
ApiTokenHash string
}
// Self-service: caller wants a new API token. Used by the /settings
// API Token card's "Regenerate" button.
func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiTokenParams) (User, error) {
row := q.db.QueryRow(ctx, regenerateApiToken, arg.ID, arg.ApiToken)
var i User
err := row.Scan(
&i.ID,
&i.Username,
&i.PasswordHash,
&i.ApiToken,
&i.IsAdmin,
&i.CreatedAt,
&i.SubsonicPassword,
&i.ListenbrainzToken,
&i.ListenbrainzEnabled,
&i.DisplayName,
&i.AutoApproveRequests,
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
// API Token card's "Regenerate" button. Only the hash is stored; the
// handler returns the raw key once.
func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiTokenParams) error {
_, err := q.db.Exec(ctx, regenerateApiToken, arg.ID, arg.ApiTokenHash)
return err
}
const resetUserPassword = `-- name: ResetUserPassword :exec
@@ -530,7 +512,7 @@ const setDebugMode = `-- name: SetDebugMode :one
UPDATE users
SET debug_mode_enabled = $2
WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash
`
type SetDebugModeParams struct {
@@ -548,7 +530,6 @@ func (q *Queries) SetDebugMode(ctx context.Context, arg SetDebugModeParams) (Use
&i.ID,
&i.Username,
&i.PasswordHash,
&i.ApiToken,
&i.IsAdmin,
&i.CreatedAt,
&i.SubsonicPassword,
@@ -560,6 +541,7 @@ func (q *Queries) SetDebugMode(ctx context.Context, arg SetDebugModeParams) (Use
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
&i.ApiTokenHash,
)
return i, err
}
@@ -617,7 +599,7 @@ const updateUserAdmin = `-- name: UpdateUserAdmin :one
UPDATE users
SET is_admin = $2
WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash
`
type UpdateUserAdminParams struct {
@@ -634,7 +616,6 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams
&i.ID,
&i.Username,
&i.PasswordHash,
&i.ApiToken,
&i.IsAdmin,
&i.CreatedAt,
&i.SubsonicPassword,
@@ -646,6 +627,7 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
&i.ApiTokenHash,
)
return i, err
}
@@ -654,7 +636,7 @@ const updateUserAutoApprove = `-- name: UpdateUserAutoApprove :one
UPDATE users
SET auto_approve_requests = $2
WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash
`
type UpdateUserAutoApproveParams struct {
@@ -670,7 +652,6 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA
&i.ID,
&i.Username,
&i.PasswordHash,
&i.ApiToken,
&i.IsAdmin,
&i.CreatedAt,
&i.SubsonicPassword,
@@ -682,6 +663,7 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
&i.ApiTokenHash,
)
return i, err
}
@@ -691,7 +673,7 @@ UPDATE users
SET display_name = $2,
email = $3
WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash
`
type UpdateUserProfileParams struct {
@@ -709,7 +691,6 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
&i.ID,
&i.Username,
&i.PasswordHash,
&i.ApiToken,
&i.IsAdmin,
&i.CreatedAt,
&i.SubsonicPassword,
@@ -721,6 +702,7 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
&i.ApiTokenHash,
)
return i, err
}
@@ -0,0 +1,7 @@
-- The keys cannot be recovered from their hashes. Rolling back gives every
-- user a new random key; Subsonic clients using apiKey need the new one.
ALTER TABLE users ADD COLUMN api_token text;
UPDATE users SET api_token = md5(random()::text || id::text || clock_timestamp()::text);
ALTER TABLE users ALTER COLUMN api_token SET NOT NULL;
ALTER TABLE users ADD CONSTRAINT users_api_token_key UNIQUE (api_token);
ALTER TABLE users DROP COLUMN api_token_hash;
@@ -0,0 +1,10 @@
-- API keys (the OpenSubsonic apiKey) are stored as their sha256, hex, the
-- same way session tokens are. A leaked database row or backup no longer
-- hands out working keys. Existing keys are hashed in place, so every
-- Subsonic client keeps working; the key itself can no longer be shown
-- again, only replaced (M462 #4983).
ALTER TABLE users ADD COLUMN api_token_hash text;
UPDATE users SET api_token_hash = encode(sha256(convert_to(api_token, 'UTF8')), 'hex');
ALTER TABLE users ALTER COLUMN api_token_hash SET NOT NULL;
ALTER TABLE users ADD CONSTRAINT users_api_token_hash_key UNIQUE (api_token_hash);
ALTER TABLE users DROP COLUMN api_token;
+9 -9
View File
@@ -1,5 +1,5 @@
-- name: CreateUser :one
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
@@ -17,7 +17,7 @@ RETURNING *;
-- and the second caller's INSERT fails with a unique violation. The
-- caller (registration handler) can retry as a regular non-admin in
-- that case (or surface a "username taken" error to the user).
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
VALUES (
$1, $2, $3,
(SELECT NOT EXISTS (SELECT 1 FROM users)),
@@ -28,8 +28,8 @@ RETURNING *;
-- name: GetUserByUsername :one
SELECT * FROM users WHERE username = $1;
-- name: GetUserByAPIToken :one
SELECT * FROM users WHERE api_token = $1;
-- name: GetUserByAPITokenHash :one
SELECT * FROM users WHERE api_token_hash = $1;
-- name: CountUsers :one
SELECT count(*) FROM users;
@@ -87,7 +87,7 @@ WHERE u.id = $1;
-- Admin-driven user creation. Distinct from CreateUser/CreateUserFirstAdminRace:
-- the caller (an admin) supplies all five fields explicitly including is_admin,
-- so an admin can promote on creation. Used by POST /api/admin/users.
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
@@ -141,11 +141,11 @@ UPDATE users
WHERE id = $1
RETURNING *;
-- name: RegenerateApiToken :one
-- name: RegenerateApiToken :exec
-- Self-service: caller wants a new API token. Used by the /settings
-- API Token card's "Regenerate" button.
UPDATE users SET api_token = $2 WHERE id = $1
RETURNING *;
-- API Token card's "Regenerate" button. Only the hash is stored; the
-- handler returns the raw key once.
UPDATE users SET api_token_hash = $2 WHERE id = $1;
-- name: GetUserByEmail :one
-- Used by forgot-password lookup. Lowercase comparison both sides
+1 -1
View File
@@ -50,7 +50,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, name string) pgtype.UUID {
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name,
PasswordHash: "test-hash",
ApiToken: "test-token-" + name,
ApiTokenHash: "test-token-" + name,
IsAdmin: false,
})
if err != nil {
+1 -1
View File
@@ -74,7 +74,7 @@ func newMergeFixture(t *testing.T) mergeFixture {
user := func(name string) dbq.User {
t.Helper()
u, err := q.CreateUser(ctx, dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiToken: name + "-merge-token",
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiTokenHash: name + "-merge-token",
})
if err != nil {
t.Fatalf("user %s: %v", name, err)
+1 -1
View File
@@ -46,7 +46,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x",
ApiToken: name + "-token", IsAdmin: false,
ApiTokenHash: name + "-token", IsAdmin: false,
})
if err != nil {
t.Fatalf("seed user %s: %v", name, err)
+2 -2
View File
@@ -50,7 +50,7 @@ func newPool(t *testing.T) *pgxpool.Pool {
func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID {
t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "rqtester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
Username: dbtest.TestUserPrefix + "rqtester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false,
})
if err != nil {
t.Fatalf("seed user: %v", err)
@@ -206,7 +206,7 @@ func TestListForUser_OnlyOwnRows(t *testing.T) {
pool := newPool(t)
alice := seedUser(t, pool)
bob, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiToken: "x2", IsAdmin: false,
Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiTokenHash: "x2", IsAdmin: false,
})
if err != nil {
t.Fatalf("seed bob: %v", err)
+1 -1
View File
@@ -51,7 +51,7 @@ func newFixture(t *testing.T, durationMs int32) fixture {
pool := testPool(t)
q := dbq.New(pool)
u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false,
})
if err != nil {
t.Fatalf("user: %v", err)
@@ -58,7 +58,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name,
PasswordHash: "x",
ApiToken: name + "-token",
ApiTokenHash: name + "-token",
IsAdmin: false,
})
if err != nil {
+1 -1
View File
@@ -42,7 +42,7 @@ func seedTestUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID {
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "tester",
PasswordHash: "x",
ApiToken: "x",
ApiTokenHash: "x",
IsAdmin: false,
})
if err != nil {
+2 -2
View File
@@ -50,7 +50,7 @@ func newFixture(t *testing.T, n int) fixture {
pool := testPool(t)
q := dbq.New(pool)
u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false,
})
if err != nil {
t.Fatalf("user: %v", err)
@@ -197,7 +197,7 @@ func TestLoadCandidates_NeverPlayedHasNilLastPlayed(t *testing.T) {
func TestLoadCandidates_CrossUserIsolation(t *testing.T) {
f := newFixture(t, 2)
bob, _ := f.q.CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false,
Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiTokenHash: "y", IsAdmin: false,
})
// Alice likes tracks[1]; Bob shouldn't see it.
_, _ = f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID})
@@ -42,7 +42,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x",
ApiToken: name + "-token", IsAdmin: false,
ApiTokenHash: name + "-token", IsAdmin: false,
})
if err != nil {
t.Fatalf("seed user: %v", err)
+1 -1
View File
@@ -57,7 +57,7 @@ func seed(t *testing.T, opts seedOpts) setup {
pool, q := testPool(t)
ctx := context.Background()
u, err := q.CreateUser(ctx, dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false,
})
if err != nil {
t.Fatalf("user: %v", err)
+2 -2
View File
@@ -190,7 +190,7 @@ func TestRouter_AdminSubtreeNotShadowed(t *testing.T) {
user, err := q.CreateUser(ctx, dbq.CreateUserParams{
Username: "test-shadowing-admin",
PasswordHash: "x",
ApiToken: "test-shadowing-token",
ApiTokenHash: "test-shadowing-token",
IsAdmin: true,
})
if err != nil {
@@ -276,7 +276,7 @@ func TestRouter_ReacquisitionSettingsSavedThroughTheAPIReachTheSweeper(t *testin
user, err := q.CreateUser(ctx, dbq.CreateUserParams{
Username: "test-reacq-settings-admin",
PasswordHash: "x",
ApiToken: "test-reacq-settings-token",
ApiTokenHash: "test-reacq-settings-token",
IsAdmin: true,
})
if err != nil {
@@ -57,7 +57,7 @@ func newFixture(t *testing.T) fixture {
pool, q := testPool(t)
ctx := context.Background()
u, err := q.CreateUser(ctx, dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false,
})
if err != nil {
t.Fatalf("user: %v", err)
+2 -2
View File
@@ -36,7 +36,7 @@ func UserFromContext(ctx context.Context) (dbq.User, bool) {
return u, ok
}
// Middleware authenticates the request against users.api_token (apiKey) or
// Middleware authenticates the request against users.api_token_hash (apiKey) or
// users.subsonic_password (t/s or p). On failure it writes a Subsonic failed
// envelope using the request's f= format; downstream handlers never see an
// unauthenticated request.
@@ -79,7 +79,7 @@ func authenticate(r *http.Request, pool *pgxpool.Pool, cfg Config) (dbq.User, in
params := r.URL.Query()
if apiKey := params.Get("apiKey"); apiKey != "" {
user, err := q.GetUserByAPIToken(r.Context(), apiKey)
user, err := q.GetUserByAPITokenHash(r.Context(), auth.HashAPIToken(apiKey))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbq.User{}, ErrWrongCredentials, "Invalid apiKey"
+1 -1
View File
@@ -39,7 +39,7 @@ func testScrobblePool(t *testing.T) (*pgxpool.Pool, dbq.User, dbq.Track) {
dbtest.ResetDB(t, pool)
q := dbq.New(pool)
u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false,
})
if err != nil {
t.Fatalf("user: %v", err)
+2 -2
View File
@@ -40,7 +40,7 @@ func testStarPool(t *testing.T) (*pgxpool.Pool, dbq.User, dbq.Track, dbq.Album,
dbtest.ResetDB(t, pool)
q := dbq.New(pool)
u, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false,
})
a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "X", SortName: "X"})
al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "X", SortTitle: "X", ArtistID: a.ID})
@@ -228,7 +228,7 @@ func TestHandleGetStarred2_CrossUserIsolation(t *testing.T) {
pool, alice, track, _, _ := testStarPool(t)
q := dbq.New(pool)
bob, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false,
Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiTokenHash: "y", IsAdmin: false,
})
_, _ = q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: alice.ID, TrackID: track.ID})
+1 -1
View File
@@ -50,7 +50,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x",
ApiToken: name + "-token", IsAdmin: false,
ApiTokenHash: name + "-token", IsAdmin: false,
})
if err != nil {
t.Fatalf("seed user: %v", err)
+1 -1
View File
@@ -44,7 +44,7 @@ func seedAdmin(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x",
ApiToken: name + "-token", IsAdmin: true,
ApiTokenHash: name + "-token", IsAdmin: true,
})
if err != nil {
t.Fatalf("seed admin %s: %v", name, err)
+5 -4
View File
@@ -46,10 +46,11 @@ export async function updateProfile(input: { display_name?: string; email?: stri
return api.put<MyProfile>('/api/me/profile', input);
}
export async function getAPIToken(): Promise<APITokenResponse> {
return api.get<APITokenResponse>('/api/me/api-token');
}
/**
* Mints a new API key and returns it. The server keeps only its hash, so
* this is the one time the key can be read; there is no way to fetch it
* again later.
*/
export async function regenerateAPIToken(): Promise<APITokenResponse> {
return api.post<APITokenResponse>('/api/me/api-token', {});
}
+13 -12
View File
@@ -19,7 +19,6 @@
import {
updateProfile,
changePassword,
getAPIToken,
regenerateAPIToken
} from '$lib/api/me';
import { errCode } from '$lib/api/errors';
@@ -167,15 +166,13 @@
// API Token card ----------------------------------------------------------
// Only set right after Regenerate: the server stores the key's hash, so
// this is the one moment the key exists outside the client it goes into.
let apiToken = $state<string | null>(null);
let tokenSaving = $state(false);
let confirmRegen = $state(false);
let regenTimer: ReturnType<typeof setTimeout> | undefined;
$effect(() => {
getAPIToken().then(r => { apiToken = r.api_token; }).catch(() => {});
});
async function copyToken() {
if (!apiToken) return;
try {
@@ -199,7 +196,7 @@
try {
const r = await regenerateAPIToken();
apiToken = r.api_token;
pushToast('API token regenerated.');
pushToast('New API token created. Copy it now; it will not be shown again.');
} catch (e: unknown) {
pushToast(`Regenerate failed: ${errCode(e)}`, 'error');
} finally {
@@ -531,19 +528,23 @@
<section class="space-y-3 rounded border border-border bg-surface p-4">
<h2 class="text-lg font-semibold">API Token</h2>
<p class="text-sm text-text-secondary">
Used by Subsonic clients (DSub, Symfonium, etc.) to authenticate to your library.
Regenerating invalidates clients that have the old token cached.
Used by Subsonic clients that sign in with an API key (OpenSubsonic apiKey).
Minstrel keeps only a fingerprint of the token, so it can't show you the current one.
Regenerate to get a new token; clients using the old one will need the new one.
</p>
{#if apiToken}
<code class="block break-all rounded bg-background p-2 text-xs">
{apiToken}
</code>
<p class="text-xs text-text-secondary">Copy this now. It won't be shown again.</p>
{/if}
<div class="flex gap-2">
<button type="button" onclick={copyToken}
class="inline-flex items-center rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
Copy
</button>
{#if apiToken}
<button type="button" onclick={copyToken}
class="inline-flex items-center rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
Copy
</button>
{/if}
<button type="button" disabled={tokenSaving}
onclick={onRegenerateToken}
class="inline-flex items-center rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
+12 -3
View File
@@ -16,7 +16,6 @@ vi.mock('$lib/api/me', () => ({
changePassword: vi.fn(),
// Default to a resolved value so the page's $effect doesn't crash
// on `.then()` of undefined when individual tests don't override.
getAPIToken: vi.fn().mockResolvedValue({ api_token: '' }),
regenerateAPIToken: vi.fn()
}));
@@ -46,7 +45,6 @@ import {
import {
updateProfile,
changePassword,
getAPIToken,
regenerateAPIToken
} from '$lib/api/me';
@@ -133,7 +131,6 @@ function setupPage() {
);
(createTokenMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
(createEnabledMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
(getAPIToken as ReturnType<typeof vi.fn>).mockResolvedValue({ api_token: 'tok_abc123' });
}
describe('Settings page — Profile card', () => {
@@ -350,5 +347,17 @@ describe('Settings page — API Token card', () => {
);
await fireEvent.click(screen.getByRole('button', { name: /click again to confirm/i }));
await waitFor(() => expect(regenerateAPIToken).toHaveBeenCalled());
// The new key is shown once, with a way to copy it.
expect(await screen.findByText('new_tok_xyz')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^copy$/i })).toBeInTheDocument();
});
test('no token is shown or fetched before Regenerate', async () => {
setupPage();
render(SettingsPage);
await waitFor(() =>
expect(screen.getByRole('button', { name: /regenerate/i })).toBeInTheDocument()
);
expect(screen.queryByRole('button', { name: /^copy$/i })).toBeNull();
});
});