diff --git a/docs/docs/usage/oauth.md b/docs/docs/usage/oauth.md
new file mode 100644
index 0000000000..c8dff35908
--- /dev/null
+++ b/docs/docs/usage/oauth.md
@@ -0,0 +1,68 @@
+---
+sidebar_position: 5
+---
+
+# OAuth Authentication
+
+This page contains details about using OAuth 2 in Immich.
+
+## Overview
+
+Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an identity layer built on top of OAuth2. OIDC is supported by most identity providers, including:
+
+- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
+- [Authelia](https://www.authelia.com/configuration/identity-providers/open-id-connect/)
+- [Okta](https://www.okta.com/openid-connect/)
+- [Google](https://developers.google.com/identity/openid-connect/openid-connect)
+
+## Prerequisites
+
+Before enabling OAuth in Immich, a new client application needs to be configured in the 3rd-party authentication server. While the specifics of this setup vary from provider to provider, the general approach should be the same.
+
+1. Create a new (Client) Application
+
+   1. The **Provider** type should be `OpenID Connect` or `OAuth2`
+   2. The **Client type** should be `Confidential`
+   3. The **Application** type should be `Web`
+   4. The **Grant** type should be `Authorization Code`
+
+2. Configure Redirect URIs/Origins
+
+   1. The **Sign-in redirect URIs** should include:
+
+      - All URLs that will be used to access the login page of the Immich web client (eg. `http://localhost:2283/auth/login`, `http://192.168.0.200:2283/auth/login`, `https://immich.example.com/auth/login`)
+
+## Enable OAuth
+
+Once you have a new OAuth client application configured, Immich can be configured using the following environment variables:
+
+| Key                 | Type    | Default              | Description                                                               |
+| ------------------- | ------- | -------------------- | ------------------------------------------------------------------------- |
+| OAUTH_ENABLED       | boolean | false                | Enable/disable OAuth2                                                     |
+| OAUTH_ISSUER_URL    | URL     | (required)           | Required. Self-discovery URL for client (from previous step)              |
+| OAUTH_CLIENT_ID     | string  | (required)           | Required. Client ID (from previous step)                                  |
+| OAUTH_CLIENT_SECRET | string  | (required)           | Required. Client Secret (previous step                                    |
+| OAUTH_SCOPE         | string  | openid email profile | Full list of scopes to send with the request (space delimited)            |
+| OAUTH_AUTO_REGISTER | boolean | true                 | When true, will automatically register a user the first time they sign in |
+| OAUTH_BUTTON_TEXT   | string  | Login with OAuth     | Text for the OAuth button on the web                                      |
+
+:::info
+The Issuer URL should look something like the following, and return a valid json document.
+
+- `https://accounts.google.com/.well-known/openid-configuration`
+- `http://localhost:9000/application/o/immich/.well-known/openid-configuration`
+
+The `.well-known/openid-configuration` part of the url is optional and will be automatically added during discovery.
+:::
+
+Here is an example of a valid configuration for setting up Immich to use OAuth with Authentik:
+
+```
+OAUTH_ENABLED=true
+OAUTH_ISSUER_URL=http://192.168.0.187:9000/application/o/immich
+OAUTH_CLIENT_ID=f08f9c5b4f77dcfd3916b1c032336b5544a7b368
+OAUTH_CLIENT_SECRET=6fe2e697644da6ff6aef73387a457d819018189086fa54b151a6067fbb884e75f7e5c90be16d3c688cf902c6974817a85eab93007d76675041eaead8c39cf5a2
+OAUTH_BUTTON_TEXT=Login with Authentik
+```
+
+[oidc]: https://openid.net/connect/
diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES
index 5ca7b25fef..8cf63e845b 100644
--- a/mobile/openapi/.openapi-generator/FILES
+++ b/mobile/openapi/.openapi-generator/FILES
@@ -46,6 +46,10 @@ doc/JobStatusResponseDto.md
 doc/LoginCredentialDto.md
 doc/LoginResponseDto.md
 doc/LogoutResponseDto.md
+doc/OAuthApi.md
+doc/OAuthCallbackDto.md
+doc/OAuthConfigDto.md
+doc/OAuthConfigResponseDto.md
 doc/RemoveAssetsDto.md
 doc/SearchAssetDto.md
 doc/ServerInfoApi.md
@@ -73,6 +77,7 @@ lib/api/asset_api.dart
 lib/api/authentication_api.dart
 lib/api/device_info_api.dart
 lib/api/job_api.dart
+lib/api/o_auth_api.dart
 lib/api/server_info_api.dart
 lib/api/user_api.dart
 lib/api_client.dart
@@ -122,6 +127,9 @@ lib/model/job_status_response_dto.dart
 lib/model/login_credential_dto.dart
 lib/model/login_response_dto.dart
 lib/model/logout_response_dto.dart
+lib/model/o_auth_callback_dto.dart
+lib/model/o_auth_config_dto.dart
+lib/model/o_auth_config_response_dto.dart
 lib/model/remove_assets_dto.dart
 lib/model/search_asset_dto.dart
 lib/model/server_info_response_dto.dart
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index 279e54cfe0..dd6f510535 100644
Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ
diff --git a/mobile/openapi/doc/LogoutResponseDto.md b/mobile/openapi/doc/LogoutResponseDto.md
index 0430349ca0..f1b22a889d 100644
Binary files a/mobile/openapi/doc/LogoutResponseDto.md and b/mobile/openapi/doc/LogoutResponseDto.md differ
diff --git a/mobile/openapi/doc/OAuthApi.md b/mobile/openapi/doc/OAuthApi.md
new file mode 100644
index 0000000000..255f71db4f
Binary files /dev/null and b/mobile/openapi/doc/OAuthApi.md differ
diff --git a/mobile/openapi/doc/OAuthCallbackDto.md b/mobile/openapi/doc/OAuthCallbackDto.md
new file mode 100644
index 0000000000..0627ea834b
Binary files /dev/null and b/mobile/openapi/doc/OAuthCallbackDto.md differ
diff --git a/mobile/openapi/doc/OAuthConfigDto.md b/mobile/openapi/doc/OAuthConfigDto.md
new file mode 100644
index 0000000000..0683026ddd
Binary files /dev/null and b/mobile/openapi/doc/OAuthConfigDto.md differ
diff --git a/mobile/openapi/doc/OAuthConfigResponseDto.md b/mobile/openapi/doc/OAuthConfigResponseDto.md
new file mode 100644
index 0000000000..8d6c3a41e8
Binary files /dev/null and b/mobile/openapi/doc/OAuthConfigResponseDto.md differ
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index 1ff46399b3..3415c62afd 100644
Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ
diff --git a/mobile/openapi/lib/api/o_auth_api.dart b/mobile/openapi/lib/api/o_auth_api.dart
new file mode 100644
index 0000000000..4282333851
Binary files /dev/null and b/mobile/openapi/lib/api/o_auth_api.dart differ
diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart
index 77da9b38d5..5e30d68e1d 100644
Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ
diff --git a/mobile/openapi/lib/model/logout_response_dto.dart b/mobile/openapi/lib/model/logout_response_dto.dart
index ac962b4456..52120aaabb 100644
Binary files a/mobile/openapi/lib/model/logout_response_dto.dart and b/mobile/openapi/lib/model/logout_response_dto.dart differ
diff --git a/mobile/openapi/lib/model/o_auth_callback_dto.dart b/mobile/openapi/lib/model/o_auth_callback_dto.dart
new file mode 100644
index 0000000000..3479f5b170
Binary files /dev/null and b/mobile/openapi/lib/model/o_auth_callback_dto.dart differ
diff --git a/mobile/openapi/lib/model/o_auth_config_dto.dart b/mobile/openapi/lib/model/o_auth_config_dto.dart
new file mode 100644
index 0000000000..23173b46be
Binary files /dev/null and b/mobile/openapi/lib/model/o_auth_config_dto.dart differ
diff --git a/mobile/openapi/lib/model/o_auth_config_response_dto.dart b/mobile/openapi/lib/model/o_auth_config_response_dto.dart
new file mode 100644
index 0000000000..29cdda644c
Binary files /dev/null and b/mobile/openapi/lib/model/o_auth_config_response_dto.dart differ
diff --git a/mobile/openapi/test/o_auth_api_test.dart b/mobile/openapi/test/o_auth_api_test.dart
new file mode 100644
index 0000000000..6e21faa8e2
Binary files /dev/null and b/mobile/openapi/test/o_auth_api_test.dart differ
diff --git a/mobile/openapi/test/o_auth_callback_dto_test.dart b/mobile/openapi/test/o_auth_callback_dto_test.dart
new file mode 100644
index 0000000000..701b4666ab
Binary files /dev/null and b/mobile/openapi/test/o_auth_callback_dto_test.dart differ
diff --git a/mobile/openapi/test/o_auth_config_dto_test.dart b/mobile/openapi/test/o_auth_config_dto_test.dart
new file mode 100644
index 0000000000..d887635d7a
Binary files /dev/null and b/mobile/openapi/test/o_auth_config_dto_test.dart differ
diff --git a/mobile/openapi/test/o_auth_config_response_dto_test.dart b/mobile/openapi/test/o_auth_config_response_dto_test.dart
new file mode 100644
index 0000000000..fac4d8bdd9
Binary files /dev/null and b/mobile/openapi/test/o_auth_config_response_dto_test.dart differ
diff --git a/server/apps/immich/src/api-v1/auth/auth.controller.ts b/server/apps/immich/src/api-v1/auth/auth.controller.ts
index 9fc787bdd3..a2dbce89ed 100644
--- a/server/apps/immich/src/api-v1/auth/auth.controller.ts
+++ b/server/apps/immich/src/api-v1/auth/auth.controller.ts
@@ -1,36 +1,31 @@
-import { Body, Controller, Post, Res, ValidationPipe, Ip } from '@nestjs/common';
+import { Body, Controller, Ip, Post, Req, Res, ValidationPipe } from '@nestjs/common';
 import { ApiBadRequestResponse, ApiBearerAuth, ApiTags } from '@nestjs/swagger';
+import { Request, Response } from 'express';
+import { AuthType, IMMICH_AUTH_TYPE_COOKIE } from '../../constants/jwt.constant';
 import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
 import { Authenticated } from '../../decorators/authenticated.decorator';
+import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
 import { AuthService } from './auth.service';
 import { LoginCredentialDto } from './dto/login-credential.dto';
-import { LoginResponseDto } from './response-dto/login-response.dto';
 import { SignUpDto } from './dto/sign-up.dto';
 import { AdminSignupResponseDto } from './response-dto/admin-signup-response.dto';
-import { ValidateAccessTokenResponseDto } from './response-dto/validate-asset-token-response.dto,';
-import { Response } from 'express';
+import { LoginResponseDto } from './response-dto/login-response.dto';
 import { LogoutResponseDto } from './response-dto/logout-response.dto';
+import { ValidateAccessTokenResponseDto } from './response-dto/validate-asset-token-response.dto,';
 
 @ApiTags('Authentication')
 @Controller('auth')
 export class AuthController {
-  constructor(private readonly authService: AuthService) {}
+  constructor(private readonly authService: AuthService, private readonly immichJwtService: ImmichJwtService) {}
 
   @Post('/login')
   async login(
     @Body(new ValidationPipe({ transform: true })) loginCredential: LoginCredentialDto,
     @Ip() clientIp: string,
-    @Res() response: Response,
+    @Res({ passthrough: true }) response: Response,
   ): Promise<LoginResponseDto> {
     const loginResponse = await this.authService.login(loginCredential, clientIp);
-
-    // Set Cookies
-    const accessTokenCookie = this.authService.getCookieWithJwtToken(loginResponse);
-    const isAuthCookie = `immich_is_authenticated=true; Path=/; Max-Age=${7 * 24 * 3600}`;
-
-    response.setHeader('Set-Cookie', [accessTokenCookie, isAuthCookie]);
-    response.send(loginResponse);
-
+    response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.PASSWORD));
     return loginResponse;
   }
 
@@ -51,13 +46,14 @@ export class AuthController {
   }
 
   @Post('/logout')
-  async logout(@Res() response: Response): Promise<LogoutResponseDto> {
-    response.clearCookie('immich_access_token');
-    response.clearCookie('immich_is_authenticated');
+  async logout(@Req() req: Request, @Res({ passthrough: true }) response: Response): Promise<LogoutResponseDto> {
+    const authType: AuthType = req.cookies[IMMICH_AUTH_TYPE_COOKIE];
 
-    const status = new LogoutResponseDto(true);
+    const cookies = this.immichJwtService.getCookieNames();
+    for (const cookie of cookies) {
+      response.clearCookie(cookie);
+    }
 
-    response.send(status);
-    return status;
+    return this.authService.logout(authType);
   }
 }
diff --git a/server/apps/immich/src/api-v1/auth/auth.module.ts b/server/apps/immich/src/api-v1/auth/auth.module.ts
index 29c009a295..4a06f0ae8a 100644
--- a/server/apps/immich/src/api-v1/auth/auth.module.ts
+++ b/server/apps/immich/src/api-v1/auth/auth.module.ts
@@ -1,16 +1,13 @@
 import { Module } from '@nestjs/common';
-import { AuthService } from './auth.service';
-import { AuthController } from './auth.controller';
-import { TypeOrmModule } from '@nestjs/typeorm';
-import { UserEntity } from '@app/database/entities/user.entity';
-import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
 import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
-import { JwtModule } from '@nestjs/jwt';
-import { jwtConfig } from '../../config/jwt.config';
+import { OAuthModule } from '../oauth/oauth.module';
+import { UserModule } from '../user/user.module';
+import { AuthController } from './auth.controller';
+import { AuthService } from './auth.service';
 
 @Module({
-  imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)],
+  imports: [UserModule, ImmichJwtModule, OAuthModule],
   controllers: [AuthController],
-  providers: [AuthService, ImmichJwtService],
+  providers: [AuthService],
 })
 export class AuthModule {}
diff --git a/server/apps/immich/src/api-v1/auth/auth.service.spec.ts b/server/apps/immich/src/api-v1/auth/auth.service.spec.ts
new file mode 100644
index 0000000000..22882d67fc
--- /dev/null
+++ b/server/apps/immich/src/api-v1/auth/auth.service.spec.ts
@@ -0,0 +1,147 @@
+import { UserEntity } from '@app/database/entities/user.entity';
+import { BadRequestException } from '@nestjs/common';
+import { Test } from '@nestjs/testing';
+import * as bcrypt from 'bcrypt';
+import { AuthType } from '../../constants/jwt.constant';
+import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
+import { OAuthService } from '../oauth/oauth.service';
+import { IUserRepository, USER_REPOSITORY } from '../user/user-repository';
+import { AuthService } from './auth.service';
+import { SignUpDto } from './dto/sign-up.dto';
+import { LoginResponseDto } from './response-dto/login-response.dto';
+
+const fixtures = {
+  login: {
+    email: 'test@immich.com',
+    password: 'password',
+  },
+};
+
+const CLIENT_IP = '127.0.0.1';
+
+jest.mock('bcrypt');
+
+describe('AuthService', () => {
+  let sut: AuthService;
+  let userRepositoryMock: jest.Mocked<IUserRepository>;
+  let immichJwtServiceMock: jest.Mocked<ImmichJwtService>;
+  let oauthServiceMock: jest.Mocked<OAuthService>;
+  let compare: jest.Mock;
+
+  afterEach(() => {
+    jest.resetModules();
+  });
+
+  beforeEach(async () => {
+    jest.mock('bcrypt');
+    compare = bcrypt.compare as jest.Mock;
+
+    userRepositoryMock = {
+      get: jest.fn(),
+      getAdmin: jest.fn(),
+      getByEmail: jest.fn(),
+      getList: jest.fn(),
+      create: jest.fn(),
+      update: jest.fn(),
+      delete: jest.fn(),
+      restore: jest.fn(),
+    };
+
+    immichJwtServiceMock = {
+      getCookieNames: jest.fn(),
+      getCookies: jest.fn(),
+      createLoginResponse: jest.fn(),
+      validateToken: jest.fn(),
+      extractJwtFromHeader: jest.fn(),
+      extractJwtFromCookie: jest.fn(),
+    } as unknown as jest.Mocked<ImmichJwtService>;
+
+    oauthServiceMock = {
+      getLogoutEndpoint: jest.fn(),
+    } as unknown as jest.Mocked<OAuthService>;
+
+    const moduleRef = await Test.createTestingModule({
+      providers: [
+        AuthService,
+        { provide: ImmichJwtService, useValue: immichJwtServiceMock },
+        { provide: OAuthService, useValue: oauthServiceMock },
+        {
+          provide: USER_REPOSITORY,
+          useValue: userRepositoryMock,
+        },
+      ],
+    }).compile();
+
+    sut = moduleRef.get(AuthService);
+  });
+
+  it('should be defined', () => {
+    expect(sut).toBeDefined();
+  });
+
+  describe('login', () => {
+    it('should check the user exists', async () => {
+      userRepositoryMock.getByEmail.mockResolvedValue(null);
+      await expect(sut.login(fixtures.login, CLIENT_IP)).rejects.toBeInstanceOf(BadRequestException);
+      expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
+    });
+
+    it('should check the user has a password', async () => {
+      userRepositoryMock.getByEmail.mockResolvedValue({} as UserEntity);
+      await expect(sut.login(fixtures.login, CLIENT_IP)).rejects.toBeInstanceOf(BadRequestException);
+      expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
+    });
+
+    it('should successfully log the user in', async () => {
+      userRepositoryMock.getByEmail.mockResolvedValue({ password: 'password' } as UserEntity);
+      compare.mockResolvedValue(true);
+      const dto = { firstName: 'test', lastName: 'immich' } as LoginResponseDto;
+      immichJwtServiceMock.createLoginResponse.mockResolvedValue(dto);
+      await expect(sut.login(fixtures.login, CLIENT_IP)).resolves.toEqual(dto);
+      expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
+      expect(immichJwtServiceMock.createLoginResponse).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('logout', () => {
+    it('should return the end session endpoint', async () => {
+      oauthServiceMock.getLogoutEndpoint.mockResolvedValue('end-session-endpoint');
+      await expect(sut.logout(AuthType.OAUTH)).resolves.toEqual({
+        successful: true,
+        redirectUri: 'end-session-endpoint',
+      });
+    });
+
+    it('should return the default redirect', async () => {
+      await expect(sut.logout(AuthType.PASSWORD)).resolves.toEqual({
+        successful: true,
+        redirectUri: '/auth/login',
+      });
+      expect(oauthServiceMock.getLogoutEndpoint).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('adminSignUp', () => {
+    const dto: SignUpDto = { email: 'test@immich.com', password: 'password', firstName: 'immich', lastName: 'admin' };
+
+    it('should only allow one admin', async () => {
+      userRepositoryMock.getAdmin.mockResolvedValue({} as UserEntity);
+      await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException);
+      expect(userRepositoryMock.getAdmin).toHaveBeenCalled();
+    });
+
+    it('should sign up the admin', async () => {
+      userRepositoryMock.getAdmin.mockResolvedValue(null);
+      userRepositoryMock.create.mockResolvedValue({ ...dto, id: 'admin', createdAt: 'today' } as UserEntity);
+      await expect(sut.adminSignUp(dto)).resolves.toEqual({
+        id: 'admin',
+        createdAt: 'today',
+        email: 'test@immich.com',
+        firstName: 'immich',
+        lastName: 'admin',
+      });
+      expect(userRepositoryMock.getAdmin).toHaveBeenCalled();
+      expect(userRepositoryMock.create).toHaveBeenCalled();
+    });
+  });
+});
diff --git a/server/apps/immich/src/api-v1/auth/auth.service.ts b/server/apps/immich/src/api-v1/auth/auth.service.ts
index dac6f8b4d4..cfcaf58937 100644
--- a/server/apps/immich/src/api-v1/auth/auth.service.ts
+++ b/server/apps/immich/src/api-v1/auth/auth.service.ts
@@ -1,106 +1,80 @@
-import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
-import { InjectRepository } from '@nestjs/typeorm';
-import { Repository } from 'typeorm';
-import { UserEntity } from '@app/database/entities/user.entity';
-import { LoginCredentialDto } from './dto/login-credential.dto';
-import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
-import { JwtPayloadDto } from './dto/jwt-payload.dto';
-import { SignUpDto } from './dto/sign-up.dto';
+import { BadRequestException, Inject, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
 import * as bcrypt from 'bcrypt';
-import { LoginResponseDto, mapLoginResponse } from './response-dto/login-response.dto';
+import { UserEntity } from '../../../../../libs/database/src/entities/user.entity';
+import { AuthType } from '../../constants/jwt.constant';
+import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
+import { IUserRepository, USER_REPOSITORY } from '../user/user-repository';
+import { LoginCredentialDto } from './dto/login-credential.dto';
+import { SignUpDto } from './dto/sign-up.dto';
 import { AdminSignupResponseDto, mapAdminSignupResponse } from './response-dto/admin-signup-response.dto';
+import { LoginResponseDto } from './response-dto/login-response.dto';
+import { LogoutResponseDto } from './response-dto/logout-response.dto';
+import { OAuthService } from '../oauth/oauth.service';
 
 @Injectable()
 export class AuthService {
   constructor(
-    @InjectRepository(UserEntity)
-    private userRepository: Repository<UserEntity>,
+    private oauthService: OAuthService,
     private immichJwtService: ImmichJwtService,
+    @Inject(USER_REPOSITORY) private userRepository: IUserRepository,
   ) {}
 
-  private async validateUser(loginCredential: LoginCredentialDto): Promise<UserEntity | null> {
-    const user = await this.userRepository.findOne({
-      where: {
-        email: loginCredential.email,
-      },
-      select: [
-        'id',
-        'email',
-        'password',
-        'salt',
-        'firstName',
-        'lastName',
-        'isAdmin',
-        'profileImagePath',
-        'shouldChangePassword',
-      ],
-    });
+  public async login(loginCredential: LoginCredentialDto, clientIp: string): Promise<LoginResponseDto> {
+    let user = await this.userRepository.getByEmail(loginCredential.email, true);
+
+    if (user) {
+      const isAuthenticated = await this.validatePassword(loginCredential.password, user);
+      if (!isAuthenticated) {
+        user = null;
+      }
+    }
 
     if (!user) {
-      return null;
-    }
-
-    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    const isAuthenticated = await this.validatePassword(user.password!, loginCredential.password, user.salt!);
-
-    if (isAuthenticated) {
-      return user;
-    }
-
-    return null;
-  }
-
-  public async login(loginCredential: LoginCredentialDto, clientIp: string): Promise<LoginResponseDto> {
-    const validatedUser = await this.validateUser(loginCredential);
-
-    if (!validatedUser) {
       Logger.warn(`Failed login attempt for user ${loginCredential.email} from ip address ${clientIp}`);
       throw new BadRequestException('Incorrect email or password');
     }
 
-    const payload = new JwtPayloadDto(validatedUser.id, validatedUser.email);
-    const accessToken = await this.immichJwtService.generateToken(payload);
-
-    return mapLoginResponse(validatedUser, accessToken);
+    return this.immichJwtService.createLoginResponse(user);
   }
 
-  public getCookieWithJwtToken(authLoginInfo: LoginResponseDto) {
-    const maxAge = 7 * 24 * 3600; // 7 days
-    return `immich_access_token=${authLoginInfo.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}`;
+  public async logout(authType: AuthType): Promise<LogoutResponseDto> {
+    if (authType === AuthType.OAUTH) {
+      const url = await this.oauthService.getLogoutEndpoint();
+      if (url) {
+        return { successful: true, redirectUri: url };
+      }
+    }
+
+    return { successful: true, redirectUri: '/auth/login' };
   }
 
-  // !TODO: refactor this method to use the userService createUser method
-  public async adminSignUp(signUpCredential: SignUpDto): Promise<AdminSignupResponseDto> {
-    const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } });
+  public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
+    const adminUser = await this.userRepository.getAdmin();
 
     if (adminUser) {
       throw new BadRequestException('The server already has an admin');
     }
 
-    const newAdminUser = new UserEntity();
-    newAdminUser.email = signUpCredential.email;
-    newAdminUser.salt = await bcrypt.genSalt();
-    newAdminUser.password = await this.hashPassword(signUpCredential.password, newAdminUser.salt);
-    newAdminUser.firstName = signUpCredential.firstName;
-    newAdminUser.lastName = signUpCredential.lastName;
-    newAdminUser.isAdmin = true;
-
     try {
-      const savedNewAdminUserUser = await this.userRepository.save(newAdminUser);
+      const admin = await this.userRepository.create({
+        isAdmin: true,
+        email: dto.email,
+        firstName: dto.firstName,
+        lastName: dto.lastName,
+        password: dto.password,
+      });
 
-      return mapAdminSignupResponse(savedNewAdminUserUser);
+      return mapAdminSignupResponse(admin);
     } catch (e) {
       Logger.error('e', 'signUp');
       throw new InternalServerErrorException('Failed to register new admin user');
     }
   }
 
-  private async hashPassword(password: string, salt: string): Promise<string> {
-    return bcrypt.hash(password, salt);
-  }
-
-  private async validatePassword(hasedPassword: string, inputPassword: string, salt: string): Promise<boolean> {
-    const hash = await bcrypt.hash(inputPassword, salt);
-    return hash === hasedPassword;
+  private async validatePassword(inputPassword: string, user: UserEntity): Promise<boolean> {
+    if (!user || !user.password) {
+      return false;
+    }
+    return await bcrypt.compare(inputPassword, user.password);
   }
 }
diff --git a/server/apps/immich/src/api-v1/auth/response-dto/logout-response.dto.ts b/server/apps/immich/src/api-v1/auth/response-dto/logout-response.dto.ts
index 4a9484fbdb..9ada897ef9 100644
--- a/server/apps/immich/src/api-v1/auth/response-dto/logout-response.dto.ts
+++ b/server/apps/immich/src/api-v1/auth/response-dto/logout-response.dto.ts
@@ -7,4 +7,7 @@ export class LogoutResponseDto {
 
   @ApiResponseProperty()
   successful!: boolean;
+
+  @ApiResponseProperty()
+  redirectUri!: string;
 }
diff --git a/server/apps/immich/src/api-v1/communication/communication.gateway.ts b/server/apps/immich/src/api-v1/communication/communication.gateway.ts
index 8cb7928b9e..b0babadfdd 100644
--- a/server/apps/immich/src/api-v1/communication/communication.gateway.ts
+++ b/server/apps/immich/src/api-v1/communication/communication.gateway.ts
@@ -6,6 +6,8 @@ import { InjectRepository } from '@nestjs/typeorm';
 import { UserEntity } from '@app/database/entities/user.entity';
 import { Repository } from 'typeorm';
 import cookieParser from 'cookie';
+import { IMMICH_ACCESS_COOKIE } from '../../constants/jwt.constant';
+
 @WebSocketGateway({ cors: true })
 export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisconnect {
   constructor(
@@ -30,8 +32,8 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco
 
       if (client.handshake.headers.cookie != undefined) {
         const cookies = cookieParser.parse(client.handshake.headers.cookie);
-        if (cookies.immich_access_token) {
-          accessToken = cookies.immich_access_token;
+        if (cookies[IMMICH_ACCESS_COOKIE]) {
+          accessToken = cookies[IMMICH_ACCESS_COOKIE];
         } else {
           client.emit('error', 'unauthorized');
           client.disconnect();
diff --git a/server/apps/immich/src/api-v1/oauth/dto/oauth-auth-code.dto.ts b/server/apps/immich/src/api-v1/oauth/dto/oauth-auth-code.dto.ts
new file mode 100644
index 0000000000..924db0052a
--- /dev/null
+++ b/server/apps/immich/src/api-v1/oauth/dto/oauth-auth-code.dto.ts
@@ -0,0 +1,9 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsNotEmpty, IsString } from 'class-validator';
+
+export class OAuthCallbackDto {
+  @IsNotEmpty()
+  @IsString()
+  @ApiProperty()
+  url!: string;
+}
diff --git a/server/apps/immich/src/api-v1/oauth/dto/oauth-config.dto.ts b/server/apps/immich/src/api-v1/oauth/dto/oauth-config.dto.ts
new file mode 100644
index 0000000000..a15a963bfa
--- /dev/null
+++ b/server/apps/immich/src/api-v1/oauth/dto/oauth-config.dto.ts
@@ -0,0 +1,9 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsNotEmpty, IsString } from 'class-validator';
+
+export class OAuthConfigDto {
+  @IsNotEmpty()
+  @IsString()
+  @ApiProperty()
+  redirectUri!: string;
+}
diff --git a/server/apps/immich/src/api-v1/oauth/oauth.controller.ts b/server/apps/immich/src/api-v1/oauth/oauth.controller.ts
new file mode 100644
index 0000000000..eb864a1cb3
--- /dev/null
+++ b/server/apps/immich/src/api-v1/oauth/oauth.controller.ts
@@ -0,0 +1,27 @@
+import { Body, Controller, Post, Res, ValidationPipe } from '@nestjs/common';
+import { ApiTags } from '@nestjs/swagger';
+import { Response } from 'express';
+import { AuthType } from '../../constants/jwt.constant';
+import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
+import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
+import { OAuthConfigDto } from './dto/oauth-config.dto';
+import { OAuthService } from './oauth.service';
+import { OAuthConfigResponseDto } from './response-dto/oauth-config-response.dto';
+
+@ApiTags('OAuth')
+@Controller('oauth')
+export class OAuthController {
+  constructor(private readonly immichJwtService: ImmichJwtService, private readonly oauthService: OAuthService) {}
+
+  @Post('/config')
+  public generateConfig(@Body(ValidationPipe) dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
+    return this.oauthService.generateConfig(dto);
+  }
+
+  @Post('/callback')
+  public async callback(@Res({ passthrough: true }) response: Response, @Body(ValidationPipe) dto: OAuthCallbackDto) {
+    const loginResponse = await this.oauthService.callback(dto);
+    response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.OAUTH));
+    return loginResponse;
+  }
+}
diff --git a/server/apps/immich/src/api-v1/oauth/oauth.module.ts b/server/apps/immich/src/api-v1/oauth/oauth.module.ts
new file mode 100644
index 0000000000..1036458812
--- /dev/null
+++ b/server/apps/immich/src/api-v1/oauth/oauth.module.ts
@@ -0,0 +1,13 @@
+import { Module } from '@nestjs/common';
+import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
+import { UserModule } from '../user/user.module';
+import { OAuthController } from './oauth.controller';
+import { OAuthService } from './oauth.service';
+
+@Module({
+  imports: [UserModule, ImmichJwtModule],
+  controllers: [OAuthController],
+  providers: [OAuthService],
+  exports: [OAuthService],
+})
+export class OAuthModule {}
diff --git a/server/apps/immich/src/api-v1/oauth/oauth.service.spec.ts b/server/apps/immich/src/api-v1/oauth/oauth.service.spec.ts
new file mode 100644
index 0000000000..9934701d1d
--- /dev/null
+++ b/server/apps/immich/src/api-v1/oauth/oauth.service.spec.ts
@@ -0,0 +1,169 @@
+import { UserEntity } from '@app/database/entities/user.entity';
+import { BadRequestException } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { generators, Issuer } from 'openid-client';
+import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
+import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
+import { OAuthService } from '../oauth/oauth.service';
+import { IUserRepository } from '../user/user-repository';
+
+interface OAuthConfig {
+  OAUTH_ENABLED: boolean;
+  OAUTH_AUTO_REGISTER: boolean;
+  OAUTH_ISSUER_URL: string;
+  OAUTH_SCOPE: string;
+  OAUTH_BUTTON_TEXT: string;
+}
+
+const mockConfig = (config: Partial<OAuthConfig>) => {
+  return (value: keyof OAuthConfig, defaultValue: any) => config[value] ?? defaultValue ?? null;
+};
+
+const email = 'user@immich.com';
+
+const user = {
+  id: 'user',
+  email,
+  firstName: 'user',
+  lastName: 'imimch',
+} as UserEntity;
+
+const loginResponse = {
+  accessToken: 'access-token',
+  userId: 'user',
+  userEmail: 'user@immich.com,',
+} as LoginResponseDto;
+
+describe('OAuthService', () => {
+  let sut: OAuthService;
+  let userRepositoryMock: jest.Mocked<IUserRepository>;
+  let configServiceMock: jest.Mocked<ConfigService>;
+  let immichJwtServiceMock: jest.Mocked<ImmichJwtService>;
+
+  beforeEach(async () => {
+    jest.spyOn(generators, 'state').mockReturnValue('state');
+    jest.spyOn(Issuer, 'discover').mockResolvedValue({
+      id_token_signing_alg_values_supported: ['HS256'],
+      Client: jest.fn().mockResolvedValue({
+        issuer: {
+          metadata: {
+            end_session_endpoint: 'http://end-session-endpoint',
+          },
+        },
+        authorizationUrl: jest.fn().mockReturnValue('http://authorization-url'),
+        callbackParams: jest.fn().mockReturnValue({ state: 'state' }),
+        callback: jest.fn().mockReturnValue({ access_token: 'access-token' }),
+        userinfo: jest.fn().mockResolvedValue({ email }),
+      }),
+    } as any);
+
+    userRepositoryMock = {
+      get: jest.fn(),
+      getAdmin: jest.fn(),
+      getByEmail: jest.fn(),
+      getList: jest.fn(),
+      create: jest.fn(),
+      update: jest.fn(),
+      delete: jest.fn(),
+      restore: jest.fn(),
+    };
+
+    immichJwtServiceMock = {
+      getCookieNames: jest.fn(),
+      getCookies: jest.fn(),
+      createLoginResponse: jest.fn(),
+      validateToken: jest.fn(),
+      extractJwtFromHeader: jest.fn(),
+      extractJwtFromCookie: jest.fn(),
+    } as unknown as jest.Mocked<ImmichJwtService>;
+
+    configServiceMock = {
+      get: jest.fn(),
+    } as unknown as jest.Mocked<ConfigService>;
+
+    sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
+  });
+
+  it('should be defined', () => {
+    expect(sut).toBeDefined();
+  });
+
+  describe('generateConfig', () => {
+    it('should work when oauth is not configured', async () => {
+      await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({ enabled: false });
+      expect(configServiceMock.get).toHaveBeenCalled();
+    });
+
+    it('should generate the config', async () => {
+      configServiceMock.get.mockImplementation(
+        mockConfig({
+          OAUTH_ENABLED: true,
+          OAUTH_BUTTON_TEXT: 'OAuth',
+        }),
+      );
+      sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
+      await expect(sut.generateConfig({ redirectUri: 'http://redirect' })).resolves.toEqual({
+        enabled: true,
+        buttonText: 'OAuth',
+        url: 'http://authorization-url',
+      });
+    });
+  });
+
+  describe('callback', () => {
+    it('should throw an error if OAuth is not enabled', async () => {
+      await expect(sut.callback({ url: '' })).rejects.toBeInstanceOf(BadRequestException);
+    });
+
+    it('should not allow auto registering', async () => {
+      configServiceMock.get.mockImplementation(
+        mockConfig({
+          OAUTH_ENABLED: true,
+          OAUTH_AUTO_REGISTER: false,
+        }),
+      );
+      sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
+      jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
+      jest.spyOn(sut['logger'], 'warn').mockImplementation(() => null);
+      userRepositoryMock.getByEmail.mockResolvedValue(null);
+      await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' })).rejects.toBeInstanceOf(
+        BadRequestException,
+      );
+      expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
+    });
+
+    it('should allow auto registering by default', async () => {
+      configServiceMock.get.mockImplementation(mockConfig({ OAUTH_ENABLED: true }));
+      sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
+      jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
+      jest.spyOn(sut['logger'], 'log').mockImplementation(() => null);
+      userRepositoryMock.getByEmail.mockResolvedValue(null);
+      userRepositoryMock.create.mockResolvedValue(user);
+      immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
+
+      await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(loginResponse);
+
+      expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
+      expect(userRepositoryMock.create).toHaveBeenCalledTimes(1);
+      expect(immichJwtServiceMock.createLoginResponse).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('getLogoutEndpoint', () => {
+    it('should return null if OAuth is not configured', async () => {
+      await expect(sut.getLogoutEndpoint()).resolves.toBeNull();
+    });
+
+    it('should get the session endpoint from the discovery document', async () => {
+      configServiceMock.get.mockImplementation(
+        mockConfig({
+          OAUTH_ENABLED: true,
+          OAUTH_ISSUER_URL: 'http://issuer',
+        }),
+      );
+      sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
+
+      await expect(sut.getLogoutEndpoint()).resolves.toBe('http://end-session-endpoint');
+    });
+  });
+});
diff --git a/server/apps/immich/src/api-v1/oauth/oauth.service.ts b/server/apps/immich/src/api-v1/oauth/oauth.service.ts
new file mode 100644
index 0000000000..74c642a11f
--- /dev/null
+++ b/server/apps/immich/src/api-v1/oauth/oauth.service.ts
@@ -0,0 +1,108 @@
+import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { ClientMetadata, generators, Issuer, UserinfoResponse } from 'openid-client';
+import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
+import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
+import { IUserRepository, USER_REPOSITORY } from '../user/user-repository';
+import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
+import { OAuthConfigDto } from './dto/oauth-config.dto';
+import { OAuthConfigResponseDto } from './response-dto/oauth-config-response.dto';
+
+type OAuthProfile = UserinfoResponse & {
+  email: string;
+};
+
+@Injectable()
+export class OAuthService {
+  private readonly logger = new Logger(OAuthService.name);
+
+  private readonly enabled: boolean;
+  private readonly autoRegister: boolean;
+  private readonly buttonText: string;
+  private readonly issuerUrl: string;
+  private readonly clientMetadata: ClientMetadata;
+  private readonly scope: string;
+
+  constructor(
+    private immichJwtService: ImmichJwtService,
+    configService: ConfigService,
+    @Inject(USER_REPOSITORY) private userRepository: IUserRepository,
+  ) {
+    this.enabled = configService.get('OAUTH_ENABLED', false);
+    this.autoRegister = configService.get('OAUTH_AUTO_REGISTER', true);
+    this.issuerUrl = configService.get<string>('OAUTH_ISSUER_URL', '');
+    this.scope = configService.get<string>('OAUTH_SCOPE', '');
+    this.buttonText = configService.get<string>('OAUTH_BUTTON_TEXT', '');
+
+    this.clientMetadata = {
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      client_id: configService.get('OAUTH_CLIENT_ID')!,
+      client_secret: configService.get('OAUTH_CLIENT_SECRET'),
+      response_types: ['code'],
+    };
+  }
+
+  public async generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
+    if (!this.enabled) {
+      return { enabled: false };
+    }
+
+    const url = (await this.getClient()).authorizationUrl({
+      redirect_uri: dto.redirectUri,
+      scope: this.scope,
+      state: generators.state(),
+    });
+    return { enabled: true, buttonText: this.buttonText, url };
+  }
+
+  public async callback(dto: OAuthCallbackDto): Promise<LoginResponseDto> {
+    const redirectUri = dto.url.split('?')[0];
+    const client = await this.getClient();
+    const params = client.callbackParams(dto.url);
+    const tokens = await client.callback(redirectUri, params, { state: params.state });
+    const profile = await client.userinfo<OAuthProfile>(tokens.access_token || '');
+
+    this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
+    let user = await this.userRepository.getByEmail(profile.email);
+
+    if (!user) {
+      if (!this.autoRegister) {
+        this.logger.warn(
+          `Unable to register ${profile.email}. To enable auto registering, set OAUTH_AUTO_REGISTER=true.`,
+        );
+        throw new BadRequestException(`User does not exist and auto registering is disabled.`);
+      }
+
+      this.logger.log(`Registering new user: ${profile.email}`);
+      user = await this.userRepository.create({
+        firstName: profile.given_name || '',
+        lastName: profile.family_name || '',
+        email: profile.email,
+      });
+    }
+
+    return this.immichJwtService.createLoginResponse(user);
+  }
+
+  public async getLogoutEndpoint(): Promise<string | null> {
+    if (!this.enabled) {
+      return null;
+    }
+    return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
+  }
+
+  private async getClient() {
+    if (!this.enabled) {
+      throw new BadRequestException('OAuth2 is not enabled');
+    }
+
+    const issuer = await Issuer.discover(this.issuerUrl);
+    const algorithms = (issuer.id_token_signing_alg_values_supported || []) as string[];
+    const metadata = { ...this.clientMetadata };
+    if (algorithms[0] === 'HS256') {
+      metadata.id_token_signed_response_alg = algorithms[0];
+    }
+
+    return new issuer.Client(metadata);
+  }
+}
diff --git a/server/apps/immich/src/api-v1/oauth/response-dto/oauth-config-response.dto.ts b/server/apps/immich/src/api-v1/oauth/response-dto/oauth-config-response.dto.ts
new file mode 100644
index 0000000000..6dc480866e
--- /dev/null
+++ b/server/apps/immich/src/api-v1/oauth/response-dto/oauth-config-response.dto.ts
@@ -0,0 +1,12 @@
+import { ApiResponseProperty } from '@nestjs/swagger';
+
+export class OAuthConfigResponseDto {
+  @ApiResponseProperty()
+  enabled!: boolean;
+
+  @ApiResponseProperty()
+  url?: string;
+
+  @ApiResponseProperty()
+  buttonText?: string;
+}
diff --git a/server/apps/immich/src/api-v1/user/user-repository.ts b/server/apps/immich/src/api-v1/user/user-repository.ts
index a80a2d3bdf..788b37db0c 100644
--- a/server/apps/immich/src/api-v1/user/user-repository.ts
+++ b/server/apps/immich/src/api-v1/user/user-repository.ts
@@ -1,18 +1,16 @@
 import { UserEntity } from '@app/database/entities/user.entity';
 import { BadRequestException } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
-import { Not, Repository } from 'typeorm';
-import { CreateUserDto } from './dto/create-user.dto';
 import * as bcrypt from 'bcrypt';
-import { UpdateUserDto } from './dto/update-user.dto';
+import { Not, Repository } from 'typeorm';
 
 export interface IUserRepository {
-  get(userId: string, withDeleted?: boolean): Promise<UserEntity | null>;
-  getByEmail(email: string): Promise<UserEntity | null>;
+  get(id: string, withDeleted?: boolean): Promise<UserEntity | null>;
+  getAdmin(): Promise<UserEntity | null>;
+  getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null>;
   getList(filter?: { excludeId?: string }): Promise<UserEntity[]>;
-  create(createUserDto: CreateUserDto): Promise<UserEntity>;
-  update(user: UserEntity, updateUserDto: UpdateUserDto): Promise<UserEntity>;
-  createProfileImage(user: UserEntity, fileInfo: Express.Multer.File): Promise<UserEntity>;
+  create(user: Partial<UserEntity>): Promise<UserEntity>;
+  update(id: string, user: Partial<UserEntity>): Promise<UserEntity>;
   delete(user: UserEntity): Promise<UserEntity>;
   restore(user: UserEntity): Promise<UserEntity>;
 }
@@ -25,25 +23,29 @@ export class UserRepository implements IUserRepository {
     private userRepository: Repository<UserEntity>,
   ) {}
 
-  private async hashPassword(password: string, salt: string): Promise<string> {
-    return bcrypt.hash(password, salt);
-  }
-
-  async get(userId: string, withDeleted?: boolean): Promise<UserEntity | null> {
+  public async get(userId: string, withDeleted?: boolean): Promise<UserEntity | null> {
     return this.userRepository.findOne({ where: { id: userId }, withDeleted: withDeleted });
   }
 
-  async getByEmail(email: string): Promise<UserEntity | null> {
-    return this.userRepository.findOne({ where: { email } });
+  public async getAdmin(): Promise<UserEntity | null> {
+    return this.userRepository.findOne({ where: { isAdmin: true } });
   }
 
-  // TODO add DTO for filtering
-  async getList({ excludeId }: { excludeId?: string } = {}): Promise<UserEntity[]> {
+  public async getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null> {
+    let builder = this.userRepository.createQueryBuilder('user').where({ email });
+
+    if (withPassword) {
+      builder = builder.addSelect('user.password');
+    }
+
+    return builder.getOne();
+  }
+
+  public async getList({ excludeId }: { excludeId?: string } = {}): Promise<UserEntity[]> {
     if (!excludeId) {
       return this.userRepository.find(); // TODO: this should also be ordered the same as below
     }
-    return this.userRepository
-    .find({
+    return this.userRepository.find({
       where: { id: Not(excludeId) },
       withDeleted: true,
       order: {
@@ -52,33 +54,27 @@ export class UserRepository implements IUserRepository {
     });
   }
 
-  async create(createUserDto: CreateUserDto): Promise<UserEntity> {
-    const newUser = new UserEntity();
-    newUser.email = createUserDto.email;
-    newUser.salt = await bcrypt.genSalt();
-    newUser.password = await this.hashPassword(createUserDto.password, newUser.salt);
-    newUser.firstName = createUserDto.firstName;
-    newUser.lastName = createUserDto.lastName;
-    newUser.isAdmin = false;
+  public async create(user: Partial<UserEntity>): Promise<UserEntity> {
+    if (user.password) {
+      user.salt = await bcrypt.genSalt();
+      user.password = await this.hashPassword(user.password, user.salt);
+    }
+    user.isAdmin = false;
 
-    return this.userRepository.save(newUser);
+    return this.userRepository.save(user);
   }
 
-  async update(user: UserEntity, updateUserDto: UpdateUserDto): Promise<UserEntity> {
-    user.lastName = updateUserDto.lastName || user.lastName;
-    user.firstName = updateUserDto.firstName || user.firstName;
-    user.profileImagePath = updateUserDto.profileImagePath || user.profileImagePath;
-    user.shouldChangePassword =
-      updateUserDto.shouldChangePassword != undefined ? updateUserDto.shouldChangePassword : user.shouldChangePassword;
+  public async update(id: string, user: Partial<UserEntity>): Promise<UserEntity> {
+    user.id = id;
 
     // If payload includes password - Create new password for user
-    if (updateUserDto.password) {
+    if (user.password) {
       user.salt = await bcrypt.genSalt();
-      user.password = await this.hashPassword(updateUserDto.password, user.salt);
+      user.password = await this.hashPassword(user.password, user.salt);
     }
 
     // TODO: can this happen? If so we can move it to the service, otherwise remove it (also from DTO)
-    if (updateUserDto.isAdmin) {
+    if (user.isAdmin) {
       const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } });
 
       if (adminUser) {
@@ -91,19 +87,18 @@ export class UserRepository implements IUserRepository {
     return this.userRepository.save(user);
   }
 
-  async delete(user: UserEntity): Promise<UserEntity> {
+  public async delete(user: UserEntity): Promise<UserEntity> {
     if (user.isAdmin) {
       throw new BadRequestException('Cannot delete admin user! stay sane!');
     }
     return this.userRepository.softRemove(user);
   }
 
-  async restore(user: UserEntity): Promise<UserEntity> {
+  public async restore(user: UserEntity): Promise<UserEntity> {
     return this.userRepository.recover(user);
   }
 
-  async createProfileImage(user: UserEntity, fileInfo: Express.Multer.File): Promise<UserEntity> {
-    user.profileImagePath = fileInfo.path;
-    return this.userRepository.save(user);
+  private async hashPassword(password: string, salt: string): Promise<string> {
+    return bcrypt.hash(password, salt);
   }
 }
diff --git a/server/apps/immich/src/api-v1/user/user.module.ts b/server/apps/immich/src/api-v1/user/user.module.ts
index 65a153377a..93c83bc196 100644
--- a/server/apps/immich/src/api-v1/user/user.module.ts
+++ b/server/apps/immich/src/api-v1/user/user.module.ts
@@ -1,24 +1,23 @@
-import { Module } from '@nestjs/common';
-import { UserService } from './user.service';
-import { UserController } from './user.controller';
-import { TypeOrmModule } from '@nestjs/typeorm';
 import { UserEntity } from '@app/database/entities/user.entity';
+import { Module } from '@nestjs/common';
+import { JwtModule } from '@nestjs/jwt';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { jwtConfig } from '../../config/jwt.config';
 import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
 import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
-import { JwtModule } from '@nestjs/jwt';
-import { jwtConfig } from '../../config/jwt.config';
 import { UserRepository, USER_REPOSITORY } from './user-repository';
+import { UserController } from './user.controller';
+import { UserService } from './user.service';
+
+const USER_REPOSITORY_PROVIDER = {
+  provide: USER_REPOSITORY,
+  useClass: UserRepository,
+};
 
 @Module({
   imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)],
   controllers: [UserController],
-  providers: [
-    UserService,
-    ImmichJwtService,
-    {
-      provide: USER_REPOSITORY,
-      useClass: UserRepository,
-    },
-  ],
+  providers: [UserService, ImmichJwtService, USER_REPOSITORY_PROVIDER],
+  exports: [USER_REPOSITORY_PROVIDER],
 })
 export class UserModule {}
diff --git a/server/apps/immich/src/api-v1/user/user.service.spec.ts b/server/apps/immich/src/api-v1/user/user.service.spec.ts
index 4fda062ca6..abed003833 100644
--- a/server/apps/immich/src/api-v1/user/user.service.spec.ts
+++ b/server/apps/immich/src/api-v1/user/user.service.spec.ts
@@ -1,5 +1,6 @@
 import { UserEntity } from '@app/database/entities/user.entity';
 import { BadRequestException, NotFoundException } from '@nestjs/common';
+import { newUserRepositoryMock } from '../../../test/test-utils';
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { IUserRepository } from './user-repository';
 import { UserService } from './user.service';
@@ -58,16 +59,7 @@ describe('UserService', () => {
   });
 
   beforeAll(() => {
-    userRepositoryMock = {
-      create: jest.fn(),
-      createProfileImage: jest.fn(),
-      get: jest.fn(),
-      getByEmail: jest.fn(),
-      getList: jest.fn(),
-      update: jest.fn(),
-      delete: jest.fn(),
-      restore: jest.fn(),
-    };
+    userRepositoryMock = newUserRepositoryMock();
 
     sui = new UserService(userRepositoryMock);
   });
diff --git a/server/apps/immich/src/api-v1/user/user.service.ts b/server/apps/immich/src/api-v1/user/user.service.ts
index b1b115ef26..311561856f 100644
--- a/server/apps/immich/src/api-v1/user/user.service.ts
+++ b/server/apps/immich/src/api-v1/user/user.service.ts
@@ -9,17 +9,17 @@ import {
   StreamableFile,
   UnauthorizedException,
 } from '@nestjs/common';
+import { Response as Res } from 'express';
+import { createReadStream } from 'fs';
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { CreateUserDto } from './dto/create-user.dto';
 import { UpdateUserDto } from './dto/update-user.dto';
-import { createReadStream } from 'fs';
-import { Response as Res } from 'express';
-import { mapUser, UserResponseDto } from './response-dto/user-response.dto';
-import { mapUserCountResponse, UserCountResponseDto } from './response-dto/user-count-response.dto';
 import {
   CreateProfileImageResponseDto,
   mapCreateProfileImageResponse,
 } from './response-dto/create-profile-image-response.dto';
+import { mapUserCountResponse, UserCountResponseDto } from './response-dto/user-count-response.dto';
+import { mapUser, UserResponseDto } from './response-dto/user-response.dto';
 import { IUserRepository, USER_REPOSITORY } from './user-repository';
 
 @Injectable()
@@ -98,7 +98,7 @@ export class UserService {
       throw new NotFoundException('User not found');
     }
     try {
-      const updatedUser = await this.userRepository.update(user, updateUserDto);
+      const updatedUser = await this.userRepository.update(user.id, updateUserDto);
 
       return mapUser(updatedUser);
     } catch (e) {
@@ -159,7 +159,7 @@ export class UserService {
     }
 
     try {
-      await this.userRepository.createProfileImage(user, fileInfo);
+      await this.userRepository.update(user.id, { profileImagePath: fileInfo.path });
 
       return mapCreateProfileImageResponse(authUser.id, fileInfo.path);
     } catch (e) {
diff --git a/server/apps/immich/src/app.module.ts b/server/apps/immich/src/app.module.ts
index 3aef3d4b4d..bdb6b8d782 100644
--- a/server/apps/immich/src/app.module.ts
+++ b/server/apps/immich/src/app.module.ts
@@ -16,6 +16,7 @@ import { ScheduleModule } from '@nestjs/schedule';
 import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
 import { DatabaseModule } from '@app/database';
 import { JobModule } from './api-v1/job/job.module';
+import { OAuthModule } from './api-v1/oauth/oauth.module';
 
 @Module({
   imports: [
@@ -27,6 +28,7 @@ import { JobModule } from './api-v1/job/job.module';
     AssetModule,
 
     AuthModule,
+    OAuthModule,
 
     ImmichJwtModule,
 
diff --git a/server/apps/immich/src/constants/jwt.constant.ts b/server/apps/immich/src/constants/jwt.constant.ts
index 4e436b3b42..fbab227755 100644
--- a/server/apps/immich/src/constants/jwt.constant.ts
+++ b/server/apps/immich/src/constants/jwt.constant.ts
@@ -1 +1,7 @@
 export const jwtSecret = process.env.JWT_SECRET;
+export const IMMICH_ACCESS_COOKIE = 'immich_access_token';
+export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type';
+export enum AuthType {
+  PASSWORD = 'password',
+  OAUTH = 'oauth',
+}
diff --git a/server/apps/immich/src/modules/immich-jwt/immich-jwt.service.spec.ts b/server/apps/immich/src/modules/immich-jwt/immich-jwt.service.spec.ts
index 1e26fc7ff1..e0ea9e0555 100644
--- a/server/apps/immich/src/modules/immich-jwt/immich-jwt.service.spec.ts
+++ b/server/apps/immich/src/modules/immich-jwt/immich-jwt.service.spec.ts
@@ -1,53 +1,96 @@
 import { Logger } from '@nestjs/common';
 import { JwtService } from '@nestjs/jwt';
 import { Request } from 'express';
+import { UserEntity } from '../../../../../libs/database/src/entities/user.entity';
+import { LoginResponseDto } from '../../api-v1/auth/response-dto/login-response.dto';
+import { AuthType } from '../../constants/jwt.constant';
 import { ImmichJwtService } from './immich-jwt.service';
 
 describe('ImmichJwtService', () => {
-  let jwtService: JwtService;
-  let service: ImmichJwtService;
+  let jwtServiceMock: jest.Mocked<JwtService>;
+  let sut: ImmichJwtService;
 
   beforeEach(() => {
-    jwtService = new JwtService();
-    service = new ImmichJwtService(jwtService);
+    jwtServiceMock = {
+      sign: jest.fn(),
+      verifyAsync: jest.fn(),
+    } as unknown as jest.Mocked<JwtService>;
+
+    sut = new ImmichJwtService(jwtServiceMock);
   });
 
   afterEach(() => {
     jest.resetModules();
   });
 
-  describe('generateToken', () => {
-    it('should generate the token', async () => {
-      const spy = jest.spyOn(jwtService, 'sign');
-      spy.mockImplementation((value) => value as string);
-      const dto = { userId: 'test-user', email: 'test-user@immich.com' };
-      const token = await service.generateToken(dto);
-      expect(token).toEqual(dto);
+  describe('getCookieNames', () => {
+    it('should return the cookie names', async () => {
+      expect(sut.getCookieNames()).toEqual(['immich_access_token', 'immich_auth_type']);
+    });
+  });
+
+  describe('getCookies', () => {
+    it('should generate the cookie headers', async () => {
+      jwtServiceMock.sign.mockImplementation((value) => value as string);
+      const dto = { accessToken: 'test-user@immich.com', userId: 'test-user' };
+      const cookies = await sut.getCookies(dto as LoginResponseDto, AuthType.PASSWORD);
+      expect(cookies).toEqual([
+        'immich_access_token=test-user@immich.com; HttpOnly; Path=/; Max-Age=604800',
+        'immich_auth_type=password; Path=/; Max-Age=604800',
+      ]);
+    });
+  });
+
+  describe('createLoginResponse', () => {
+    it('should create the login response', async () => {
+      jwtServiceMock.sign.mockReturnValue('fancy-token');
+      const user: UserEntity = {
+        id: 'user',
+        firstName: 'immich',
+        lastName: 'user',
+        isAdmin: false,
+        email: 'test@immich.com',
+        password: 'changeme',
+        salt: '123',
+        profileImagePath: '',
+        shouldChangePassword: false,
+        createdAt: 'today',
+      };
+
+      const dto: LoginResponseDto = {
+        accessToken: 'fancy-token',
+        firstName: 'immich',
+        isAdmin: false,
+        lastName: 'user',
+        profileImagePath: '',
+        shouldChangePassword: false,
+        userEmail: 'test@immich.com',
+        userId: 'user',
+      };
+      await expect(sut.createLoginResponse(user)).resolves.toEqual(dto);
     });
   });
 
   describe('validateToken', () => {
     it('should validate the token', async () => {
       const dto = { userId: 'test-user', email: 'test-user@immich.com' };
-      const spy = jest.spyOn(jwtService, 'verifyAsync');
-      spy.mockImplementation(() => dto as any);
-      const response = await service.validateToken('access-token');
+      jwtServiceMock.verifyAsync.mockImplementation(() => dto as any);
+      const response = await sut.validateToken('access-token');
 
-      expect(spy).toHaveBeenCalledTimes(1);
+      expect(jwtServiceMock.verifyAsync).toHaveBeenCalledTimes(1);
       expect(response).toEqual({ userId: 'test-user', status: true });
     });
 
     it('should handle an invalid token', async () => {
-      const verifyAsync = jest.spyOn(jwtService, 'verifyAsync');
-      verifyAsync.mockImplementation(() => {
+      jwtServiceMock.verifyAsync.mockImplementation(() => {
         throw new Error('Invalid token!');
       });
 
       const error = jest.spyOn(Logger, 'error');
       error.mockImplementation(() => null);
-      const response = await service.validateToken('access-token');
+      const response = await sut.validateToken('access-token');
 
-      expect(verifyAsync).toHaveBeenCalledTimes(1);
+      expect(jwtServiceMock.verifyAsync).toHaveBeenCalledTimes(1);
       expect(error).toHaveBeenCalledTimes(1);
       expect(response).toEqual({ userId: null, status: false });
     });
@@ -58,7 +101,7 @@ describe('ImmichJwtService', () => {
       const request = {
         headers: {},
       } as Request;
-      const token = service.extractJwtFromHeader(request);
+      const token = sut.extractJwtFromHeader(request);
       expect(token).toBe(null);
     });
 
@@ -75,15 +118,15 @@ describe('ImmichJwtService', () => {
         },
       } as Request;
 
-      expect(service.extractJwtFromHeader(upper)).toBe('token');
-      expect(service.extractJwtFromHeader(lower)).toBe('token');
+      expect(sut.extractJwtFromHeader(upper)).toBe('token');
+      expect(sut.extractJwtFromHeader(lower)).toBe('token');
     });
   });
 
   describe('extracJwtFromCookie', () => {
     it('should handle no cookie', () => {
       const request = {} as Request;
-      const token = service.extractJwtFromCookie(request);
+      const token = sut.extractJwtFromCookie(request);
       expect(token).toBe(null);
     });
 
@@ -93,7 +136,7 @@ describe('ImmichJwtService', () => {
           immich_access_token: 'cookie',
         },
       } as Request;
-      const token = service.extractJwtFromCookie(request);
+      const token = sut.extractJwtFromCookie(request);
       expect(token).toBe('cookie');
     });
   });
diff --git a/server/apps/immich/src/modules/immich-jwt/immich-jwt.service.ts b/server/apps/immich/src/modules/immich-jwt/immich-jwt.service.ts
index a4ea95687f..4a2dc5408a 100644
--- a/server/apps/immich/src/modules/immich-jwt/immich-jwt.service.ts
+++ b/server/apps/immich/src/modules/immich-jwt/immich-jwt.service.ts
@@ -1,8 +1,10 @@
+import { UserEntity } from '@app/database/entities/user.entity';
 import { Injectable, Logger } from '@nestjs/common';
 import { JwtService } from '@nestjs/jwt';
 import { Request } from 'express';
 import { JwtPayloadDto } from '../../api-v1/auth/dto/jwt-payload.dto';
-import { jwtSecret } from '../../constants/jwt.constant';
+import { LoginResponseDto, mapLoginResponse } from '../../api-v1/auth/response-dto/login-response.dto';
+import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE, jwtSecret } from '../../constants/jwt.constant';
 
 export type JwtValidationResult = {
   status: boolean;
@@ -13,10 +15,24 @@ export type JwtValidationResult = {
 export class ImmichJwtService {
   constructor(private jwtService: JwtService) {}
 
-  public async generateToken(payload: JwtPayloadDto) {
-    return this.jwtService.sign({
-      ...payload,
-    });
+  public getCookieNames() {
+    return [IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE];
+  }
+
+  public getCookies(loginResponse: LoginResponseDto, authType: AuthType) {
+    const maxAge = 7 * 24 * 3600; // 7 days
+
+    const accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}`;
+    const authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; Path=/; Max-Age=${maxAge}`;
+
+    return [accessTokenCookie, authTypeCookie];
+  }
+
+  public async createLoginResponse(user: UserEntity): Promise<LoginResponseDto> {
+    const payload = new JwtPayloadDto(user.id, user.email);
+    const accessToken = await this.generateToken(payload);
+
+    return mapLoginResponse(user, accessToken);
   }
 
   public async validateToken(accessToken: string): Promise<JwtValidationResult> {
@@ -48,10 +64,12 @@ export class ImmichJwtService {
   }
 
   public extractJwtFromCookie(req: Request) {
-    if (req.cookies?.immich_access_token) {
-      return req.cookies.immich_access_token;
-    }
+    return req.cookies?.[IMMICH_ACCESS_COOKIE] || null;
+  }
 
-    return null;
+  private async generateToken(payload: JwtPayloadDto) {
+    return this.jwtService.sign({
+      ...payload,
+    });
   }
 }
diff --git a/server/apps/immich/test/test-utils.ts b/server/apps/immich/test/test-utils.ts
index 18e618e1d7..b9ea5f5f6a 100644
--- a/server/apps/immich/test/test-utils.ts
+++ b/server/apps/immich/test/test-utils.ts
@@ -1,6 +1,7 @@
-import { DataSource } from 'typeorm';
 import { CanActivate, ExecutionContext } from '@nestjs/common';
 import { TestingModuleBuilder } from '@nestjs/testing';
+import { DataSource } from 'typeorm';
+import { IUserRepository } from '../src/api-v1/user/user-repository';
 import { AuthUserDto } from '../src/decorators/auth-user.decorator';
 import { JwtAuthGuard } from '../src/modules/immich-jwt/guards/jwt-auth.guard';
 
@@ -14,6 +15,19 @@ export async function clearDb(db: DataSource) {
   }
 }
 
+export function newUserRepositoryMock(): jest.Mocked<IUserRepository> {
+  return {
+    get: jest.fn(),
+    getAdmin: jest.fn(),
+    getByEmail: jest.fn(),
+    getList: jest.fn(),
+    create: jest.fn(),
+    update: jest.fn(),
+    delete: jest.fn(),
+    restore: jest.fn(),
+  };
+}
+
 export function getAuthUser(): AuthUserDto {
   return {
     id: '3108ac14-8afb-4b7e-87fd-39ebb6b79750',
diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json
index db362c37eb..e7586d7c7d 100644
--- a/server/immich-openapi-specs.json
+++ b/server/immich-openapi-specs.json
@@ -1 +1 @@
-{"openapi":"3.0.0","paths":{"/user":{"get":{"operationId":"getAllUsers","parameters":[{"name":"isAll","required":true,"in":"query","schema":{"type":"boolean"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserResponseDto"}}}}}},"tags":["User"],"security":[{"bearer":[]}]},"post":{"operationId":"createUser","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]},"put":{"operationId":"updateUser","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/info/{userId}":{"get":{"operationId":"getUserById","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"]}},"/user/me":{"get":{"operationId":"getMyUserInfo","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/count":{"get":{"operationId":"getUserCount","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserCountResponseDto"}}}}},"tags":["User"]}},"/user/{userId}":{"delete":{"operationId":"deleteUser","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/{userId}/restore":{"post":{"operationId":"restoreUser","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/profile-image":{"post":{"operationId":"createProfileImage","parameters":[],"requestBody":{"required":true,"description":"A new avatar for the user","content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/CreateProfileImageDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProfileImageResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/profile-image/{userId}":{"get":{"operationId":"getProfileImage","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["User"]}},"/asset/upload":{"post":{"operationId":"uploadFile","parameters":[],"requestBody":{"required":true,"description":"Asset Upload Information","content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/AssetFileUploadDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetFileUploadResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/download":{"get":{"operationId":"downloadFile","parameters":[{"name":"aid","required":true,"in":"query","schema":{"title":"Device Asset ID","type":"string"}},{"name":"did","required":true,"in":"query","schema":{"title":"Device ID","type":"string"}},{"name":"isThumb","required":false,"in":"query","schema":{"title":"Is serve thumbnail (resize) file","type":"boolean"}},{"name":"isWeb","required":false,"in":"query","schema":{"title":"Is request made from web","type":"boolean"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/file":{"get":{"operationId":"serveFile","parameters":[{"name":"aid","required":true,"in":"query","schema":{"title":"Device Asset ID","type":"string"}},{"name":"did","required":true,"in":"query","schema":{"title":"Device ID","type":"string"}},{"name":"isThumb","required":false,"in":"query","schema":{"title":"Is serve thumbnail (resize) file","type":"boolean"}},{"name":"isWeb","required":false,"in":"query","schema":{"title":"Is request made from web","type":"boolean"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/thumbnail/{assetId}":{"get":{"operationId":"getAssetThumbnail","parameters":[{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}},{"name":"format","required":false,"in":"query","schema":{"$ref":"#/components/schemas/ThumbnailFormat"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/curated-objects":{"get":{"operationId":"getCuratedObjects","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CuratedObjectsResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/curated-locations":{"get":{"operationId":"getCuratedLocations","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CuratedLocationsResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/search-terms":{"get":{"operationId":"getAssetSearchTerms","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"type":"string"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/search":{"post":{"operationId":"searchAsset","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchAssetDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/count-by-time-bucket":{"post":{"operationId":"getAssetCountByTimeBucket","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetAssetCountByTimeBucketDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetCountByTimeBucketResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/count-by-user-id":{"get":{"operationId":"getAssetCountByUserId","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetCountByUserIdResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset":{"get":{"operationId":"getAllAssets","summary":"","description":"Get all AssetEntity belong to the user","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]},"delete":{"operationId":"deleteAsset","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteAssetDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/DeleteAssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/time-bucket":{"post":{"operationId":"getAssetByTimeBucket","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetAssetByTimeBucketDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/{deviceId}":{"get":{"operationId":"getUserAssetsByDeviceId","summary":"","description":"Get all asset of a device that are in the database, ID only.","parameters":[{"name":"deviceId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"type":"string"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/assetById/{assetId}":{"get":{"operationId":"getAssetById","summary":"","description":"Get a single asset's information","parameters":[{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]},"put":{"operationId":"updateAssetById","summary":"","description":"Update an asset","parameters":[{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAssetDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/check":{"post":{"operationId":"checkDuplicateAsset","summary":"","description":"Check duplicated asset before uploading - for Web upload used","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckDuplicateAssetDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckDuplicateAssetResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/exist":{"post":{"operationId":"checkExistingAssets","summary":"","description":"Checks if multiple assets exist on the server and returns all existing - used by background backup","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckExistingAssetsDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckExistingAssetsResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/auth/login":{"post":{"operationId":"login","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginCredentialDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponseDto"}}}}},"tags":["Authentication"]}},"/auth/admin-sign-up":{"post":{"operationId":"adminSignUp","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignUpDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSignupResponseDto"}}}},"400":{"description":"The server already has an admin"}},"tags":["Authentication"]}},"/auth/validateToken":{"post":{"operationId":"validateAccessToken","parameters":[],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidateAccessTokenResponseDto"}}}}},"tags":["Authentication"],"security":[{"bearer":[]}]}},"/auth/logout":{"post":{"operationId":"logout","parameters":[],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogoutResponseDto"}}}}},"tags":["Authentication"]}},"/device-info":{"post":{"operationId":"createDeviceInfo","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDeviceInfoDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceInfoResponseDto"}}}}},"tags":["Device Info"],"security":[{"bearer":[]}]},"patch":{"operationId":"updateDeviceInfo","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDeviceInfoDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceInfoResponseDto"}}}}},"tags":["Device Info"],"security":[{"bearer":[]}]}},"/server-info":{"get":{"operationId":"getServerInfo","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerInfoResponseDto"}}}}},"tags":["Server Info"]}},"/server-info/ping":{"get":{"operationId":"pingServer","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerPingResponse"}}}}},"tags":["Server Info"]}},"/server-info/version":{"get":{"operationId":"getServerVersion","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerVersionReponseDto"}}}}},"tags":["Server Info"]}},"/server-info/stats":{"get":{"operationId":"getStats","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerStatsResponseDto"}}}}},"tags":["Server Info"]}},"/album/count-by-user-id":{"get":{"operationId":"getAlbumCountByUserId","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumCountResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album":{"post":{"operationId":"createAlbum","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAlbumDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"get":{"operationId":"getAllAlbums","parameters":[{"name":"shared","required":false,"in":"query","schema":{"type":"boolean"}},{"name":"assetId","required":false,"in":"query","description":"Only returns albums that contain the asset\nIgnores the shared parameter\nundefined: get all albums","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/users":{"put":{"operationId":"addUsersToAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddUsersDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/assets":{"put":{"operationId":"addAssetsToAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddAssetsDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddAssetsResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"delete":{"operationId":"removeAssetFromAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RemoveAssetsDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}":{"get":{"operationId":"getAlbumInfo","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"delete":{"operationId":"deleteAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Album"],"security":[{"bearer":[]}]},"patch":{"operationId":"updateAlbumInfo","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAlbumDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/user/{userId}":{"delete":{"operationId":"removeUserFromAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}},{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/download":{"get":{"operationId":"downloadArchive","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/jobs":{"get":{"operationId":"getAllJobsStatus","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllJobStatusResponseDto"}}}}},"tags":["Job"],"security":[{"bearer":[]}]}},"/jobs/{jobId}":{"get":{"operationId":"getJobStatus","parameters":[{"name":"jobId","required":true,"in":"path","schema":{"$ref":"#/components/schemas/JobId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobStatusResponseDto"}}}}},"tags":["Job"],"security":[{"bearer":[]}]},"put":{"operationId":"sendJobCommand","parameters":[{"name":"jobId","required":true,"in":"path","schema":{"$ref":"#/components/schemas/JobId"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobCommandDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"number"}}}}},"tags":["Job"],"security":[{"bearer":[]}]}}},"info":{"title":"Immich","description":"Immich API","version":"1.17.0","contact":{}},"tags":[],"servers":[{"url":"/api"}],"components":{"securitySchemes":{"bearer":{"scheme":"Bearer","bearerFormat":"JWT","type":"http","name":"JWT","description":"Enter JWT token","in":"header"}},"schemas":{"UserResponseDto":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"createdAt":{"type":"string"},"profileImagePath":{"type":"string"},"shouldChangePassword":{"type":"boolean"},"isAdmin":{"type":"boolean"},"deletedAt":{"format":"date-time","type":"string","nullable":true}},"required":["id","email","firstName","lastName","createdAt","profileImagePath","shouldChangePassword","isAdmin","deletedAt"]},"CreateUserDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"},"firstName":{"type":"string","example":"John"},"lastName":{"type":"string","example":"Doe"}},"required":["email","password","firstName","lastName"]},"UserCountResponseDto":{"type":"object","properties":{"userCount":{"type":"integer"}},"required":["userCount"]},"UpdateUserDto":{"type":"object","properties":{"id":{"type":"string"},"password":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"isAdmin":{"type":"boolean"},"shouldChangePassword":{"type":"boolean"},"profileImagePath":{"type":"string"}},"required":["id"]},"CreateProfileImageDto":{"type":"object","properties":{"file":{"type":"string","format":"binary"}},"required":["file"]},"CreateProfileImageResponseDto":{"type":"object","properties":{"userId":{"type":"string"},"profileImagePath":{"type":"string"}},"required":["userId","profileImagePath"]},"AssetFileUploadDto":{"type":"object","properties":{"assetData":{"type":"string","format":"binary"}},"required":["assetData"]},"AssetFileUploadResponseDto":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]},"ThumbnailFormat":{"type":"string","enum":["JPEG","WEBP"]},"CuratedObjectsResponseDto":{"type":"object","properties":{"id":{"type":"string"},"object":{"type":"string"},"resizePath":{"type":"string"},"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["id","object","resizePath","deviceAssetId","deviceId"]},"CuratedLocationsResponseDto":{"type":"object","properties":{"id":{"type":"string"},"city":{"type":"string"},"resizePath":{"type":"string"},"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["id","city","resizePath","deviceAssetId","deviceId"]},"SearchAssetDto":{"type":"object","properties":{"searchTerm":{"type":"string"}},"required":["searchTerm"]},"AssetTypeEnum":{"type":"string","enum":["IMAGE","VIDEO","AUDIO","OTHER"]},"ExifResponseDto":{"type":"object","properties":{"id":{"type":"integer","nullable":true,"default":null,"format":"int64"},"fileSizeInByte":{"type":"integer","nullable":true,"default":null,"format":"int64"},"make":{"type":"string","nullable":true,"default":null},"model":{"type":"string","nullable":true,"default":null},"imageName":{"type":"string","nullable":true,"default":null},"exifImageWidth":{"type":"number","nullable":true,"default":null},"exifImageHeight":{"type":"number","nullable":true,"default":null},"orientation":{"type":"string","nullable":true,"default":null},"dateTimeOriginal":{"format":"date-time","type":"string","nullable":true,"default":null},"modifyDate":{"format":"date-time","type":"string","nullable":true,"default":null},"lensModel":{"type":"string","nullable":true,"default":null},"fNumber":{"type":"number","nullable":true,"default":null},"focalLength":{"type":"number","nullable":true,"default":null},"iso":{"type":"number","nullable":true,"default":null},"exposureTime":{"type":"number","nullable":true,"default":null},"latitude":{"type":"number","nullable":true,"default":null},"longitude":{"type":"number","nullable":true,"default":null},"city":{"type":"string","nullable":true,"default":null},"state":{"type":"string","nullable":true,"default":null},"country":{"type":"string","nullable":true,"default":null}}},"SmartInfoResponseDto":{"type":"object","properties":{"id":{"type":"string"},"tags":{"nullable":true,"type":"array","items":{"type":"string"}},"objects":{"nullable":true,"type":"array","items":{"type":"string"}}}},"AssetResponseDto":{"type":"object","properties":{"type":{"$ref":"#/components/schemas/AssetTypeEnum"},"id":{"type":"string"},"deviceAssetId":{"type":"string"},"ownerId":{"type":"string"},"deviceId":{"type":"string"},"originalPath":{"type":"string"},"resizePath":{"type":"string","nullable":true},"createdAt":{"type":"string"},"modifiedAt":{"type":"string"},"isFavorite":{"type":"boolean"},"mimeType":{"type":"string","nullable":true},"duration":{"type":"string"},"webpPath":{"type":"string","nullable":true},"encodedVideoPath":{"type":"string","nullable":true},"exifInfo":{"$ref":"#/components/schemas/ExifResponseDto"},"smartInfo":{"$ref":"#/components/schemas/SmartInfoResponseDto"}},"required":["type","id","deviceAssetId","ownerId","deviceId","originalPath","resizePath","createdAt","modifiedAt","isFavorite","mimeType","duration","webpPath","encodedVideoPath"]},"TimeGroupEnum":{"type":"string","enum":["day","month"]},"GetAssetCountByTimeBucketDto":{"type":"object","properties":{"timeGroup":{"$ref":"#/components/schemas/TimeGroupEnum"}},"required":["timeGroup"]},"AssetCountByTimeBucket":{"type":"object","properties":{"timeBucket":{"type":"string"},"count":{"type":"integer"}},"required":["timeBucket","count"]},"AssetCountByTimeBucketResponseDto":{"type":"object","properties":{"totalCount":{"type":"integer"},"buckets":{"type":"array","items":{"$ref":"#/components/schemas/AssetCountByTimeBucket"}}},"required":["totalCount","buckets"]},"AssetCountByUserIdResponseDto":{"type":"object","properties":{"photos":{"type":"integer"},"videos":{"type":"integer"}},"required":["photos","videos"]},"GetAssetByTimeBucketDto":{"type":"object","properties":{"timeBucket":{"title":"Array of date time buckets","example":["2015-06-01T00:00:00.000Z","2016-02-01T00:00:00.000Z","2016-03-01T00:00:00.000Z"],"type":"array","items":{"type":"string"}}},"required":["timeBucket"]},"UpdateAssetDto":{"type":"object","properties":{"isFavorite":{"type":"boolean"}},"required":["isFavorite"]},"DeleteAssetDto":{"type":"object","properties":{"ids":{"title":"Array of asset IDs to delete","example":["bf973405-3f2a-48d2-a687-2ed4167164be","dd41870b-5d00-46d2-924e-1d8489a0aa0f","fad77c3f-deef-4e7e-9608-14c1aa4e559a"],"type":"array","items":{"type":"string"}}},"required":["ids"]},"DeleteAssetStatus":{"type":"string","enum":["SUCCESS","FAILED"]},"DeleteAssetResponseDto":{"type":"object","properties":{"status":{"$ref":"#/components/schemas/DeleteAssetStatus"},"id":{"type":"string"}},"required":["status","id"]},"CheckDuplicateAssetDto":{"type":"object","properties":{"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["deviceAssetId","deviceId"]},"CheckDuplicateAssetResponseDto":{"type":"object","properties":{"isExist":{"type":"boolean"},"id":{"type":"string"}},"required":["isExist"]},"CheckExistingAssetsDto":{"type":"object","properties":{"deviceAssetIds":{"type":"array","items":{"type":"string"}},"deviceId":{"type":"string"}},"required":["deviceAssetIds","deviceId"]},"CheckExistingAssetsResponseDto":{"type":"object","properties":{"existingIds":{"type":"array","items":{"type":"string"}}},"required":["existingIds"]},"LoginCredentialDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"}},"required":["email","password"]},"LoginResponseDto":{"type":"object","properties":{"accessToken":{"type":"string","readOnly":true},"userId":{"type":"string","readOnly":true},"userEmail":{"type":"string","readOnly":true},"firstName":{"type":"string","readOnly":true},"lastName":{"type":"string","readOnly":true},"profileImagePath":{"type":"string","readOnly":true},"isAdmin":{"type":"boolean","readOnly":true},"shouldChangePassword":{"type":"boolean","readOnly":true}},"required":["accessToken","userId","userEmail","firstName","lastName","profileImagePath","isAdmin","shouldChangePassword"]},"SignUpDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"},"firstName":{"type":"string","example":"Admin"},"lastName":{"type":"string","example":"Doe"}},"required":["email","password","firstName","lastName"]},"AdminSignupResponseDto":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"createdAt":{"type":"string"}},"required":["id","email","firstName","lastName","createdAt"]},"ValidateAccessTokenResponseDto":{"type":"object","properties":{"authStatus":{"type":"boolean"}},"required":["authStatus"]},"LogoutResponseDto":{"type":"object","properties":{"successful":{"type":"boolean","readOnly":true}},"required":["successful"]},"DeviceTypeEnum":{"type":"string","enum":["IOS","ANDROID","WEB"]},"CreateDeviceInfoDto":{"type":"object","properties":{"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"deviceId":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["deviceType","deviceId"]},"DeviceInfoResponseDto":{"type":"object","properties":{"id":{"type":"integer"},"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"userId":{"type":"string"},"deviceId":{"type":"string"},"createdAt":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["id","deviceType","userId","deviceId","createdAt","isAutoBackup"]},"UpdateDeviceInfoDto":{"type":"object","properties":{"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"deviceId":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["deviceType","deviceId"]},"ServerInfoResponseDto":{"type":"object","properties":{"diskSizeRaw":{"type":"integer","format":"int64"},"diskUseRaw":{"type":"integer","format":"int64"},"diskAvailableRaw":{"type":"integer","format":"int64"},"diskUsagePercentage":{"type":"number","format":"float"},"diskSize":{"type":"string"},"diskUse":{"type":"string"},"diskAvailable":{"type":"string"}},"required":["diskSizeRaw","diskUseRaw","diskAvailableRaw","diskUsagePercentage","diskSize","diskUse","diskAvailable"]},"ServerPingResponse":{"type":"object","properties":{"res":{"type":"string","readOnly":true,"example":"pong"}},"required":["res"]},"ServerVersionReponseDto":{"type":"object","properties":{"major":{"type":"integer"},"minor":{"type":"integer"},"patch":{"type":"integer"},"build":{"type":"integer"}},"required":["major","minor","patch","build"]},"UsageByUserDto":{"type":"object","properties":{"userId":{"type":"string"},"objects":{"type":"integer"},"videos":{"type":"integer"},"photos":{"type":"integer"},"usageRaw":{"type":"integer","format":"int64"},"usage":{"type":"string"}},"required":["userId","objects","videos","photos","usageRaw","usage"]},"ServerStatsResponseDto":{"type":"object","properties":{"photos":{"type":"integer"},"videos":{"type":"integer"},"objects":{"type":"integer"},"usageRaw":{"type":"integer","format":"int64"},"usage":{"type":"string"},"usageByUser":{"title":"Array of usage for each user","example":[{"photos":1,"videos":1,"objects":1,"diskUsageRaw":1}],"type":"array","items":{"$ref":"#/components/schemas/UsageByUserDto"}}},"required":["photos","videos","objects","usageRaw","usage","usageByUser"]},"AlbumCountResponseDto":{"type":"object","properties":{"owned":{"type":"integer"},"shared":{"type":"integer"},"sharing":{"type":"integer"}},"required":["owned","shared","sharing"]},"CreateAlbumDto":{"type":"object","properties":{"albumName":{"type":"string"},"sharedWithUserIds":{"type":"array","items":{"type":"string"}},"assetIds":{"type":"array","items":{"type":"string"}}},"required":["albumName"]},"AlbumResponseDto":{"type":"object","properties":{"assetCount":{"type":"integer"},"id":{"type":"string"},"ownerId":{"type":"string"},"albumName":{"type":"string"},"createdAt":{"type":"string"},"albumThumbnailAssetId":{"type":"string","nullable":true},"shared":{"type":"boolean"},"sharedUsers":{"type":"array","items":{"$ref":"#/components/schemas/UserResponseDto"}},"assets":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}},"required":["assetCount","id","ownerId","albumName","createdAt","albumThumbnailAssetId","shared","sharedUsers","assets"]},"AddUsersDto":{"type":"object","properties":{"sharedUserIds":{"type":"array","items":{"type":"string"}}},"required":["sharedUserIds"]},"AddAssetsDto":{"type":"object","properties":{"assetIds":{"type":"array","items":{"type":"string"}}},"required":["assetIds"]},"AddAssetsResponseDto":{"type":"object","properties":{"successfullyAdded":{"type":"integer"},"alreadyInAlbum":{"type":"array","items":{"type":"string"}},"album":{"$ref":"#/components/schemas/AlbumResponseDto"}},"required":["successfullyAdded","alreadyInAlbum"]},"RemoveAssetsDto":{"type":"object","properties":{"assetIds":{"type":"array","items":{"type":"string"}}},"required":["assetIds"]},"UpdateAlbumDto":{"type":"object","properties":{"albumName":{"type":"string"},"albumThumbnailAssetId":{"type":"string"}}},"JobCounts":{"type":"object","properties":{"active":{"type":"integer"},"completed":{"type":"integer"},"failed":{"type":"integer"},"delayed":{"type":"integer"},"waiting":{"type":"integer"}},"required":["active","completed","failed","delayed","waiting"]},"AllJobStatusResponseDto":{"type":"object","properties":{"thumbnailGenerationQueueCount":{"$ref":"#/components/schemas/JobCounts"},"metadataExtractionQueueCount":{"$ref":"#/components/schemas/JobCounts"},"videoConversionQueueCount":{"$ref":"#/components/schemas/JobCounts"},"machineLearningQueueCount":{"$ref":"#/components/schemas/JobCounts"},"isThumbnailGenerationActive":{"type":"boolean"},"isMetadataExtractionActive":{"type":"boolean"},"isVideoConversionActive":{"type":"boolean"},"isMachineLearningActive":{"type":"boolean"}},"required":["thumbnailGenerationQueueCount","metadataExtractionQueueCount","videoConversionQueueCount","machineLearningQueueCount","isThumbnailGenerationActive","isMetadataExtractionActive","isVideoConversionActive","isMachineLearningActive"]},"JobId":{"type":"string","enum":["thumbnail-generation","metadata-extraction","video-conversion","machine-learning"]},"JobStatusResponseDto":{"type":"object","properties":{"isActive":{"type":"boolean"},"queueCount":{"type":"object"}},"required":["isActive","queueCount"]},"JobCommand":{"type":"string","enum":["start","stop"]},"JobCommandDto":{"type":"object","properties":{"command":{"$ref":"#/components/schemas/JobCommand"}},"required":["command"]}}}}
\ No newline at end of file
+{"openapi":"3.0.0","paths":{"/user":{"get":{"operationId":"getAllUsers","parameters":[{"name":"isAll","required":true,"in":"query","schema":{"type":"boolean"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserResponseDto"}}}}}},"tags":["User"],"security":[{"bearer":[]}]},"post":{"operationId":"createUser","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]},"put":{"operationId":"updateUser","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/info/{userId}":{"get":{"operationId":"getUserById","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"]}},"/user/me":{"get":{"operationId":"getMyUserInfo","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/count":{"get":{"operationId":"getUserCount","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserCountResponseDto"}}}}},"tags":["User"]}},"/user/{userId}":{"delete":{"operationId":"deleteUser","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/{userId}/restore":{"post":{"operationId":"restoreUser","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/profile-image":{"post":{"operationId":"createProfileImage","parameters":[],"requestBody":{"required":true,"description":"A new avatar for the user","content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/CreateProfileImageDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProfileImageResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/profile-image/{userId}":{"get":{"operationId":"getProfileImage","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["User"]}},"/asset/upload":{"post":{"operationId":"uploadFile","parameters":[],"requestBody":{"required":true,"description":"Asset Upload Information","content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/AssetFileUploadDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetFileUploadResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/download":{"get":{"operationId":"downloadFile","parameters":[{"name":"aid","required":true,"in":"query","schema":{"title":"Device Asset ID","type":"string"}},{"name":"did","required":true,"in":"query","schema":{"title":"Device ID","type":"string"}},{"name":"isThumb","required":false,"in":"query","schema":{"title":"Is serve thumbnail (resize) file","type":"boolean"}},{"name":"isWeb","required":false,"in":"query","schema":{"title":"Is request made from web","type":"boolean"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/file":{"get":{"operationId":"serveFile","parameters":[{"name":"aid","required":true,"in":"query","schema":{"title":"Device Asset ID","type":"string"}},{"name":"did","required":true,"in":"query","schema":{"title":"Device ID","type":"string"}},{"name":"isThumb","required":false,"in":"query","schema":{"title":"Is serve thumbnail (resize) file","type":"boolean"}},{"name":"isWeb","required":false,"in":"query","schema":{"title":"Is request made from web","type":"boolean"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/thumbnail/{assetId}":{"get":{"operationId":"getAssetThumbnail","parameters":[{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}},{"name":"format","required":false,"in":"query","schema":{"$ref":"#/components/schemas/ThumbnailFormat"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/curated-objects":{"get":{"operationId":"getCuratedObjects","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CuratedObjectsResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/curated-locations":{"get":{"operationId":"getCuratedLocations","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CuratedLocationsResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/search-terms":{"get":{"operationId":"getAssetSearchTerms","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"type":"string"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/search":{"post":{"operationId":"searchAsset","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchAssetDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/count-by-time-bucket":{"post":{"operationId":"getAssetCountByTimeBucket","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetAssetCountByTimeBucketDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetCountByTimeBucketResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/count-by-user-id":{"get":{"operationId":"getAssetCountByUserId","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetCountByUserIdResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset":{"get":{"operationId":"getAllAssets","summary":"","description":"Get all AssetEntity belong to the user","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]},"delete":{"operationId":"deleteAsset","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteAssetDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/DeleteAssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/time-bucket":{"post":{"operationId":"getAssetByTimeBucket","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetAssetByTimeBucketDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/{deviceId}":{"get":{"operationId":"getUserAssetsByDeviceId","summary":"","description":"Get all asset of a device that are in the database, ID only.","parameters":[{"name":"deviceId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"type":"string"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/assetById/{assetId}":{"get":{"operationId":"getAssetById","summary":"","description":"Get a single asset's information","parameters":[{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]},"put":{"operationId":"updateAssetById","summary":"","description":"Update an asset","parameters":[{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAssetDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/check":{"post":{"operationId":"checkDuplicateAsset","summary":"","description":"Check duplicated asset before uploading - for Web upload used","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckDuplicateAssetDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckDuplicateAssetResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/exist":{"post":{"operationId":"checkExistingAssets","summary":"","description":"Checks if multiple assets exist on the server and returns all existing - used by background backup","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckExistingAssetsDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckExistingAssetsResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/auth/login":{"post":{"operationId":"login","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginCredentialDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponseDto"}}}}},"tags":["Authentication"]}},"/auth/admin-sign-up":{"post":{"operationId":"adminSignUp","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignUpDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSignupResponseDto"}}}},"400":{"description":"The server already has an admin"}},"tags":["Authentication"]}},"/auth/validateToken":{"post":{"operationId":"validateAccessToken","parameters":[],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidateAccessTokenResponseDto"}}}}},"tags":["Authentication"],"security":[{"bearer":[]}]}},"/auth/logout":{"post":{"operationId":"logout","parameters":[],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogoutResponseDto"}}}}},"tags":["Authentication"]}},"/oauth/config":{"post":{"operationId":"generateConfig","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthConfigDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthConfigResponseDto"}}}}},"tags":["OAuth"]}},"/oauth/callback":{"post":{"operationId":"callback","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthCallbackDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponseDto"}}}}},"tags":["OAuth"]}},"/device-info":{"post":{"operationId":"createDeviceInfo","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDeviceInfoDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceInfoResponseDto"}}}}},"tags":["Device Info"],"security":[{"bearer":[]}]},"patch":{"operationId":"updateDeviceInfo","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDeviceInfoDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceInfoResponseDto"}}}}},"tags":["Device Info"],"security":[{"bearer":[]}]}},"/server-info":{"get":{"operationId":"getServerInfo","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerInfoResponseDto"}}}}},"tags":["Server Info"]}},"/server-info/ping":{"get":{"operationId":"pingServer","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerPingResponse"}}}}},"tags":["Server Info"]}},"/server-info/version":{"get":{"operationId":"getServerVersion","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerVersionReponseDto"}}}}},"tags":["Server Info"]}},"/server-info/stats":{"get":{"operationId":"getStats","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerStatsResponseDto"}}}}},"tags":["Server Info"]}},"/album/count-by-user-id":{"get":{"operationId":"getAlbumCountByUserId","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumCountResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album":{"post":{"operationId":"createAlbum","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAlbumDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"get":{"operationId":"getAllAlbums","parameters":[{"name":"shared","required":false,"in":"query","schema":{"type":"boolean"}},{"name":"assetId","required":false,"in":"query","description":"Only returns albums that contain the asset\nIgnores the shared parameter\nundefined: get all albums","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/users":{"put":{"operationId":"addUsersToAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddUsersDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/assets":{"put":{"operationId":"addAssetsToAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddAssetsDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddAssetsResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"delete":{"operationId":"removeAssetFromAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RemoveAssetsDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}":{"get":{"operationId":"getAlbumInfo","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"delete":{"operationId":"deleteAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Album"],"security":[{"bearer":[]}]},"patch":{"operationId":"updateAlbumInfo","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAlbumDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/user/{userId}":{"delete":{"operationId":"removeUserFromAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}},{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/download":{"get":{"operationId":"downloadArchive","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/jobs":{"get":{"operationId":"getAllJobsStatus","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllJobStatusResponseDto"}}}}},"tags":["Job"],"security":[{"bearer":[]}]}},"/jobs/{jobId}":{"get":{"operationId":"getJobStatus","parameters":[{"name":"jobId","required":true,"in":"path","schema":{"$ref":"#/components/schemas/JobId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobStatusResponseDto"}}}}},"tags":["Job"],"security":[{"bearer":[]}]},"put":{"operationId":"sendJobCommand","parameters":[{"name":"jobId","required":true,"in":"path","schema":{"$ref":"#/components/schemas/JobId"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobCommandDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"number"}}}}},"tags":["Job"],"security":[{"bearer":[]}]}}},"info":{"title":"Immich","description":"Immich API","version":"1.17.0","contact":{}},"tags":[],"servers":[{"url":"/api"}],"components":{"securitySchemes":{"bearer":{"scheme":"Bearer","bearerFormat":"JWT","type":"http","name":"JWT","description":"Enter JWT token","in":"header"}},"schemas":{"UserResponseDto":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"createdAt":{"type":"string"},"profileImagePath":{"type":"string"},"shouldChangePassword":{"type":"boolean"},"isAdmin":{"type":"boolean"},"deletedAt":{"format":"date-time","type":"string","nullable":true}},"required":["id","email","firstName","lastName","createdAt","profileImagePath","shouldChangePassword","isAdmin","deletedAt"]},"CreateUserDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"},"firstName":{"type":"string","example":"John"},"lastName":{"type":"string","example":"Doe"}},"required":["email","password","firstName","lastName"]},"UserCountResponseDto":{"type":"object","properties":{"userCount":{"type":"integer"}},"required":["userCount"]},"UpdateUserDto":{"type":"object","properties":{"id":{"type":"string"},"password":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"isAdmin":{"type":"boolean"},"shouldChangePassword":{"type":"boolean"},"profileImagePath":{"type":"string"}},"required":["id"]},"CreateProfileImageDto":{"type":"object","properties":{"file":{"type":"string","format":"binary"}},"required":["file"]},"CreateProfileImageResponseDto":{"type":"object","properties":{"userId":{"type":"string"},"profileImagePath":{"type":"string"}},"required":["userId","profileImagePath"]},"AssetFileUploadDto":{"type":"object","properties":{"assetData":{"type":"string","format":"binary"}},"required":["assetData"]},"AssetFileUploadResponseDto":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]},"ThumbnailFormat":{"type":"string","enum":["JPEG","WEBP"]},"CuratedObjectsResponseDto":{"type":"object","properties":{"id":{"type":"string"},"object":{"type":"string"},"resizePath":{"type":"string"},"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["id","object","resizePath","deviceAssetId","deviceId"]},"CuratedLocationsResponseDto":{"type":"object","properties":{"id":{"type":"string"},"city":{"type":"string"},"resizePath":{"type":"string"},"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["id","city","resizePath","deviceAssetId","deviceId"]},"SearchAssetDto":{"type":"object","properties":{"searchTerm":{"type":"string"}},"required":["searchTerm"]},"AssetTypeEnum":{"type":"string","enum":["IMAGE","VIDEO","AUDIO","OTHER"]},"ExifResponseDto":{"type":"object","properties":{"id":{"type":"integer","nullable":true,"default":null,"format":"int64"},"fileSizeInByte":{"type":"integer","nullable":true,"default":null,"format":"int64"},"make":{"type":"string","nullable":true,"default":null},"model":{"type":"string","nullable":true,"default":null},"imageName":{"type":"string","nullable":true,"default":null},"exifImageWidth":{"type":"number","nullable":true,"default":null},"exifImageHeight":{"type":"number","nullable":true,"default":null},"orientation":{"type":"string","nullable":true,"default":null},"dateTimeOriginal":{"format":"date-time","type":"string","nullable":true,"default":null},"modifyDate":{"format":"date-time","type":"string","nullable":true,"default":null},"lensModel":{"type":"string","nullable":true,"default":null},"fNumber":{"type":"number","nullable":true,"default":null},"focalLength":{"type":"number","nullable":true,"default":null},"iso":{"type":"number","nullable":true,"default":null},"exposureTime":{"type":"number","nullable":true,"default":null},"latitude":{"type":"number","nullable":true,"default":null},"longitude":{"type":"number","nullable":true,"default":null},"city":{"type":"string","nullable":true,"default":null},"state":{"type":"string","nullable":true,"default":null},"country":{"type":"string","nullable":true,"default":null}}},"SmartInfoResponseDto":{"type":"object","properties":{"id":{"type":"string"},"tags":{"nullable":true,"type":"array","items":{"type":"string"}},"objects":{"nullable":true,"type":"array","items":{"type":"string"}}}},"AssetResponseDto":{"type":"object","properties":{"type":{"$ref":"#/components/schemas/AssetTypeEnum"},"id":{"type":"string"},"deviceAssetId":{"type":"string"},"ownerId":{"type":"string"},"deviceId":{"type":"string"},"originalPath":{"type":"string"},"resizePath":{"type":"string","nullable":true},"createdAt":{"type":"string"},"modifiedAt":{"type":"string"},"isFavorite":{"type":"boolean"},"mimeType":{"type":"string","nullable":true},"duration":{"type":"string"},"webpPath":{"type":"string","nullable":true},"encodedVideoPath":{"type":"string","nullable":true},"exifInfo":{"$ref":"#/components/schemas/ExifResponseDto"},"smartInfo":{"$ref":"#/components/schemas/SmartInfoResponseDto"}},"required":["type","id","deviceAssetId","ownerId","deviceId","originalPath","resizePath","createdAt","modifiedAt","isFavorite","mimeType","duration","webpPath","encodedVideoPath"]},"TimeGroupEnum":{"type":"string","enum":["day","month"]},"GetAssetCountByTimeBucketDto":{"type":"object","properties":{"timeGroup":{"$ref":"#/components/schemas/TimeGroupEnum"}},"required":["timeGroup"]},"AssetCountByTimeBucket":{"type":"object","properties":{"timeBucket":{"type":"string"},"count":{"type":"integer"}},"required":["timeBucket","count"]},"AssetCountByTimeBucketResponseDto":{"type":"object","properties":{"totalCount":{"type":"integer"},"buckets":{"type":"array","items":{"$ref":"#/components/schemas/AssetCountByTimeBucket"}}},"required":["totalCount","buckets"]},"AssetCountByUserIdResponseDto":{"type":"object","properties":{"photos":{"type":"integer"},"videos":{"type":"integer"}},"required":["photos","videos"]},"GetAssetByTimeBucketDto":{"type":"object","properties":{"timeBucket":{"title":"Array of date time buckets","example":["2015-06-01T00:00:00.000Z","2016-02-01T00:00:00.000Z","2016-03-01T00:00:00.000Z"],"type":"array","items":{"type":"string"}}},"required":["timeBucket"]},"UpdateAssetDto":{"type":"object","properties":{"isFavorite":{"type":"boolean"}},"required":["isFavorite"]},"DeleteAssetDto":{"type":"object","properties":{"ids":{"title":"Array of asset IDs to delete","example":["bf973405-3f2a-48d2-a687-2ed4167164be","dd41870b-5d00-46d2-924e-1d8489a0aa0f","fad77c3f-deef-4e7e-9608-14c1aa4e559a"],"type":"array","items":{"type":"string"}}},"required":["ids"]},"DeleteAssetStatus":{"type":"string","enum":["SUCCESS","FAILED"]},"DeleteAssetResponseDto":{"type":"object","properties":{"status":{"$ref":"#/components/schemas/DeleteAssetStatus"},"id":{"type":"string"}},"required":["status","id"]},"CheckDuplicateAssetDto":{"type":"object","properties":{"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["deviceAssetId","deviceId"]},"CheckDuplicateAssetResponseDto":{"type":"object","properties":{"isExist":{"type":"boolean"},"id":{"type":"string"}},"required":["isExist"]},"CheckExistingAssetsDto":{"type":"object","properties":{"deviceAssetIds":{"type":"array","items":{"type":"string"}},"deviceId":{"type":"string"}},"required":["deviceAssetIds","deviceId"]},"CheckExistingAssetsResponseDto":{"type":"object","properties":{"existingIds":{"type":"array","items":{"type":"string"}}},"required":["existingIds"]},"LoginCredentialDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"}},"required":["email","password"]},"LoginResponseDto":{"type":"object","properties":{"accessToken":{"type":"string","readOnly":true},"userId":{"type":"string","readOnly":true},"userEmail":{"type":"string","readOnly":true},"firstName":{"type":"string","readOnly":true},"lastName":{"type":"string","readOnly":true},"profileImagePath":{"type":"string","readOnly":true},"isAdmin":{"type":"boolean","readOnly":true},"shouldChangePassword":{"type":"boolean","readOnly":true}},"required":["accessToken","userId","userEmail","firstName","lastName","profileImagePath","isAdmin","shouldChangePassword"]},"SignUpDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"},"firstName":{"type":"string","example":"Admin"},"lastName":{"type":"string","example":"Doe"}},"required":["email","password","firstName","lastName"]},"AdminSignupResponseDto":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"createdAt":{"type":"string"}},"required":["id","email","firstName","lastName","createdAt"]},"ValidateAccessTokenResponseDto":{"type":"object","properties":{"authStatus":{"type":"boolean"}},"required":["authStatus"]},"LogoutResponseDto":{"type":"object","properties":{"successful":{"type":"boolean","readOnly":true},"redirectUri":{"type":"string","readOnly":true}},"required":["successful","redirectUri"]},"OAuthConfigDto":{"type":"object","properties":{"redirectUri":{"type":"string"}},"required":["redirectUri"]},"OAuthConfigResponseDto":{"type":"object","properties":{"enabled":{"type":"boolean","readOnly":true},"url":{"type":"string","readOnly":true},"buttonText":{"type":"string","readOnly":true}},"required":["enabled"]},"OAuthCallbackDto":{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]},"DeviceTypeEnum":{"type":"string","enum":["IOS","ANDROID","WEB"]},"CreateDeviceInfoDto":{"type":"object","properties":{"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"deviceId":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["deviceType","deviceId"]},"DeviceInfoResponseDto":{"type":"object","properties":{"id":{"type":"integer"},"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"userId":{"type":"string"},"deviceId":{"type":"string"},"createdAt":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["id","deviceType","userId","deviceId","createdAt","isAutoBackup"]},"UpdateDeviceInfoDto":{"type":"object","properties":{"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"deviceId":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["deviceType","deviceId"]},"ServerInfoResponseDto":{"type":"object","properties":{"diskSizeRaw":{"type":"integer","format":"int64"},"diskUseRaw":{"type":"integer","format":"int64"},"diskAvailableRaw":{"type":"integer","format":"int64"},"diskUsagePercentage":{"type":"number","format":"float"},"diskSize":{"type":"string"},"diskUse":{"type":"string"},"diskAvailable":{"type":"string"}},"required":["diskSizeRaw","diskUseRaw","diskAvailableRaw","diskUsagePercentage","diskSize","diskUse","diskAvailable"]},"ServerPingResponse":{"type":"object","properties":{"res":{"type":"string","readOnly":true,"example":"pong"}},"required":["res"]},"ServerVersionReponseDto":{"type":"object","properties":{"major":{"type":"integer"},"minor":{"type":"integer"},"patch":{"type":"integer"},"build":{"type":"integer"}},"required":["major","minor","patch","build"]},"UsageByUserDto":{"type":"object","properties":{"userId":{"type":"string"},"objects":{"type":"integer"},"videos":{"type":"integer"},"photos":{"type":"integer"},"usageRaw":{"type":"integer","format":"int64"},"usage":{"type":"string"}},"required":["userId","objects","videos","photos","usageRaw","usage"]},"ServerStatsResponseDto":{"type":"object","properties":{"photos":{"type":"integer"},"videos":{"type":"integer"},"objects":{"type":"integer"},"usageRaw":{"type":"integer","format":"int64"},"usage":{"type":"string"},"usageByUser":{"title":"Array of usage for each user","example":[{"photos":1,"videos":1,"objects":1,"diskUsageRaw":1}],"type":"array","items":{"$ref":"#/components/schemas/UsageByUserDto"}}},"required":["photos","videos","objects","usageRaw","usage","usageByUser"]},"AlbumCountResponseDto":{"type":"object","properties":{"owned":{"type":"integer"},"shared":{"type":"integer"},"sharing":{"type":"integer"}},"required":["owned","shared","sharing"]},"CreateAlbumDto":{"type":"object","properties":{"albumName":{"type":"string"},"sharedWithUserIds":{"type":"array","items":{"type":"string"}},"assetIds":{"type":"array","items":{"type":"string"}}},"required":["albumName"]},"AlbumResponseDto":{"type":"object","properties":{"assetCount":{"type":"integer"},"id":{"type":"string"},"ownerId":{"type":"string"},"albumName":{"type":"string"},"createdAt":{"type":"string"},"albumThumbnailAssetId":{"type":"string","nullable":true},"shared":{"type":"boolean"},"sharedUsers":{"type":"array","items":{"$ref":"#/components/schemas/UserResponseDto"}},"assets":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}},"required":["assetCount","id","ownerId","albumName","createdAt","albumThumbnailAssetId","shared","sharedUsers","assets"]},"AddUsersDto":{"type":"object","properties":{"sharedUserIds":{"type":"array","items":{"type":"string"}}},"required":["sharedUserIds"]},"AddAssetsDto":{"type":"object","properties":{"assetIds":{"type":"array","items":{"type":"string"}}},"required":["assetIds"]},"AddAssetsResponseDto":{"type":"object","properties":{"successfullyAdded":{"type":"integer"},"alreadyInAlbum":{"type":"array","items":{"type":"string"}},"album":{"$ref":"#/components/schemas/AlbumResponseDto"}},"required":["successfullyAdded","alreadyInAlbum"]},"RemoveAssetsDto":{"type":"object","properties":{"assetIds":{"type":"array","items":{"type":"string"}}},"required":["assetIds"]},"UpdateAlbumDto":{"type":"object","properties":{"albumName":{"type":"string"},"albumThumbnailAssetId":{"type":"string"}}},"JobCounts":{"type":"object","properties":{"active":{"type":"integer"},"completed":{"type":"integer"},"failed":{"type":"integer"},"delayed":{"type":"integer"},"waiting":{"type":"integer"}},"required":["active","completed","failed","delayed","waiting"]},"AllJobStatusResponseDto":{"type":"object","properties":{"thumbnailGenerationQueueCount":{"$ref":"#/components/schemas/JobCounts"},"metadataExtractionQueueCount":{"$ref":"#/components/schemas/JobCounts"},"videoConversionQueueCount":{"$ref":"#/components/schemas/JobCounts"},"machineLearningQueueCount":{"$ref":"#/components/schemas/JobCounts"},"isThumbnailGenerationActive":{"type":"boolean"},"isMetadataExtractionActive":{"type":"boolean"},"isVideoConversionActive":{"type":"boolean"},"isMachineLearningActive":{"type":"boolean"}},"required":["thumbnailGenerationQueueCount","metadataExtractionQueueCount","videoConversionQueueCount","machineLearningQueueCount","isThumbnailGenerationActive","isMetadataExtractionActive","isVideoConversionActive","isMachineLearningActive"]},"JobId":{"type":"string","enum":["thumbnail-generation","metadata-extraction","video-conversion","machine-learning"]},"JobStatusResponseDto":{"type":"object","properties":{"isActive":{"type":"boolean"},"queueCount":{"type":"object"}},"required":["isActive","queueCount"]},"JobCommand":{"type":"string","enum":["start","stop"]},"JobCommandDto":{"type":"object","properties":{"command":{"$ref":"#/components/schemas/JobCommand"}},"required":["command"]}}}}
\ No newline at end of file
diff --git a/server/libs/common/src/config/app.config.ts b/server/libs/common/src/config/app.config.ts
index 44f58975bf..944fd10bca 100644
--- a/server/libs/common/src/config/app.config.ts
+++ b/server/libs/common/src/config/app.config.ts
@@ -16,6 +16,12 @@ const jwtSecretValidator: Joi.CustomValidator<string> = (value) => {
   return value;
 };
 
+const WHEN_OAUTH_ENABLED = Joi.when('OAUTH_ENABLED', {
+  is: true,
+  then: Joi.string().required(),
+  otherwise: Joi.string().optional(),
+});
+
 export const immichAppConfig: ConfigModuleOptions = {
   envFilePath: '.env',
   isGlobal: true,
@@ -28,5 +34,12 @@ export const immichAppConfig: ConfigModuleOptions = {
     DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false),
     REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0, 1, 2, 3).default(3),
     LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'),
+    OAUTH_ENABLED: Joi.bool().valid(true, false).default(false),
+    OAUTH_BUTTON_TEXT: Joi.string().optional().default('Login with OAuth'),
+    OAUTH_AUTO_REGISTER: Joi.bool().valid(true, false).default(true),
+    OAUTH_ISSUER_URL: WHEN_OAUTH_ENABLED,
+    OAUTH_SCOPE: Joi.string().optional().default('openid email profile'),
+    OAUTH_CLIENT_ID: WHEN_OAUTH_ENABLED,
+    OAUTH_CLIENT_SECRET: WHEN_OAUTH_ENABLED,
   }),
 };
diff --git a/server/libs/database/src/entities/user.entity.ts b/server/libs/database/src/entities/user.entity.ts
index 649f226afe..d0f0b50c7d 100644
--- a/server/libs/database/src/entities/user.entity.ts
+++ b/server/libs/database/src/entities/user.entity.ts
@@ -1,4 +1,4 @@
-import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, DeleteDateColumn } from 'typeorm';
+import { Column, CreateDateColumn, DeleteDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
 
 @Entity('users')
 export class UserEntity {
@@ -17,10 +17,10 @@ export class UserEntity {
   @Column()
   email!: string;
 
-  @Column({ select: false })
+  @Column({ default: '', select: false })
   password?: string;
 
-  @Column({ select: false })
+  @Column({ default: '', select: false })
   salt?: string;
 
   @Column({ default: '' })
diff --git a/server/package-lock.json b/server/package-lock.json
index eea4bc2939..0d6da043a9 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -41,6 +41,7 @@
         "lodash": "^4.17.21",
         "luxon": "^3.0.3",
         "nest-commander": "^3.3.0",
+        "openid-client": "^5.2.1",
         "passport": "^0.6.0",
         "passport-jwt": "^4.0.0",
         "pg": "^8.7.1",
@@ -7449,6 +7450,14 @@
         "@sideway/pinpoint": "^2.0.0"
       }
     },
+    "node_modules/jose": {
+      "version": "4.10.3",
+      "resolved": "https://registry.npmjs.org/jose/-/jose-4.10.3.tgz",
+      "integrity": "sha512-3S4wQnaoJKSAx9uHSoyf8B/lxjs1qCntHWL6wNFszJazo+FtWe+qD0zVfY0BlqJ5HHK4jcnM98k3BQzVLbzE4g==",
+      "funding": {
+        "url": "https://github.com/sponsors/panva"
+      }
+    },
     "node_modules/js-tokens": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -8439,6 +8448,14 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/oidc-token-hash": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.1.tgz",
+      "integrity": "sha512-EvoOtz6FIEBzE+9q253HsLCVRiK/0doEJ2HCvvqMQb3dHZrP3WlJKYtJ55CRTw4jmYomzH4wkPuCj/I3ZvpKxQ==",
+      "engines": {
+        "node": "^10.13.0 || >=12.0.0"
+      }
+    },
     "node_modules/on-finished": {
       "version": "2.4.1",
       "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -8472,6 +8489,28 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/openid-client": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.2.1.tgz",
+      "integrity": "sha512-KPxqWnxobG/70Cxqyvd43RWfCfHedFnCdHSBpw5f7WnTnuBAeBnvot/BIo+brrcTr0wyAYUlL/qejQSGwWtdIg==",
+      "dependencies": {
+        "jose": "^4.10.0",
+        "lru-cache": "^6.0.0",
+        "object-hash": "^2.0.1",
+        "oidc-token-hash": "^5.0.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/panva"
+      }
+    },
+    "node_modules/openid-client/node_modules/object-hash": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
+      "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/optionator": {
       "version": "0.9.1",
       "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
@@ -17131,6 +17170,11 @@
         "@sideway/pinpoint": "^2.0.0"
       }
     },
+    "jose": {
+      "version": "4.10.3",
+      "resolved": "https://registry.npmjs.org/jose/-/jose-4.10.3.tgz",
+      "integrity": "sha512-3S4wQnaoJKSAx9uHSoyf8B/lxjs1qCntHWL6wNFszJazo+FtWe+qD0zVfY0BlqJ5HHK4jcnM98k3BQzVLbzE4g=="
+    },
     "js-tokens": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -17939,6 +17983,11 @@
       "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz",
       "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g=="
     },
+    "oidc-token-hash": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.1.tgz",
+      "integrity": "sha512-EvoOtz6FIEBzE+9q253HsLCVRiK/0doEJ2HCvvqMQb3dHZrP3WlJKYtJ55CRTw4jmYomzH4wkPuCj/I3ZvpKxQ=="
+    },
     "on-finished": {
       "version": "2.4.1",
       "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -17963,6 +18012,24 @@
         "mimic-fn": "^2.1.0"
       }
     },
+    "openid-client": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.2.1.tgz",
+      "integrity": "sha512-KPxqWnxobG/70Cxqyvd43RWfCfHedFnCdHSBpw5f7WnTnuBAeBnvot/BIo+brrcTr0wyAYUlL/qejQSGwWtdIg==",
+      "requires": {
+        "jose": "^4.10.0",
+        "lru-cache": "^6.0.0",
+        "object-hash": "^2.0.1",
+        "oidc-token-hash": "^5.0.1"
+      },
+      "dependencies": {
+        "object-hash": {
+          "version": "2.2.0",
+          "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
+          "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="
+        }
+      }
+    },
     "optionator": {
       "version": "0.9.1",
       "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
diff --git a/server/package.json b/server/package.json
index 95d7f6415b..ea0398f0c4 100644
--- a/server/package.json
+++ b/server/package.json
@@ -62,6 +62,7 @@
     "local-reverse-geocoder": "^0.12.5",
     "lodash": "^4.17.21",
     "luxon": "^3.0.3",
+    "openid-client": "^5.2.1",
     "nest-commander": "^3.3.0",
     "passport": "^0.6.0",
     "passport-jwt": "^4.0.0",
diff --git a/web/src/api/api.ts b/web/src/api/api.ts
index 237a47c776..e98ba36da3 100644
--- a/web/src/api/api.ts
+++ b/web/src/api/api.ts
@@ -6,6 +6,7 @@ import {
 	Configuration,
 	DeviceInfoApi,
 	JobApi,
+	OAuthApi,
 	ServerInfoApi,
 	UserApi
 } from './open-api';
@@ -15,6 +16,7 @@ class ImmichApi {
 	public albumApi: AlbumApi;
 	public assetApi: AssetApi;
 	public authenticationApi: AuthenticationApi;
+	public oauthApi: OAuthApi;
 	public deviceInfoApi: DeviceInfoApi;
 	public serverInfoApi: ServerInfoApi;
 	public jobApi: JobApi;
@@ -26,6 +28,7 @@ class ImmichApi {
 		this.albumApi = new AlbumApi(this.config);
 		this.assetApi = new AssetApi(this.config);
 		this.authenticationApi = new AuthenticationApi(this.config);
+		this.oauthApi = new OAuthApi(this.config);
 		this.deviceInfoApi = new DeviceInfoApi(this.config);
 		this.serverInfoApi = new ServerInfoApi(this.config);
 		this.jobApi = new JobApi(this.config);
diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts
index 9dffce492a..209618f461 100644
--- a/web/src/api/open-api/api.ts
+++ b/web/src/api/open-api/api.ts
@@ -1125,6 +1125,63 @@ export interface LogoutResponseDto {
      * @memberof LogoutResponseDto
      */
     'successful': boolean;
+    /**
+     * 
+     * @type {string}
+     * @memberof LogoutResponseDto
+     */
+    'redirectUri': string;
+}
+/**
+ * 
+ * @export
+ * @interface OAuthCallbackDto
+ */
+export interface OAuthCallbackDto {
+    /**
+     * 
+     * @type {string}
+     * @memberof OAuthCallbackDto
+     */
+    'url': string;
+}
+/**
+ * 
+ * @export
+ * @interface OAuthConfigDto
+ */
+export interface OAuthConfigDto {
+    /**
+     * 
+     * @type {string}
+     * @memberof OAuthConfigDto
+     */
+    'redirectUri': string;
+}
+/**
+ * 
+ * @export
+ * @interface OAuthConfigResponseDto
+ */
+export interface OAuthConfigResponseDto {
+    /**
+     * 
+     * @type {boolean}
+     * @memberof OAuthConfigResponseDto
+     */
+    'enabled': boolean;
+    /**
+     * 
+     * @type {string}
+     * @memberof OAuthConfigResponseDto
+     */
+    'url'?: string;
+    /**
+     * 
+     * @type {string}
+     * @memberof OAuthConfigResponseDto
+     */
+    'buttonText'?: string;
 }
 /**
  * 
@@ -4459,6 +4516,174 @@ export class JobApi extends BaseAPI {
 }
 
 
+/**
+ * OAuthApi - axios parameter creator
+ * @export
+ */
+export const OAuthApiAxiosParamCreator = function (configuration?: Configuration) {
+    return {
+        /**
+         * 
+         * @param {OAuthCallbackDto} oAuthCallbackDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        callback: async (oAuthCallbackDto: OAuthCallbackDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'oAuthCallbackDto' is not null or undefined
+            assertParamExists('callback', 'oAuthCallbackDto', oAuthCallbackDto)
+            const localVarPath = `/oauth/callback`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+
+    
+            localVarHeaderParameter['Content-Type'] = 'application/json';
+
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+            localVarRequestOptions.data = serializeDataIfNeeded(oAuthCallbackDto, localVarRequestOptions, configuration)
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {OAuthConfigDto} oAuthConfigDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        generateConfig: async (oAuthConfigDto: OAuthConfigDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'oAuthConfigDto' is not null or undefined
+            assertParamExists('generateConfig', 'oAuthConfigDto', oAuthConfigDto)
+            const localVarPath = `/oauth/config`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+
+    
+            localVarHeaderParameter['Content-Type'] = 'application/json';
+
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+            localVarRequestOptions.data = serializeDataIfNeeded(oAuthConfigDto, localVarRequestOptions, configuration)
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+    }
+};
+
+/**
+ * OAuthApi - functional programming interface
+ * @export
+ */
+export const OAuthApiFp = function(configuration?: Configuration) {
+    const localVarAxiosParamCreator = OAuthApiAxiosParamCreator(configuration)
+    return {
+        /**
+         * 
+         * @param {OAuthCallbackDto} oAuthCallbackDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async callback(oAuthCallbackDto: OAuthCallbackDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<LoginResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.callback(oAuthCallbackDto, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
+        /**
+         * 
+         * @param {OAuthConfigDto} oAuthConfigDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async generateConfig(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<OAuthConfigResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.generateConfig(oAuthConfigDto, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
+    }
+};
+
+/**
+ * OAuthApi - factory interface
+ * @export
+ */
+export const OAuthApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
+    const localVarFp = OAuthApiFp(configuration)
+    return {
+        /**
+         * 
+         * @param {OAuthCallbackDto} oAuthCallbackDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        callback(oAuthCallbackDto: OAuthCallbackDto, options?: any): AxiosPromise<LoginResponseDto> {
+            return localVarFp.callback(oAuthCallbackDto, options).then((request) => request(axios, basePath));
+        },
+        /**
+         * 
+         * @param {OAuthConfigDto} oAuthConfigDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        generateConfig(oAuthConfigDto: OAuthConfigDto, options?: any): AxiosPromise<OAuthConfigResponseDto> {
+            return localVarFp.generateConfig(oAuthConfigDto, options).then((request) => request(axios, basePath));
+        },
+    };
+};
+
+/**
+ * OAuthApi - object-oriented interface
+ * @export
+ * @class OAuthApi
+ * @extends {BaseAPI}
+ */
+export class OAuthApi extends BaseAPI {
+    /**
+     * 
+     * @param {OAuthCallbackDto} oAuthCallbackDto 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof OAuthApi
+     */
+    public callback(oAuthCallbackDto: OAuthCallbackDto, options?: AxiosRequestConfig) {
+        return OAuthApiFp(this.configuration).callback(oAuthCallbackDto, options).then((request) => request(this.axios, this.basePath));
+    }
+
+    /**
+     * 
+     * @param {OAuthConfigDto} oAuthConfigDto 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof OAuthApi
+     */
+    public generateConfig(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig) {
+        return OAuthApiFp(this.configuration).generateConfig(oAuthConfigDto, options).then((request) => request(this.axios, this.basePath));
+    }
+}
+
+
 /**
  * ServerInfoApi - axios parameter creator
  * @export
diff --git a/web/src/lib/components/forms/login-form.svelte b/web/src/lib/components/forms/login-form.svelte
index 299f3dad35..7ba30cb2fd 100644
--- a/web/src/lib/components/forms/login-form.svelte
+++ b/web/src/lib/components/forms/login-form.svelte
@@ -1,17 +1,49 @@
 <script lang="ts">
+	import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
 	import { loginPageMessage } from '$lib/constants';
-	import { api } from '@api';
-	import { createEventDispatcher } from 'svelte';
+	import { api, OAuthConfigResponseDto } from '@api';
+	import { createEventDispatcher, onMount } from 'svelte';
 
 	let error: string;
 	let email = '';
 	let password = '';
+	let oauthError: string;
+	let oauthConfig: OAuthConfigResponseDto = { enabled: false };
+	let loading = true;
 
 	const dispatch = createEventDispatcher();
 
+	onMount(async () => {
+		const search = window.location.search;
+		if (search.includes('code=') || search.includes('error=')) {
+			try {
+				loading = true;
+				await api.oauthApi.callback({ url: window.location.href });
+				dispatch('success');
+				return;
+			} catch (e) {
+				console.error('Error [login-form] [oauth.callback]', e);
+				oauthError = 'Unable to complete OAuth login';
+				loading = false;
+			}
+		}
+
+		try {
+			const redirectUri = window.location.href.split('?')[0];
+			console.log(`OAuth Redirect URI: ${redirectUri}`);
+			const { data } = await api.oauthApi.generateConfig({ redirectUri });
+			oauthConfig = data;
+		} catch (e) {
+			console.error('Error [login-form] [oauth.generateConfig]', e);
+		}
+
+		loading = false;
+	});
+
 	const login = async () => {
 		try {
 			error = '';
+			loading = true;
 
 			const { data } = await api.authenticationApi.login({
 				email,
@@ -27,6 +59,7 @@
 			return;
 		} catch (e) {
 			error = 'Incorrect email or password';
+			loading = false;
 			return;
 		}
 	};
@@ -48,41 +81,65 @@
 		</p>
 	{/if}
 
-	<form on:submit|preventDefault={login} autocomplete="off">
-		<div class="m-4 flex flex-col gap-2">
-			<label class="immich-form-label" for="email">Email</label>
-			<input
-				class="immich-form-input"
-				id="email"
-				name="email"
-				type="email"
-				bind:value={email}
-				required
-			/>
+	{#if loading}
+		<div class="flex place-items-center place-content-center">
+			<LoadingSpinner />
 		</div>
+	{:else}
+		<form on:submit|preventDefault={login} autocomplete="off">
+			<div class="m-4 flex flex-col gap-2">
+				<label class="immich-form-label" for="email">Email</label>
+				<input
+					class="immich-form-input"
+					id="email"
+					name="email"
+					type="email"
+					bind:value={email}
+					required
+				/>
+			</div>
 
-		<div class="m-4 flex flex-col gap-2">
-			<label class="immich-form-label" for="password">Password</label>
-			<input
-				class="immich-form-input"
-				id="password"
-				name="password"
-				type="password"
-				bind:value={password}
-				required
-			/>
-		</div>
+			<div class="m-4 flex flex-col gap-2">
+				<label class="immich-form-label" for="password">Password</label>
+				<input
+					class="immich-form-input"
+					id="password"
+					name="password"
+					type="password"
+					bind:value={password}
+					required
+				/>
+			</div>
 
-		{#if error}
-			<p class="text-red-400 pl-4">{error}</p>
-		{/if}
+			{#if error}
+				<p class="text-red-400 pl-4">{error}</p>
+			{/if}
 
-		<div class="flex w-full">
-			<button
-				type="submit"
-				class="m-4 p-2 bg-immich-primary dark:bg-immich-dark-primary dark:text-immich-dark-gray dark:hover:bg-immich-dark-primary/80 hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full font-semibold"
-				>Login</button
-			>
-		</div>
-	</form>
+			<div class="flex w-full">
+				<button
+					type="submit"
+					disabled={loading}
+					class="m-4 p-2 bg-immich-primary dark:bg-immich-dark-primary dark:text-immich-dark-gray dark:hover:bg-immich-dark-primary/80 hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full font-semibold"
+					>Login</button
+				>
+			</div>
+
+			{#if oauthConfig.enabled}
+				<div class="flex flex-col gap-4 px-4">
+					<hr />
+					{#if oauthError}
+						<p class="text-red-400">{oauthError}</p>
+					{/if}
+					<a href={oauthConfig.url} class="flex w-full">
+						<button
+							type="button"
+							disabled={loading}
+							class="bg-immich-primary dark:bg-immich-dark-primary dark:text-immich-dark-gray dark:hover:bg-immich-dark-primary/80 hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full font-semibold"
+							>{oauthConfig.buttonText || 'Login with OAuth'}</button
+						>
+					</a>
+				</div>
+			{/if}
+		</form>
+	{/if}
 </div>
diff --git a/web/src/lib/components/shared-components/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar.svelte
index a10425f075..9f3a78c4e0 100644
--- a/web/src/lib/components/shared-components/navigation-bar.svelte
+++ b/web/src/lib/components/shared-components/navigation-bar.svelte
@@ -38,8 +38,11 @@
 	};
 
 	const logOut = async () => {
+		const { data } = await api.authenticationApi.logout();
+
 		await fetch('auth/logout', { method: 'POST' });
-		goto('/auth/login');
+
+		goto(data.redirectUri || '/auth/login');
 	};
 </script>
 
diff --git a/web/src/routes/auth/logout/+server.ts b/web/src/routes/auth/logout/+server.ts
index c3e4c396ed..4b82fbdd8c 100644
--- a/web/src/routes/auth/logout/+server.ts
+++ b/web/src/routes/auth/logout/+server.ts
@@ -10,7 +10,7 @@ export const POST: RequestHandler = async () => {
 
 	headers.append(
 		'set-cookie',
-		'immich_is_authenticated=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT;'
+		'immich_auth_type=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT;'
 	);
 	headers.append(
 		'set-cookie',