feat: reset oauth ids (#20798)

This commit is contained in:
Jason Rasmussen 2025-08-08 15:42:38 -04:00 committed by GitHub
parent 9ecaa3fa9d
commit 538d5c81ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 247 additions and 6 deletions

View File

@ -355,6 +355,9 @@
"trash_number_of_days_description": "Number of days to keep the assets in trash before permanently removing them",
"trash_settings": "Trash Settings",
"trash_settings_description": "Manage trash settings",
"unlink_all_oauth_accounts": "Unlink all OAuth accounts",
"unlink_all_oauth_accounts_description": "Remember to unlink all OAuth accounts before migrating to a new provider.",
"unlink_all_oauth_accounts_prompt": "Are you sure you want to unlink all OAuth accounts? This will reset the OAuth ID for each user and cannot be undone.",
"user_cleanup_job": "User cleanup",
"user_delete_delay": "<b>{user}</b>'s account and assets will be scheduled for permanent deletion in {delay, plural, one {# day} other {# days}}.",
"user_delete_delay_settings": "Delete delay",
@ -921,6 +924,7 @@
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
"profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.",
"quota_higher_than_disk_size": "You set a quota higher than the disk size",
"something_went_wrong": "Something went wrong",
"unable_to_add_album_users": "Unable to add users to album",
"unable_to_add_assets_to_shared_link": "Unable to add assets to shared link",
"unable_to_add_comment": "Unable to add comment",

View File

@ -108,6 +108,7 @@ Class | Method | HTTP request | Description
*AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets |
*AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets |
*AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail |
*AuthAdminApi* | [**unlinkAllOAuthAccountsAdmin**](doc//AuthAdminApi.md#unlinkalloauthaccountsadmin) | **POST** /admin/auth/unlink-all |
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password |
*AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code |
*AuthenticationApi* | [**getAuthStatus**](doc//AuthenticationApi.md#getauthstatus) | **GET** /auth/status |

View File

@ -34,6 +34,7 @@ part 'api/api_keys_api.dart';
part 'api/activities_api.dart';
part 'api/albums_api.dart';
part 'api/assets_api.dart';
part 'api/auth_admin_api.dart';
part 'api/authentication_api.dart';
part 'api/deprecated_api.dart';
part 'api/download_api.dart';

View File

@ -0,0 +1,54 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AuthAdminApi {
AuthAdminApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
/// This endpoint is an admin-only route, and requires the `adminAuth.unlinkAll` permission.
///
/// Note: This method returns the HTTP [Response].
Future<Response> unlinkAllOAuthAccountsAdminWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/auth/unlink-all';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// This endpoint is an admin-only route, and requires the `adminAuth.unlinkAll` permission.
Future<void> unlinkAllOAuthAccountsAdmin() async {
final response = await unlinkAllOAuthAccountsAdminWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
}

View File

@ -150,6 +150,7 @@ class Permission {
static const adminUserPeriodRead = Permission._(r'adminUser.read');
static const adminUserPeriodUpdate = Permission._(r'adminUser.update');
static const adminUserPeriodDelete = Permission._(r'adminUser.delete');
static const adminAuthPeriodUnlinkAll = Permission._(r'adminAuth.unlinkAll');
/// List of all possible values in this [enum][Permission].
static const values = <Permission>[
@ -280,6 +281,7 @@ class Permission {
adminUserPeriodRead,
adminUserPeriodUpdate,
adminUserPeriodDelete,
adminAuthPeriodUnlinkAll,
];
static Permission? fromJson(dynamic value) => PermissionTypeTransformer().decode(value);
@ -445,6 +447,7 @@ class PermissionTypeTransformer {
case r'adminUser.read': return Permission.adminUserPeriodRead;
case r'adminUser.update': return Permission.adminUserPeriodUpdate;
case r'adminUser.delete': return Permission.adminUserPeriodDelete;
case r'adminAuth.unlinkAll': return Permission.adminAuthPeriodUnlinkAll;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');

View File

@ -214,6 +214,34 @@
"description": "This endpoint requires the `activity.delete` permission."
}
},
"/admin/auth/unlink-all": {
"post": {
"operationId": "unlinkAllOAuthAccountsAdmin",
"parameters": [],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Auth (admin)"
],
"x-immich-admin-only": true,
"x-immich-permission": "adminAuth.unlinkAll",
"description": "This endpoint is an admin-only route, and requires the `adminAuth.unlinkAll` permission."
}
},
"/admin/notifications": {
"post": {
"operationId": "createNotification",
@ -12687,7 +12715,8 @@
"adminUser.create",
"adminUser.read",
"adminUser.update",
"adminUser.delete"
"adminUser.delete",
"adminAuth.unlinkAll"
],
"type": "string"
},

View File

@ -1646,6 +1646,15 @@ export function deleteActivity({ id }: {
method: "DELETE"
}));
}
/**
* This endpoint is an admin-only route, and requires the `adminAuth.unlinkAll` permission.
*/
export function unlinkAllOAuthAccountsAdmin(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/admin/auth/unlink-all", {
...opts,
method: "POST"
}));
}
export function createNotification({ notificationCreateDto }: {
notificationCreateDto: NotificationCreateDto;
}, opts?: Oazapfts.RequestOpts) {
@ -4669,7 +4678,8 @@ export enum Permission {
AdminUserCreate = "adminUser.create",
AdminUserRead = "adminUser.read",
AdminUserUpdate = "adminUser.update",
AdminUserDelete = "adminUser.delete"
AdminUserDelete = "adminUser.delete",
AdminAuthUnlinkAll = "adminAuth.unlinkAll"
}
export enum AssetMediaStatus {
Created = "created",

View File

@ -0,0 +1,18 @@
import { Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AuthAdminService } from 'src/services/auth-admin.service';
@ApiTags('Auth (admin)')
@Controller('admin/auth')
export class AuthAdminController {
constructor(private service: AuthAdminService) {}
@Post('unlink-all')
@Authenticated({ permission: Permission.AdminAuthUnlinkAll, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
unlinkAllOAuthAccountsAdmin(@Auth() auth: AuthDto): Promise<void> {
return this.service.unlinkAll(auth);
}
}

View File

@ -4,6 +4,7 @@ import { APIKeyController } from 'src/controllers/api-key.controller';
import { AppController } from 'src/controllers/app.controller';
import { AssetMediaController } from 'src/controllers/asset-media.controller';
import { AssetController } from 'src/controllers/asset.controller';
import { AuthAdminController } from 'src/controllers/auth-admin.controller';
import { AuthController } from 'src/controllers/auth.controller';
import { DownloadController } from 'src/controllers/download.controller';
import { DuplicateController } from 'src/controllers/duplicate.controller';
@ -40,6 +41,7 @@ export const controllers = [
AssetController,
AssetMediaController,
AuthController,
AuthAdminController,
DownloadController,
DuplicateController,
FaceController,

View File

@ -235,6 +235,8 @@ export enum Permission {
AdminUserRead = 'adminUser.read',
AdminUserUpdate = 'adminUser.update',
AdminUserDelete = 'adminUser.delete',
AdminAuthUnlinkAll = 'adminAuth.unlinkAll',
}
export enum SharedLinkType {

View File

@ -194,6 +194,10 @@ export class UserRepository {
.executeTakeFirstOrThrow();
}
async updateAll(dto: Updateable<UserTable>) {
await this.db.updateTable('user').set(dto).execute();
}
restore(id: string) {
return this.db
.updateTable('user')

View File

@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { AuthDto } from 'src/dtos/auth.dto';
import { BaseService } from 'src/services/base.service';
@Injectable()
export class AuthAdminService extends BaseService {
async unlinkAll(_auth: AuthDto) {
// TODO replace '' with null
await this.userRepository.updateAll({ oauthId: '' });
}
}

View File

@ -5,6 +5,7 @@ import { ApiService } from 'src/services/api.service';
import { AssetMediaService } from 'src/services/asset-media.service';
import { AssetService } from 'src/services/asset.service';
import { AuditService } from 'src/services/audit.service';
import { AuthAdminService } from 'src/services/auth-admin.service';
import { AuthService } from 'src/services/auth.service';
import { BackupService } from 'src/services/backup.service';
import { CliService } from 'src/services/cli.service';
@ -49,6 +50,7 @@ export const services = [
AssetService,
AuditService,
AuthService,
AuthAdminService,
BackupService,
CliService,
DatabaseService,

View File

@ -0,0 +1,66 @@
import { Kysely } from 'kysely';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { DB } from 'src/schema';
import { AuthAdminService } from 'src/services/auth-admin.service';
import { newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(AuthAdminService, {
database: db || defaultDatabase,
real: [UserRepository],
mock: [LoggingRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(AuthAdminService.name, () => {
describe('unlinkAll', () => {
it('should reset user.oauthId', async () => {
const { sut, ctx } = setup();
const userRepo = ctx.get(UserRepository);
const { user } = await ctx.newUser({ oauthId: 'test-oauth-id' });
const auth = factory.auth();
await expect(sut.unlinkAll(auth)).resolves.toBeUndefined();
await expect(userRepo.get(user.id, { withDeleted: true })).resolves.toEqual(
expect.objectContaining({ oauthId: '' }),
);
});
it('should reset a deleted user', async () => {
const { sut, ctx } = setup();
const userRepo = ctx.get(UserRepository);
const { user } = await ctx.newUser({ oauthId: 'test-oauth-id', deletedAt: new Date() });
const auth = factory.auth();
await expect(sut.unlinkAll(auth)).resolves.toBeUndefined();
await expect(userRepo.get(user.id, { withDeleted: true })).resolves.toEqual(
expect.objectContaining({ oauthId: '' }),
);
});
it('should reset multiple users', async () => {
const { sut, ctx } = setup();
const userRepo = ctx.get(UserRepository);
const { user: user1 } = await ctx.newUser({ oauthId: '1' });
const { user: user2 } = await ctx.newUser({ oauthId: '2', deletedAt: new Date() });
const auth = factory.auth();
await expect(sut.unlinkAll(auth)).resolves.toBeUndefined();
await expect(userRepo.get(user1.id, { withDeleted: true })).resolves.toEqual(
expect.objectContaining({ oauthId: '' }),
);
await expect(userRepo.get(user2.id, { withDeleted: true })).resolves.toEqual(
expect.objectContaining({ oauthId: '' }),
);
});
});
});

View File

@ -1,5 +1,9 @@
<script lang="ts">
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
@ -7,8 +11,10 @@
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import AuthDisableLoginConfirmModal from '$lib/modals/AuthDisableLoginConfirmModal.svelte';
import { OAuthTokenEndpointAuthMethod, type SystemConfigDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { handleError } from '$lib/utils/handle-error';
import { OAuthTokenEndpointAuthMethod, unlinkAllOAuthAccountsAdmin, type SystemConfigDto } from '@immich/sdk';
import { Button, modalManager, Text } from '@immich/ui';
import { mdiRestart } from '@mdi/js';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@ -44,6 +50,26 @@
onSave({ passwordLogin: config.passwordLogin, oauth: config.oauth });
};
const handleUnlinkAllOAuthAccounts = async () => {
const confirmed = await modalManager.showDialog({
icon: mdiRestart,
title: $t('admin.unlink_all_oauth_accounts'),
prompt: $t('admin.unlink_all_oauth_accounts_prompt'),
confirmColor: 'danger',
});
if (!confirmed) {
return;
}
try {
await unlinkAllOAuthAccountsAdmin({});
notificationController.show({ message: $t('success'), type: NotificationType.Info });
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
</script>
<div>
@ -56,7 +82,7 @@
subtitle={$t('admin.oauth_settings_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<p class="text-sm dark:text-immich-dark-fg">
<Text size="small">
<FormatMessage key="admin.oauth_settings_more_details">
{#snippet children({ message })}
<a
@ -69,7 +95,7 @@
</a>
{/snippet}
</FormatMessage>
</p>
</Text>
<SettingSwitch
{disabled}
@ -79,6 +105,14 @@
{#if config.oauth.enabled}
<hr />
<div class="flex items-center gap-2 justify-between">
<Text size="small">{$t('admin.unlink_all_oauth_accounts_description')}</Text>
<Button size="small" onclick={handleUnlinkAllOAuthAccounts}
>{$t('admin.unlink_all_oauth_accounts')}</Button
>
</div>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ISSUER_URL"