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