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
No known key found for this signature in database
23 changed files with 304 additions and 58 deletions

View File

@ -356,19 +356,17 @@ describe(`/oauth`, () => {
expect(body).toEqual(errorDto.badRequest('User does not exist and auto registering is disabled.'));
});
it('should link to an existing user by email', async () => {
const { userId } = await utils.userSetup(admin.accessToken, {
it('should not auto-link to an existing user by email', async () => {
await utils.userSetup(admin.accessToken, {
name: 'OAuth User 3',
email: 'oauth-user3@immich.app',
password: 'password',
});
const callbackParams = await loginWithOAuth('oauth-user3');
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
expect(status).toBe(201);
expect(body).toMatchObject({
userId,
userEmail: 'oauth-user3@immich.app',
});
expect(status).toBe(400);
expect(body.message).toBe('oauth_account_link_required');
expect(body.userEmail).toBe('oauth-user3@immich.app');
});
});
});

View File

@ -1642,6 +1642,8 @@
"notifications": "Notifications",
"notifications_setting_description": "Manage notifications",
"oauth": "OAuth",
"oauth_link_existing_account": "Log in with your Immich password to link your OAuth account.",
"oauth_link_password_login_required": "An account with this email already exists. Password login is required to link your OAuth account. Please contact your administrator.",
"obtainium_configurator": "Obtainium Configurator",
"obtainium_configurator_instructions": "Use Obtainium to install and update the Android app directly from Immich GitHub's release. Create an API key and select a variant to create your Obtainium configuration link",
"ocr": "OCR",

View File

