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
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:
@@ -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', {});
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user