From d3c35ec9c5a158febd8622d2d05d647e6b9095d7 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen 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@@yk2gKGE7nv zs8&lWz&|)dOG`mNKM`nxK1>cM#ia$)1T{20H7~U&u_VmYFOkF1h)iXfrRw<<3y|x|HN2I~YkAXt*Yqg04a|*ijHCpsNO(3>3tsHYl~Y XAV04-6IQ#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_USl{oWFUCoSexbW4kC6nPjJB-9b7bu2 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}9u^ 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`*`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!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>nGi1u 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!^Lw>N5J7!1PAUU7D?zCBqIOSC~@G zT)E=}sVY8BO~yk4b7q#5Z`IyUi%Vk@oJ&0MfxPuVo0l@#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%-UzwTUkyCcBYKJ%rUnkp$RA|ow6-OxR&xSHwJa3+btcrb#$PHe78gzE<~c0Gzc2iXH?ao z5fW}}*Q}}QNy$5Ghw8!8-y>L){*s0Y2x({#T7$ri9Y?M=hsF&m!&hjptQ9xZBP8QG zg77Xrnw1&}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>T7aQP 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&e2qCahwa7HlqHUcqb$@&??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>Y~Bx7YnmgBJV?VJAx;y_}@$1Wx>LBXKqgYJd2@W3q; z%oOib&e5UMY*9gAkUZnYFukT`Vt)#5gmGdKnofeQ&$XLelebm=Pvr9H?-p>1Muso( zXV!P+`NXPUy+RE68c`&&5m7)fv>Erxq*dN%f{B!Eg^muD9|T>O)Os)yl3E;0fS z1{Br-d|jKeI*}w}#_kn3^35jTW8h& za(H#_WSZ&`!ul4nf@hCB2OLTiyU2xI!H_^$q>COIbPEl(Bd3H`X_=7kxS=_HH-hgNnQN3dDoIE+AIZ2m)svgD4_d9*yu=v?3sxE)Z$dXc>NPiY zX=$A7S)a~)6^J-wlA1uySH3s8XlD6K&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}ox86172(l k#!ob2{`}dZ$K7^Xla&+%3wP+!bNS<(GnBG#8v2Qsy!Z@*@lW5e~@RjJMTvx>^|08f&4*Nx( zcTA06b{31FE8$00{^Hd`NVkYNkxamv7+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 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+pWbDxMObip3zG=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-lC@{f{qmi&IXdbc9np*5F2@glK080VcyW1lN-tl%IZNquOy}cEdOJRS z_41EBkTK=+TxdIdGI;!?$6t%NsZZP?!kL=M0ob}}5|&6513`9Cm;ub0mNHM>l{=|*+4?rEt*z9h z{L}K`-654RB(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^naX?TD?X)8v!BM^r!?PFy-JJUz!9$r7mR4cVsS zs0Ko311o5J+^wgfaq60{U)9=>Ij%32Yd6olFD&PdCkjL8cMdudDknet4Hee-5nyPh zt7VXQ<)#8=;|v6S9-&vrj40l#I79rjwK4rxxGb8Ibj83 z+l#!w7@x{@g~(4OkGVL)zZCa`#lbH(OYtxU1N1Evib?r044b6hNjMyfOWY@%l!l1>@mMhz;TyCi(D5!3@EtoZG=79T4*N{-& zt$V;_>O6xJt!inoWow48Xz$VhtExw!7N?IkqcRiTi+$j-6+4&2upLwUJDLX#I_Rw8@`$cm$0GF(=L!@boDQz7!L8haRMa3A-{?G zRR|eta*9*Ej#JTzwS+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<8Dn=apS3U~mwZE~=7#Kiy3M5hO){z#$ml8Q_xRBi*`~(0*6+-d~L=!(l7$4zo zZxj$x8dd9V=X_YF5Bn*8&a8H|!H0z^gShn#4&X*|m93!nBS`7NYzUsYEwvx|IK-Sdh?V|AS+;7l<-_+oAvt@%7XH~X4Wrf)7u#dt!S0D zoHp9hMo2z~K@E=AluO=o0XhMoBO~lOz-PH;k6A-U`*iU?p-8^B|~W0lwp4EbeCg9(JhN9ckwFs*0fk SgTcxoD z-+S|B;+VxTEH-&|{g|%Pmqnf?uv|T*BSXE{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&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 { 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 { - response.clearCookie('immich_access_token'); - response.clearCookie('immich_is_authenticated'); + async logout(@Req() req: Request, @Res({ passthrough: true }) response: Response): Promise { + 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; + let immichJwtServiceMock: jest.Mocked; + let oauthServiceMock: jest.Mocked; + 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; + + oauthServiceMock = { + getLogoutEndpoint: jest.fn(), + } as unknown as jest.Mocked; + + 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, + private oauthService: OAuthService, private immichJwtService: ImmichJwtService, + @Inject(USER_REPOSITORY) private userRepository: IUserRepository, ) {} - private async validateUser(loginCredential: LoginCredentialDto): Promise { - 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 { + 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 { - 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 { + 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 { - const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } }); + public async adminSignUp(dto: SignUpDto): Promise { + 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 { - return bcrypt.hash(password, salt); - } - - private async validatePassword(hasedPassword: string, inputPassword: string, salt: string): Promise { - const hash = await bcrypt.hash(inputPassword, salt); - return hash === hasedPassword; + private async validatePassword(inputPassword: string, user: UserEntity): Promise { + 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 { + 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) => { + 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; + let configServiceMock: jest.Mocked; + let immichJwtServiceMock: jest.Mocked; + + 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; + + configServiceMock = { + get: jest.fn(), + } as unknown as jest.Mocked; + + 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('OAUTH_ISSUER_URL', ''); + this.scope = configService.get('OAUTH_SCOPE', ''); + this.buttonText = configService.get('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 { + 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 { + 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(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 { + 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; - getByEmail(email: string): Promise; + get(id: string, withDeleted?: boolean): Promise; + getAdmin(): Promise; + getByEmail(email: string, withPassword?: boolean): Promise; getList(filter?: { excludeId?: string }): Promise; - create(createUserDto: CreateUserDto): Promise; - update(user: UserEntity, updateUserDto: UpdateUserDto): Promise; - createProfileImage(user: UserEntity, fileInfo: Express.Multer.File): Promise; + create(user: Partial): Promise; + update(id: string, user: Partial): Promise; delete(user: UserEntity): Promise; restore(user: UserEntity): Promise; } @@ -25,25 +23,29 @@ export class UserRepository implements IUserRepository { private userRepository: Repository, ) {} - private async hashPassword(password: string, salt: string): Promise { - return bcrypt.hash(password, salt); - } - - async get(userId: string, withDeleted?: boolean): Promise { + public async get(userId: string, withDeleted?: boolean): Promise { return this.userRepository.findOne({ where: { id: userId }, withDeleted: withDeleted }); } - async getByEmail(email: string): Promise { - return this.userRepository.findOne({ where: { email } }); + public async getAdmin(): Promise { + return this.userRepository.findOne({ where: { isAdmin: true } }); } - // TODO add DTO for filtering - async getList({ excludeId }: { excludeId?: string } = {}): Promise { + public async getByEmail(email: string, withPassword?: boolean): Promise { + 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 { 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 { - 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): Promise { + 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 { - 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): Promise { + 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 { + public async delete(user: UserEntity): Promise { if (user.isAdmin) { throw new BadRequestException('Cannot delete admin user! stay sane!'); } return this.userRepository.softRemove(user); } - async restore(user: UserEntity): Promise { + public async restore(user: UserEntity): Promise { return this.userRepository.recover(user); } - async createProfileImage(user: UserEntity, fileInfo: Express.Multer.File): Promise { - user.profileImagePath = fileInfo.path; - return this.userRepository.save(user); + private async hashPassword(password: string, salt: string): Promise { + 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; + let sut: ImmichJwtService; beforeEach(() => { - jwtService = new JwtService(); - service = new ImmichJwtService(jwtService); + jwtServiceMock = { + sign: jest.fn(), + verifyAsync: jest.fn(), + } as unknown as jest.Mocked; + + 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 { + const payload = new JwtPayloadDto(user.id, user.email); + const accessToken = await this.generateToken(payload); + + return mapLoginResponse(user, accessToken); } public async validateToken(accessToken: string): Promise { @@ -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 { + 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 = (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 => { + // 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 => { + // 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> { + 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> { + 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 { + 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 { + 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 @@ 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',