@ -7448,7 +7448,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthCallbackDto"
"$ref": "#/components/schemas/OAuthLinkDto"
}
}
},
@ -19116,6 +19116,27 @@
],
"type": "object"
},
"OAuthLinkDto": {
"properties": {
"codeVerifier": {
"description": "OAuth code verifier (PKCE)",
"type": "string"
},
"linkToken": {
"description": "OAuth link token from prior callback",
"type": "string"
},
"state": {
"description": "OAuth state parameter",
"type": "string"
},
"url": {
"description": "OAuth callback URL",
"type": "string"
}
},
"type": "object"
},
"OAuthTokenEndpointAuthMethod": {
"description": "OAuth token endpoint auth method",
"enum": [

View File

@ -1421,6 +1421,16 @@ export type OAuthCallbackDto = {
/** OAuth callback URL */
url: string;
};
export type OAuthLinkDto = {
/** OAuth code verifier (PKCE) */
codeVerifier?: string;
/** OAuth link token from prior callback */
linkToken?: string;
/** OAuth state parameter */
state?: string;
/** OAuth callback URL */
url?: string;
};
export type PartnerResponseDto = {
avatarColor: UserAvatarColor;
/** User email */
@ -4947,8 +4957,8 @@ export function finishOAuth({ oAuthCallbackDto }: {
/**
* Link OAuth account
*/
export function linkOAuthAccount({ oAuthCallbackDto }: {
oAuthCallbackDto: OAuthCallbackDto;
export function linkOAuthAccount({ oAuthLinkDto }: {
oAuthLinkDto: OAuthLinkDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
@ -4956,7 +4966,7 @@ export function linkOAuthAccount({ oAuthCallbackDto }: {
}>("/oauth/link", oazapfts.json({
...opts,
method: "POST",
body: oAuthCallbackDto
body: oAuthLinkDto
})));
}
/**

View File

@ -9,6 +9,7 @@ import {
OAuthBackchannelLogoutDto,
OAuthCallbackDto,
OAuthConfigDto,
OAuthLinkDto,
} from 'src/dtos/auth.dto';
import { UserAdminResponseDto } from 'src/dtos/user.dto';
import { ApiTag, AuthType, ImmichCookie } from 'src/enum';
@ -97,7 +98,7 @@ export class OAuthController {
linkOAuthAccount(
@Req() request: Request,
@Auth() auth: AuthDto,
@Body() dto: OAuthCallbackDto,
@Body() dto: OAuthLinkDto,
): Promise<UserAdminResponseDto> {
return this.service.link(auth, dto, request.headers);
}

View File

@ -110,6 +110,18 @@ const OAuthCallbackSchema = z
})
.meta({ id: 'OAuthCallbackDto' });
const OAuthLinkSchema = z
.object({
url: z.string().optional().describe('OAuth callback URL'),
state: z.string().optional().describe('OAuth state parameter'),
codeVerifier: z.string().optional().describe('OAuth code verifier (PKCE)'),
linkToken: z.string().optional().describe('OAuth link token from prior callback'),
})
.refine((data) => data.url || data.linkToken, {
message: 'Either url or linkToken is required',
})
.meta({ id: 'OAuthLinkDto' });
const OAuthConfigSchema = z
.object({
redirectUri: z.string().describe('OAuth redirect URI'),
@ -149,6 +161,7 @@ export class SessionUnlockDto extends createZodDto(SessionUnlockSchema) {}
export class PinCodeChangeDto extends createZodDto(PinCodeChangeSchema) {}
export class ValidateAccessTokenResponseDto extends createZodDto(ValidateAccessTokenResponseSchema) {}
export class OAuthCallbackDto extends createZodDto(OAuthCallbackSchema) {}
export class OAuthLinkDto extends createZodDto(OAuthLinkSchema) {}
export class OAuthConfigDto extends createZodDto(OAuthConfigSchema) {}
export class OAuthAuthorizeResponseDto extends createZodDto(OAuthAuthorizeResponseSchema) {}
export class OAuthBackchannelLogoutDto extends createZodDto(OAuthBackchannelLogoutSchema) {}

View File

@ -25,6 +25,7 @@ import { MemoryRepository } from 'src/repositories/memory.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { MoveRepository } from 'src/repositories/move.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { OAuthLinkTokenRepository } from 'src/repositories/oauth-link-token.repository';
import { OAuthRepository } from 'src/repositories/oauth.repository';
import { OcrRepository } from 'src/repositories/ocr.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
@ -78,6 +79,7 @@ export const repositories = [
MetadataRepository,
MoveRepository,
NotificationRepository,
OAuthLinkTokenRepository,
OAuthRepository,
OcrRepository,
PartnerRepository,

View File

@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { InjectKysely } from 'nestjs-kysely';
import { DB } from 'src/schema';
import { OAuthLinkTokenTable } from 'src/schema/tables/oauth-link-token.table';
@Injectable()
export class OAuthLinkTokenRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
create(dto: Insertable<OAuthLinkTokenTable>) {
return this.db.insertInto('oauth_link_token').values(dto).returningAll().executeTakeFirstOrThrow();
}
// Atomic consume: delete and return in one query (single-use guarantee)
consumeToken(token: Buffer) {
return this.db
.deleteFrom('oauth_link_token')
.where('token', '=', token)
.where('expiresAt', '>', DateTime.now().toJSDate())
.returningAll()
.executeTakeFirst();
}
async deleteByEmail(userEmail: string) {
await this.db.deleteFrom('oauth_link_token').where('userEmail', '=', userEmail).execute();
}
async cleanup() {
const result = await this.db
.deleteFrom('oauth_link_token')
.where('expiresAt', '<=', DateTime.now().toJSDate())
.execute();
return Number(result[0]?.numDeletedRows ?? 0);
}
}

View File

@ -50,6 +50,7 @@ import { MemoryTable } from 'src/schema/tables/memory.table';
import { MoveTable } from 'src/schema/tables/move.table';
import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table';
import { NotificationTable } from 'src/schema/tables/notification.table';
import { OAuthLinkTokenTable } from 'src/schema/tables/oauth-link-token.table';
import { OcrSearchTable } from 'src/schema/tables/ocr-search.table';
import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table';
import { PartnerTable } from 'src/schema/tables/partner.table';
@ -108,6 +109,7 @@ export class ImmichDatabase {
MoveTable,
NaturalEarthCountriesTable,
NotificationTable,
OAuthLinkTokenTable,
OcrSearchTable,
PartnerAuditTable,
PartnerTable,
@ -210,6 +212,8 @@ export interface DB {
notification: NotificationTable;
oauth_link_token: OAuthLinkTokenTable;
move_history: MoveTable;
naturalearth_countries: NaturalEarthCountriesTable;

View File

@ -0,0 +1,15 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TABLE "oauth_link_token" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "token" bytea NOT NULL, "oauthSub" character varying NOT NULL, "userEmail" character varying NOT NULL, "expiresAt" timestamp with time zone NOT NULL, "createdAt" timestamp with time zone NOT NULL DEFAULT now());`.execute(
db,
);
await sql`ALTER TABLE "oauth_link_token" ADD CONSTRAINT "PK_oauth_link_token_id" PRIMARY KEY ("id");`.execute(db);
await sql`CREATE INDEX "IDX_oauth_link_token_token" ON "oauth_link_token" ("token")`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP INDEX "IDX_oauth_link_token_token";`.execute(db);
await sql`ALTER TABLE "oauth_link_token" DROP CONSTRAINT "PK_oauth_link_token_id";`.execute(db);
await sql`DROP TABLE "oauth_link_token";`.execute(db);
}

View File

@ -0,0 +1,22 @@
import { Column, CreateDateColumn, Generated, PrimaryGeneratedColumn, Table, Timestamp } from '@immich/sql-tools';
@Table({ name: 'oauth_link_token' })
export class OAuthLinkTokenTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@Column({ type: 'bytea', index: true })
token!: Buffer;
@Column()
oauthSub!: string;
@Column()
userEmail!: string;
@Column({ type: 'timestamp with time zone' })
expiresAt!: Timestamp;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
}

View File

@ -702,24 +702,28 @@ describe(AuthService.name, () => {
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
});
it('should link an existing user', async () => {
it('should reject when existing user found by email and create a link token', async () => {
const user = UserFactory.create();
const profile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauthLinkToken.deleteByEmail.mockResolvedValue();
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{},
loginDetails,
);
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{},
loginDetails,
),
).rejects.toThrow('oauth_account_link_required');
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
expect(mocks.user.update).not.toHaveBeenCalled();
expect(mocks.oauthLinkToken.deleteByEmail).toHaveBeenCalledTimes(1);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledTimes(1);
});
it('should normalize the email from the OAuth profile before linking', async () => {
@ -749,6 +753,8 @@ describe(AuthService.name, () => {
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.user.getByEmail.mockResolvedValueOnce(user);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.oauthLinkToken.deleteByEmail.mockResolvedValue();
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await expect(
sut.callback(
@ -756,7 +762,7 @@ describe(AuthService.name, () => {
{},
loginDetails,
),
).rejects.toThrow(BadRequestException);
).rejects.toThrow('oauth_account_link_required');
expect(mocks.user.update).not.toHaveBeenCalled();
expect(mocks.user.create).not.toHaveBeenCalled();

View File

@ -320,14 +320,23 @@ export class AuthService extends BaseService {
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub);
// link by email
if (!user && normalizedEmail) {
const emailUser = await this.userRepository.getByEmail(normalizedEmail);
if (emailUser) {
if (emailUser.oauthId) {
throw new BadRequestException('User already exists, but is linked to another account.');
}
user = await this.userRepository.update(emailUser.id, { oauthId: profile.sub });
await this.oauthLinkTokenRepository.deleteByEmail(emailUser.email);
const plainToken = this.cryptoRepository.randomBytesAsText(32);
const hashedToken = this.cryptoRepository.hashSha256(plainToken);
await this.oauthLinkTokenRepository.create({
token: hashedToken,
oauthSub: profile.sub,
userEmail: emailUser.email,
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
});
throw new BadRequestException({
message: 'oauth_account_link_required',
userEmail: emailUser.email,
linkToken: plainToken,
});
}
}

View File

@ -32,6 +32,7 @@ import { MemoryRepository } from 'src/repositories/memory.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { MoveRepository } from 'src/repositories/move.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { OAuthLinkTokenRepository } from 'src/repositories/oauth-link-token.repository';
import { OAuthRepository } from 'src/repositories/oauth.repository';
import { OcrRepository } from 'src/repositories/ocr.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
@ -88,6 +89,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
MetadataRepository,
MoveRepository,
NotificationRepository,
OAuthLinkTokenRepository,
OAuthRepository,
OcrRepository,
PartnerRepository,
@ -146,6 +148,7 @@ export class BaseService {
protected metadataRepository: MetadataRepository,
protected moveRepository: MoveRepository,
protected notificationRepository: NotificationRepository,
protected oauthLinkTokenRepository: OAuthLinkTokenRepository,
protected oauthRepository: OAuthRepository,
protected ocrRepository: OcrRepository,
protected partnerRepository: PartnerRepository,

View File

@ -24,6 +24,11 @@ export class SessionService extends BaseService {
this.logger.log(`Deleted ${sessions.length} expired session tokens`);
const expiredLinkTokens = await this.oauthLinkTokenRepository.cleanup();
if (expiredLinkTokens > 0) {
this.logger.log(`Deleted ${expiredLinkTokens} expired OAuth link tokens`);
}
return JobStatus.Success;
}

View File

@ -43,6 +43,7 @@ import { MemoryRepository } from 'src/repositories/memory.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { MoveRepository } from 'src/repositories/move.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { OAuthLinkTokenRepository } from 'src/repositories/oauth-link-token.repository';
import { OAuthRepository } from 'src/repositories/oauth.repository';
import { OcrRepository } from 'src/repositories/ocr.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
@ -239,6 +240,7 @@ export type ServiceOverrides = {
metadata: MetadataRepository;
move: MoveRepository;
notification: NotificationRepository;
oauthLinkToken: OAuthLinkTokenRepository;
ocr: OcrRepository;
oauth: OAuthRepository;
partner: PartnerRepository;
@ -321,6 +323,7 @@ export const getMocks = () => {
move: automock(MoveRepository, { strict: false }),
notification: automock(NotificationRepository),
ocr: automock(OcrRepository, { strict: false }),
oauthLinkToken: automock(OAuthLinkTokenRepository),
oauth: automock(OAuthRepository, { args: [loggerMock] }),
partner: automock(PartnerRepository, { strict: false }),
person: automock(PersonRepository, { strict: false }),
@ -387,6 +390,7 @@ export const newTestService = <T extends BaseService>(
overrides.metadata || (mocks.metadata as As<MetadataRepository>),
overrides.move || (mocks.move as As<MoveRepository>),
overrides.notification || (mocks.notification as As<NotificationRepository>),
overrides.oauthLinkToken || (mocks.oauthLinkToken as As<OAuthLinkTokenRepository>),
overrides.oauth || (mocks.oauth as As<OAuthRepository>),
overrides.ocr || (mocks.ocr as As<OcrRepository>),
overrides.partner || (mocks.partner as As<PartnerRepository>),

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',

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

View File

@ -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}

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>

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;

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;

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