mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
fix: require users to authenticate existing Immich account before OAuth linking
This commit is contained in:
@@ -356,19 +356,17 @@ describe(`/oauth`, () => {
|
|||||||
expect(body).toEqual(errorDto.badRequest('User does not exist and auto registering is disabled.'));
|
expect(body).toEqual(errorDto.badRequest('User does not exist and auto registering is disabled.'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should link to an existing user by email', async () => {
|
it('should not auto-link to an existing user by email', async () => {
|
||||||
const { userId } = await utils.userSetup(admin.accessToken, {
|
await utils.userSetup(admin.accessToken, {
|
||||||
name: 'OAuth User 3',
|
name: 'OAuth User 3',
|
||||||
email: 'oauth-user3@immich.app',
|
email: 'oauth-user3@immich.app',
|
||||||
password: 'password',
|
password: 'password',
|
||||||
});
|
});
|
||||||
const callbackParams = await loginWithOAuth('oauth-user3');
|
const callbackParams = await loginWithOAuth('oauth-user3');
|
||||||
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
||||||
expect(status).toBe(201);
|
expect(status).toBe(400);
|
||||||
expect(body).toMatchObject({
|
expect(body.message).toBe('oauth_account_link_required');
|
||||||
userId,
|
expect(body.userEmail).toBe('oauth-user3@immich.app');
|
||||||
userEmail: 'oauth-user3@immich.app',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1642,6 +1642,8 @@
|
|||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"notifications_setting_description": "Manage notifications",
|
"notifications_setting_description": "Manage notifications",
|
||||||
"oauth": "OAuth",
|
"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": "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",
|
"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",
|
"ocr": "OCR",
|
||||||
|
|||||||
@@ -7448,7 +7448,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/OAuthCallbackDto"
|
"$ref": "#/components/schemas/OAuthLinkDto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -19116,6 +19116,27 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
"OAuthTokenEndpointAuthMethod": {
|
||||||
"description": "OAuth token endpoint auth method",
|
"description": "OAuth token endpoint auth method",
|
||||||
"enum": [
|
"enum": [
|
||||||
|
|||||||
@@ -1421,6 +1421,16 @@ export type OAuthCallbackDto = {
|
|||||||
/** OAuth callback URL */
|
/** OAuth callback URL */
|
||||||
url: string;
|
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 = {
|
export type PartnerResponseDto = {
|
||||||
avatarColor: UserAvatarColor;
|
avatarColor: UserAvatarColor;
|
||||||
/** User email */
|
/** User email */
|
||||||
@@ -4947,8 +4957,8 @@ export function finishOAuth({ oAuthCallbackDto }: {
|
|||||||
/**
|
/**
|
||||||
* Link OAuth account
|
* Link OAuth account
|
||||||
*/
|
*/
|
||||||
export function linkOAuthAccount({ oAuthCallbackDto }: {
|
export function linkOAuthAccount({ oAuthLinkDto }: {
|
||||||
oAuthCallbackDto: OAuthCallbackDto;
|
oAuthLinkDto: OAuthLinkDto;
|
||||||
}, opts?: Oazapfts.RequestOpts) {
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
return oazapfts.ok(oazapfts.fetchJson<{
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
status: 200;
|
status: 200;
|
||||||
@@ -4956,7 +4966,7 @@ export function linkOAuthAccount({ oAuthCallbackDto }: {
|
|||||||
}>("/oauth/link", oazapfts.json({
|
}>("/oauth/link", oazapfts.json({
|
||||||
...opts,
|
...opts,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: oAuthCallbackDto
|
body: oAuthLinkDto
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
OAuthBackchannelLogoutDto,
|
OAuthBackchannelLogoutDto,
|
||||||
OAuthCallbackDto,
|
OAuthCallbackDto,
|
||||||
OAuthConfigDto,
|
OAuthConfigDto,
|
||||||
|
OAuthLinkDto,
|
||||||
} from 'src/dtos/auth.dto';
|
} from 'src/dtos/auth.dto';
|
||||||
import { UserAdminResponseDto } from 'src/dtos/user.dto';
|
import { UserAdminResponseDto } from 'src/dtos/user.dto';
|
||||||
import { ApiTag, AuthType, ImmichCookie } from 'src/enum';
|
import { ApiTag, AuthType, ImmichCookie } from 'src/enum';
|
||||||
@@ -97,7 +98,7 @@ export class OAuthController {
|
|||||||
linkOAuthAccount(
|
linkOAuthAccount(
|
||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@Auth() auth: AuthDto,
|
@Auth() auth: AuthDto,
|
||||||
@Body() dto: OAuthCallbackDto,
|
@Body() dto: OAuthLinkDto,
|
||||||
): Promise<UserAdminResponseDto> {
|
): Promise<UserAdminResponseDto> {
|
||||||
return this.service.link(auth, dto, request.headers);
|
return this.service.link(auth, dto, request.headers);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,18 @@ const OAuthCallbackSchema = z
|
|||||||
})
|
})
|
||||||
.meta({ id: 'OAuthCallbackDto' });
|
.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
|
const OAuthConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
redirectUri: z.string().describe('OAuth redirect URI'),
|
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 PinCodeChangeDto extends createZodDto(PinCodeChangeSchema) {}
|
||||||
export class ValidateAccessTokenResponseDto extends createZodDto(ValidateAccessTokenResponseSchema) {}
|
export class ValidateAccessTokenResponseDto extends createZodDto(ValidateAccessTokenResponseSchema) {}
|
||||||
export class OAuthCallbackDto extends createZodDto(OAuthCallbackSchema) {}
|
export class OAuthCallbackDto extends createZodDto(OAuthCallbackSchema) {}
|
||||||
|
export class OAuthLinkDto extends createZodDto(OAuthLinkSchema) {}
|
||||||
export class OAuthConfigDto extends createZodDto(OAuthConfigSchema) {}
|
export class OAuthConfigDto extends createZodDto(OAuthConfigSchema) {}
|
||||||
export class OAuthAuthorizeResponseDto extends createZodDto(OAuthAuthorizeResponseSchema) {}
|
export class OAuthAuthorizeResponseDto extends createZodDto(OAuthAuthorizeResponseSchema) {}
|
||||||
export class OAuthBackchannelLogoutDto extends createZodDto(OAuthBackchannelLogoutSchema) {}
|
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 { MetadataRepository } from 'src/repositories/metadata.repository';
|
||||||
import { MoveRepository } from 'src/repositories/move.repository';
|
import { MoveRepository } from 'src/repositories/move.repository';
|
||||||
import { NotificationRepository } from 'src/repositories/notification.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 { OAuthRepository } from 'src/repositories/oauth.repository';
|
||||||
import { OcrRepository } from 'src/repositories/ocr.repository';
|
import { OcrRepository } from 'src/repositories/ocr.repository';
|
||||||
import { PartnerRepository } from 'src/repositories/partner.repository';
|
import { PartnerRepository } from 'src/repositories/partner.repository';
|
||||||
@@ -78,6 +79,7 @@ export const repositories = [
|
|||||||
MetadataRepository,
|
MetadataRepository,
|
||||||
MoveRepository,
|
MoveRepository,
|
||||||
NotificationRepository,
|
NotificationRepository,
|
||||||
|
OAuthLinkTokenRepository,
|
||||||
OAuthRepository,
|
OAuthRepository,
|
||||||
OcrRepository,
|
OcrRepository,
|
||||||
PartnerRepository,
|
PartnerRepository,
|
||||||
|
|||||||
@@ -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 { MoveTable } from 'src/schema/tables/move.table';
|
||||||
import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table';
|
import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table';
|
||||||
import { NotificationTable } from 'src/schema/tables/notification.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 { OcrSearchTable } from 'src/schema/tables/ocr-search.table';
|
||||||
import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table';
|
import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table';
|
||||||
import { PartnerTable } from 'src/schema/tables/partner.table';
|
import { PartnerTable } from 'src/schema/tables/partner.table';
|
||||||
@@ -108,6 +109,7 @@ export class ImmichDatabase {
|
|||||||
MoveTable,
|
MoveTable,
|
||||||
NaturalEarthCountriesTable,
|
NaturalEarthCountriesTable,
|
||||||
NotificationTable,
|
NotificationTable,
|
||||||
|
OAuthLinkTokenTable,
|
||||||
OcrSearchTable,
|
OcrSearchTable,
|
||||||
PartnerAuditTable,
|
PartnerAuditTable,
|
||||||
PartnerTable,
|
PartnerTable,
|
||||||
@@ -210,6 +212,8 @@ export interface DB {
|
|||||||
|
|
||||||
notification: NotificationTable;
|
notification: NotificationTable;
|
||||||
|
|
||||||
|
oauth_link_token: OAuthLinkTokenTable;
|
||||||
|
|
||||||
move_history: MoveTable;
|
move_history: MoveTable;
|
||||||
|
|
||||||
naturalearth_countries: NaturalEarthCountriesTable;
|
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);
|
||||||
|
}
|
||||||
@@ -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);
|
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 user = UserFactory.create();
|
||||||
const profile = OAuthProfileFactory.create();
|
const profile = OAuthProfileFactory.create();
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
||||||
mocks.user.getByEmail.mockResolvedValue(user);
|
mocks.user.getByEmail.mockResolvedValue(user);
|
||||||
mocks.user.update.mockResolvedValue(user);
|
mocks.oauthLinkToken.deleteByEmail.mockResolvedValue();
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
|
||||||
|
|
||||||
await sut.callback(
|
await expect(
|
||||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
|
sut.callback(
|
||||||
{},
|
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
|
||||||
loginDetails,
|
{},
|
||||||
);
|
loginDetails,
|
||||||
|
),
|
||||||
|
).rejects.toThrow('oauth_account_link_required');
|
||||||
|
|
||||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
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 () => {
|
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.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
|
||||||
mocks.user.getByEmail.mockResolvedValueOnce(user);
|
mocks.user.getByEmail.mockResolvedValueOnce(user);
|
||||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||||
|
mocks.oauthLinkToken.deleteByEmail.mockResolvedValue();
|
||||||
|
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.callback(
|
sut.callback(
|
||||||
@@ -756,7 +762,7 @@ describe(AuthService.name, () => {
|
|||||||
{},
|
{},
|
||||||
loginDetails,
|
loginDetails,
|
||||||
),
|
),
|
||||||
).rejects.toThrow(BadRequestException);
|
).rejects.toThrow('oauth_account_link_required');
|
||||||
|
|
||||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
expect(mocks.user.update).not.toHaveBeenCalled();
|
||||||
expect(mocks.user.create).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)}`);
|
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
||||||
let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub);
|
let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub);
|
||||||
|
|
||||||
// link by email
|
|
||||||
if (!user && normalizedEmail) {
|
if (!user && normalizedEmail) {
|
||||||
const emailUser = await this.userRepository.getByEmail(normalizedEmail);
|
const emailUser = await this.userRepository.getByEmail(normalizedEmail);
|
||||||
if (emailUser) {
|
if (emailUser) {
|
||||||
if (emailUser.oauthId) {
|
await this.oauthLinkTokenRepository.deleteByEmail(emailUser.email);
|
||||||
throw new BadRequestException('User already exists, but is linked to another account.');
|
const plainToken = this.cryptoRepository.randomBytesAsText(32);
|
||||||
}
|
const hashedToken = this.cryptoRepository.hashSha256(plainToken);
|
||||||
user = await this.userRepository.update(emailUser.id, { oauthId: profile.sub });
|
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 { MetadataRepository } from 'src/repositories/metadata.repository';
|
||||||
import { MoveRepository } from 'src/repositories/move.repository';
|
import { MoveRepository } from 'src/repositories/move.repository';
|
||||||
import { NotificationRepository } from 'src/repositories/notification.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 { OAuthRepository } from 'src/repositories/oauth.repository';
|
||||||
import { OcrRepository } from 'src/repositories/ocr.repository';
|
import { OcrRepository } from 'src/repositories/ocr.repository';
|
||||||
import { PartnerRepository } from 'src/repositories/partner.repository';
|
import { PartnerRepository } from 'src/repositories/partner.repository';
|
||||||
@@ -88,6 +89,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
|
|||||||
MetadataRepository,
|
MetadataRepository,
|
||||||
MoveRepository,
|
MoveRepository,
|
||||||
NotificationRepository,
|
NotificationRepository,
|
||||||
|
OAuthLinkTokenRepository,
|
||||||
OAuthRepository,
|
OAuthRepository,
|
||||||
OcrRepository,
|
OcrRepository,
|
||||||
PartnerRepository,
|
PartnerRepository,
|
||||||
@@ -146,6 +148,7 @@ export class BaseService {
|
|||||||
protected metadataRepository: MetadataRepository,
|
protected metadataRepository: MetadataRepository,
|
||||||
protected moveRepository: MoveRepository,
|
protected moveRepository: MoveRepository,
|
||||||
protected notificationRepository: NotificationRepository,
|
protected notificationRepository: NotificationRepository,
|
||||||
|
protected oauthLinkTokenRepository: OAuthLinkTokenRepository,
|
||||||
protected oauthRepository: OAuthRepository,
|
protected oauthRepository: OAuthRepository,
|
||||||
protected ocrRepository: OcrRepository,
|
protected ocrRepository: OcrRepository,
|
||||||
protected partnerRepository: PartnerRepository,
|
protected partnerRepository: PartnerRepository,
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ export class SessionService extends BaseService {
|
|||||||
|
|
||||||
this.logger.log(`Deleted ${sessions.length} expired session tokens`);
|
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;
|
return JobStatus.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { MemoryRepository } from 'src/repositories/memory.repository';
|
|||||||
import { MetadataRepository } from 'src/repositories/metadata.repository';
|
import { MetadataRepository } from 'src/repositories/metadata.repository';
|
||||||
import { MoveRepository } from 'src/repositories/move.repository';
|
import { MoveRepository } from 'src/repositories/move.repository';
|
||||||
import { NotificationRepository } from 'src/repositories/notification.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 { OAuthRepository } from 'src/repositories/oauth.repository';
|
||||||
import { OcrRepository } from 'src/repositories/ocr.repository';
|
import { OcrRepository } from 'src/repositories/ocr.repository';
|
||||||
import { PartnerRepository } from 'src/repositories/partner.repository';
|
import { PartnerRepository } from 'src/repositories/partner.repository';
|
||||||
@@ -239,6 +240,7 @@ export type ServiceOverrides = {
|
|||||||
metadata: MetadataRepository;
|
metadata: MetadataRepository;
|
||||||
move: MoveRepository;
|
move: MoveRepository;
|
||||||
notification: NotificationRepository;
|
notification: NotificationRepository;
|
||||||
|
oauthLinkToken: OAuthLinkTokenRepository;
|
||||||
ocr: OcrRepository;
|
ocr: OcrRepository;
|
||||||
oauth: OAuthRepository;
|
oauth: OAuthRepository;
|
||||||
partner: PartnerRepository;
|
partner: PartnerRepository;
|
||||||
@@ -321,6 +323,7 @@ export const getMocks = () => {
|
|||||||
move: automock(MoveRepository, { strict: false }),
|
move: automock(MoveRepository, { strict: false }),
|
||||||
notification: automock(NotificationRepository),
|
notification: automock(NotificationRepository),
|
||||||
ocr: automock(OcrRepository, { strict: false }),
|
ocr: automock(OcrRepository, { strict: false }),
|
||||||
|
oauthLinkToken: automock(OAuthLinkTokenRepository),
|
||||||
oauth: automock(OAuthRepository, { args: [loggerMock] }),
|
oauth: automock(OAuthRepository, { args: [loggerMock] }),
|
||||||
partner: automock(PartnerRepository, { strict: false }),
|
partner: automock(PartnerRepository, { strict: false }),
|
||||||
person: automock(PersonRepository, { 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.metadata || (mocks.metadata as As<MetadataRepository>),
|
||||||
overrides.move || (mocks.move as As<MoveRepository>),
|
overrides.move || (mocks.move as As<MoveRepository>),
|
||||||
overrides.notification || (mocks.notification as As<NotificationRepository>),
|
overrides.notification || (mocks.notification as As<NotificationRepository>),
|
||||||
|
overrides.oauthLinkToken || (mocks.oauthLinkToken as As<OAuthLinkTokenRepository>),
|
||||||
overrides.oauth || (mocks.oauth as As<OAuthRepository>),
|
overrides.oauth || (mocks.oauth as As<OAuthRepository>),
|
||||||
overrides.ocr || (mocks.ocr as As<OcrRepository>),
|
overrides.ocr || (mocks.ocr as As<OcrRepository>),
|
||||||
overrides.partner || (mocks.partner as As<PartnerRepository>),
|
overrides.partner || (mocks.partner as As<PartnerRepository>),
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const Docs = {
|
|||||||
export const Route = {
|
export const Route = {
|
||||||
// auth
|
// auth
|
||||||
login: (params?: { continue?: string; autoLaunch?: 0 | 1 }) => '/auth/login' + asQueryString(params),
|
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),
|
logout: (params?: { continue?: string }) => '/auth/logout' + asQueryString(params),
|
||||||
register: () => '/auth/register',
|
register: () => '/auth/register',
|
||||||
changePassword: () => '/auth/change-password',
|
changePassword: () => '/auth/change-password',
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ export const oauth = {
|
|||||||
return finishOAuth({ oAuthCallbackDto: { url: location.href } });
|
return finishOAuth({ oAuthCallbackDto: { url: location.href } });
|
||||||
},
|
},
|
||||||
link: (location: Location) => {
|
link: (location: Location) => {
|
||||||
return linkOAuthAccount({ oAuthCallbackDto: { url: location.href } });
|
return linkOAuthAccount({ oAuthLinkDto: { url: location.href } });
|
||||||
},
|
},
|
||||||
unlink: () => {
|
unlink: () => {
|
||||||
return unlinkOAuthAccount();
|
return unlinkOAuthAccount();
|
||||||
|
|||||||
@@ -2,32 +2,13 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
|
import { Route } from '$lib/route';
|
||||||
import { oauth } from '$lib/utils';
|
import { oauth } from '$lib/utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { Button, LoadingSpinner, toastManager } from '@immich/ui';
|
import { Button, toastManager } from '@immich/ui';
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fade } from 'svelte/transition';
|
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 () => {
|
const handleUnlink = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await oauth.unlink();
|
const response = await oauth.unlink();
|
||||||
@@ -42,15 +23,11 @@
|
|||||||
<section class="my-4">
|
<section class="my-4">
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<div class="sm:ms-8 flex justify-end">
|
<div class="sm:ms-8 flex justify-end">
|
||||||
{#if loading}
|
{#if featureFlagsManager.value.oauth}
|
||||||
<div class="flex place-content-center place-items-center">
|
|
||||||
<LoadingSpinner />
|
|
||||||
</div>
|
|
||||||
{:else if featureFlagsManager.value.oauth}
|
|
||||||
{#if authManager.user.oauthId}
|
{#if authManager.user.oauthId}
|
||||||
<Button shape="round" size="small" onclick={() => handleUnlink()}>{$t('unlink_oauth')}</Button>
|
<Button shape="round" size="small" onclick={() => handleUnlink()}>{$t('unlink_oauth')}</Button>
|
||||||
{:else}
|
{: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
|
>{$t('link_to_oauth')}</Button
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte';
|
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 { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
import { oauth } from '$lib/utils';
|
import { oauth } from '$lib/utils';
|
||||||
import { getServerErrorMessage, handleError } from '$lib/utils/handle-error';
|
import { getServerErrorMessage, handleError } from '$lib/utils/handle-error';
|
||||||
import { login, type LoginResponseDto } from '@immich/sdk';
|
import { isHttpError, login, type LoginResponseDto } from '@immich/sdk';
|
||||||
import { Alert, Button, Field, Input, PasswordInput, Stack } from '@immich/ui';
|
import { Alert, Button, Field, Input, PasswordInput, Stack, toastManager } from '@immich/ui';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
@@ -43,6 +45,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (oauth.isCallback(globalThis.location)) {
|
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 {
|
try {
|
||||||
const user = await oauth.login(globalThis.location);
|
const user = await oauth.login(globalThis.location);
|
||||||
|
|
||||||
@@ -54,6 +70,11 @@
|
|||||||
await onSuccess(user);
|
await onSuccess(user);
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} 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);
|
console.error('Error [login-form] [oauth.callback]', error);
|
||||||
oauthError = getServerErrorMessage(error) || $t('errors.unable_to_complete_oauth_login');
|
oauthError = getServerErrorMessage(error) || $t('errors.unable_to_complete_oauth_login');
|
||||||
oauthLoading = false;
|
oauthLoading = false;
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ export const load = (async ({ parent, url }) => {
|
|||||||
await parent();
|
await parent();
|
||||||
|
|
||||||
const continueUrl = url.searchParams.get('continue') || Route.photos();
|
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);
|
redirect(307, continueUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user