From d3c35ec9c5a158febd8622d2d05d647e6b9095d7 Mon Sep 17 00:00:00 2001
From: Jason Rasmussen <jrasm91@gmail.com>
Date: Mon, 14 Nov 2022 21:24:25 -0500
Subject: [PATCH] feat(server,web): OIDC Implementation (#884)

* chore: merge

* feat: nullable password

* feat: server debugger

* chore: regenerate api

* feat: auto-register flag

* refactor: oauth endpoints

* chore: regenerate api

* fix: default scope configuration

* refactor: pass in redirect uri from client

* chore: docs

* fix: bugs

* refactor: auth services and user repository

* fix: select password

* fix: tests

* fix: get signing algorithm from discovery document

* refactor: cookie constants

* feat: oauth logout

* test: auth services

* fix: query param check

* fix: regenerate open-api
---
 docs/docs/usage/oauth.md                      |  68 ++++++
 mobile/openapi/.openapi-generator/FILES       |   8 +
 mobile/openapi/README.md                      | Bin 10095 -> 10427 bytes
 mobile/openapi/doc/LogoutResponseDto.md       | Bin 426 -> 472 bytes
 mobile/openapi/doc/OAuthApi.md                | Bin 0 -> 2311 bytes
 mobile/openapi/doc/OAuthCallbackDto.md        | Bin 0 -> 409 bytes
 mobile/openapi/doc/OAuthConfigDto.md          | Bin 0 -> 415 bytes
 mobile/openapi/doc/OAuthConfigResponseDto.md  | Bin 0 -> 533 bytes
 mobile/openapi/lib/api.dart                   | Bin 3586 -> 3736 bytes
 mobile/openapi/lib/api/o_auth_api.dart        | Bin 0 -> 3715 bytes
 mobile/openapi/lib/api_client.dart            | Bin 14226 -> 14486 bytes
 .../lib/model/logout_response_dto.dart        | Bin 3418 -> 3698 bytes
 .../lib/model/o_auth_callback_dto.dart        | Bin 0 -> 3318 bytes
 .../openapi/lib/model/o_auth_config_dto.dart  | Bin 0 -> 3374 bytes
 .../lib/model/o_auth_config_response_dto.dart | Bin 0 -> 4784 bytes
 mobile/openapi/test/o_auth_api_test.dart      | Bin 0 -> 709 bytes
 .../test/o_auth_callback_dto_test.dart        | Bin 0 -> 562 bytes
 .../openapi/test/o_auth_config_dto_test.dart  | Bin 0 -> 572 bytes
 .../test/o_auth_config_response_dto_test.dart | Bin 0 -> 677 bytes
 .../immich/src/api-v1/auth/auth.controller.ts |  36 ++-
 .../immich/src/api-v1/auth/auth.module.ts     |  15 +-
 .../src/api-v1/auth/auth.service.spec.ts      | 147 ++++++++++++
 .../immich/src/api-v1/auth/auth.service.ts    | 118 ++++-----
 .../auth/response-dto/logout-response.dto.ts  |   3 +
 .../communication/communication.gateway.ts    |   6 +-
 .../api-v1/oauth/dto/oauth-auth-code.dto.ts   |   9 +
 .../src/api-v1/oauth/dto/oauth-config.dto.ts  |   9 +
 .../src/api-v1/oauth/oauth.controller.ts      |  27 +++
 .../immich/src/api-v1/oauth/oauth.module.ts   |  13 +
 .../src/api-v1/oauth/oauth.service.spec.ts    | 169 +++++++++++++
 .../immich/src/api-v1/oauth/oauth.service.ts  | 108 +++++++++
 .../response-dto/oauth-config-response.dto.ts |  12 +
 .../immich/src/api-v1/user/user-repository.ts |  79 +++---
 .../immich/src/api-v1/user/user.module.ts     |  27 +--
 .../src/api-v1/user/user.service.spec.ts      |  12 +-
 .../immich/src/api-v1/user/user.service.ts    |  12 +-
 server/apps/immich/src/app.module.ts          |   2 +
 .../apps/immich/src/constants/jwt.constant.ts |   6 +
 .../immich-jwt/immich-jwt.service.spec.ts     |  91 +++++--
 .../modules/immich-jwt/immich-jwt.service.ts  |  36 ++-
 server/apps/immich/test/test-utils.ts         |  16 +-
 server/immich-openapi-specs.json              |   2 +-
 server/libs/common/src/config/app.config.ts   |  13 +
 .../libs/database/src/entities/user.entity.ts |   6 +-
 server/package-lock.json                      |  67 ++++++
 server/package.json                           |   1 +
 web/src/api/api.ts                            |   3 +
 web/src/api/open-api/api.ts                   | 225 ++++++++++++++++++
 .../lib/components/forms/login-form.svelte    | 127 +++++++---
 .../shared-components/navigation-bar.svelte   |   5 +-
 web/src/routes/auth/logout/+server.ts         |   2 +-
 51 files changed, 1230 insertions(+), 250 deletions(-)
 create mode 100644 docs/docs/usage/oauth.md
 create mode 100644 mobile/openapi/doc/OAuthApi.md
 create mode 100644 mobile/openapi/doc/OAuthCallbackDto.md
 create mode 100644 mobile/openapi/doc/OAuthConfigDto.md
 create mode 100644 mobile/openapi/doc/OAuthConfigResponseDto.md
 create mode 100644 mobile/openapi/lib/api/o_auth_api.dart
 create mode 100644 mobile/openapi/lib/model/o_auth_callback_dto.dart
 create mode 100644 mobile/openapi/lib/model/o_auth_config_dto.dart
 create mode 100644 mobile/openapi/lib/model/o_auth_config_response_dto.dart
 create mode 100644 mobile/openapi/test/o_auth_api_test.dart
 create mode 100644 mobile/openapi/test/o_auth_callback_dto_test.dart
 create mode 100644 mobile/openapi/test/o_auth_config_dto_test.dart
 create mode 100644 mobile/openapi/test/o_auth_config_response_dto_test.dart
 create mode 100644 server/apps/immich/src/api-v1/auth/auth.service.spec.ts
 create mode 100644 server/apps/immich/src/api-v1/oauth/dto/oauth-auth-code.dto.ts
 create mode 100644 server/apps/immich/src/api-v1/oauth/dto/oauth-config.dto.ts
 create mode 100644 server/apps/immich/src/api-v1/oauth/oauth.controller.ts
 create mode 100644 server/apps/immich/src/api-v1/oauth/oauth.module.ts
 create mode 100644 server/apps/immich/src/api-v1/oauth/oauth.service.spec.ts
 create mode 100644 server/apps/immich/src/api-v1/oauth/oauth.service.ts
 create mode 100644 server/apps/immich/src/api-v1/oauth/response-dto/oauth-config-response.dto.ts

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 279e54cfe0ca307c46b3f23d3cddee91747d105e..dd6f5105358b03d1c0c7e557e3280f351805fef3 100644
GIT binary patch
delta 336
zcmaFww>xmdE75v?$I_Av$AU~Pg&KuuEv@9loSdY@<ZLaiSdEnYWPN>@yk2gKGE7nv
zs8&lWz&|)dOG`mNKM`nxK1>cM#ia$)1T{20H7~U&u_V<wKQApa9mQw^kli4&VQP}W
zYOvY>mYFOkF1h)iXfrRw<<3y|x|HN2I~YkAXt*Yqg04a|*ijHCpsNO(3>3tsHYl~Y
XAV04-6<rT2#5suyBAXkP-!TIKp5${w

delta 21
dcmdlT_}*{BE78ei;u4#;iPiINKCAMM831#E3DE!m

diff --git a/mobile/openapi/doc/LogoutResponseDto.md b/mobile/openapi/doc/LogoutResponseDto.md
index 0430349ca0a1198065cfd02f8decb627e957fe5d..f1b22a889d5d1058a0f4d94b043ebff18d2c2caf 100644
GIT binary patch
delta 37
scmZ3*e1mxdE2ET_R#9q7W>IQ#NoY}~mX<<|f|gcrNl|8A`oxbr0pxTIMF0Q*

delta 11
Scmcb?yoz}PE8}Ei#-#ul0t2f6

