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
+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();
});
});