mirror of
https://github.com/immich-app/immich.git
synced 2026-04-29 04:20:38 -04:00
fix: require users to authenticate existing Immich account before OAuth linking
This commit is contained in:
parent
b8591cb591
commit
5731c261eb
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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
|
||||
})));
|
||||
}
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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) {}
|
||||
|
||||
@ -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,
|
||||
|
||||
37
server/src/repositories/oauth-link-token.repository.ts
Normal file
37
server/src/repositories/oauth-link-token.repository.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
22
server/src/schema/tables/oauth-link-token.table.ts
Normal file
22
server/src/schema/tables/oauth-link-token.table.ts
Normal 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>;
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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>),
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
web/src/routes/auth/link/+page.svelte
Normal file
77
web/src/routes/auth/link/+page.svelte
Normal 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
web/src/routes/auth/link/+page.ts
Normal file
16
web/src/routes/auth/link/+page.ts
Normal 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;
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user