You've already forked immich
							
							
				mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 00:18:28 +02:00 
			
		
		
		
	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
This commit is contained in:
		
							
								
								
									
										68
									
								
								docs/docs/usage/oauth.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								docs/docs/usage/oauth.md
									
									
									
									
									
										Normal file
									
								
							| @@ -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/ | ||||
| @@ -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 | ||||
|   | ||||
| @@ -103,6 +103,8 @@ Class | Method | HTTP request | Description | ||||
| *JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs |  | ||||
| *JobApi* | [**getJobStatus**](doc//JobApi.md#getjobstatus) | **GET** /jobs/{jobId} |  | ||||
| *JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{jobId} |  | ||||
| *OAuthApi* | [**callback**](doc//OAuthApi.md#callback) | **POST** /oauth/callback |  | ||||
| *OAuthApi* | [**generateConfig**](doc//OAuthApi.md#generateconfig) | **POST** /oauth/config |  | ||||
| *ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info |  | ||||
| *ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version |  | ||||
| *ServerInfoApi* | [**getStats**](doc//ServerInfoApi.md#getstats) | **GET** /server-info/stats |  | ||||
| @@ -160,6 +162,9 @@ Class | Method | HTTP request | Description | ||||
|  - [LoginCredentialDto](doc//LoginCredentialDto.md) | ||||
|  - [LoginResponseDto](doc//LoginResponseDto.md) | ||||
|  - [LogoutResponseDto](doc//LogoutResponseDto.md) | ||||
|  - [OAuthCallbackDto](doc//OAuthCallbackDto.md) | ||||
|  - [OAuthConfigDto](doc//OAuthConfigDto.md) | ||||
|  - [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md) | ||||
|  - [RemoveAssetsDto](doc//RemoveAssetsDto.md) | ||||
|  - [SearchAssetDto](doc//SearchAssetDto.md) | ||||
|  - [ServerInfoResponseDto](doc//ServerInfoResponseDto.md) | ||||
|   | ||||
| @@ -9,6 +9,7 @@ import 'package:openapi/api.dart'; | ||||
| Name | Type | Description | Notes | ||||
| ------------ | ------------- | ------------- | ------------- | ||||
| **successful** | **bool** |  | [readonly]  | ||||
| **redirectUri** | **String** |  | [readonly]  | ||||
|  | ||||
| [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) | ||||
|  | ||||
|   | ||||
							
								
								
									
										97
									
								
								mobile/openapi/doc/OAuthApi.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								mobile/openapi/doc/OAuthApi.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,97 @@ | ||||
| # openapi.api.OAuthApi | ||||
|  | ||||
| ## Load the API package | ||||
| ```dart | ||||
| import 'package:openapi/api.dart'; | ||||
| ``` | ||||
|  | ||||
| All URIs are relative to */api* | ||||
|  | ||||
| Method | HTTP request | Description | ||||
| ------------- | ------------- | ------------- | ||||
| [**callback**](OAuthApi.md#callback) | **POST** /oauth/callback |  | ||||
| [**generateConfig**](OAuthApi.md#generateconfig) | **POST** /oauth/config |  | ||||
|  | ||||
|  | ||||
| # **callback** | ||||
| > LoginResponseDto callback(oAuthCallbackDto) | ||||
|  | ||||
|  | ||||
|  | ||||
| ### Example | ||||
| ```dart | ||||
| import 'package:openapi/api.dart'; | ||||
|  | ||||
| final api_instance = OAuthApi(); | ||||
| final oAuthCallbackDto = OAuthCallbackDto(); // OAuthCallbackDto |  | ||||
|  | ||||
| try { | ||||
|     final result = api_instance.callback(oAuthCallbackDto); | ||||
|     print(result); | ||||
| } catch (e) { | ||||
|     print('Exception when calling OAuthApi->callback: $e\n'); | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### Parameters | ||||
|  | ||||
| Name | Type | Description  | Notes | ||||
| ------------- | ------------- | ------------- | ------------- | ||||
|  **oAuthCallbackDto** | [**OAuthCallbackDto**](OAuthCallbackDto.md)|  |  | ||||
|  | ||||
| ### Return type | ||||
|  | ||||
| [**LoginResponseDto**](LoginResponseDto.md) | ||||
|  | ||||
| ### Authorization | ||||
|  | ||||
| No authorization required | ||||
|  | ||||
| ### HTTP request headers | ||||
|  | ||||
|  - **Content-Type**: application/json | ||||
|  - **Accept**: application/json | ||||
|  | ||||
| [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) | ||||
|  | ||||
| # **generateConfig** | ||||
| > OAuthConfigResponseDto generateConfig(oAuthConfigDto) | ||||
|  | ||||
|  | ||||
|  | ||||
| ### Example | ||||
| ```dart | ||||
| import 'package:openapi/api.dart'; | ||||
|  | ||||
| final api_instance = OAuthApi(); | ||||
| final oAuthConfigDto = OAuthConfigDto(); // OAuthConfigDto |  | ||||
|  | ||||
| try { | ||||
|     final result = api_instance.generateConfig(oAuthConfigDto); | ||||
|     print(result); | ||||
| } catch (e) { | ||||
|     print('Exception when calling OAuthApi->generateConfig: $e\n'); | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### Parameters | ||||
|  | ||||
| Name | Type | Description  | Notes | ||||
| ------------- | ------------- | ------------- | ------------- | ||||
|  **oAuthConfigDto** | [**OAuthConfigDto**](OAuthConfigDto.md)|  |  | ||||
|  | ||||
| ### Return type | ||||
|  | ||||
| [**OAuthConfigResponseDto**](OAuthConfigResponseDto.md) | ||||
|  | ||||
| ### Authorization | ||||
|  | ||||
| No authorization required | ||||
|  | ||||
| ### HTTP request headers | ||||
|  | ||||
|  - **Content-Type**: application/json | ||||
|  - **Accept**: application/json | ||||
|  | ||||
| [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) | ||||
|  | ||||
							
								
								
									
										15
									
								
								mobile/openapi/doc/OAuthCallbackDto.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								mobile/openapi/doc/OAuthCallbackDto.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| # openapi.model.OAuthCallbackDto | ||||
|  | ||||
| ## Load the model package | ||||
| ```dart | ||||
| import 'package:openapi/api.dart'; | ||||
| ``` | ||||
|  | ||||
| ## Properties | ||||
| Name | Type | Description | Notes | ||||
| ------------ | ------------- | ------------- | ------------- | ||||
| **url** | **String** |  |  | ||||
|  | ||||
| [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) | ||||
|  | ||||
|  | ||||
							
								
								
									
										15
									
								
								mobile/openapi/doc/OAuthConfigDto.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								mobile/openapi/doc/OAuthConfigDto.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| # openapi.model.OAuthConfigDto | ||||
|  | ||||
| ## Load the model package | ||||
| ```dart | ||||
| import 'package:openapi/api.dart'; | ||||
| ``` | ||||
|  | ||||
| ## Properties | ||||
| Name | Type | Description | Notes | ||||
| ------------ | ------------- | ------------- | ------------- | ||||
| **redirectUri** | **String** |  |  | ||||
|  | ||||
| [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) | ||||
|  | ||||
|  | ||||
							
								
								
									
										17
									
								
								mobile/openapi/doc/OAuthConfigResponseDto.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								mobile/openapi/doc/OAuthConfigResponseDto.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| # openapi.model.OAuthConfigResponseDto | ||||
|  | ||||
| ## Load the model package | ||||
| ```dart | ||||
| import 'package:openapi/api.dart'; | ||||
| ``` | ||||
|  | ||||
| ## Properties | ||||
| Name | Type | Description | Notes | ||||
| ------------ | ------------- | ------------- | ------------- | ||||
| **enabled** | **bool** |  | [readonly]  | ||||
| **url** | **String** |  | [optional] [readonly]  | ||||
| **buttonText** | **String** |  | [optional] [readonly]  | ||||
|  | ||||
| [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) | ||||
|  | ||||
|  | ||||
| @@ -32,6 +32,7 @@ part 'api/asset_api.dart'; | ||||
| part 'api/authentication_api.dart'; | ||||
| part 'api/device_info_api.dart'; | ||||
| part 'api/job_api.dart'; | ||||
| part 'api/o_auth_api.dart'; | ||||
| part 'api/server_info_api.dart'; | ||||
| part 'api/user_api.dart'; | ||||
|  | ||||
| @@ -74,6 +75,9 @@ part 'model/job_status_response_dto.dart'; | ||||
| part 'model/login_credential_dto.dart'; | ||||
| part 'model/login_response_dto.dart'; | ||||
| part 'model/logout_response_dto.dart'; | ||||
| part 'model/o_auth_callback_dto.dart'; | ||||
| part 'model/o_auth_config_dto.dart'; | ||||
| part 'model/o_auth_config_response_dto.dart'; | ||||
| part 'model/remove_assets_dto.dart'; | ||||
| part 'model/search_asset_dto.dart'; | ||||
| part 'model/server_info_response_dto.dart'; | ||||
|   | ||||
							
								
								
									
										112
									
								
								mobile/openapi/lib/api/o_auth_api.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								mobile/openapi/lib/api/o_auth_api.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,112 @@ | ||||
| // | ||||
| // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||
| // | ||||
| // @dart=2.12 | ||||
|  | ||||
| // ignore_for_file: unused_element, unused_import | ||||
| // ignore_for_file: always_put_required_named_parameters_first | ||||
| // ignore_for_file: constant_identifier_names | ||||
| // ignore_for_file: lines_longer_than_80_chars | ||||
|  | ||||
| part of openapi.api; | ||||
|  | ||||
|  | ||||
| class OAuthApi { | ||||
|   OAuthApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; | ||||
|  | ||||
|   final ApiClient apiClient; | ||||
|  | ||||
|   /// Performs an HTTP 'POST /oauth/callback' operation and returns the [Response]. | ||||
|   /// Parameters: | ||||
|   /// | ||||
|   /// * [OAuthCallbackDto] oAuthCallbackDto (required): | ||||
|   Future<Response> callbackWithHttpInfo(OAuthCallbackDto oAuthCallbackDto,) async { | ||||
|     // ignore: prefer_const_declarations | ||||
|     final path = r'/oauth/callback'; | ||||
|  | ||||
|     // ignore: prefer_final_locals | ||||
|     Object? postBody = oAuthCallbackDto; | ||||
|  | ||||
|     final queryParams = <QueryParam>[]; | ||||
|     final headerParams = <String, String>{}; | ||||
|     final formParams = <String, String>{}; | ||||
|  | ||||
|     const contentTypes = <String>['application/json']; | ||||
|  | ||||
|  | ||||
|     return apiClient.invokeAPI( | ||||
|       path, | ||||
|       'POST', | ||||
|       queryParams, | ||||
|       postBody, | ||||
|       headerParams, | ||||
|       formParams, | ||||
|       contentTypes.isEmpty ? null : contentTypes.first, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   /// Parameters: | ||||
|   /// | ||||
|   /// * [OAuthCallbackDto] oAuthCallbackDto (required): | ||||
|   Future<LoginResponseDto?> callback(OAuthCallbackDto oAuthCallbackDto,) async { | ||||
|     final response = await callbackWithHttpInfo(oAuthCallbackDto,); | ||||
|     if (response.statusCode >= HttpStatus.badRequest) { | ||||
|       throw ApiException(response.statusCode, await _decodeBodyBytes(response)); | ||||
|     } | ||||
|     // When a remote server returns no body with a status of 204, we shall not decode it. | ||||
|     // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" | ||||
|     // FormatException when trying to decode an empty string. | ||||
|     if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { | ||||
|       return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'LoginResponseDto',) as LoginResponseDto; | ||||
|      | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   /// Performs an HTTP 'POST /oauth/config' operation and returns the [Response]. | ||||
|   /// Parameters: | ||||
|   /// | ||||
|   /// * [OAuthConfigDto] oAuthConfigDto (required): | ||||
|   Future<Response> generateConfigWithHttpInfo(OAuthConfigDto oAuthConfigDto,) async { | ||||
|     // ignore: prefer_const_declarations | ||||
|     final path = r'/oauth/config'; | ||||
|  | ||||
|     // ignore: prefer_final_locals | ||||
|     Object? postBody = oAuthConfigDto; | ||||
|  | ||||
|     final queryParams = <QueryParam>[]; | ||||
|     final headerParams = <String, String>{}; | ||||
|     final formParams = <String, String>{}; | ||||
|  | ||||
|     const contentTypes = <String>['application/json']; | ||||
|  | ||||
|  | ||||
|     return apiClient.invokeAPI( | ||||
|       path, | ||||
|       'POST', | ||||
|       queryParams, | ||||
|       postBody, | ||||
|       headerParams, | ||||
|       formParams, | ||||
|       contentTypes.isEmpty ? null : contentTypes.first, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   /// Parameters: | ||||
|   /// | ||||
|   /// * [OAuthConfigDto] oAuthConfigDto (required): | ||||
|   Future<OAuthConfigResponseDto?> generateConfig(OAuthConfigDto oAuthConfigDto,) async { | ||||
|     final response = await generateConfigWithHttpInfo(oAuthConfigDto,); | ||||
|     if (response.statusCode >= HttpStatus.badRequest) { | ||||
|       throw ApiException(response.statusCode, await _decodeBodyBytes(response)); | ||||
|     } | ||||
|     // When a remote server returns no body with a status of 204, we shall not decode it. | ||||
|     // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" | ||||
|     // FormatException when trying to decode an empty string. | ||||
|     if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { | ||||
|       return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'OAuthConfigResponseDto',) as OAuthConfigResponseDto; | ||||
|      | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| } | ||||
| @@ -270,6 +270,12 @@ class ApiClient { | ||||
|           return LoginResponseDto.fromJson(value); | ||||
|         case 'LogoutResponseDto': | ||||
|           return LogoutResponseDto.fromJson(value); | ||||
|         case 'OAuthCallbackDto': | ||||
|           return OAuthCallbackDto.fromJson(value); | ||||
|         case 'OAuthConfigDto': | ||||
|           return OAuthConfigDto.fromJson(value); | ||||
|         case 'OAuthConfigResponseDto': | ||||
|           return OAuthConfigResponseDto.fromJson(value); | ||||
|         case 'RemoveAssetsDto': | ||||
|           return RemoveAssetsDto.fromJson(value); | ||||
|         case 'SearchAssetDto': | ||||
|   | ||||
| @@ -14,25 +14,31 @@ class LogoutResponseDto { | ||||
|   /// Returns a new [LogoutResponseDto] instance. | ||||
|   LogoutResponseDto({ | ||||
|     required this.successful, | ||||
|     required this.redirectUri, | ||||
|   }); | ||||
|  | ||||
|   bool successful; | ||||
|  | ||||
|   String redirectUri; | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) => identical(this, other) || other is LogoutResponseDto && | ||||
|      other.successful == successful; | ||||
|      other.successful == successful && | ||||
|      other.redirectUri == redirectUri; | ||||
|  | ||||
|   @override | ||||
|   int get hashCode => | ||||
|     // ignore: unnecessary_parenthesis | ||||
|     (successful.hashCode); | ||||
|     (successful.hashCode) + | ||||
|     (redirectUri.hashCode); | ||||
|  | ||||
|   @override | ||||
|   String toString() => 'LogoutResponseDto[successful=$successful]'; | ||||
|   String toString() => 'LogoutResponseDto[successful=$successful, redirectUri=$redirectUri]'; | ||||
|  | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final _json = <String, dynamic>{}; | ||||
|       _json[r'successful'] = successful; | ||||
|       _json[r'redirectUri'] = redirectUri; | ||||
|     return _json; | ||||
|   } | ||||
|  | ||||
| @@ -56,6 +62,7 @@ class LogoutResponseDto { | ||||
|  | ||||
|       return LogoutResponseDto( | ||||
|         successful: mapValueOfType<bool>(json, r'successful')!, | ||||
|         redirectUri: mapValueOfType<String>(json, r'redirectUri')!, | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
| @@ -106,6 +113,7 @@ class LogoutResponseDto { | ||||
|   /// The list of required keys that must be present in a JSON. | ||||
|   static const requiredKeys = <String>{ | ||||
|     'successful', | ||||
|     'redirectUri', | ||||
|   }; | ||||
| } | ||||
|  | ||||
|   | ||||
							
								
								
									
										111
									
								
								mobile/openapi/lib/model/o_auth_callback_dto.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								mobile/openapi/lib/model/o_auth_callback_dto.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,111 @@ | ||||
| // | ||||
| // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||
| // | ||||
| // @dart=2.12 | ||||
|  | ||||
| // ignore_for_file: unused_element, unused_import | ||||
| // ignore_for_file: always_put_required_named_parameters_first | ||||
| // ignore_for_file: constant_identifier_names | ||||
| // ignore_for_file: lines_longer_than_80_chars | ||||
|  | ||||
| part of openapi.api; | ||||
|  | ||||
| class OAuthCallbackDto { | ||||
|   /// Returns a new [OAuthCallbackDto] instance. | ||||
|   OAuthCallbackDto({ | ||||
|     required this.url, | ||||
|   }); | ||||
|  | ||||
|   String url; | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) => identical(this, other) || other is OAuthCallbackDto && | ||||
|      other.url == url; | ||||
|  | ||||
|   @override | ||||
|   int get hashCode => | ||||
|     // ignore: unnecessary_parenthesis | ||||
|     (url.hashCode); | ||||
|  | ||||
|   @override | ||||
|   String toString() => 'OAuthCallbackDto[url=$url]'; | ||||
|  | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final _json = <String, dynamic>{}; | ||||
|       _json[r'url'] = url; | ||||
|     return _json; | ||||
|   } | ||||
|  | ||||
|   /// Returns a new [OAuthCallbackDto] instance and imports its values from | ||||
|   /// [value] if it's a [Map], null otherwise. | ||||
|   // ignore: prefer_constructors_over_static_methods | ||||
|   static OAuthCallbackDto? fromJson(dynamic value) { | ||||
|     if (value is Map) { | ||||
|       final json = value.cast<String, dynamic>(); | ||||
|  | ||||
|       // Ensure that the map contains the required keys. | ||||
|       // Note 1: the values aren't checked for validity beyond being non-null. | ||||
|       // Note 2: this code is stripped in release mode! | ||||
|       assert(() { | ||||
|         requiredKeys.forEach((key) { | ||||
|           assert(json.containsKey(key), 'Required key "OAuthCallbackDto[$key]" is missing from JSON.'); | ||||
|           assert(json[key] != null, 'Required key "OAuthCallbackDto[$key]" has a null value in JSON.'); | ||||
|         }); | ||||
|         return true; | ||||
|       }()); | ||||
|  | ||||
|       return OAuthCallbackDto( | ||||
|         url: mapValueOfType<String>(json, r'url')!, | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   static List<OAuthCallbackDto>? listFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final result = <OAuthCallbackDto>[]; | ||||
|     if (json is List && json.isNotEmpty) { | ||||
|       for (final row in json) { | ||||
|         final value = OAuthCallbackDto.fromJson(row); | ||||
|         if (value != null) { | ||||
|           result.add(value); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return result.toList(growable: growable); | ||||
|   } | ||||
|  | ||||
|   static Map<String, OAuthCallbackDto> mapFromJson(dynamic json) { | ||||
|     final map = <String, OAuthCallbackDto>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||
|       for (final entry in json.entries) { | ||||
|         final value = OAuthCallbackDto.fromJson(entry.value); | ||||
|         if (value != null) { | ||||
|           map[entry.key] = value; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
|  | ||||
|   // maps a json object with a list of OAuthCallbackDto-objects as value to a dart map | ||||
|   static Map<String, List<OAuthCallbackDto>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final map = <String, List<OAuthCallbackDto>>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||
|       for (final entry in json.entries) { | ||||
|         final value = OAuthCallbackDto.listFromJson(entry.value, growable: growable,); | ||||
|         if (value != null) { | ||||
|           map[entry.key] = value; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
|  | ||||
|   /// The list of required keys that must be present in a JSON. | ||||
|   static const requiredKeys = <String>{ | ||||
|     'url', | ||||
|   }; | ||||
| } | ||||
|  | ||||
							
								
								
									
										111
									
								
								mobile/openapi/lib/model/o_auth_config_dto.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								mobile/openapi/lib/model/o_auth_config_dto.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,111 @@ | ||||
| // | ||||
| // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||
| // | ||||
| // @dart=2.12 | ||||
|  | ||||
| // ignore_for_file: unused_element, unused_import | ||||
| // ignore_for_file: always_put_required_named_parameters_first | ||||
| // ignore_for_file: constant_identifier_names | ||||
| // ignore_for_file: lines_longer_than_80_chars | ||||
|  | ||||
| part of openapi.api; | ||||
|  | ||||
| class OAuthConfigDto { | ||||
|   /// Returns a new [OAuthConfigDto] instance. | ||||
|   OAuthConfigDto({ | ||||
|     required this.redirectUri, | ||||
|   }); | ||||
|  | ||||
|   String redirectUri; | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) => identical(this, other) || other is OAuthConfigDto && | ||||
|      other.redirectUri == redirectUri; | ||||
|  | ||||
|   @override | ||||
|   int get hashCode => | ||||
|     // ignore: unnecessary_parenthesis | ||||
|     (redirectUri.hashCode); | ||||
|  | ||||
|   @override | ||||
|   String toString() => 'OAuthConfigDto[redirectUri=$redirectUri]'; | ||||
|  | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final _json = <String, dynamic>{}; | ||||
|       _json[r'redirectUri'] = redirectUri; | ||||
|     return _json; | ||||
|   } | ||||
|  | ||||
|   /// Returns a new [OAuthConfigDto] instance and imports its values from | ||||
|   /// [value] if it's a [Map], null otherwise. | ||||
|   // ignore: prefer_constructors_over_static_methods | ||||
|   static OAuthConfigDto? fromJson(dynamic value) { | ||||
|     if (value is Map) { | ||||
|       final json = value.cast<String, dynamic>(); | ||||
|  | ||||
|       // Ensure that the map contains the required keys. | ||||
|       // Note 1: the values aren't checked for validity beyond being non-null. | ||||
|       // Note 2: this code is stripped in release mode! | ||||
|       assert(() { | ||||
|         requiredKeys.forEach((key) { | ||||
|           assert(json.containsKey(key), 'Required key "OAuthConfigDto[$key]" is missing from JSON.'); | ||||
|           assert(json[key] != null, 'Required key "OAuthConfigDto[$key]" has a null value in JSON.'); | ||||
|         }); | ||||
|         return true; | ||||
|       }()); | ||||
|  | ||||
|       return OAuthConfigDto( | ||||
|         redirectUri: mapValueOfType<String>(json, r'redirectUri')!, | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   static List<OAuthConfigDto>? listFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final result = <OAuthConfigDto>[]; | ||||
|     if (json is List && json.isNotEmpty) { | ||||
|       for (final row in json) { | ||||
|         final value = OAuthConfigDto.fromJson(row); | ||||
|         if (value != null) { | ||||
|           result.add(value); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return result.toList(growable: growable); | ||||
|   } | ||||
|  | ||||
|   static Map<String, OAuthConfigDto> mapFromJson(dynamic json) { | ||||
|     final map = <String, OAuthConfigDto>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||
|       for (final entry in json.entries) { | ||||
|         final value = OAuthConfigDto.fromJson(entry.value); | ||||
|         if (value != null) { | ||||
|           map[entry.key] = value; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
|  | ||||
|   // maps a json object with a list of OAuthConfigDto-objects as value to a dart map | ||||
|   static Map<String, List<OAuthConfigDto>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final map = <String, List<OAuthConfigDto>>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||
|       for (final entry in json.entries) { | ||||
|         final value = OAuthConfigDto.listFromJson(entry.value, growable: growable,); | ||||
|         if (value != null) { | ||||
|           map[entry.key] = value; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
|  | ||||
|   /// The list of required keys that must be present in a JSON. | ||||
|   static const requiredKeys = <String>{ | ||||
|     'redirectUri', | ||||
|   }; | ||||
| } | ||||
|  | ||||
							
								
								
									
										145
									
								
								mobile/openapi/lib/model/o_auth_config_response_dto.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								mobile/openapi/lib/model/o_auth_config_response_dto.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,145 @@ | ||||
| // | ||||
| // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||
| // | ||||
| // @dart=2.12 | ||||
|  | ||||
| // ignore_for_file: unused_element, unused_import | ||||
| // ignore_for_file: always_put_required_named_parameters_first | ||||
| // ignore_for_file: constant_identifier_names | ||||
| // ignore_for_file: lines_longer_than_80_chars | ||||
|  | ||||
| part of openapi.api; | ||||
|  | ||||
| class OAuthConfigResponseDto { | ||||
|   /// Returns a new [OAuthConfigResponseDto] instance. | ||||
|   OAuthConfigResponseDto({ | ||||
|     required this.enabled, | ||||
|     this.url, | ||||
|     this.buttonText, | ||||
|   }); | ||||
|  | ||||
|   bool enabled; | ||||
|  | ||||
|   /// | ||||
|   /// Please note: This property should have been non-nullable! Since the specification file | ||||
|   /// does not include a default value (using the "default:" property), however, the generated | ||||
|   /// source code must fall back to having a nullable type. | ||||
|   /// Consider adding a "default:" property in the specification file to hide this note. | ||||
|   /// | ||||
|   String? url; | ||||
|  | ||||
|   /// | ||||
|   /// Please note: This property should have been non-nullable! Since the specification file | ||||
|   /// does not include a default value (using the "default:" property), however, the generated | ||||
|   /// source code must fall back to having a nullable type. | ||||
|   /// Consider adding a "default:" property in the specification file to hide this note. | ||||
|   /// | ||||
|   String? buttonText; | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) => identical(this, other) || other is OAuthConfigResponseDto && | ||||
|      other.enabled == enabled && | ||||
|      other.url == url && | ||||
|      other.buttonText == buttonText; | ||||
|  | ||||
|   @override | ||||
|   int get hashCode => | ||||
|     // ignore: unnecessary_parenthesis | ||||
|     (enabled.hashCode) + | ||||
|     (url == null ? 0 : url!.hashCode) + | ||||
|     (buttonText == null ? 0 : buttonText!.hashCode); | ||||
|  | ||||
|   @override | ||||
|   String toString() => 'OAuthConfigResponseDto[enabled=$enabled, url=$url, buttonText=$buttonText]'; | ||||
|  | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final _json = <String, dynamic>{}; | ||||
|       _json[r'enabled'] = enabled; | ||||
|     if (url != null) { | ||||
|       _json[r'url'] = url; | ||||
|     } else { | ||||
|       _json[r'url'] = null; | ||||
|     } | ||||
|     if (buttonText != null) { | ||||
|       _json[r'buttonText'] = buttonText; | ||||
|     } else { | ||||
|       _json[r'buttonText'] = null; | ||||
|     } | ||||
|     return _json; | ||||
|   } | ||||
|  | ||||
|   /// Returns a new [OAuthConfigResponseDto] instance and imports its values from | ||||
|   /// [value] if it's a [Map], null otherwise. | ||||
|   // ignore: prefer_constructors_over_static_methods | ||||
|   static OAuthConfigResponseDto? fromJson(dynamic value) { | ||||
|     if (value is Map) { | ||||
|       final json = value.cast<String, dynamic>(); | ||||
|  | ||||
|       // Ensure that the map contains the required keys. | ||||
|       // Note 1: the values aren't checked for validity beyond being non-null. | ||||
|       // Note 2: this code is stripped in release mode! | ||||
|       assert(() { | ||||
|         requiredKeys.forEach((key) { | ||||
|           assert(json.containsKey(key), 'Required key "OAuthConfigResponseDto[$key]" is missing from JSON.'); | ||||
|           assert(json[key] != null, 'Required key "OAuthConfigResponseDto[$key]" has a null value in JSON.'); | ||||
|         }); | ||||
|         return true; | ||||
|       }()); | ||||
|  | ||||
|       return OAuthConfigResponseDto( | ||||
|         enabled: mapValueOfType<bool>(json, r'enabled')!, | ||||
|         url: mapValueOfType<String>(json, r'url'), | ||||
|         buttonText: mapValueOfType<String>(json, r'buttonText'), | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   static List<OAuthConfigResponseDto>? listFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final result = <OAuthConfigResponseDto>[]; | ||||
|     if (json is List && json.isNotEmpty) { | ||||
|       for (final row in json) { | ||||
|         final value = OAuthConfigResponseDto.fromJson(row); | ||||
|         if (value != null) { | ||||
|           result.add(value); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return result.toList(growable: growable); | ||||
|   } | ||||
|  | ||||
|   static Map<String, OAuthConfigResponseDto> mapFromJson(dynamic json) { | ||||
|     final map = <String, OAuthConfigResponseDto>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||
|       for (final entry in json.entries) { | ||||
|         final value = OAuthConfigResponseDto.fromJson(entry.value); | ||||
|         if (value != null) { | ||||
|           map[entry.key] = value; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
|  | ||||
|   // maps a json object with a list of OAuthConfigResponseDto-objects as value to a dart map | ||||
|   static Map<String, List<OAuthConfigResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final map = <String, List<OAuthConfigResponseDto>>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||
|       for (final entry in json.entries) { | ||||
|         final value = OAuthConfigResponseDto.listFromJson(entry.value, growable: growable,); | ||||
|         if (value != null) { | ||||
|           map[entry.key] = value; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
|  | ||||
|   /// The list of required keys that must be present in a JSON. | ||||
|   static const requiredKeys = <String>{ | ||||
|     'enabled', | ||||
|   }; | ||||
| } | ||||
|  | ||||
							
								
								
									
										31
									
								
								mobile/openapi/test/o_auth_api_test.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								mobile/openapi/test/o_auth_api_test.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| // | ||||
| // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||
| // | ||||
| // @dart=2.12 | ||||
|  | ||||
| // ignore_for_file: unused_element, unused_import | ||||
| // ignore_for_file: always_put_required_named_parameters_first | ||||
| // ignore_for_file: constant_identifier_names | ||||
| // ignore_for_file: lines_longer_than_80_chars | ||||
|  | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:test/test.dart'; | ||||
|  | ||||
|  | ||||
| /// tests for OAuthApi | ||||
| void main() { | ||||
|   // final instance = OAuthApi(); | ||||
|  | ||||
|   group('tests for OAuthApi', () { | ||||
|     //Future<LoginResponseDto> callback(OAuthCallbackDto oAuthCallbackDto) async | ||||
|     test('test callback', () async { | ||||
|       // TODO | ||||
|     }); | ||||
|  | ||||
|     //Future<OAuthConfigResponseDto> getConfig() async | ||||
|     test('test getConfig', () async { | ||||
|       // TODO | ||||
|     }); | ||||
|  | ||||
|   }); | ||||
| } | ||||
							
								
								
									
										27
									
								
								mobile/openapi/test/o_auth_callback_dto_test.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								mobile/openapi/test/o_auth_callback_dto_test.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| // | ||||
| // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||
| // | ||||
| // @dart=2.12 | ||||
|  | ||||
| // ignore_for_file: unused_element, unused_import | ||||
| // ignore_for_file: always_put_required_named_parameters_first | ||||
| // ignore_for_file: constant_identifier_names | ||||
| // ignore_for_file: lines_longer_than_80_chars | ||||
|  | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:test/test.dart'; | ||||
|  | ||||
| // tests for OAuthCallbackDto | ||||
| void main() { | ||||
|   // final instance = OAuthCallbackDto(); | ||||
|  | ||||
|   group('test OAuthCallbackDto', () { | ||||
|     // String url | ||||
|     test('to test the property `url`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
|  | ||||
|  | ||||
|   }); | ||||
|  | ||||
| } | ||||
							
								
								
									
										27
									
								
								mobile/openapi/test/o_auth_config_dto_test.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								mobile/openapi/test/o_auth_config_dto_test.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| // | ||||
| // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||
| // | ||||
| // @dart=2.12 | ||||
|  | ||||
| // ignore_for_file: unused_element, unused_import | ||||
| // ignore_for_file: always_put_required_named_parameters_first | ||||
| // ignore_for_file: constant_identifier_names | ||||
| // ignore_for_file: lines_longer_than_80_chars | ||||
|  | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:test/test.dart'; | ||||
|  | ||||
| // tests for OAuthConfigDto | ||||
| void main() { | ||||
|   // final instance = OAuthConfigDto(); | ||||
|  | ||||
|   group('test OAuthConfigDto', () { | ||||
|     // String redirectUri | ||||
|     test('to test the property `redirectUri`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
|  | ||||
|  | ||||
|   }); | ||||
|  | ||||
| } | ||||
							
								
								
									
										32
									
								
								mobile/openapi/test/o_auth_config_response_dto_test.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								mobile/openapi/test/o_auth_config_response_dto_test.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| // | ||||
| // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||
| // | ||||
| // @dart=2.12 | ||||
|  | ||||
| // ignore_for_file: unused_element, unused_import | ||||
| // ignore_for_file: always_put_required_named_parameters_first | ||||
| // ignore_for_file: constant_identifier_names | ||||
| // ignore_for_file: lines_longer_than_80_chars | ||||
|  | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:test/test.dart'; | ||||
|  | ||||
| // tests for OAuthConfigResponseDto | ||||
| void main() { | ||||
|   // final instance = OAuthConfigResponseDto(); | ||||
|  | ||||
|   group('test OAuthConfigResponseDto', () { | ||||
|     // bool enabled | ||||
|     test('to test the property `enabled`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
|  | ||||
|     // String url | ||||
|     test('to test the property `url`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
|  | ||||
|  | ||||
|   }); | ||||
|  | ||||
| } | ||||
| @@ -1,36 +1,31 @@ | ||||
| import { Body, Controller, Post, Res, ValidationPipe, Ip } from '@nestjs/common'; | ||||
| import { Body, Controller, Ip, Post, Req, Res, ValidationPipe } from '@nestjs/common'; | ||||
| import { ApiBadRequestResponse, ApiBearerAuth, ApiTags } from '@nestjs/swagger'; | ||||
| import { Request, Response } from 'express'; | ||||
| import { AuthType, IMMICH_AUTH_TYPE_COOKIE } from '../../constants/jwt.constant'; | ||||
| import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; | ||||
| import { Authenticated } from '../../decorators/authenticated.decorator'; | ||||
| import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service'; | ||||
| import { AuthService } from './auth.service'; | ||||
| import { LoginCredentialDto } from './dto/login-credential.dto'; | ||||
| import { LoginResponseDto } from './response-dto/login-response.dto'; | ||||
| import { SignUpDto } from './dto/sign-up.dto'; | ||||
| import { AdminSignupResponseDto } from './response-dto/admin-signup-response.dto'; | ||||
| import { ValidateAccessTokenResponseDto } from './response-dto/validate-asset-token-response.dto,'; | ||||
| import { Response } from 'express'; | ||||
| import { LoginResponseDto } from './response-dto/login-response.dto'; | ||||
| import { LogoutResponseDto } from './response-dto/logout-response.dto'; | ||||
| import { ValidateAccessTokenResponseDto } from './response-dto/validate-asset-token-response.dto,'; | ||||
|  | ||||
| @ApiTags('Authentication') | ||||
| @Controller('auth') | ||||
| export class AuthController { | ||||
|   constructor(private readonly authService: AuthService) {} | ||||
|   constructor(private readonly authService: AuthService, private readonly immichJwtService: ImmichJwtService) {} | ||||
|  | ||||
|   @Post('/login') | ||||
|   async login( | ||||
|     @Body(new ValidationPipe({ transform: true })) loginCredential: LoginCredentialDto, | ||||
|     @Ip() clientIp: string, | ||||
|     @Res() response: Response, | ||||
|     @Res({ passthrough: true }) response: Response, | ||||
|   ): Promise<LoginResponseDto> { | ||||
|     const loginResponse = await this.authService.login(loginCredential, clientIp); | ||||
|  | ||||
|     // Set Cookies | ||||
|     const accessTokenCookie = this.authService.getCookieWithJwtToken(loginResponse); | ||||
|     const isAuthCookie = `immich_is_authenticated=true; Path=/; Max-Age=${7 * 24 * 3600}`; | ||||
|  | ||||
|     response.setHeader('Set-Cookie', [accessTokenCookie, isAuthCookie]); | ||||
|     response.send(loginResponse); | ||||
|  | ||||
|     response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.PASSWORD)); | ||||
|     return loginResponse; | ||||
|   } | ||||
|  | ||||
| @@ -51,13 +46,14 @@ export class AuthController { | ||||
|   } | ||||
|  | ||||
|   @Post('/logout') | ||||
|   async logout(@Res() response: Response): Promise<LogoutResponseDto> { | ||||
|     response.clearCookie('immich_access_token'); | ||||
|     response.clearCookie('immich_is_authenticated'); | ||||
|   async logout(@Req() req: Request, @Res({ passthrough: true }) response: Response): Promise<LogoutResponseDto> { | ||||
|     const authType: AuthType = req.cookies[IMMICH_AUTH_TYPE_COOKIE]; | ||||
|  | ||||
|     const status = new LogoutResponseDto(true); | ||||
|     const cookies = this.immichJwtService.getCookieNames(); | ||||
|     for (const cookie of cookies) { | ||||
|       response.clearCookie(cookie); | ||||
|     } | ||||
|  | ||||
|     response.send(status); | ||||
|     return status; | ||||
|     return this.authService.logout(authType); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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 {} | ||||
|   | ||||
							
								
								
									
										147
									
								
								server/apps/immich/src/api-v1/auth/auth.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										147
									
								
								server/apps/immich/src/api-v1/auth/auth.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,147 @@ | ||||
| import { UserEntity } from '@app/database/entities/user.entity'; | ||||
| import { BadRequestException } from '@nestjs/common'; | ||||
| import { Test } from '@nestjs/testing'; | ||||
| import * as bcrypt from 'bcrypt'; | ||||
| import { AuthType } from '../../constants/jwt.constant'; | ||||
| import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service'; | ||||
| import { OAuthService } from '../oauth/oauth.service'; | ||||
| import { IUserRepository, USER_REPOSITORY } from '../user/user-repository'; | ||||
| import { AuthService } from './auth.service'; | ||||
| import { SignUpDto } from './dto/sign-up.dto'; | ||||
| import { LoginResponseDto } from './response-dto/login-response.dto'; | ||||
|  | ||||
| const fixtures = { | ||||
|   login: { | ||||
|     email: 'test@immich.com', | ||||
|     password: 'password', | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| const CLIENT_IP = '127.0.0.1'; | ||||
|  | ||||
| jest.mock('bcrypt'); | ||||
|  | ||||
| describe('AuthService', () => { | ||||
|   let sut: AuthService; | ||||
|   let userRepositoryMock: jest.Mocked<IUserRepository>; | ||||
|   let immichJwtServiceMock: jest.Mocked<ImmichJwtService>; | ||||
|   let oauthServiceMock: jest.Mocked<OAuthService>; | ||||
|   let compare: jest.Mock; | ||||
|  | ||||
|   afterEach(() => { | ||||
|     jest.resetModules(); | ||||
|   }); | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     jest.mock('bcrypt'); | ||||
|     compare = bcrypt.compare as jest.Mock; | ||||
|  | ||||
|     userRepositoryMock = { | ||||
|       get: jest.fn(), | ||||
|       getAdmin: jest.fn(), | ||||
|       getByEmail: jest.fn(), | ||||
|       getList: jest.fn(), | ||||
|       create: jest.fn(), | ||||
|       update: jest.fn(), | ||||
|       delete: jest.fn(), | ||||
|       restore: jest.fn(), | ||||
|     }; | ||||
|  | ||||
|     immichJwtServiceMock = { | ||||
|       getCookieNames: jest.fn(), | ||||
|       getCookies: jest.fn(), | ||||
|       createLoginResponse: jest.fn(), | ||||
|       validateToken: jest.fn(), | ||||
|       extractJwtFromHeader: jest.fn(), | ||||
|       extractJwtFromCookie: jest.fn(), | ||||
|     } as unknown as jest.Mocked<ImmichJwtService>; | ||||
|  | ||||
|     oauthServiceMock = { | ||||
|       getLogoutEndpoint: jest.fn(), | ||||
|     } as unknown as jest.Mocked<OAuthService>; | ||||
|  | ||||
|     const moduleRef = await Test.createTestingModule({ | ||||
|       providers: [ | ||||
|         AuthService, | ||||
|         { provide: ImmichJwtService, useValue: immichJwtServiceMock }, | ||||
|         { provide: OAuthService, useValue: oauthServiceMock }, | ||||
|         { | ||||
|           provide: USER_REPOSITORY, | ||||
|           useValue: userRepositoryMock, | ||||
|         }, | ||||
|       ], | ||||
|     }).compile(); | ||||
|  | ||||
|     sut = moduleRef.get(AuthService); | ||||
|   }); | ||||
|  | ||||
|   it('should be defined', () => { | ||||
|     expect(sut).toBeDefined(); | ||||
|   }); | ||||
|  | ||||
|   describe('login', () => { | ||||
|     it('should check the user exists', async () => { | ||||
|       userRepositoryMock.getByEmail.mockResolvedValue(null); | ||||
|       await expect(sut.login(fixtures.login, CLIENT_IP)).rejects.toBeInstanceOf(BadRequestException); | ||||
|       expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1); | ||||
|     }); | ||||
|  | ||||
|     it('should check the user has a password', async () => { | ||||
|       userRepositoryMock.getByEmail.mockResolvedValue({} as UserEntity); | ||||
|       await expect(sut.login(fixtures.login, CLIENT_IP)).rejects.toBeInstanceOf(BadRequestException); | ||||
|       expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1); | ||||
|     }); | ||||
|  | ||||
|     it('should successfully log the user in', async () => { | ||||
|       userRepositoryMock.getByEmail.mockResolvedValue({ password: 'password' } as UserEntity); | ||||
|       compare.mockResolvedValue(true); | ||||
|       const dto = { firstName: 'test', lastName: 'immich' } as LoginResponseDto; | ||||
|       immichJwtServiceMock.createLoginResponse.mockResolvedValue(dto); | ||||
|       await expect(sut.login(fixtures.login, CLIENT_IP)).resolves.toEqual(dto); | ||||
|       expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1); | ||||
|       expect(immichJwtServiceMock.createLoginResponse).toHaveBeenCalledTimes(1); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('logout', () => { | ||||
|     it('should return the end session endpoint', async () => { | ||||
|       oauthServiceMock.getLogoutEndpoint.mockResolvedValue('end-session-endpoint'); | ||||
|       await expect(sut.logout(AuthType.OAUTH)).resolves.toEqual({ | ||||
|         successful: true, | ||||
|         redirectUri: 'end-session-endpoint', | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     it('should return the default redirect', async () => { | ||||
|       await expect(sut.logout(AuthType.PASSWORD)).resolves.toEqual({ | ||||
|         successful: true, | ||||
|         redirectUri: '/auth/login', | ||||
|       }); | ||||
|       expect(oauthServiceMock.getLogoutEndpoint).not.toHaveBeenCalled(); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('adminSignUp', () => { | ||||
|     const dto: SignUpDto = { email: 'test@immich.com', password: 'password', firstName: 'immich', lastName: 'admin' }; | ||||
|  | ||||
|     it('should only allow one admin', async () => { | ||||
|       userRepositoryMock.getAdmin.mockResolvedValue({} as UserEntity); | ||||
|       await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException); | ||||
|       expect(userRepositoryMock.getAdmin).toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should sign up the admin', async () => { | ||||
|       userRepositoryMock.getAdmin.mockResolvedValue(null); | ||||
|       userRepositoryMock.create.mockResolvedValue({ ...dto, id: 'admin', createdAt: 'today' } as UserEntity); | ||||
|       await expect(sut.adminSignUp(dto)).resolves.toEqual({ | ||||
|         id: 'admin', | ||||
|         createdAt: 'today', | ||||
|         email: 'test@immich.com', | ||||
|         firstName: 'immich', | ||||
|         lastName: 'admin', | ||||
|       }); | ||||
|       expect(userRepositoryMock.getAdmin).toHaveBeenCalled(); | ||||
|       expect(userRepositoryMock.create).toHaveBeenCalled(); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
| @@ -1,106 +1,80 @@ | ||||
| import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; | ||||
| import { InjectRepository } from '@nestjs/typeorm'; | ||||
| import { Repository } from 'typeorm'; | ||||
| import { UserEntity } from '@app/database/entities/user.entity'; | ||||
| import { LoginCredentialDto } from './dto/login-credential.dto'; | ||||
| import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service'; | ||||
| import { JwtPayloadDto } from './dto/jwt-payload.dto'; | ||||
| import { SignUpDto } from './dto/sign-up.dto'; | ||||
| import { BadRequestException, Inject, Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; | ||||
| import * as bcrypt from 'bcrypt'; | ||||
| import { LoginResponseDto, mapLoginResponse } from './response-dto/login-response.dto'; | ||||
| import { UserEntity } from '../../../../../libs/database/src/entities/user.entity'; | ||||
| import { AuthType } from '../../constants/jwt.constant'; | ||||
| import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service'; | ||||
| import { IUserRepository, USER_REPOSITORY } from '../user/user-repository'; | ||||
| import { LoginCredentialDto } from './dto/login-credential.dto'; | ||||
| import { SignUpDto } from './dto/sign-up.dto'; | ||||
| import { AdminSignupResponseDto, mapAdminSignupResponse } from './response-dto/admin-signup-response.dto'; | ||||
| import { LoginResponseDto } from './response-dto/login-response.dto'; | ||||
| import { LogoutResponseDto } from './response-dto/logout-response.dto'; | ||||
| import { OAuthService } from '../oauth/oauth.service'; | ||||
|  | ||||
| @Injectable() | ||||
| export class AuthService { | ||||
|   constructor( | ||||
|     @InjectRepository(UserEntity) | ||||
|     private userRepository: Repository<UserEntity>, | ||||
|     private oauthService: OAuthService, | ||||
|     private immichJwtService: ImmichJwtService, | ||||
|     @Inject(USER_REPOSITORY) private userRepository: IUserRepository, | ||||
|   ) {} | ||||
|  | ||||
|   private async validateUser(loginCredential: LoginCredentialDto): Promise<UserEntity | null> { | ||||
|     const user = await this.userRepository.findOne({ | ||||
|       where: { | ||||
|         email: loginCredential.email, | ||||
|       }, | ||||
|       select: [ | ||||
|         'id', | ||||
|         'email', | ||||
|         'password', | ||||
|         'salt', | ||||
|         'firstName', | ||||
|         'lastName', | ||||
|         'isAdmin', | ||||
|         'profileImagePath', | ||||
|         'shouldChangePassword', | ||||
|       ], | ||||
|     }); | ||||
|   public async login(loginCredential: LoginCredentialDto, clientIp: string): Promise<LoginResponseDto> { | ||||
|     let user = await this.userRepository.getByEmail(loginCredential.email, true); | ||||
|  | ||||
|     if (user) { | ||||
|       const isAuthenticated = await this.validatePassword(loginCredential.password, user); | ||||
|       if (!isAuthenticated) { | ||||
|         user = null; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (!user) { | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion | ||||
|     const isAuthenticated = await this.validatePassword(user.password!, loginCredential.password, user.salt!); | ||||
|  | ||||
|     if (isAuthenticated) { | ||||
|       return user; | ||||
|     } | ||||
|  | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   public async login(loginCredential: LoginCredentialDto, clientIp: string): Promise<LoginResponseDto> { | ||||
|     const validatedUser = await this.validateUser(loginCredential); | ||||
|  | ||||
|     if (!validatedUser) { | ||||
|       Logger.warn(`Failed login attempt for user ${loginCredential.email} from ip address ${clientIp}`); | ||||
|       throw new BadRequestException('Incorrect email or password'); | ||||
|     } | ||||
|  | ||||
|     const payload = new JwtPayloadDto(validatedUser.id, validatedUser.email); | ||||
|     const accessToken = await this.immichJwtService.generateToken(payload); | ||||
|  | ||||
|     return mapLoginResponse(validatedUser, accessToken); | ||||
|     return this.immichJwtService.createLoginResponse(user); | ||||
|   } | ||||
|  | ||||
|   public getCookieWithJwtToken(authLoginInfo: LoginResponseDto) { | ||||
|     const maxAge = 7 * 24 * 3600; // 7 days | ||||
|     return `immich_access_token=${authLoginInfo.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}`; | ||||
|   public async logout(authType: AuthType): Promise<LogoutResponseDto> { | ||||
|     if (authType === AuthType.OAUTH) { | ||||
|       const url = await this.oauthService.getLogoutEndpoint(); | ||||
|       if (url) { | ||||
|         return { successful: true, redirectUri: url }; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return { successful: true, redirectUri: '/auth/login' }; | ||||
|   } | ||||
|  | ||||
|   // !TODO: refactor this method to use the userService createUser method | ||||
|   public async adminSignUp(signUpCredential: SignUpDto): Promise<AdminSignupResponseDto> { | ||||
|     const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } }); | ||||
|   public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> { | ||||
|     const adminUser = await this.userRepository.getAdmin(); | ||||
|  | ||||
|     if (adminUser) { | ||||
|       throw new BadRequestException('The server already has an admin'); | ||||
|     } | ||||
|  | ||||
|     const newAdminUser = new UserEntity(); | ||||
|     newAdminUser.email = signUpCredential.email; | ||||
|     newAdminUser.salt = await bcrypt.genSalt(); | ||||
|     newAdminUser.password = await this.hashPassword(signUpCredential.password, newAdminUser.salt); | ||||
|     newAdminUser.firstName = signUpCredential.firstName; | ||||
|     newAdminUser.lastName = signUpCredential.lastName; | ||||
|     newAdminUser.isAdmin = true; | ||||
|  | ||||
|     try { | ||||
|       const savedNewAdminUserUser = await this.userRepository.save(newAdminUser); | ||||
|       const admin = await this.userRepository.create({ | ||||
|         isAdmin: true, | ||||
|         email: dto.email, | ||||
|         firstName: dto.firstName, | ||||
|         lastName: dto.lastName, | ||||
|         password: dto.password, | ||||
|       }); | ||||
|  | ||||
|       return mapAdminSignupResponse(savedNewAdminUserUser); | ||||
|       return mapAdminSignupResponse(admin); | ||||
|     } catch (e) { | ||||
|       Logger.error('e', 'signUp'); | ||||
|       throw new InternalServerErrorException('Failed to register new admin user'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private async hashPassword(password: string, salt: string): Promise<string> { | ||||
|     return bcrypt.hash(password, salt); | ||||
|   } | ||||
|  | ||||
|   private async validatePassword(hasedPassword: string, inputPassword: string, salt: string): Promise<boolean> { | ||||
|     const hash = await bcrypt.hash(inputPassword, salt); | ||||
|     return hash === hasedPassword; | ||||
|   private async validatePassword(inputPassword: string, user: UserEntity): Promise<boolean> { | ||||
|     if (!user || !user.password) { | ||||
|       return false; | ||||
|     } | ||||
|     return await bcrypt.compare(inputPassword, user.password); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -7,4 +7,7 @@ export class LogoutResponseDto { | ||||
|  | ||||
|   @ApiResponseProperty() | ||||
|   successful!: boolean; | ||||
|  | ||||
|   @ApiResponseProperty() | ||||
|   redirectUri!: string; | ||||
| } | ||||
|   | ||||
| @@ -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(); | ||||
|   | ||||
| @@ -0,0 +1,9 @@ | ||||
| import { ApiProperty } from '@nestjs/swagger'; | ||||
| import { IsNotEmpty, IsString } from 'class-validator'; | ||||
|  | ||||
| export class OAuthCallbackDto { | ||||
|   @IsNotEmpty() | ||||
|   @IsString() | ||||
|   @ApiProperty() | ||||
|   url!: string; | ||||
| } | ||||
| @@ -0,0 +1,9 @@ | ||||
| import { ApiProperty } from '@nestjs/swagger'; | ||||
| import { IsNotEmpty, IsString } from 'class-validator'; | ||||
|  | ||||
| export class OAuthConfigDto { | ||||
|   @IsNotEmpty() | ||||
|   @IsString() | ||||
|   @ApiProperty() | ||||
|   redirectUri!: string; | ||||
| } | ||||
							
								
								
									
										27
									
								
								server/apps/immich/src/api-v1/oauth/oauth.controller.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								server/apps/immich/src/api-v1/oauth/oauth.controller.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| import { Body, Controller, Post, Res, ValidationPipe } from '@nestjs/common'; | ||||
| import { ApiTags } from '@nestjs/swagger'; | ||||
| import { Response } from 'express'; | ||||
| import { AuthType } from '../../constants/jwt.constant'; | ||||
| import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service'; | ||||
| import { OAuthCallbackDto } from './dto/oauth-auth-code.dto'; | ||||
| import { OAuthConfigDto } from './dto/oauth-config.dto'; | ||||
| import { OAuthService } from './oauth.service'; | ||||
| import { OAuthConfigResponseDto } from './response-dto/oauth-config-response.dto'; | ||||
|  | ||||
| @ApiTags('OAuth') | ||||
| @Controller('oauth') | ||||
| export class OAuthController { | ||||
|   constructor(private readonly immichJwtService: ImmichJwtService, private readonly oauthService: OAuthService) {} | ||||
|  | ||||
|   @Post('/config') | ||||
|   public generateConfig(@Body(ValidationPipe) dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> { | ||||
|     return this.oauthService.generateConfig(dto); | ||||
|   } | ||||
|  | ||||
|   @Post('/callback') | ||||
|   public async callback(@Res({ passthrough: true }) response: Response, @Body(ValidationPipe) dto: OAuthCallbackDto) { | ||||
|     const loginResponse = await this.oauthService.callback(dto); | ||||
|     response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.OAUTH)); | ||||
|     return loginResponse; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										13
									
								
								server/apps/immich/src/api-v1/oauth/oauth.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								server/apps/immich/src/api-v1/oauth/oauth.module.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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 {} | ||||
							
								
								
									
										169
									
								
								server/apps/immich/src/api-v1/oauth/oauth.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										169
									
								
								server/apps/immich/src/api-v1/oauth/oauth.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,169 @@ | ||||
| import { UserEntity } from '@app/database/entities/user.entity'; | ||||
| import { BadRequestException } from '@nestjs/common'; | ||||
| import { ConfigService } from '@nestjs/config'; | ||||
| import { generators, Issuer } from 'openid-client'; | ||||
| import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service'; | ||||
| import { LoginResponseDto } from '../auth/response-dto/login-response.dto'; | ||||
| import { OAuthService } from '../oauth/oauth.service'; | ||||
| import { IUserRepository } from '../user/user-repository'; | ||||
|  | ||||
| interface OAuthConfig { | ||||
|   OAUTH_ENABLED: boolean; | ||||
|   OAUTH_AUTO_REGISTER: boolean; | ||||
|   OAUTH_ISSUER_URL: string; | ||||
|   OAUTH_SCOPE: string; | ||||
|   OAUTH_BUTTON_TEXT: string; | ||||
| } | ||||
|  | ||||
| const mockConfig = (config: Partial<OAuthConfig>) => { | ||||
|   return (value: keyof OAuthConfig, defaultValue: any) => config[value] ?? defaultValue ?? null; | ||||
| }; | ||||
|  | ||||
| const email = 'user@immich.com'; | ||||
|  | ||||
| const user = { | ||||
|   id: 'user', | ||||
|   email, | ||||
|   firstName: 'user', | ||||
|   lastName: 'imimch', | ||||
| } as UserEntity; | ||||
|  | ||||
| const loginResponse = { | ||||
|   accessToken: 'access-token', | ||||
|   userId: 'user', | ||||
|   userEmail: 'user@immich.com,', | ||||
| } as LoginResponseDto; | ||||
|  | ||||
| describe('OAuthService', () => { | ||||
|   let sut: OAuthService; | ||||
|   let userRepositoryMock: jest.Mocked<IUserRepository>; | ||||
|   let configServiceMock: jest.Mocked<ConfigService>; | ||||
|   let immichJwtServiceMock: jest.Mocked<ImmichJwtService>; | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     jest.spyOn(generators, 'state').mockReturnValue('state'); | ||||
|     jest.spyOn(Issuer, 'discover').mockResolvedValue({ | ||||
|       id_token_signing_alg_values_supported: ['HS256'], | ||||
|       Client: jest.fn().mockResolvedValue({ | ||||
|         issuer: { | ||||
|           metadata: { | ||||
|             end_session_endpoint: 'http://end-session-endpoint', | ||||
|           }, | ||||
|         }, | ||||
|         authorizationUrl: jest.fn().mockReturnValue('http://authorization-url'), | ||||
|         callbackParams: jest.fn().mockReturnValue({ state: 'state' }), | ||||
|         callback: jest.fn().mockReturnValue({ access_token: 'access-token' }), | ||||
|         userinfo: jest.fn().mockResolvedValue({ email }), | ||||
|       }), | ||||
|     } as any); | ||||
|  | ||||
|     userRepositoryMock = { | ||||
|       get: jest.fn(), | ||||
|       getAdmin: jest.fn(), | ||||
|       getByEmail: jest.fn(), | ||||
|       getList: jest.fn(), | ||||
|       create: jest.fn(), | ||||
|       update: jest.fn(), | ||||
|       delete: jest.fn(), | ||||
|       restore: jest.fn(), | ||||
|     }; | ||||
|  | ||||
|     immichJwtServiceMock = { | ||||
|       getCookieNames: jest.fn(), | ||||
|       getCookies: jest.fn(), | ||||
|       createLoginResponse: jest.fn(), | ||||
|       validateToken: jest.fn(), | ||||
|       extractJwtFromHeader: jest.fn(), | ||||
|       extractJwtFromCookie: jest.fn(), | ||||
|     } as unknown as jest.Mocked<ImmichJwtService>; | ||||
|  | ||||
|     configServiceMock = { | ||||
|       get: jest.fn(), | ||||
|     } as unknown as jest.Mocked<ConfigService>; | ||||
|  | ||||
|     sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock); | ||||
|   }); | ||||
|  | ||||
|   it('should be defined', () => { | ||||
|     expect(sut).toBeDefined(); | ||||
|   }); | ||||
|  | ||||
|   describe('generateConfig', () => { | ||||
|     it('should work when oauth is not configured', async () => { | ||||
|       await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({ enabled: false }); | ||||
|       expect(configServiceMock.get).toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should generate the config', async () => { | ||||
|       configServiceMock.get.mockImplementation( | ||||
|         mockConfig({ | ||||
|           OAUTH_ENABLED: true, | ||||
|           OAUTH_BUTTON_TEXT: 'OAuth', | ||||
|         }), | ||||
|       ); | ||||
|       sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock); | ||||
|       await expect(sut.generateConfig({ redirectUri: 'http://redirect' })).resolves.toEqual({ | ||||
|         enabled: true, | ||||
|         buttonText: 'OAuth', | ||||
|         url: 'http://authorization-url', | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('callback', () => { | ||||
|     it('should throw an error if OAuth is not enabled', async () => { | ||||
|       await expect(sut.callback({ url: '' })).rejects.toBeInstanceOf(BadRequestException); | ||||
|     }); | ||||
|  | ||||
|     it('should not allow auto registering', async () => { | ||||
|       configServiceMock.get.mockImplementation( | ||||
|         mockConfig({ | ||||
|           OAUTH_ENABLED: true, | ||||
|           OAUTH_AUTO_REGISTER: false, | ||||
|         }), | ||||
|       ); | ||||
|       sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock); | ||||
|       jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null); | ||||
|       jest.spyOn(sut['logger'], 'warn').mockImplementation(() => null); | ||||
|       userRepositoryMock.getByEmail.mockResolvedValue(null); | ||||
|       await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' })).rejects.toBeInstanceOf( | ||||
|         BadRequestException, | ||||
|       ); | ||||
|       expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1); | ||||
|     }); | ||||
|  | ||||
|     it('should allow auto registering by default', async () => { | ||||
|       configServiceMock.get.mockImplementation(mockConfig({ OAUTH_ENABLED: true })); | ||||
|       sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock); | ||||
|       jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null); | ||||
|       jest.spyOn(sut['logger'], 'log').mockImplementation(() => null); | ||||
|       userRepositoryMock.getByEmail.mockResolvedValue(null); | ||||
|       userRepositoryMock.create.mockResolvedValue(user); | ||||
|       immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse); | ||||
|  | ||||
|       await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(loginResponse); | ||||
|  | ||||
|       expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1); | ||||
|       expect(userRepositoryMock.create).toHaveBeenCalledTimes(1); | ||||
|       expect(immichJwtServiceMock.createLoginResponse).toHaveBeenCalledTimes(1); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('getLogoutEndpoint', () => { | ||||
|     it('should return null if OAuth is not configured', async () => { | ||||
|       await expect(sut.getLogoutEndpoint()).resolves.toBeNull(); | ||||
|     }); | ||||
|  | ||||
|     it('should get the session endpoint from the discovery document', async () => { | ||||
|       configServiceMock.get.mockImplementation( | ||||
|         mockConfig({ | ||||
|           OAUTH_ENABLED: true, | ||||
|           OAUTH_ISSUER_URL: 'http://issuer', | ||||
|         }), | ||||
|       ); | ||||
|       sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock); | ||||
|  | ||||
|       await expect(sut.getLogoutEndpoint()).resolves.toBe('http://end-session-endpoint'); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										108
									
								
								server/apps/immich/src/api-v1/oauth/oauth.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								server/apps/immich/src/api-v1/oauth/oauth.service.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,108 @@ | ||||
| import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'; | ||||
| import { ConfigService } from '@nestjs/config'; | ||||
| import { ClientMetadata, generators, Issuer, UserinfoResponse } from 'openid-client'; | ||||
| import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service'; | ||||
| import { LoginResponseDto } from '../auth/response-dto/login-response.dto'; | ||||
| import { IUserRepository, USER_REPOSITORY } from '../user/user-repository'; | ||||
| import { OAuthCallbackDto } from './dto/oauth-auth-code.dto'; | ||||
| import { OAuthConfigDto } from './dto/oauth-config.dto'; | ||||
| import { OAuthConfigResponseDto } from './response-dto/oauth-config-response.dto'; | ||||
|  | ||||
| type OAuthProfile = UserinfoResponse & { | ||||
|   email: string; | ||||
| }; | ||||
|  | ||||
| @Injectable() | ||||
| export class OAuthService { | ||||
|   private readonly logger = new Logger(OAuthService.name); | ||||
|  | ||||
|   private readonly enabled: boolean; | ||||
|   private readonly autoRegister: boolean; | ||||
|   private readonly buttonText: string; | ||||
|   private readonly issuerUrl: string; | ||||
|   private readonly clientMetadata: ClientMetadata; | ||||
|   private readonly scope: string; | ||||
|  | ||||
|   constructor( | ||||
|     private immichJwtService: ImmichJwtService, | ||||
|     configService: ConfigService, | ||||
|     @Inject(USER_REPOSITORY) private userRepository: IUserRepository, | ||||
|   ) { | ||||
|     this.enabled = configService.get('OAUTH_ENABLED', false); | ||||
|     this.autoRegister = configService.get('OAUTH_AUTO_REGISTER', true); | ||||
|     this.issuerUrl = configService.get<string>('OAUTH_ISSUER_URL', ''); | ||||
|     this.scope = configService.get<string>('OAUTH_SCOPE', ''); | ||||
|     this.buttonText = configService.get<string>('OAUTH_BUTTON_TEXT', ''); | ||||
|  | ||||
|     this.clientMetadata = { | ||||
|       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion | ||||
|       client_id: configService.get('OAUTH_CLIENT_ID')!, | ||||
|       client_secret: configService.get('OAUTH_CLIENT_SECRET'), | ||||
|       response_types: ['code'], | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   public async generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> { | ||||
|     if (!this.enabled) { | ||||
|       return { enabled: false }; | ||||
|     } | ||||
|  | ||||
|     const url = (await this.getClient()).authorizationUrl({ | ||||
|       redirect_uri: dto.redirectUri, | ||||
|       scope: this.scope, | ||||
|       state: generators.state(), | ||||
|     }); | ||||
|     return { enabled: true, buttonText: this.buttonText, url }; | ||||
|   } | ||||
|  | ||||
|   public async callback(dto: OAuthCallbackDto): Promise<LoginResponseDto> { | ||||
|     const redirectUri = dto.url.split('?')[0]; | ||||
|     const client = await this.getClient(); | ||||
|     const params = client.callbackParams(dto.url); | ||||
|     const tokens = await client.callback(redirectUri, params, { state: params.state }); | ||||
|     const profile = await client.userinfo<OAuthProfile>(tokens.access_token || ''); | ||||
|  | ||||
|     this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`); | ||||
|     let user = await this.userRepository.getByEmail(profile.email); | ||||
|  | ||||
|     if (!user) { | ||||
|       if (!this.autoRegister) { | ||||
|         this.logger.warn( | ||||
|           `Unable to register ${profile.email}. To enable auto registering, set OAUTH_AUTO_REGISTER=true.`, | ||||
|         ); | ||||
|         throw new BadRequestException(`User does not exist and auto registering is disabled.`); | ||||
|       } | ||||
|  | ||||
|       this.logger.log(`Registering new user: ${profile.email}`); | ||||
|       user = await this.userRepository.create({ | ||||
|         firstName: profile.given_name || '', | ||||
|         lastName: profile.family_name || '', | ||||
|         email: profile.email, | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     return this.immichJwtService.createLoginResponse(user); | ||||
|   } | ||||
|  | ||||
|   public async getLogoutEndpoint(): Promise<string | null> { | ||||
|     if (!this.enabled) { | ||||
|       return null; | ||||
|     } | ||||
|     return (await this.getClient()).issuer.metadata.end_session_endpoint || null; | ||||
|   } | ||||
|  | ||||
|   private async getClient() { | ||||
|     if (!this.enabled) { | ||||
|       throw new BadRequestException('OAuth2 is not enabled'); | ||||
|     } | ||||
|  | ||||
|     const issuer = await Issuer.discover(this.issuerUrl); | ||||
|     const algorithms = (issuer.id_token_signing_alg_values_supported || []) as string[]; | ||||
|     const metadata = { ...this.clientMetadata }; | ||||
|     if (algorithms[0] === 'HS256') { | ||||
|       metadata.id_token_signed_response_alg = algorithms[0]; | ||||
|     } | ||||
|  | ||||
|     return new issuer.Client(metadata); | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,12 @@ | ||||
| import { ApiResponseProperty } from '@nestjs/swagger'; | ||||
|  | ||||
| export class OAuthConfigResponseDto { | ||||
|   @ApiResponseProperty() | ||||
|   enabled!: boolean; | ||||
|  | ||||
|   @ApiResponseProperty() | ||||
|   url?: string; | ||||
|  | ||||
|   @ApiResponseProperty() | ||||
|   buttonText?: string; | ||||
| } | ||||
| @@ -1,18 +1,16 @@ | ||||
| import { UserEntity } from '@app/database/entities/user.entity'; | ||||
| import { BadRequestException } from '@nestjs/common'; | ||||
| import { InjectRepository } from '@nestjs/typeorm'; | ||||
| import { Not, Repository } from 'typeorm'; | ||||
| import { CreateUserDto } from './dto/create-user.dto'; | ||||
| import * as bcrypt from 'bcrypt'; | ||||
| import { UpdateUserDto } from './dto/update-user.dto'; | ||||
| import { Not, Repository } from 'typeorm'; | ||||
|  | ||||
| export interface IUserRepository { | ||||
|   get(userId: string, withDeleted?: boolean): Promise<UserEntity | null>; | ||||
|   getByEmail(email: string): Promise<UserEntity | null>; | ||||
|   get(id: string, withDeleted?: boolean): Promise<UserEntity | null>; | ||||
|   getAdmin(): Promise<UserEntity | null>; | ||||
|   getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null>; | ||||
|   getList(filter?: { excludeId?: string }): Promise<UserEntity[]>; | ||||
|   create(createUserDto: CreateUserDto): Promise<UserEntity>; | ||||
|   update(user: UserEntity, updateUserDto: UpdateUserDto): Promise<UserEntity>; | ||||
|   createProfileImage(user: UserEntity, fileInfo: Express.Multer.File): Promise<UserEntity>; | ||||
|   create(user: Partial<UserEntity>): Promise<UserEntity>; | ||||
|   update(id: string, user: Partial<UserEntity>): Promise<UserEntity>; | ||||
|   delete(user: UserEntity): Promise<UserEntity>; | ||||
|   restore(user: UserEntity): Promise<UserEntity>; | ||||
| } | ||||
| @@ -25,25 +23,29 @@ export class UserRepository implements IUserRepository { | ||||
|     private userRepository: Repository<UserEntity>, | ||||
|   ) {} | ||||
|  | ||||
|   private async hashPassword(password: string, salt: string): Promise<string> { | ||||
|     return bcrypt.hash(password, salt); | ||||
|   } | ||||
|  | ||||
|   async get(userId: string, withDeleted?: boolean): Promise<UserEntity | null> { | ||||
|   public async get(userId: string, withDeleted?: boolean): Promise<UserEntity | null> { | ||||
|     return this.userRepository.findOne({ where: { id: userId }, withDeleted: withDeleted }); | ||||
|   } | ||||
|  | ||||
|   async getByEmail(email: string): Promise<UserEntity | null> { | ||||
|     return this.userRepository.findOne({ where: { email } }); | ||||
|   public async getAdmin(): Promise<UserEntity | null> { | ||||
|     return this.userRepository.findOne({ where: { isAdmin: true } }); | ||||
|   } | ||||
|  | ||||
|   // TODO add DTO for filtering | ||||
|   async getList({ excludeId }: { excludeId?: string } = {}): Promise<UserEntity[]> { | ||||
|   public async getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null> { | ||||
|     let builder = this.userRepository.createQueryBuilder('user').where({ email }); | ||||
|  | ||||
|     if (withPassword) { | ||||
|       builder = builder.addSelect('user.password'); | ||||
|     } | ||||
|  | ||||
|     return builder.getOne(); | ||||
|   } | ||||
|  | ||||
|   public async getList({ excludeId }: { excludeId?: string } = {}): Promise<UserEntity[]> { | ||||
|     if (!excludeId) { | ||||
|       return this.userRepository.find(); // TODO: this should also be ordered the same as below | ||||
|     } | ||||
|     return this.userRepository | ||||
|     .find({ | ||||
|     return this.userRepository.find({ | ||||
|       where: { id: Not(excludeId) }, | ||||
|       withDeleted: true, | ||||
|       order: { | ||||
| @@ -52,33 +54,27 @@ export class UserRepository implements IUserRepository { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   async create(createUserDto: CreateUserDto): Promise<UserEntity> { | ||||
|     const newUser = new UserEntity(); | ||||
|     newUser.email = createUserDto.email; | ||||
|     newUser.salt = await bcrypt.genSalt(); | ||||
|     newUser.password = await this.hashPassword(createUserDto.password, newUser.salt); | ||||
|     newUser.firstName = createUserDto.firstName; | ||||
|     newUser.lastName = createUserDto.lastName; | ||||
|     newUser.isAdmin = false; | ||||
|   public async create(user: Partial<UserEntity>): Promise<UserEntity> { | ||||
|     if (user.password) { | ||||
|       user.salt = await bcrypt.genSalt(); | ||||
|       user.password = await this.hashPassword(user.password, user.salt); | ||||
|     } | ||||
|     user.isAdmin = false; | ||||
|  | ||||
|     return this.userRepository.save(newUser); | ||||
|     return this.userRepository.save(user); | ||||
|   } | ||||
|  | ||||
|   async update(user: UserEntity, updateUserDto: UpdateUserDto): Promise<UserEntity> { | ||||
|     user.lastName = updateUserDto.lastName || user.lastName; | ||||
|     user.firstName = updateUserDto.firstName || user.firstName; | ||||
|     user.profileImagePath = updateUserDto.profileImagePath || user.profileImagePath; | ||||
|     user.shouldChangePassword = | ||||
|       updateUserDto.shouldChangePassword != undefined ? updateUserDto.shouldChangePassword : user.shouldChangePassword; | ||||
|   public async update(id: string, user: Partial<UserEntity>): Promise<UserEntity> { | ||||
|     user.id = id; | ||||
|  | ||||
|     // If payload includes password - Create new password for user | ||||
|     if (updateUserDto.password) { | ||||
|     if (user.password) { | ||||
|       user.salt = await bcrypt.genSalt(); | ||||
|       user.password = await this.hashPassword(updateUserDto.password, user.salt); | ||||
|       user.password = await this.hashPassword(user.password, user.salt); | ||||
|     } | ||||
|  | ||||
|     // TODO: can this happen? If so we can move it to the service, otherwise remove it (also from DTO) | ||||
|     if (updateUserDto.isAdmin) { | ||||
|     if (user.isAdmin) { | ||||
|       const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } }); | ||||
|  | ||||
|       if (adminUser) { | ||||
| @@ -91,19 +87,18 @@ export class UserRepository implements IUserRepository { | ||||
|     return this.userRepository.save(user); | ||||
|   } | ||||
|  | ||||
|   async delete(user: UserEntity): Promise<UserEntity> { | ||||
|   public async delete(user: UserEntity): Promise<UserEntity> { | ||||
|     if (user.isAdmin) { | ||||
|       throw new BadRequestException('Cannot delete admin user! stay sane!'); | ||||
|     } | ||||
|     return this.userRepository.softRemove(user); | ||||
|   } | ||||
|  | ||||
|   async restore(user: UserEntity): Promise<UserEntity> { | ||||
|   public async restore(user: UserEntity): Promise<UserEntity> { | ||||
|     return this.userRepository.recover(user); | ||||
|   } | ||||
|  | ||||
|   async createProfileImage(user: UserEntity, fileInfo: Express.Multer.File): Promise<UserEntity> { | ||||
|     user.profileImagePath = fileInfo.path; | ||||
|     return this.userRepository.save(user); | ||||
|   private async hashPassword(password: string, salt: string): Promise<string> { | ||||
|     return bcrypt.hash(password, salt); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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 {} | ||||
|   | ||||
| @@ -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); | ||||
|   }); | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -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, | ||||
|  | ||||
|   | ||||
| @@ -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', | ||||
| } | ||||
|   | ||||
| @@ -1,53 +1,96 @@ | ||||
| import { Logger } from '@nestjs/common'; | ||||
| import { JwtService } from '@nestjs/jwt'; | ||||
| import { Request } from 'express'; | ||||
| import { UserEntity } from '../../../../../libs/database/src/entities/user.entity'; | ||||
| import { LoginResponseDto } from '../../api-v1/auth/response-dto/login-response.dto'; | ||||
| import { AuthType } from '../../constants/jwt.constant'; | ||||
| import { ImmichJwtService } from './immich-jwt.service'; | ||||
|  | ||||
| describe('ImmichJwtService', () => { | ||||
|   let jwtService: JwtService; | ||||
|   let service: ImmichJwtService; | ||||
|   let jwtServiceMock: jest.Mocked<JwtService>; | ||||
|   let sut: ImmichJwtService; | ||||
|  | ||||
|   beforeEach(() => { | ||||
|     jwtService = new JwtService(); | ||||
|     service = new ImmichJwtService(jwtService); | ||||
|     jwtServiceMock = { | ||||
|       sign: jest.fn(), | ||||
|       verifyAsync: jest.fn(), | ||||
|     } as unknown as jest.Mocked<JwtService>; | ||||
|  | ||||
|     sut = new ImmichJwtService(jwtServiceMock); | ||||
|   }); | ||||
|  | ||||
|   afterEach(() => { | ||||
|     jest.resetModules(); | ||||
|   }); | ||||
|  | ||||
|   describe('generateToken', () => { | ||||
|     it('should generate the token', async () => { | ||||
|       const spy = jest.spyOn(jwtService, 'sign'); | ||||
|       spy.mockImplementation((value) => value as string); | ||||
|       const dto = { userId: 'test-user', email: 'test-user@immich.com' }; | ||||
|       const token = await service.generateToken(dto); | ||||
|       expect(token).toEqual(dto); | ||||
|   describe('getCookieNames', () => { | ||||
|     it('should return the cookie names', async () => { | ||||
|       expect(sut.getCookieNames()).toEqual(['immich_access_token', 'immich_auth_type']); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('getCookies', () => { | ||||
|     it('should generate the cookie headers', async () => { | ||||
|       jwtServiceMock.sign.mockImplementation((value) => value as string); | ||||
|       const dto = { accessToken: 'test-user@immich.com', userId: 'test-user' }; | ||||
|       const cookies = await sut.getCookies(dto as LoginResponseDto, AuthType.PASSWORD); | ||||
|       expect(cookies).toEqual([ | ||||
|         'immich_access_token=test-user@immich.com; HttpOnly; Path=/; Max-Age=604800', | ||||
|         'immich_auth_type=password; Path=/; Max-Age=604800', | ||||
|       ]); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('createLoginResponse', () => { | ||||
|     it('should create the login response', async () => { | ||||
|       jwtServiceMock.sign.mockReturnValue('fancy-token'); | ||||
|       const user: UserEntity = { | ||||
|         id: 'user', | ||||
|         firstName: 'immich', | ||||
|         lastName: 'user', | ||||
|         isAdmin: false, | ||||
|         email: 'test@immich.com', | ||||
|         password: 'changeme', | ||||
|         salt: '123', | ||||
|         profileImagePath: '', | ||||
|         shouldChangePassword: false, | ||||
|         createdAt: 'today', | ||||
|       }; | ||||
|  | ||||
|       const dto: LoginResponseDto = { | ||||
|         accessToken: 'fancy-token', | ||||
|         firstName: 'immich', | ||||
|         isAdmin: false, | ||||
|         lastName: 'user', | ||||
|         profileImagePath: '', | ||||
|         shouldChangePassword: false, | ||||
|         userEmail: 'test@immich.com', | ||||
|         userId: 'user', | ||||
|       }; | ||||
|       await expect(sut.createLoginResponse(user)).resolves.toEqual(dto); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('validateToken', () => { | ||||
|     it('should validate the token', async () => { | ||||
|       const dto = { userId: 'test-user', email: 'test-user@immich.com' }; | ||||
|       const spy = jest.spyOn(jwtService, 'verifyAsync'); | ||||
|       spy.mockImplementation(() => dto as any); | ||||
|       const response = await service.validateToken('access-token'); | ||||
|       jwtServiceMock.verifyAsync.mockImplementation(() => dto as any); | ||||
|       const response = await sut.validateToken('access-token'); | ||||
|  | ||||
|       expect(spy).toHaveBeenCalledTimes(1); | ||||
|       expect(jwtServiceMock.verifyAsync).toHaveBeenCalledTimes(1); | ||||
|       expect(response).toEqual({ userId: 'test-user', status: true }); | ||||
|     }); | ||||
|  | ||||
|     it('should handle an invalid token', async () => { | ||||
|       const verifyAsync = jest.spyOn(jwtService, 'verifyAsync'); | ||||
|       verifyAsync.mockImplementation(() => { | ||||
|       jwtServiceMock.verifyAsync.mockImplementation(() => { | ||||
|         throw new Error('Invalid token!'); | ||||
|       }); | ||||
|  | ||||
|       const error = jest.spyOn(Logger, 'error'); | ||||
|       error.mockImplementation(() => null); | ||||
|       const response = await service.validateToken('access-token'); | ||||
|       const response = await sut.validateToken('access-token'); | ||||
|  | ||||
|       expect(verifyAsync).toHaveBeenCalledTimes(1); | ||||
|       expect(jwtServiceMock.verifyAsync).toHaveBeenCalledTimes(1); | ||||
|       expect(error).toHaveBeenCalledTimes(1); | ||||
|       expect(response).toEqual({ userId: null, status: false }); | ||||
|     }); | ||||
| @@ -58,7 +101,7 @@ describe('ImmichJwtService', () => { | ||||
|       const request = { | ||||
|         headers: {}, | ||||
|       } as Request; | ||||
|       const token = service.extractJwtFromHeader(request); | ||||
|       const token = sut.extractJwtFromHeader(request); | ||||
|       expect(token).toBe(null); | ||||
|     }); | ||||
|  | ||||
| @@ -75,15 +118,15 @@ describe('ImmichJwtService', () => { | ||||
|         }, | ||||
|       } as Request; | ||||
|  | ||||
|       expect(service.extractJwtFromHeader(upper)).toBe('token'); | ||||
|       expect(service.extractJwtFromHeader(lower)).toBe('token'); | ||||
|       expect(sut.extractJwtFromHeader(upper)).toBe('token'); | ||||
|       expect(sut.extractJwtFromHeader(lower)).toBe('token'); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('extracJwtFromCookie', () => { | ||||
|     it('should handle no cookie', () => { | ||||
|       const request = {} as Request; | ||||
|       const token = service.extractJwtFromCookie(request); | ||||
|       const token = sut.extractJwtFromCookie(request); | ||||
|       expect(token).toBe(null); | ||||
|     }); | ||||
|  | ||||
| @@ -93,7 +136,7 @@ describe('ImmichJwtService', () => { | ||||
|           immich_access_token: 'cookie', | ||||
|         }, | ||||
|       } as Request; | ||||
|       const token = service.extractJwtFromCookie(request); | ||||
|       const token = sut.extractJwtFromCookie(request); | ||||
|       expect(token).toBe('cookie'); | ||||
|     }); | ||||
|   }); | ||||
|   | ||||
| @@ -1,8 +1,10 @@ | ||||
| import { UserEntity } from '@app/database/entities/user.entity'; | ||||
| import { Injectable, Logger } from '@nestjs/common'; | ||||
| import { JwtService } from '@nestjs/jwt'; | ||||
| import { Request } from 'express'; | ||||
| import { JwtPayloadDto } from '../../api-v1/auth/dto/jwt-payload.dto'; | ||||
| import { jwtSecret } from '../../constants/jwt.constant'; | ||||
| import { LoginResponseDto, mapLoginResponse } from '../../api-v1/auth/response-dto/login-response.dto'; | ||||
| import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE, jwtSecret } from '../../constants/jwt.constant'; | ||||
|  | ||||
| export type JwtValidationResult = { | ||||
|   status: boolean; | ||||
| @@ -13,10 +15,24 @@ export type JwtValidationResult = { | ||||
| export class ImmichJwtService { | ||||
|   constructor(private jwtService: JwtService) {} | ||||
|  | ||||
|   public async generateToken(payload: JwtPayloadDto) { | ||||
|     return this.jwtService.sign({ | ||||
|       ...payload, | ||||
|     }); | ||||
|   public getCookieNames() { | ||||
|     return [IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE]; | ||||
|   } | ||||
|  | ||||
|   public getCookies(loginResponse: LoginResponseDto, authType: AuthType) { | ||||
|     const maxAge = 7 * 24 * 3600; // 7 days | ||||
|  | ||||
|     const accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}`; | ||||
|     const authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; Path=/; Max-Age=${maxAge}`; | ||||
|  | ||||
|     return [accessTokenCookie, authTypeCookie]; | ||||
|   } | ||||
|  | ||||
|   public async createLoginResponse(user: UserEntity): Promise<LoginResponseDto> { | ||||
|     const payload = new JwtPayloadDto(user.id, user.email); | ||||
|     const accessToken = await this.generateToken(payload); | ||||
|  | ||||
|     return mapLoginResponse(user, accessToken); | ||||
|   } | ||||
|  | ||||
|   public async validateToken(accessToken: string): Promise<JwtValidationResult> { | ||||
| @@ -48,10 +64,12 @@ export class ImmichJwtService { | ||||
|   } | ||||
|  | ||||
|   public extractJwtFromCookie(req: Request) { | ||||
|     if (req.cookies?.immich_access_token) { | ||||
|       return req.cookies.immich_access_token; | ||||
|     } | ||||
|     return req.cookies?.[IMMICH_ACCESS_COOKIE] || null; | ||||
|   } | ||||
|  | ||||
|     return null; | ||||
|   private async generateToken(payload: JwtPayloadDto) { | ||||
|     return this.jwtService.sign({ | ||||
|       ...payload, | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import { DataSource } from 'typeorm'; | ||||
| import { CanActivate, ExecutionContext } from '@nestjs/common'; | ||||
| import { TestingModuleBuilder } from '@nestjs/testing'; | ||||
| import { DataSource } from 'typeorm'; | ||||
| import { IUserRepository } from '../src/api-v1/user/user-repository'; | ||||
| import { AuthUserDto } from '../src/decorators/auth-user.decorator'; | ||||
| import { JwtAuthGuard } from '../src/modules/immich-jwt/guards/jwt-auth.guard'; | ||||
|  | ||||
| @@ -14,6 +15,19 @@ export async function clearDb(db: DataSource) { | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function newUserRepositoryMock(): jest.Mocked<IUserRepository> { | ||||
|   return { | ||||
|     get: jest.fn(), | ||||
|     getAdmin: jest.fn(), | ||||
|     getByEmail: jest.fn(), | ||||
|     getList: jest.fn(), | ||||
|     create: jest.fn(), | ||||
|     update: jest.fn(), | ||||
|     delete: jest.fn(), | ||||
|     restore: jest.fn(), | ||||
|   }; | ||||
| } | ||||
|  | ||||
| export function getAuthUser(): AuthUserDto { | ||||
|   return { | ||||
|     id: '3108ac14-8afb-4b7e-87fd-39ebb6b79750', | ||||
|   | ||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -16,6 +16,12 @@ const jwtSecretValidator: Joi.CustomValidator<string> = (value) => { | ||||
|   return value; | ||||
| }; | ||||
|  | ||||
| const WHEN_OAUTH_ENABLED = Joi.when('OAUTH_ENABLED', { | ||||
|   is: true, | ||||
|   then: Joi.string().required(), | ||||
|   otherwise: Joi.string().optional(), | ||||
| }); | ||||
|  | ||||
| export const immichAppConfig: ConfigModuleOptions = { | ||||
|   envFilePath: '.env', | ||||
|   isGlobal: true, | ||||
| @@ -28,5 +34,12 @@ export const immichAppConfig: ConfigModuleOptions = { | ||||
|     DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false), | ||||
|     REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0, 1, 2, 3).default(3), | ||||
|     LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'), | ||||
|     OAUTH_ENABLED: Joi.bool().valid(true, false).default(false), | ||||
|     OAUTH_BUTTON_TEXT: Joi.string().optional().default('Login with OAuth'), | ||||
|     OAUTH_AUTO_REGISTER: Joi.bool().valid(true, false).default(true), | ||||
|     OAUTH_ISSUER_URL: WHEN_OAUTH_ENABLED, | ||||
|     OAUTH_SCOPE: Joi.string().optional().default('openid email profile'), | ||||
|     OAUTH_CLIENT_ID: WHEN_OAUTH_ENABLED, | ||||
|     OAUTH_CLIENT_SECRET: WHEN_OAUTH_ENABLED, | ||||
|   }), | ||||
| }; | ||||
|   | ||||
| @@ -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: '' }) | ||||
|   | ||||
							
								
								
									
										67
									
								
								server/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										67
									
								
								server/package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -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", | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -1125,6 +1125,63 @@ export interface LogoutResponseDto { | ||||
|      * @memberof LogoutResponseDto | ||||
|      */ | ||||
|     'successful': boolean; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof LogoutResponseDto | ||||
|      */ | ||||
|     'redirectUri': string; | ||||
| } | ||||
| /** | ||||
|  *  | ||||
|  * @export | ||||
|  * @interface OAuthCallbackDto | ||||
|  */ | ||||
| export interface OAuthCallbackDto { | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof OAuthCallbackDto | ||||
|      */ | ||||
|     'url': string; | ||||
| } | ||||
| /** | ||||
|  *  | ||||
|  * @export | ||||
|  * @interface OAuthConfigDto | ||||
|  */ | ||||
| export interface OAuthConfigDto { | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof OAuthConfigDto | ||||
|      */ | ||||
|     'redirectUri': string; | ||||
| } | ||||
| /** | ||||
|  *  | ||||
|  * @export | ||||
|  * @interface OAuthConfigResponseDto | ||||
|  */ | ||||
| export interface OAuthConfigResponseDto { | ||||
|     /** | ||||
|      *  | ||||
|      * @type {boolean} | ||||
|      * @memberof OAuthConfigResponseDto | ||||
|      */ | ||||
|     'enabled': boolean; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof OAuthConfigResponseDto | ||||
|      */ | ||||
|     'url'?: string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof OAuthConfigResponseDto | ||||
|      */ | ||||
|     'buttonText'?: string; | ||||
| } | ||||
| /** | ||||
|  *  | ||||
| @@ -4459,6 +4516,174 @@ export class JobApi extends BaseAPI { | ||||
| } | ||||
|  | ||||
|  | ||||
| /** | ||||
|  * OAuthApi - axios parameter creator | ||||
|  * @export | ||||
|  */ | ||||
| export const OAuthApiAxiosParamCreator = function (configuration?: Configuration) { | ||||
|     return { | ||||
|         /** | ||||
|          *  | ||||
|          * @param {OAuthCallbackDto} oAuthCallbackDto  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         callback: async (oAuthCallbackDto: OAuthCallbackDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|             // verify required parameter 'oAuthCallbackDto' is not null or undefined | ||||
|             assertParamExists('callback', 'oAuthCallbackDto', oAuthCallbackDto) | ||||
|             const localVarPath = `/oauth/callback`; | ||||
|             // use dummy base URL string because the URL constructor only accepts absolute URLs. | ||||
|             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); | ||||
|             let baseOptions; | ||||
|             if (configuration) { | ||||
|                 baseOptions = configuration.baseOptions; | ||||
|             } | ||||
|  | ||||
|             const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; | ||||
|             const localVarHeaderParameter = {} as any; | ||||
|             const localVarQueryParameter = {} as any; | ||||
|  | ||||
|  | ||||
|      | ||||
|             localVarHeaderParameter['Content-Type'] = 'application/json'; | ||||
|  | ||||
|             setSearchParams(localVarUrlObj, localVarQueryParameter); | ||||
|             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; | ||||
|             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; | ||||
|             localVarRequestOptions.data = serializeDataIfNeeded(oAuthCallbackDto, localVarRequestOptions, configuration) | ||||
|  | ||||
|             return { | ||||
|                 url: toPathString(localVarUrlObj), | ||||
|                 options: localVarRequestOptions, | ||||
|             }; | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {OAuthConfigDto} oAuthConfigDto  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         generateConfig: async (oAuthConfigDto: OAuthConfigDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|             // verify required parameter 'oAuthConfigDto' is not null or undefined | ||||
|             assertParamExists('generateConfig', 'oAuthConfigDto', oAuthConfigDto) | ||||
|             const localVarPath = `/oauth/config`; | ||||
|             // use dummy base URL string because the URL constructor only accepts absolute URLs. | ||||
|             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); | ||||
|             let baseOptions; | ||||
|             if (configuration) { | ||||
|                 baseOptions = configuration.baseOptions; | ||||
|             } | ||||
|  | ||||
|             const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; | ||||
|             const localVarHeaderParameter = {} as any; | ||||
|             const localVarQueryParameter = {} as any; | ||||
|  | ||||
|  | ||||
|      | ||||
|             localVarHeaderParameter['Content-Type'] = 'application/json'; | ||||
|  | ||||
|             setSearchParams(localVarUrlObj, localVarQueryParameter); | ||||
|             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; | ||||
|             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; | ||||
|             localVarRequestOptions.data = serializeDataIfNeeded(oAuthConfigDto, localVarRequestOptions, configuration) | ||||
|  | ||||
|             return { | ||||
|                 url: toPathString(localVarUrlObj), | ||||
|                 options: localVarRequestOptions, | ||||
|             }; | ||||
|         }, | ||||
|     } | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * OAuthApi - functional programming interface | ||||
|  * @export | ||||
|  */ | ||||
| export const OAuthApiFp = function(configuration?: Configuration) { | ||||
|     const localVarAxiosParamCreator = OAuthApiAxiosParamCreator(configuration) | ||||
|     return { | ||||
|         /** | ||||
|          *  | ||||
|          * @param {OAuthCallbackDto} oAuthCallbackDto  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         async callback(oAuthCallbackDto: OAuthCallbackDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<LoginResponseDto>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.callback(oAuthCallbackDto, options); | ||||
|             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {OAuthConfigDto} oAuthConfigDto  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         async generateConfig(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<OAuthConfigResponseDto>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.generateConfig(oAuthConfigDto, options); | ||||
|             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); | ||||
|         }, | ||||
|     } | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * OAuthApi - factory interface | ||||
|  * @export | ||||
|  */ | ||||
| export const OAuthApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { | ||||
|     const localVarFp = OAuthApiFp(configuration) | ||||
|     return { | ||||
|         /** | ||||
|          *  | ||||
|          * @param {OAuthCallbackDto} oAuthCallbackDto  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         callback(oAuthCallbackDto: OAuthCallbackDto, options?: any): AxiosPromise<LoginResponseDto> { | ||||
|             return localVarFp.callback(oAuthCallbackDto, options).then((request) => request(axios, basePath)); | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {OAuthConfigDto} oAuthConfigDto  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         generateConfig(oAuthConfigDto: OAuthConfigDto, options?: any): AxiosPromise<OAuthConfigResponseDto> { | ||||
|             return localVarFp.generateConfig(oAuthConfigDto, options).then((request) => request(axios, basePath)); | ||||
|         }, | ||||
|     }; | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * OAuthApi - object-oriented interface | ||||
|  * @export | ||||
|  * @class OAuthApi | ||||
|  * @extends {BaseAPI} | ||||
|  */ | ||||
| export class OAuthApi extends BaseAPI { | ||||
|     /** | ||||
|      *  | ||||
|      * @param {OAuthCallbackDto} oAuthCallbackDto  | ||||
|      * @param {*} [options] Override http request option. | ||||
|      * @throws {RequiredError} | ||||
|      * @memberof OAuthApi | ||||
|      */ | ||||
|     public callback(oAuthCallbackDto: OAuthCallbackDto, options?: AxiosRequestConfig) { | ||||
|         return OAuthApiFp(this.configuration).callback(oAuthCallbackDto, options).then((request) => request(this.axios, this.basePath)); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      *  | ||||
|      * @param {OAuthConfigDto} oAuthConfigDto  | ||||
|      * @param {*} [options] Override http request option. | ||||
|      * @throws {RequiredError} | ||||
|      * @memberof OAuthApi | ||||
|      */ | ||||
|     public generateConfig(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig) { | ||||
|         return OAuthApiFp(this.configuration).generateConfig(oAuthConfigDto, options).then((request) => request(this.axios, this.basePath)); | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
| /** | ||||
|  * ServerInfoApi - axios parameter creator | ||||
|  * @export | ||||
|   | ||||
| @@ -1,17 +1,49 @@ | ||||
| <script lang="ts"> | ||||
| 	import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; | ||||
| 	import { loginPageMessage } from '$lib/constants'; | ||||
| 	import { api } from '@api'; | ||||
| 	import { createEventDispatcher } from 'svelte'; | ||||
| 	import { api, OAuthConfigResponseDto } from '@api'; | ||||
| 	import { createEventDispatcher, onMount } from 'svelte'; | ||||
|  | ||||
| 	let error: string; | ||||
| 	let email = ''; | ||||
| 	let password = ''; | ||||
| 	let oauthError: string; | ||||
| 	let oauthConfig: OAuthConfigResponseDto = { enabled: false }; | ||||
| 	let loading = true; | ||||
|  | ||||
| 	const dispatch = createEventDispatcher(); | ||||
|  | ||||
| 	onMount(async () => { | ||||
| 		const search = window.location.search; | ||||
| 		if (search.includes('code=') || search.includes('error=')) { | ||||
| 			try { | ||||
| 				loading = true; | ||||
| 				await api.oauthApi.callback({ url: window.location.href }); | ||||
| 				dispatch('success'); | ||||
| 				return; | ||||
| 			} catch (e) { | ||||
| 				console.error('Error [login-form] [oauth.callback]', e); | ||||
| 				oauthError = 'Unable to complete OAuth login'; | ||||
| 				loading = false; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		try { | ||||
| 			const redirectUri = window.location.href.split('?')[0]; | ||||
| 			console.log(`OAuth Redirect URI: ${redirectUri}`); | ||||
| 			const { data } = await api.oauthApi.generateConfig({ redirectUri }); | ||||
| 			oauthConfig = data; | ||||
| 		} catch (e) { | ||||
| 			console.error('Error [login-form] [oauth.generateConfig]', e); | ||||
| 		} | ||||
|  | ||||
| 		loading = false; | ||||
| 	}); | ||||
|  | ||||
| 	const login = async () => { | ||||
| 		try { | ||||
| 			error = ''; | ||||
| 			loading = true; | ||||
|  | ||||
| 			const { data } = await api.authenticationApi.login({ | ||||
| 				email, | ||||
| @@ -27,6 +59,7 @@ | ||||
| 			return; | ||||
| 		} catch (e) { | ||||
| 			error = 'Incorrect email or password'; | ||||
| 			loading = false; | ||||
| 			return; | ||||
| 		} | ||||
| 	}; | ||||
| @@ -48,41 +81,65 @@ | ||||
| 		</p> | ||||
| 	{/if} | ||||
|  | ||||
| 	<form on:submit|preventDefault={login} autocomplete="off"> | ||||
| 		<div class="m-4 flex flex-col gap-2"> | ||||
| 			<label class="immich-form-label" for="email">Email</label> | ||||
| 			<input | ||||
| 				class="immich-form-input" | ||||
| 				id="email" | ||||
| 				name="email" | ||||
| 				type="email" | ||||
| 				bind:value={email} | ||||
| 				required | ||||
| 			/> | ||||
| 	{#if loading} | ||||
| 		<div class="flex place-items-center place-content-center"> | ||||
| 			<LoadingSpinner /> | ||||
| 		</div> | ||||
| 	{:else} | ||||
| 		<form on:submit|preventDefault={login} autocomplete="off"> | ||||
| 			<div class="m-4 flex flex-col gap-2"> | ||||
| 				<label class="immich-form-label" for="email">Email</label> | ||||
| 				<input | ||||
| 					class="immich-form-input" | ||||
| 					id="email" | ||||
| 					name="email" | ||||
| 					type="email" | ||||
| 					bind:value={email} | ||||
| 					required | ||||
| 				/> | ||||
| 			</div> | ||||
|  | ||||
| 		<div class="m-4 flex flex-col gap-2"> | ||||
| 			<label class="immich-form-label" for="password">Password</label> | ||||
| 			<input | ||||
| 				class="immich-form-input" | ||||
| 				id="password" | ||||
| 				name="password" | ||||
| 				type="password" | ||||
| 				bind:value={password} | ||||
| 				required | ||||
| 			/> | ||||
| 		</div> | ||||
| 			<div class="m-4 flex flex-col gap-2"> | ||||
| 				<label class="immich-form-label" for="password">Password</label> | ||||
| 				<input | ||||
| 					class="immich-form-input" | ||||
| 					id="password" | ||||
| 					name="password" | ||||
| 					type="password" | ||||
| 					bind:value={password} | ||||
| 					required | ||||
| 				/> | ||||
| 			</div> | ||||
|  | ||||
| 		{#if error} | ||||
| 			<p class="text-red-400 pl-4">{error}</p> | ||||
| 		{/if} | ||||
| 			{#if error} | ||||
| 				<p class="text-red-400 pl-4">{error}</p> | ||||
| 			{/if} | ||||
|  | ||||
| 		<div class="flex w-full"> | ||||
| 			<button | ||||
| 				type="submit" | ||||
| 				class="m-4 p-2 bg-immich-primary dark:bg-immich-dark-primary dark:text-immich-dark-gray dark:hover:bg-immich-dark-primary/80 hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full font-semibold" | ||||
| 				>Login</button | ||||
| 			> | ||||
| 		</div> | ||||
| 	</form> | ||||
| 			<div class="flex w-full"> | ||||
| 				<button | ||||
| 					type="submit" | ||||
| 					disabled={loading} | ||||
| 					class="m-4 p-2 bg-immich-primary dark:bg-immich-dark-primary dark:text-immich-dark-gray dark:hover:bg-immich-dark-primary/80 hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full font-semibold" | ||||
| 					>Login</button | ||||
| 				> | ||||
| 			</div> | ||||
|  | ||||
| 			{#if oauthConfig.enabled} | ||||
| 				<div class="flex flex-col gap-4 px-4"> | ||||
| 					<hr /> | ||||
| 					{#if oauthError} | ||||
| 						<p class="text-red-400">{oauthError}</p> | ||||
| 					{/if} | ||||
| 					<a href={oauthConfig.url} class="flex w-full"> | ||||
| 						<button | ||||
| 							type="button" | ||||
| 							disabled={loading} | ||||
| 							class="bg-immich-primary dark:bg-immich-dark-primary dark:text-immich-dark-gray dark:hover:bg-immich-dark-primary/80 hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full font-semibold" | ||||
| 							>{oauthConfig.buttonText || 'Login with OAuth'}</button | ||||
| 						> | ||||
| 					</a> | ||||
| 				</div> | ||||
| 			{/if} | ||||
| 		</form> | ||||
| 	{/if} | ||||
| </div> | ||||
|   | ||||
| @@ -38,8 +38,11 @@ | ||||
| 	}; | ||||
|  | ||||
| 	const logOut = async () => { | ||||
| 		const { data } = await api.authenticationApi.logout(); | ||||
|  | ||||
| 		await fetch('auth/logout', { method: 'POST' }); | ||||
| 		goto('/auth/login'); | ||||
|  | ||||
| 		goto(data.redirectUri || '/auth/login'); | ||||
| 	}; | ||||
| </script> | ||||
|  | ||||
|   | ||||
| @@ -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', | ||||
|   | ||||
		Reference in New Issue
	
	Block a user