diff --git a/mobile/openapi/doc/OAuthApi.md b/mobile/openapi/doc/OAuthApi.md
new file mode 100644
index 0000000000000000000000000000000000000000..255f71db4fd94f7f6d55bc7cab3847930920e1d3
GIT binary patch
literal 2311
zcmeHI-*3|}5PtVxaZ-`mT1xZ2ib-Xw#3Z&=r9731ndH_QQrkE#Y|#DhoSnE!qQW)_
zAs&cSwYeYP`R=~Y?SVF^gpnivxtwg>dSWDFy&k;LA_uodn9R<=i0rdiA-lf5&V_YM
zZj81L`nCP2MoB%259p75C}WeNfOqq=5`;ytSO_P-A~+4)hjPZw(XDk3J9xcV%qZf^
z7E4F3r&wlI8Yi`4N%J9J^EK(dvnA)5D2k7CV$QDyv8T}{??wBNk~p7TzFlw*sTS0A
z8hzyQ5Gzz@g~JoAZse*96unvS9)t@X4@SeJViBHmc1)vPNj1mPXjS4VO(S{-+Hc^b
zK0bMdlupa&!Sq&aO!2G*up6mF0W{|iQk71q4B;ijc?^c*I-pxK1~raB!H}j^Zo?ef
zbM_8?FrcTZXR+KCjzF4)N5AfH9CD47$_=VqI@vvq#ARz3;IIa-0{YY23<E3R+Zt6E
zoK&lQSIKcKbObN(z3O|cii&=UGhxLB9omwy3;IP3Ebff|YVZx{^+G!=pGQ2(Y7vvs
z*1Mx6y6u426_(H#<epy*8xeN6^Jl3DFh{qw3LG7md4hI1<RQ8?4^d@%T4^i4lm19h
z>_USl{oWFUCoSexbW4kC6nPjJB-9b<!J%@A9~<XKv}99A+7-d3pGv|GQIpJX>7bu2
zm#;{g{tj^3TwM)%LwGRxyQYw(Bl~ES&Zm>p^Ql*EuCwii>V`tejkXD~D?ybTO}r_a
z5a&9_;)x&|AKS{VN_NWoAyV&A_3=(1C0C0KT5syP9a*b7MEzAw$BKJJM?*``(HN-q
yG&TRezdx+#cCmj**Ku%dRX<qkKc9*&wD?C9eV9uRS3>}9u<XCOAGso)uI`@~tl<>^

literal 0
HcmV?d00001

diff --git a/mobile/openapi/doc/OAuthCallbackDto.md b/mobile/openapi/doc/OAuthCallbackDto.md
new file mode 100644
index 0000000000000000000000000000000000000000..0627ea834b7b10dedf2b0edffbea61f8ded0ad4a
GIT binary patch
literal 409
zcma)&KTpFj5XJZV6endUwPbgPDUc3G{U>0nD#gTZYvK4T=Q1Gi;ju#{3JZ9#-reus
z`ME-hHt6V7PsVz+tw%mTXzyQj_tr20R28mB8|1UaiNTm#`edPPTcZ<$>mwz`*`<Da
zaa<JF0?h6TIyvo9ZYFkifQ{}ge&EY{ELUTPF1g5#LRK5%OvtN~i&sj@zcdIjCND(h
zgm~uU!f6s&1MK(rIRY}_x`amW20k2alv+G3>t?-Fy{Qa!V{ZfN@+5hrB&YDh9KWaQ
Z-Qz!J^H)e4Oe7chhsD>zU(4qJ@CnTEeNq4b

literal 0
HcmV?d00001

diff --git a/mobile/openapi/doc/OAuthConfigDto.md b/mobile/openapi/doc/OAuthConfigDto.md
new file mode 100644
index 0000000000000000000000000000000000000000..0683026ddd2067c692006b4b928b5d3b9bfcdff0
GIT binary patch
literal 415
zcma)&Jx>EM42F0A3QISXMoPNFbZ{MzXsP&^s!A7K+-pQHj&kmR#E&OADp6R#i}g6K
zU&pSHBLy418Q9U$Jk}>U+;Q0Zz7YnfDtut;P!5EX0;BETY){a(t+PqM50R7L>|DP-
z`z?xR5zKCibaL9H{Fubk2x~hKe&EY{EUBTfOFoL{kZH}575XA_@q)Dei$SSG&L`^R
zHF>47Ua4nEKJ*i02H0+I^C~EetFk&C{V2QLl`)H_W!<cnW^k3`?le#kTfV0Eob+^r
eagN{J_2&Mcz4;`Xf{W~f{IK{M_-pta06qZ~-+#&g

literal 0
HcmV?d00001

diff --git a/mobile/openapi/doc/OAuthConfigResponseDto.md b/mobile/openapi/doc/OAuthConfigResponseDto.md
new file mode 100644
index 0000000000000000000000000000000000000000..8d6c3a41e81499b735ead2426e41ed22b7f98bb4
GIT binary patch
literal 533
zcma)3v2MaJ5Z(P1CuN{mO4!>eZ5WUWMU*Xy6cZo8YT~n;i&Uh3e4P-rl%;sF-`#uf
z?B@u`p;gW#Eop3$x3uTe3)!Qoci1_y9cD~IL=n7_(!eK#kqJ&!AF4)CmZesng=rmm
zhS^kqoO~C9_du9E4b;(T>qFDC!A@*ci|_^evm3SyyUH8KhHO9^;=qWnRw!PfIQ|a{
zDZ}Fq4QeR^SIPrXGaduVJt|GMITsLky}vZ1d}nWL{llX1I;tsd1c#oP>>fXP^1e$a
z^87gj2TZUY0*A(QToeyUve+%tY`sic9cili7A>nGi1<i89wYDO@T*R@t2?#f17ft=
Qk+J;8;xh1N_*@9_4b(%YQvd(}

literal 0
HcmV?d00001

diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index 1ff46399b323ea61a96328995c32705002fad988..3415c62afd79a807134010b86aa3c073cd9472d2 100644
GIT binary patch
delta 80
zcmZpYnIXHuo0%;?KC!eUW3wsqF*be(J3cuvCnqT}IeYR)HVIysSbknwW;zxTK@P>u
Izd6}i0Hui?K>z>%

delta 17
YcmbOs+a$BWn|X6O^9Huf6S>$~05?ztegFUf

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 0000000000000000000000000000000000000000..42823338517da089f0d0ea9305d2ed15781f5d15
GIT binary patch
literal 3715
zcmeHJ-)|Z@5Ps*cnD(K-X%BkQJydBEjwYmy)C*ifRh>|@b=X;OyVzUXTS}|^?>DyB
z3rn}DPRdF5kOyErJO1X^H#7A6t$rVdZ>Qs5UyUwCm&57k6wc1yj3PK4!^L<Ce~wSj
z&;EWyYF6`)geh}$(EIJ6WsiIzq{29rDo%NZhfs(@V-jPAD-<RQXTHj%GL1J_wq_e0
z=Y@$Cek!=4>w>N5J7<c14Ju9GN;hAhOQ8)DCguql<S9qxuIol)85gMIOo|1mnI#kP
zv#0TV$&_xj$eaP0g3M8{ocHMSywz&WGp03+hlN=Va}M8HP#romlAL6m3=e?R{o{2P
z4!0*b+Ib!fAi<OsnW-+434oLfmO&-k-f(aGl;{a6O7cnr6YzREoj`jszM4W`GV-fG
zXIXZ`<{#VEdBqHuf)o=_XbL4Xm?gsO5_L|=!s{McbeL6s551(6{{k}??n#MyYUDLY
zn}8h&bV9Yd_V!spmheTeaSXwwcib#r8<U@lRCe}ZzeA0>!1PAUU7D?zCBqIOSC~@G
zT)E=}sVY8BO~yk4b7q#5Z`IyUi%Vk@oJ&0MfxPuV<C_mSr)uU>o0l@#5Zr#`C77?-
zrvlYxViT!J^~GP+@pyLqe8;sVvINyu@yaMJ77_U0<8QaS%C^#Xsybko3ZQR8{F`oa
zbRLv|vo_1~jL#hx`X96uZSu!*4N6vQN!H`yv;2s|$$7`U2e65Z%4bKkb~x?C8$=;h
z;k+AakZmIhl3l-hT#r_{*}woqk!93AwYRSM{k^VD)2$8TKO*oOx!@wOn20~vGWlP#
z*E2+wZD$9;nsL+I8T%xQT%-UzwT<Q*w?~7{6#7Ia2**cYyXwkGdN(Y&q}hqubj!{G
z>UkyCcBYKJ%rUnkp$RA|ow6-OxR&xSHwJa3+btcrb#$PHe78gzE<~c0Gzc2iXH?ao
z5fW}}*Q}}QNy$5Ghw8!8-y>L){*s0Y2x({#T7$ri9Y?M=hsF&m!&hjptQ9xZBP8QG
zg7<du91<_coOw?M2*!s<6Y=p|fnRcJTucCI?$K>7Xrnw1&}Y<9%v70xwGG0kjl-A0
z7eYeEL+!fLJ65ZJ4UX7)A&n>OlP47V_tkk+tB#N-zDc*m@q@X{eYH7y2~s{e%lKCu
z+A-MijQMeKB53b3w(Vvp?2G+;aryA2EM2e-S8uAjH;oi2U)=MaahN+>CY*n^V-_gv
zwnIuUIn}UfDj?rp;dswm#=*AlVaEjj9yW~MeqmSqk2g#xS@?q#&dt>I|9_lLeaF<9
wTyB~Q>rVFmV9V4IYrU}#=SM?r&OL0JhfP!e8$g?;nZ4z{X@2?ht#jM@2W4E)Z2$lO

literal 0
HcmV?d00001

diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart
index 77da9b38d5d9346ebf348171af760fdb80903d41..5e30d68e1d03327d97f3a3c85919368310855bec 100644
GIT binary patch
delta 108
zcmbP~Kdo@X9SvT8$I_Av=fs?xq{QUx$%1m?Xd;P9%G?k&`FUxX=^zy-e02Gb^76>?
Kn>T7a<O2XnD<>QP

delta 12
UcmbPMI4OU_9gWQoG`H~q04nkZJpcdz

diff --git a/mobile/openapi/lib/model/logout_response_dto.dart b/mobile/openapi/lib/model/logout_response_dto.dart
index ac962b4456c1c840b0148677c0d4b899f43df2da..52120aaabb437a95cf63c67150e8a8fc1e0a7f05 100644
GIT binary patch
delta 295
zcmca5^+{&KVn%5Ng`(8L(#)dN6orzE%wjzt1IS4(2`$Q;JfBfsD7d63GcO&e2qC<a
zu}@P$O^r(d1oBHVQj3svDcIVgsCH*c6;{v&E7m|ZWf4=Pln$~=TNPyK-%Qo23JURA
z#rb*BMe0a0>ahwa7HlqHUcqb$@&??6RtmX^1!0LfrK$dDA(aKGHV{wPX@E=v+KFu3
YW?pu8Hc15qWHEIepmS@jxoWw%05xJ|=l}o!

delta 44
zcmV+{0Mq~S9NHSNqXCn`0UML_0g02Q0%emZ15cBY1ADVK1fm189SA%I27NmUeF_Sa
CP!BEu

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 0000000000000000000000000000000000000000..3479f5b170ec91b5f3b7eb7361b99980df1c8599
GIT binary patch
literal 3318
zcmds3ZExE)5dQ98aVd&e0Tg-7ry`l%28}Z`ZDSzK1`LKF&=MW7kx7lDY8a{i`|e0l
zR%~}`KMj}xTNZim_dG`q2ED-mE`PtDoc=bx9p7EvkFVhR{l{?(R};9M+{35I)%)u|
zk5G&x-{wr);aT$PtVgfnK`PDDh0^Ill>8iOSsR|GyyQzRZ5-akvQpX})L`X`txa0h
zHr4#ET4+?4Y>B@W)A+Pp8#J!XVf9Q&W0|z6$kCx#2(F#FIq0kulAE+t@&Uzc!DRaS
zmozJwHoYFYv!E8BDlS<i68v8DdRfVgfyrfUi+8Ln=PY}?vI?Gh00S(*9k;cX1{g@b
zg6Xc@41^0W;|VJ4?nYz<Afyep5GJX08RPQi1f2tXwpz#sz(x1^R(<7KW2(3{SE{67
zHM2^?a2QSIUwDQo*n;a57>>Y~Bx7YnmgBJV?VJAx;y_}@$1Wx>LBXKqgYJd2@W3q;
z%oOib&e5UMY*9gAkUZnYFukT`Vt)#5gmGdKnofeQ&$XLelebm=Pvr9H?-p>1Muso(
zXV!P+`NXPUy+RE68c`&&5m<F&ltc@@+6y6B3F#MHgCXpRKW#320ldgm_tAWRhNAfN
zTiW&9@6*bL8ue3P048(roG>7)fv>Erxq*dN%f{B!Eg^muD9|T>O)<M!46-h<?bPFy
zFb-CoidUL15ZR6ty3P<7CMC$E$Pp{D6zNo`oI2na_8|Gj#o!9o6wt>Os)yl3E;0fS
z1{Br-d|jKeI*}w}#_kn3^35jTW8h<HYRwV(%;FD+C943Vw+wmX7Fu#V^0grnHHx>&
za(H#_WSZ&`!ul4nf@hCB2OLTiyU2xI!<?@b)_l&1O;X8ID&wxXGct#HWdvt@8YEv;
zp(!Deq$Ott1H`?fCJdYmT-&IH7KIk#{XpSj2xFENQG~&7D@9_BDwQh<`J##wjG=$m
zOhQ0Kc(Dijm$*E8K_M=MF_aP^2W~!3Zj(ONmpu4S$Iq0M;ArR&^us=oqBPtHbWPvo
zz8@M^!DGV?t7{$>H_^$q>COIbPEl(Bd3H`X_=7kxS=_HH-hgNnQN3dDoIE+AIZ2<!
z`^LRC_9^0)oDJAN3dG~C<Ix)^as3(#_zd>m)svgD4_d9*yu=v?3sxE)Z$dXc>NPiY
zX=$A7S)a~)6^J-wlA1<w!0|EAdm;=%dAzJ_2O$LAs4=Zp1V0iDJ}G6t={qvqV{t-o
zxIvW&1Ml=^%iabCU6X`eN+V%;?m6Dk^tGi~N8X=Ry3w+V(uzW<&LRABR@{ti+JIxD
zoeF#PqRt=eMF`brPIIC&z4y$7G2SgZ`u)eMyY>uySH3s8XlD6K&pEXBlN4Eoi*)1h
zha2{1+d*8_>(Il5-UK%xo1f{WGnBZVcRKSgJ;g#zy`rQ4(6cx&Gqf^1pEVX$TLxj;
z_)4R|RZ01)#!A=%mxw=fbK32~sTV`7=7htu7{%!dN!Acb2M|Fh+NaTo6lm)pHe&cO
zo}!H|YQE#@e}o<COk1%Ie&Uv$Tb{=M0jLIWkB2@*N%#q8C!Kn9`s1BoS>x86172(l
k#!ob2{`}dZ$K7^X<qxFWj)u{b8(8iV=dRTkJsd@U18nFYXaE2J

literal 0
HcmV?d00001

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 0000000000000000000000000000000000000000..23173b46be239cc56f634a4928a2f2096326125b
GIT binary patch
literal 3374
zcmds3ZExE)5dQ98aVd&e0Tg-7ry-f$28%m1ZDSx!2MmTG&=O^_l}U}HY8a{i`|e0d
zmK?9kJ`I?G#3p&~_dG|AMuX7^u0KD_&VQTUP4BNCrZ@2R-G^xiH#4}KJ;2A=&AYdM
zoS+y<zDb$3<BRCk#ekM#C6(rJrgWT%f?q-<E5p;67ktg74V$%CmrC2C8m!o`t%=LZ
z#+v_G361KKt?_@!G=5vI4I0<xxO$?bu}s=nr07s&f@`O4jyfxZ<R&hZT%nlFnT&t_
zDNb^x&0v7;ET{}r$t5dAgnw6qK~gYdV0K;E{8uG2vAVGe9tQv;O#hzSN=pL_B;UZi
zFSP*SLQ8mr;{CILEC4hKgUyABFmud4v7faF@pyNJ?g2hoE#wMC+e5egMt$R2W6~(H
zRH~q`HM2^?cpS`@U(gOHn{#~z;|ciUB&-O?Q`nq*|L%W*c#`7jsf*B`cSA$_KMY1l
z3oC9RX9g>la&+%3wP+!bNS<(GnBG#8v2Qsy!Z@*@lW5e~@RjJMTvx>^|08f&4*Nx(
zcTA06b{31FE8$00{^Hd`NVkYNkxam<Tcae}^Bu~BWCg@ua1X}tMEr4g<;&zn=6cw1
zGhCobXYEC`!;F`wogFp#Uw{diOu_TXfUpL>v7+JzGOgCNrMcTe{ADOGBzVp-*F^}j
zDiA1yij6Q1mYuGYnrFxgM<`t-2qhB}$YSJ|6-kU#%vDNV@*4+`e(fS~#jA_yBWo&&
z!2~WY0vm?Y>;w23*Clo$QNoOUQtZIPfQW-(Po=3eN9Hh#|2V8!2^hO&$U3*tlIttq
z8lqsWc&99fSC>wvt{q`;Xd%gY@|CB6<BVb#sjypE@~y&(FFCPLDtS&tJT!Mf<}j~>
z0FU1WNn4g^N=QUN!I{AT@$94y17`}?HfUi+p*8Wor*JWZDNFJoz~J|lBC%SP$`v*F
zqKXp?VR&E9P(Vd^aX^TdxVd;i0j`BHln|i?Za>ZLq9In7?E6p0%bb+pWbDxMOb<w0
z8j1wBy65t#7aDMlznT?RSG?KW1!wz)*XR9wf{uDS9WMz9zY`~B*~7NvHH;<!RV;))
zL(fiV($c4nW#^W)H;TWdXbt)g0+HDdWAYjb+<l9s?7`nVe00-!rPYQl3!H|KvBL0h
z*L2mRUvpCxmPX8h?fJsjgXlvBsd*FwoSp)$BViEG({*V(I3WNBwPCd(5Rz!)k<#^t
zzBl6oCPxH{TT}=z=uVIJ46eb@HAwiS6e5<Up64A+UsalQ<hxhnTFWX*Drm}d3C%BO
z#m&gN0XRC^$#76B>ip3zG=ci8X;O4%`GJvUq<0IBK7Uwm*O;;I$KQ=Enpyt2bP4Ut
zCPtp&CcTyX^@p9=_YQaUw&`9(E5S{Z%k#VF%tWr&ou2$dH?c@_ujuGM_B?L5*)*~F
z?zI$ETh?IGxTIm=YNY(bWFu^jTf`cAarHZJ?uAgNzT5ClMqzq*k}ZVL5hM_L4yiLC
zaXL7Pi4dO0OtjHO%TY)F8_ZBg+G=%h6SjQZGc)`Tz%+sfyy_`R!cB8y(p^V)KE4Fj
r6&@`);A6&M{6rq+Z=OAF+zY2weowmXIG8-T5!HLfxySg`0Ef_Db%Zxi

literal 0
HcmV?d00001

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 0000000000000000000000000000000000000000..29cdda644cc2e47f3e1eb86efca82be16ed09873
GIT binary patch
literal 4784
zcmeHLTW=Fb6n^)wIEf;>Q5OezsseUEO{ky{S0SxbMUge$9oq}*Su-<BYK8pwerGP;
z4PHZeX{D->lC@{f{qmi&IXdbc9np*5F2@glK080VcyW1lN-tl%IZNquOy}cEdOJRS
z_41EBkTK=+TxdIdGI;!?$6t%Ns<fQUDm|GMr97ru)kfwMS<0nUHjUql<*L%QlS7m@
zVr?d?+D^3mSQi@XN-XhrB{ZH^Y6IcgbhBrbGFB)%DRL+*W`)#_-E=A|3nk5@tkfK2
zb|KW{$48TFA++iBpw3b?qiQ9USQP{OKJE3gQW!(y7qwmdRH<1pzmR4Ho$}OHblW33
zLKrTjt+g^FNXZ+z>ZZP?!kL=M0ob}}5|&6513`9Cm;ub0mNHM>l{=|*+4?rEt*z9h
z{L}K`-65<dnpRawfzutpP8_qfzm`%MNvg6)*d;X3O2cVw*JKt|UFNh9pCnDCR3K6h
zRb7^B!5+OUR3>4RB(stkf}IIlREjwFL3mzC!=mAAR@S*hOmaCBb!q97C~HYcZ3;Da
zLiWSW@&3kw!;}`)jr=5a>PY5NNiD3*gLqTb8YW~FBrj`YX(q~&rXu@5$Or6Z*+@e$
zne2My!p(joe+E^eN#waF>o5Rz?{Ls506(q~GL9bS(TDY&)sT2XNX-Aic189N1iPtw
z7xU*8szxJ0$cn3eu4!Q_O~YX_p1zkE+8ed24{10e4;Psz6HaFuZ$5wap9m@LZc%#h
z01EKqWX0x$j@WHq5TGRZk{|au8}s?T`L^Xup)Ad%MRgls5DIWOau#gxmtj}QOd2Ef
znqd=2xR9nWjw}f%Fo^tz^n<?$rg5{-2|Xgn(dFJYU1J+t+9Gc*T3lQ9`{qFV_z#JT
zf4@7rSHalfK}0>aX?TD?X)8v<gGTjr-)Vd+R?obGl=3wuNRf@8^R=lIG<sZ}6-tye
zd5<$1(iZvc-BX`hPjaRE!HE7f$irt(Qp^BkNa>!BM^r!?PFy-JJUz!9$r7mR4cVsS
zs0Ko311o5J+^wgfaq60{U)9=>Ij%32Yd6olFD&PdCkjL8cMdudDknet4Hee-5nyPh
zt7VXQ<)#8=;|v6S9-&vr<aO%H%=Oq!VFJ{Ked8G8Le4P3-SF3Sh9)-?ZvP2(0b67f
ztPP7Q=lb^tJ9_!VS>j40l#I79rjwK4rxxG<X2o;D#N$c|h2%&EnJ{*%q!K@k87~mN
zGi7Sc14LLiWR_xuQKqcGX5bDsga3nE8y+u#@f_2J9v?dzrwxHZm-j7Y3z>b8Ibj83
z+l#!w7@x{@g~(4OkGVL)zZCa`#lbH(OYt<AVXGBHVFGF_aJ(Zd0XKWW4D3PJ5t2<a
zv1!rXU)Wrja3-=vlEC;TQcM<DIb4J2FIXI5O8twl_YoLre+Mue;PQH(ad}x7w?c3g
z(d&2P^Fbd`W<CC=rR|EDV)J~bCctIbB*Nx`xNK?TZ7b>xU1N1E<KbO$*c3px@Afa*
zhzU4zOsJ0Ed1V^UF0sWt<C!)}IEE=T$aJ{pH$gT6ShL0DA+h0OTsmx68X)*4XG7Wu
z2;Sd0<AZ+@OdM#0_Ip#HH@Y1koluF}mq_NlM)Z!}x=B9Q)eUc;Kw<2AMy7YMzI-2Q
zX?Vc|r0>vib?r044b6hNjMyfOWY@%l!l1>@mMhz;TyCi(D5!3@EtoZG=79T4*N{-&
zt$V;_>O6xJt!inoWow48Xz$VhtExw!7N?IkqcRiTi+$j-6+4&2upLwU<fOC{15n#w
zR=52luEWmk#7OukFqXAu>JDLX#I_Rw8@`$cm$0GF(=L!@boDQz7!L8haRMa3A-{?G
zRR|eta*9*Ej#<SX2l$gF`oxj_zU(@216K>JTzw<U2pTpX*fDTGHS|am(IX}|KJTR(
ztV|b1Gbf4R%EhYk%kE8K7dYbX;`iF_j6C$j;0@E6Uvu!z#xKK=2@Roz-?l1+t7})Q
zxq4xO&YDT-?{zd(x2x%^3jcF8#TnZ~uT@v+rjMJBPXC2sJHmer<fg9T4#3l!w;1d;
p?#+BEc}+90pPNkh+1b>S+uiOh5KY2Iw+`Ai>$m&A#?u~Z{Vzx32ZR6s

literal 0
HcmV?d00001

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 0000000000000000000000000000000000000000..6e21faa8e25e1d90d9766821db16a7431bdffcc6
GIT binary patch
literal 709
zcma)&K~KU!5QXpk72^p=kSZr5Vr(cF5~K;K2Tz-|otBMdcir7;h~d9G+XBJB!9%y1
z+3(GJJ7E}vAw;ifa{WAAPFGPnjbS!lO#2WguuM{TN#gnJ?TRo@KIPoF@o;cE416g{
zsSL708CHl2@1d5p#hhV<8<ehpsKrJr<1Q+A_06}I>Dn=apS3U~mwZE~=7#<bjiuqn
zUY2J{TF0ehA}2#p2sDk{UNozOM9V5AOTyfmOLlj|vNbn02-=L$(LDR$CEhEIl4}vt
zH}J3SjLr~;)`k8!0DdVU_>Kiy3M5hO){z#$ml8Q_xRBi*`~(0*6+-d~L=!(l7$4zo
zZxj$x8dd9V=X_YF5Bn*8&a8H|!H0z^g<PT4v=5A(dW4Ku)jPeV+k}|xE4l*ZQ=K$=
z!0lFM<Oxmo*7P`_^=ptOyT>Shn#4&X*|m93!nBS`7NYzUsYEwvx|IK-<q`O&r?=h(
Ezi_7IdjJ3c

literal 0
HcmV?d00001

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 0000000000000000000000000000000000000000..701b4666abdfb7a542bd2adb7de70bbcdd2e24e3
GIT binary patch
literal 562
zcmZvYF;BxV5QTUDiepMEl?FPYR+XqoiYh@PK^T}^lQ@ZmV;kE6RjB`+vrDITuze@J
z@7+76DM?dUy_LoFW4_H_R%M>Sdh?V|AS+;7l<-_+oAvt@%7XH~X4Wrf)7u#dt!S0D
zoHp9hMo2z~K@E=AluO=o<tL|J^hR5Mkzn$N?Hx6Pr<Q*W!XmC%kGEkKU(c;WbL%eS
zl~&F(<*BG~plAfQ5qB4dNuju-QmYnazGI5s-O#FI))8{d2#I0UCu{j!8?KlUDV}NA
zI$4}aJa;|~*GUkDhTZ{w2^6crcMnX;546m@Cf{1r&@-V%WB4TisBVN}5=300;;=k}
zM&kuRX>0XhMoBO~lOz-PH;k6A-U`*iU?p-8^B|~W0lwp4EbeCg9(JhN9ckwFs*0fk
SgTcxoD<Xdg0kDeif&2mO9=EFi

literal 0
HcmV?d00001

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 0000000000000000000000000000000000000000..d887635d7a4838109ffa7019b868d22cac9fdd38
GIT binary patch
literal 572
zcmZvYPfNo<5XJBNDaKP;sEzg{Ed`57rJxOHdhoPel1;Lh?#A7TQl#JA*{ubsJ?x)>
z-+S|B;+VxTEH-&|{g|%Pmqnf?uv|T*BS<n>XE{7)$!hs_MVKe=OJQ+78Q)HrFIBCL
zmAo>RSE`j$=ymU8$z>~dQsd}Ut6gU-UQ~$oOY9x*dgNAq^vaT4iyhsau=Jv|j?AsQ
zEH8|9C^T|aQbScKX@lHdY}P6*9dC`U3ByKces{x*Mp(z#F(X8sDBeXar>2uybSkDZ
z_ESf*e+Wv4vA<4$Z|Z9Yv=Yb`JvI+USE^3Xuur2(*a@WvL-=L@#8yg+7E~CakT5@!
z42CmCU~Ns`4I*!R21FzHH4ApHXqB#kR!D0saAOszAtWNX2_L{lLT70@i+kAqVYf#&
Y;r6-+0lk(&=2?;jpM#G>zwjQ|50Zqt<NyEw

literal 0
HcmV?d00001

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 0000000000000000000000000000000000000000..fac4d8bdd9c605e70ce0ce4e9278ff0d78519047
GIT binary patch
literal 677
zcma)&QBT4!6oudSE3QvKf=qcb5E6+ShD2F{_~271+udmF+S+y+V)*ag6?hV&59@lD
z^PO|<;y8+9n7*#E>&JAFE~l$Bf!X{i?L(5mB3r?8mds~wS0rnd_XW3pG926vqjnUP
z(iT~%Eh~k@5j3iCSTK}Wqw@XJENY{zKTB}=#dnUG#xsi_jj)6(Uejr~rME}x$lSW~
zc&?T6TzMu63KXS48*q0vObUgLNv$f9`7KxM?uO-CZe0`|Izq?r{GC@g(gqbbBBp21
zZrvIGl6Z7JZjY0o4Q*!!^h+R{Hh%k{RVk_^Izvq{@jCj{qJWwU)$PG|1VBtF6qg{v
zDmlXB0^043BLXU`8`JHyK^KIbJ{--1{YGmEw9Q6hK|F+xWT3+Z{1(Aj+SmFWtdF|&
p!GpV<%0tbTqpGVc$%4oJP$qnQ@m8n`8Y};_C(Zw?$tgtaqaSxS+OGfr

literal 0
HcmV?d00001

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