fix: require users to authenticate existing Immich account before OAuth linking

This commit is contained in:
bo0tzz
2026-04-16 20:54:52 +02:00
parent b8591cb591
commit 5731c261eb
23 changed files with 304 additions and 58 deletions
+1
View File
@@ -51,6 +51,7 @@ export const Docs = {
export const Route = {
// auth
login: (params?: { continue?: string; autoLaunch?: 0 | 1 }) => '/auth/login' + asQueryString(params),
authLink: (params?: { linkToken?: string; email?: string }) => '/auth/link' + asQueryString(params),
logout: (params?: { continue?: string }) => '/auth/logout' + asQueryString(params),
register: () => '/auth/register',
changePassword: () => '/auth/change-password',
+1 -1
View File
@@ -294,7 +294,7 @@ export const oauth = {
return finishOAuth({ oAuthCallbackDto: { url: location.href } });
},
link: (location: Location) => {
return linkOAuthAccount({ oAuthCallbackDto: { url: location.href } });
return linkOAuthAccount({ oAuthLinkDto: { url: location.href } });
},
unlink: () => {
return unlinkOAuthAccount();
@@ -2,32 +2,13 @@
import { goto } from '$app/navigation';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { oauth } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { Button, LoadingSpinner, toastManager } from '@immich/ui';
import { onMount } from 'svelte';
import { Button, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
let loading = $state(true);
onMount(async () => {
if (oauth.isCallback(globalThis.location)) {
try {
loading = true;
const response = await oauth.link(globalThis.location);
authManager.setUser(response);
toastManager.primary($t('linked_oauth_account'));
} catch (error) {
handleError(error, $t('errors.unable_to_link_oauth_account'));
} finally {
await goto('?open=oauth');
}
}
loading = false;
});
const handleUnlink = async () => {
try {
const response = await oauth.unlink();
@@ -42,15 +23,11 @@
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<div class="sm:ms-8 flex justify-end">
{#if loading}
<div class="flex place-content-center place-items-center">
<LoadingSpinner />
</div>
{:else if featureFlagsManager.value.oauth}
{#if featureFlagsManager.value.oauth}
{#if authManager.user.oauthId}
<Button shape="round" size="small" onclick={() => handleUnlink()}>{$t('unlink_oauth')}</Button>
{:else}
<Button shape="round" size="small" onclick={() => oauth.authorize(globalThis.location)}
<Button shape="round" size="small" onclick={() => goto(Route.login({ autoLaunch: 1 }))}
>{$t('link_to_oauth')}</Button
>
{/if}
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import { goto } from '$app/navigation';
import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { getServerErrorMessage } from '$lib/utils/handle-error';
import { linkOAuthAccount, login } from '@immich/sdk';
import { Alert, Button, Field, Input, PasswordInput, Stack, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
data: PageData;
}
let { data }: Props = $props();
let email = $state(data.email || authManager.user?.email || '');
let password = $state('');
let errorMessage = $state('');
let loading = $state(false);
// Strip sensitive params from URL after reading — keeps linkToken out of browser history
history.replaceState(null, '', Route.authLink());
const handleSubmit = async (event: Event) => {
event.preventDefault();
try {
errorMessage = '';
loading = true;
const user = await login({ loginCredentialDto: { email, password } });
eventManager.emit('AuthLogin', user);
const response = await linkOAuthAccount({ oAuthLinkDto: { linkToken: data.linkToken } });
authManager.setUser(response);
toastManager.primary($t('linked_oauth_account'));
await goto(Route.photos(), { invalidateAll: true });
} catch (error) {
errorMessage = getServerErrorMessage(error) || $t('errors.incorrect_email_or_password');
loading = false;
}
};
</script>
<AuthPageLayout title={data.meta.title}>
<Stack gap={4}>
{#if featureFlagsManager.value.passwordLogin}
<Alert color="primary">
{$t('oauth_link_existing_account')}
</Alert>
<form onsubmit={handleSubmit} class="flex flex-col gap-4">
{#if errorMessage}
<Alert color="danger" title={errorMessage} closable />
{/if}
<Field label={$t('email')}>
<Input id="email" name="email" type="email" autocomplete="email" bind:value={email} />
</Field>
<Field label={$t('password')}>
<PasswordInput id="password" bind:value={password} autocomplete="current-password" />
</Field>
<Button type="submit" size="large" shape="round" fullWidth {loading} class="mt-6">
{$t('to_login')}
</Button>
</form>
{:else}
<Alert color="warning">
{$t('oauth_link_password_login_required')}
</Alert>
{/if}
</Stack>
</AuthPageLayout>
+16
View File
@@ -0,0 +1,16 @@
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
const linkToken = url.searchParams.get('linkToken') || '';
const email = url.searchParams.get('email') || '';
const $t = await getFormatter();
return {
meta: {
title: $t('link_to_oauth'),
},
linkToken,
email,
};
}) satisfies PageLoad;
+23 -2
View File
@@ -1,14 +1,16 @@
<script lang="ts">
import { goto } from '$app/navigation';
import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte';
import { OpenQueryParam } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { Route } from '$lib/route';
import { oauth } from '$lib/utils';
import { getServerErrorMessage, handleError } from '$lib/utils/handle-error';
import { login, type LoginResponseDto } from '@immich/sdk';
import { Alert, Button, Field, Input, PasswordInput, Stack } from '@immich/ui';
import { isHttpError, login, type LoginResponseDto } from '@immich/sdk';
import { Alert, Button, Field, Input, PasswordInput, Stack, toastManager } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -43,6 +45,20 @@
}
if (oauth.isCallback(globalThis.location)) {
const params = new URLSearchParams(globalThis.location.search);
if (params.has('error')) {
if (authManager.authenticated) {
const message = params.get('error_description') || $t('errors.unable_to_link_oauth_account');
await goto(Route.userSettings({ isOpen: OpenQueryParam.OAUTH }));
toastManager.warning(message);
} else {
oauthError =
params.get('error_description') || params.get('error') || $t('errors.unable_to_complete_oauth_login');
oauthLoading = false;
}
return;
}
try {
const user = await oauth.login(globalThis.location);
@@ -54,6 +70,11 @@
await onSuccess(user);
return;
} catch (error) {
if (isHttpError(error) && error.data?.message === 'oauth_account_link_required') {
const errorData = error.data as unknown as Record<string, string>;
await goto(Route.authLink({ linkToken: errorData.linkToken, email: errorData.userEmail }));
return;
}
console.error('Error [login-form] [oauth.callback]', error);
oauthError = getServerErrorMessage(error) || $t('errors.unable_to_complete_oauth_login');
oauthLoading = false;
+3 -1
View File
@@ -9,7 +9,9 @@ export const load = (async ({ parent, url }) => {
await parent();
const continueUrl = url.searchParams.get('continue') || Route.photos();
if (authManager.authenticated) {
const isOAuthCallback = url.searchParams.has('code') || url.searchParams.has('error');
const isOAuthAutoLaunch = url.searchParams.has('autoLaunch');
if (authManager.authenticated && !isOAuthCallback && !isOAuthAutoLaunch) {
redirect(307, continueUrl);
}