diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 8d15a19d7d7..70cd8fa2971 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -1861,6 +1861,19 @@ export const ModelType = { export type ModelType = typeof ModelType[keyof typeof ModelType]; +/** + * + * @export + * @interface OAuthAuthorizeResponseDto + */ +export interface OAuthAuthorizeResponseDto { + /** + * + * @type {string} + * @memberof OAuthAuthorizeResponseDto + */ + 'url': string; +} /** * * @export @@ -8890,6 +8903,41 @@ export class JobApi extends BaseAPI { */ export const OAuthApiAxiosParamCreator = function (configuration?: Configuration) { return { + /** + * + * @param {OAuthConfigDto} oAuthConfigDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + authorizeOAuth: async (oAuthConfigDto: OAuthConfigDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'oAuthConfigDto' is not null or undefined + assertParamExists('authorizeOAuth', 'oAuthConfigDto', oAuthConfigDto) + const localVarPath = `/oauth/authorize`; + // 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, + }; + }, /** * * @param {OAuthCallbackDto} oAuthCallbackDto @@ -8926,9 +8974,10 @@ export const OAuthApiAxiosParamCreator = function (configuration?: Configuration }; }, /** - * + * @deprecated use feature flags and /oauth/authorize * @param {OAuthConfigDto} oAuthConfigDto * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ generateConfig: async (oAuthConfigDto: OAuthConfigDto, options: AxiosRequestConfig = {}): Promise => { @@ -9081,6 +9130,16 @@ export const OAuthApiAxiosParamCreator = function (configuration?: Configuration export const OAuthApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = OAuthApiAxiosParamCreator(configuration) return { + /** + * + * @param {OAuthConfigDto} oAuthConfigDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async authorizeOAuth(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.authorizeOAuth(oAuthConfigDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {OAuthCallbackDto} oAuthCallbackDto @@ -9092,9 +9151,10 @@ export const OAuthApiFp = function(configuration?: Configuration) { return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** - * + * @deprecated use feature flags and /oauth/authorize * @param {OAuthConfigDto} oAuthConfigDto * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ async generateConfig(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { @@ -9139,6 +9199,15 @@ export const OAuthApiFp = function(configuration?: Configuration) { export const OAuthApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { const localVarFp = OAuthApiFp(configuration) return { + /** + * + * @param {OAuthApiAuthorizeOAuthRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + authorizeOAuth(requestParameters: OAuthApiAuthorizeOAuthRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.authorizeOAuth(requestParameters.oAuthConfigDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {OAuthApiCallbackRequest} requestParameters Request parameters. @@ -9149,9 +9218,10 @@ export const OAuthApiFactory = function (configuration?: Configuration, basePath return localVarFp.callback(requestParameters.oAuthCallbackDto, options).then((request) => request(axios, basePath)); }, /** - * + * @deprecated use feature flags and /oauth/authorize * @param {OAuthApiGenerateConfigRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ generateConfig(requestParameters: OAuthApiGenerateConfigRequest, options?: AxiosRequestConfig): AxiosPromise { @@ -9185,6 +9255,20 @@ export const OAuthApiFactory = function (configuration?: Configuration, basePath }; }; +/** + * Request parameters for authorizeOAuth operation in OAuthApi. + * @export + * @interface OAuthApiAuthorizeOAuthRequest + */ +export interface OAuthApiAuthorizeOAuthRequest { + /** + * + * @type {OAuthConfigDto} + * @memberof OAuthApiAuthorizeOAuth + */ + readonly oAuthConfigDto: OAuthConfigDto +} + /** * Request parameters for callback operation in OAuthApi. * @export @@ -9234,6 +9318,17 @@ export interface OAuthApiLinkRequest { * @extends {BaseAPI} */ export class OAuthApi extends BaseAPI { + /** + * + * @param {OAuthApiAuthorizeOAuthRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof OAuthApi + */ + public authorizeOAuth(requestParameters: OAuthApiAuthorizeOAuthRequest, options?: AxiosRequestConfig) { + return OAuthApiFp(this.configuration).authorizeOAuth(requestParameters.oAuthConfigDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {OAuthApiCallbackRequest} requestParameters Request parameters. @@ -9246,9 +9341,10 @@ export class OAuthApi extends BaseAPI { } /** - * + * @deprecated use feature flags and /oauth/authorize * @param {OAuthApiGenerateConfigRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} * @memberof OAuthApi */ diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 517c33f249a..5c6b65f46f5 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -73,6 +73,7 @@ doc/MemoryLaneResponseDto.md doc/MergePersonDto.md doc/ModelType.md doc/OAuthApi.md +doc/OAuthAuthorizeResponseDto.md doc/OAuthCallbackDto.md doc/OAuthConfigDto.md doc/OAuthConfigResponseDto.md @@ -225,6 +226,7 @@ lib/model/map_marker_response_dto.dart lib/model/memory_lane_response_dto.dart lib/model/merge_person_dto.dart lib/model/model_type.dart +lib/model/o_auth_authorize_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 @@ -352,6 +354,7 @@ test/memory_lane_response_dto_test.dart test/merge_person_dto_test.dart test/model_type_test.dart test/o_auth_api_test.dart +test/o_auth_authorize_response_dto_test.dart test/o_auth_callback_dto_test.dart test/o_auth_config_dto_test.dart test/o_auth_config_response_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 76503a41499..5972ea91cf3 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -124,6 +124,7 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | *JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs | *JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{id} | +*OAuthApi* | [**authorizeOAuth**](doc//OAuthApi.md#authorizeoauth) | **POST** /oauth/authorize | *OAuthApi* | [**callback**](doc//OAuthApi.md#callback) | **POST** /oauth/callback | *OAuthApi* | [**generateConfig**](doc//OAuthApi.md#generateconfig) | **POST** /oauth/config | *OAuthApi* | [**link**](doc//OAuthApi.md#link) | **POST** /oauth/link | @@ -244,6 +245,7 @@ Class | Method | HTTP request | Description - [MemoryLaneResponseDto](doc//MemoryLaneResponseDto.md) - [MergePersonDto](doc//MergePersonDto.md) - [ModelType](doc//ModelType.md) + - [OAuthAuthorizeResponseDto](doc//OAuthAuthorizeResponseDto.md) - [OAuthCallbackDto](doc//OAuthCallbackDto.md) - [OAuthConfigDto](doc//OAuthConfigDto.md) - [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md) diff --git a/mobile/openapi/doc/OAuthApi.md b/mobile/openapi/doc/OAuthApi.md index 519a96d9ee7..5a5afc2a15e 100644 --- a/mobile/openapi/doc/OAuthApi.md +++ b/mobile/openapi/doc/OAuthApi.md @@ -9,6 +9,7 @@ All URIs are relative to */api* Method | HTTP request | Description ------------- | ------------- | ------------- +[**authorizeOAuth**](OAuthApi.md#authorizeoauth) | **POST** /oauth/authorize | [**callback**](OAuthApi.md#callback) | **POST** /oauth/callback | [**generateConfig**](OAuthApi.md#generateconfig) | **POST** /oauth/config | [**link**](OAuthApi.md#link) | **POST** /oauth/link | @@ -16,6 +17,47 @@ Method | HTTP request | Description [**unlink**](OAuthApi.md#unlink) | **POST** /oauth/unlink | +# **authorizeOAuth** +> OAuthAuthorizeResponseDto authorizeOAuth(oAuthConfigDto) + + + +### Example +```dart +import 'package:openapi/api.dart'; + +final api_instance = OAuthApi(); +final oAuthConfigDto = OAuthConfigDto(); // OAuthConfigDto | + +try { + final result = api_instance.authorizeOAuth(oAuthConfigDto); + print(result); +} catch (e) { + print('Exception when calling OAuthApi->authorizeOAuth: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **oAuthConfigDto** | [**OAuthConfigDto**](OAuthConfigDto.md)| | + +### Return type + +[**OAuthAuthorizeResponseDto**](OAuthAuthorizeResponseDto.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) + # **callback** > LoginResponseDto callback(oAuthCallbackDto) @@ -62,6 +104,8 @@ No authorization required +@deprecated use feature flags and /oauth/authorize + ### Example ```dart import 'package:openapi/api.dart'; diff --git a/mobile/openapi/doc/OAuthAuthorizeResponseDto.md b/mobile/openapi/doc/OAuthAuthorizeResponseDto.md new file mode 100644 index 00000000000..fe32d2b85fe --- /dev/null +++ b/mobile/openapi/doc/OAuthAuthorizeResponseDto.md @@ -0,0 +1,15 @@ +# openapi.model.OAuthAuthorizeResponseDto + +## 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) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 08ac25105b0..b31f92e8c2a 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -107,6 +107,7 @@ part 'model/map_marker_response_dto.dart'; part 'model/memory_lane_response_dto.dart'; part 'model/merge_person_dto.dart'; part 'model/model_type.dart'; +part 'model/o_auth_authorize_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'; diff --git a/mobile/openapi/lib/api/o_auth_api.dart b/mobile/openapi/lib/api/o_auth_api.dart index b8778596a71..0f2e72e2fa5 100644 --- a/mobile/openapi/lib/api/o_auth_api.dart +++ b/mobile/openapi/lib/api/o_auth_api.dart @@ -16,6 +16,53 @@ class OAuthApi { final ApiClient apiClient; + /// Performs an HTTP 'POST /oauth/authorize' operation and returns the [Response]. + /// Parameters: + /// + /// * [OAuthConfigDto] oAuthConfigDto (required): + Future authorizeOAuthWithHttpInfo(OAuthConfigDto oAuthConfigDto,) async { + // ignore: prefer_const_declarations + final path = r'/oauth/authorize'; + + // ignore: prefer_final_locals + Object? postBody = oAuthConfigDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [OAuthConfigDto] oAuthConfigDto (required): + Future authorizeOAuth(OAuthConfigDto oAuthConfigDto,) async { + final response = await authorizeOAuthWithHttpInfo(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), 'OAuthAuthorizeResponseDto',) as OAuthAuthorizeResponseDto; + + } + return null; + } + /// Performs an HTTP 'POST /oauth/callback' operation and returns the [Response]. /// Parameters: /// @@ -63,7 +110,10 @@ class OAuthApi { return null; } - /// Performs an HTTP 'POST /oauth/config' operation and returns the [Response]. + /// @deprecated use feature flags and /oauth/authorize + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// /// * [OAuthConfigDto] oAuthConfigDto (required): @@ -92,6 +142,8 @@ class OAuthApi { ); } + /// @deprecated use feature flags and /oauth/authorize + /// /// Parameters: /// /// * [OAuthConfigDto] oAuthConfigDto (required): diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index f900ea54b4f..f660f07e95e 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -307,6 +307,8 @@ class ApiClient { return MergePersonDto.fromJson(value); case 'ModelType': return ModelTypeTypeTransformer().decode(value); + case 'OAuthAuthorizeResponseDto': + return OAuthAuthorizeResponseDto.fromJson(value); case 'OAuthCallbackDto': return OAuthCallbackDto.fromJson(value); case 'OAuthConfigDto': diff --git a/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart b/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart new file mode 100644 index 00000000000..1a1c0928736 --- /dev/null +++ b/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart @@ -0,0 +1,98 @@ +// +// 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 OAuthAuthorizeResponseDto { + /// Returns a new [OAuthAuthorizeResponseDto] instance. + OAuthAuthorizeResponseDto({ + required this.url, + }); + + String url; + + @override + bool operator ==(Object other) => identical(this, other) || other is OAuthAuthorizeResponseDto && + other.url == url; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (url.hashCode); + + @override + String toString() => 'OAuthAuthorizeResponseDto[url=$url]'; + + Map toJson() { + final json = {}; + json[r'url'] = this.url; + return json; + } + + /// Returns a new [OAuthAuthorizeResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static OAuthAuthorizeResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return OAuthAuthorizeResponseDto( + url: mapValueOfType(json, r'url')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = OAuthAuthorizeResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = OAuthAuthorizeResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of OAuthAuthorizeResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = OAuthAuthorizeResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'url', + }; +} + diff --git a/mobile/openapi/test/o_auth_api_test.dart b/mobile/openapi/test/o_auth_api_test.dart index bc8b5f3810e..7a3ee0a9fbd 100644 --- a/mobile/openapi/test/o_auth_api_test.dart +++ b/mobile/openapi/test/o_auth_api_test.dart @@ -17,11 +17,18 @@ void main() { // final instance = OAuthApi(); group('tests for OAuthApi', () { + //Future authorizeOAuth(OAuthConfigDto oAuthConfigDto) async + test('test authorizeOAuth', () async { + // TODO + }); + //Future callback(OAuthCallbackDto oAuthCallbackDto) async test('test callback', () async { // TODO }); + // @deprecated use feature flags and /oauth/authorize + // //Future generateConfig(OAuthConfigDto oAuthConfigDto) async test('test generateConfig', () async { // TODO diff --git a/mobile/openapi/test/o_auth_authorize_response_dto_test.dart b/mobile/openapi/test/o_auth_authorize_response_dto_test.dart new file mode 100644 index 00000000000..76b016642ad --- /dev/null +++ b/mobile/openapi/test/o_auth_authorize_response_dto_test.dart @@ -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 OAuthAuthorizeResponseDto +void main() { + // final instance = OAuthAuthorizeResponseDto(); + + group('test OAuthAuthorizeResponseDto', () { + // String url + test('to test the property `url`', () async { + // TODO + }); + + + }); + +} diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 00765a38f27..4178f39eba0 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -2477,6 +2477,37 @@ ] } }, + "/oauth/authorize": { + "post": { + "operationId": "authorizeOAuth", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthConfigDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthAuthorizeResponseDto" + } + } + }, + "description": "" + } + }, + "tags": [ + "OAuth" + ] + } + }, "/oauth/callback": { "post": { "operationId": "callback", @@ -2510,6 +2541,8 @@ }, "/oauth/config": { "post": { + "deprecated": true, + "description": "@deprecated use feature flags and /oauth/authorize", "operationId": "generateConfig", "parameters": [], "requestBody": { @@ -6202,6 +6235,17 @@ ], "type": "string" }, + "OAuthAuthorizeResponseDto": { + "properties": { + "url": { + "type": "string" + } + }, + "required": [ + "url" + ], + "type": "object" + }, "OAuthCallbackDto": { "properties": { "url": { diff --git a/server/src/domain/auth/auth.service.ts b/server/src/domain/auth/auth.service.ts index cebb3d46269..bf7891ed0e6 100644 --- a/server/src/domain/auth/auth.service.ts +++ b/server/src/domain/auth/auth.service.ts @@ -1,5 +1,12 @@ import { SystemConfig, UserEntity } from '@app/infra/entities'; -import { BadRequestException, Inject, Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { + BadRequestException, + Inject, + Injectable, + InternalServerErrorException, + Logger, + UnauthorizedException, +} from '@nestjs/common'; import cookieParser from 'cookie'; import { IncomingHttpHeaders } from 'http'; import { DateTime } from 'luxon'; @@ -27,6 +34,7 @@ import { mapAdminSignupResponse, mapLoginResponse, mapUserToken, + OAuthAuthorizeResponseDto, OAuthConfigResponseDto, } from './response-dto'; import { IUserTokenRepository } from './user-token.repository'; @@ -201,6 +209,22 @@ export class AuthService { return { ...response, buttonText, url, autoLaunch }; } + async authorize(dto: OAuthConfigDto): Promise { + const config = await this.configCore.getConfig(); + if (!config.oauth.enabled) { + throw new BadRequestException('OAuth is not enabled'); + } + + const client = await this.getOAuthClient(config); + const url = await client.authorizationUrl({ + redirect_uri: this.normalize(config, dto.redirectUri), + scope: config.oauth.scope, + state: generators.state(), + }); + + return { url }; + } + async callback( dto: OAuthCallbackDto, loginDetails: LoginDetails, @@ -280,8 +304,13 @@ export class AuthService { const redirectUri = this.normalize(config, url.split('?')[0]); const client = await this.getOAuthClient(config); const params = client.callbackParams(url); - const tokens = await client.callback(redirectUri, params, { state: params.state }); - return client.userinfo(tokens.access_token || ''); + try { + const tokens = await client.callback(redirectUri, params, { state: params.state }); + return client.userinfo(tokens.access_token || ''); + } catch (error: Error | any) { + this.logger.error(`Unable to complete OAuth login: ${error}`, error?.stack); + throw new InternalServerErrorException(`Unable to complete OAuth login: ${error}`, { cause: error }); + } } private async getOAuthClient(config: SystemConfig) { diff --git a/server/src/domain/auth/dto/oauth-config.dto.ts b/server/src/domain/auth/dto/oauth-config.dto.ts index a15a963bfa6..a14fc19dc2e 100644 --- a/server/src/domain/auth/dto/oauth-config.dto.ts +++ b/server/src/domain/auth/dto/oauth-config.dto.ts @@ -1,9 +1,7 @@ -import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsString } from 'class-validator'; export class OAuthConfigDto { @IsNotEmpty() @IsString() - @ApiProperty() redirectUri!: string; } diff --git a/server/src/domain/auth/response-dto/oauth-config-response.dto.ts b/server/src/domain/auth/response-dto/oauth-config-response.dto.ts index c239405fc01..dadd66f59a8 100644 --- a/server/src/domain/auth/response-dto/oauth-config-response.dto.ts +++ b/server/src/domain/auth/response-dto/oauth-config-response.dto.ts @@ -5,3 +5,7 @@ export class OAuthConfigResponseDto { buttonText?: string; autoLaunch?: boolean; } + +export class OAuthAuthorizeResponseDto { + url!: string; +} diff --git a/server/src/immich/controllers/oauth.controller.ts b/server/src/immich/controllers/oauth.controller.ts index 24cc8afe45b..eec86271994 100644 --- a/server/src/immich/controllers/oauth.controller.ts +++ b/server/src/immich/controllers/oauth.controller.ts @@ -3,6 +3,7 @@ import { AuthUserDto, LoginDetails, LoginResponseDto, + OAuthAuthorizeResponseDto, OAuthCallbackDto, OAuthConfigDto, OAuthConfigResponseDto, @@ -31,12 +32,19 @@ export class OAuthController { }; } + /** @deprecated use feature flags and /oauth/authorize */ @PublicRoute() @Post('config') generateConfig(@Body() dto: OAuthConfigDto): Promise { return this.service.generateConfig(dto); } + @PublicRoute() + @Post('authorize') + authorizeOAuth(@Body() dto: OAuthConfigDto): Promise { + return this.service.authorize(dto); + } + @PublicRoute() @Post('callback') async callback( diff --git a/server/test/e2e/oauth.e2e-spec.ts b/server/test/e2e/oauth.e2e-spec.ts new file mode 100644 index 00000000000..a9f00278193 --- /dev/null +++ b/server/test/e2e/oauth.e2e-spec.ts @@ -0,0 +1,42 @@ +import { AppModule, OAuthController } from '@app/immich'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { errorStub } from '../fixtures'; +import { api, db } from '../test-utils'; + +describe(`${OAuthController.name} (e2e)`, () => { + let app: INestApplication; + let server: any; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = await moduleFixture.createNestApplication().init(); + server = app.getHttpServer(); + }); + + beforeEach(async () => { + await db.reset(); + await api.adminSignUp(server); + }); + + afterAll(async () => { + await db.disconnect(); + await app.close(); + }); + + describe('POST /oauth/authorize', () => { + beforeEach(async () => { + await db.reset(); + }); + + it(`should throw an error if a redirect uri is not provided`, async () => { + const { status, body } = await request(server).post('/oauth/authorize').send({}); + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest); + }); + }); +}); diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 8d15a19d7d7..70cd8fa2971 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1861,6 +1861,19 @@ export const ModelType = { export type ModelType = typeof ModelType[keyof typeof ModelType]; +/** + * + * @export + * @interface OAuthAuthorizeResponseDto + */ +export interface OAuthAuthorizeResponseDto { + /** + * + * @type {string} + * @memberof OAuthAuthorizeResponseDto + */ + 'url': string; +} /** * * @export @@ -8890,6 +8903,41 @@ export class JobApi extends BaseAPI { */ export const OAuthApiAxiosParamCreator = function (configuration?: Configuration) { return { + /** + * + * @param {OAuthConfigDto} oAuthConfigDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + authorizeOAuth: async (oAuthConfigDto: OAuthConfigDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'oAuthConfigDto' is not null or undefined + assertParamExists('authorizeOAuth', 'oAuthConfigDto', oAuthConfigDto) + const localVarPath = `/oauth/authorize`; + // 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, + }; + }, /** * * @param {OAuthCallbackDto} oAuthCallbackDto @@ -8926,9 +8974,10 @@ export const OAuthApiAxiosParamCreator = function (configuration?: Configuration }; }, /** - * + * @deprecated use feature flags and /oauth/authorize * @param {OAuthConfigDto} oAuthConfigDto * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ generateConfig: async (oAuthConfigDto: OAuthConfigDto, options: AxiosRequestConfig = {}): Promise => { @@ -9081,6 +9130,16 @@ export const OAuthApiAxiosParamCreator = function (configuration?: Configuration export const OAuthApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = OAuthApiAxiosParamCreator(configuration) return { + /** + * + * @param {OAuthConfigDto} oAuthConfigDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async authorizeOAuth(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.authorizeOAuth(oAuthConfigDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {OAuthCallbackDto} oAuthCallbackDto @@ -9092,9 +9151,10 @@ export const OAuthApiFp = function(configuration?: Configuration) { return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** - * + * @deprecated use feature flags and /oauth/authorize * @param {OAuthConfigDto} oAuthConfigDto * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ async generateConfig(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { @@ -9139,6 +9199,15 @@ export const OAuthApiFp = function(configuration?: Configuration) { export const OAuthApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { const localVarFp = OAuthApiFp(configuration) return { + /** + * + * @param {OAuthApiAuthorizeOAuthRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + authorizeOAuth(requestParameters: OAuthApiAuthorizeOAuthRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.authorizeOAuth(requestParameters.oAuthConfigDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {OAuthApiCallbackRequest} requestParameters Request parameters. @@ -9149,9 +9218,10 @@ export const OAuthApiFactory = function (configuration?: Configuration, basePath return localVarFp.callback(requestParameters.oAuthCallbackDto, options).then((request) => request(axios, basePath)); }, /** - * + * @deprecated use feature flags and /oauth/authorize * @param {OAuthApiGenerateConfigRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ generateConfig(requestParameters: OAuthApiGenerateConfigRequest, options?: AxiosRequestConfig): AxiosPromise { @@ -9185,6 +9255,20 @@ export const OAuthApiFactory = function (configuration?: Configuration, basePath }; }; +/** + * Request parameters for authorizeOAuth operation in OAuthApi. + * @export + * @interface OAuthApiAuthorizeOAuthRequest + */ +export interface OAuthApiAuthorizeOAuthRequest { + /** + * + * @type {OAuthConfigDto} + * @memberof OAuthApiAuthorizeOAuth + */ + readonly oAuthConfigDto: OAuthConfigDto +} + /** * Request parameters for callback operation in OAuthApi. * @export @@ -9234,6 +9318,17 @@ export interface OAuthApiLinkRequest { * @extends {BaseAPI} */ export class OAuthApi extends BaseAPI { + /** + * + * @param {OAuthApiAuthorizeOAuthRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof OAuthApi + */ + public authorizeOAuth(requestParameters: OAuthApiAuthorizeOAuthRequest, options?: AxiosRequestConfig) { + return OAuthApiFp(this.configuration).authorizeOAuth(requestParameters.oAuthConfigDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {OAuthApiCallbackRequest} requestParameters Request parameters. @@ -9246,9 +9341,10 @@ export class OAuthApi extends BaseAPI { } /** - * + * @deprecated use feature flags and /oauth/authorize * @param {OAuthApiGenerateConfigRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} * @memberof OAuthApi */ diff --git a/web/src/api/utils.ts b/web/src/api/utils.ts index 26b6550666d..f82596eef2e 100644 --- a/web/src/api/utils.ts +++ b/web/src/api/utils.ts @@ -1,3 +1,4 @@ +import { goto } from '$app/navigation'; import type { AxiosError, AxiosPromise } from 'axios'; import { notificationController, @@ -32,9 +33,17 @@ export const oauth = { } return false; }, + authorize: async (location: Location) => { + try { + const redirectUri = location.href.split('?')[0]; + const { data } = await api.oauthApi.authorizeOAuth({ oAuthConfigDto: { redirectUri } }); + goto(data.url); + } catch (error) { + handleError(error, 'Unable to login with OAuth'); + } + }, getConfig: (location: Location) => { const redirectUri = location.href.split('?')[0]; - console.log(`OAuth Redirect URI: ${redirectUri}`); return api.oauthApi.generateConfig({ oAuthConfigDto: { redirectUri } }); }, login: (location: Location) => { diff --git a/web/src/lib/components/forms/login-form.svelte b/web/src/lib/components/forms/login-form.svelte index 8ededa6b7f0..0dbc069f352 100644 --- a/web/src/lib/components/forms/login-form.svelte +++ b/web/src/lib/components/forms/login-form.svelte @@ -2,8 +2,9 @@ import { goto } from '$app/navigation'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import { AppRoute } from '$lib/constants'; + import { featureFlags } from '$lib/stores/feature-flags.store'; import { getServerErrorMessage, handleError } from '$lib/utils/handle-error'; - import { OAuthConfigResponseDto, api, oauth } from '@api'; + import { api, oauth } from '@api'; import { createEventDispatcher, onMount } from 'svelte'; import { fade } from 'svelte/transition'; import Button from '../elements/buttons/button.svelte'; @@ -11,14 +12,18 @@ let errorMessage: string; let email = ''; let password = ''; - let oauthError: string; - export let authConfig: OAuthConfigResponseDto; + let oauthError = ''; let loading = false; let oauthLoading = true; const dispatch = createEventDispatcher(); onMount(async () => { + if (!$featureFlags.oauth) { + oauthLoading = false; + return; + } + if (oauth.isCallback(window.location)) { try { await oauth.login(window.location); @@ -26,25 +31,18 @@ return; } catch (e) { console.error('Error [login-form] [oauth.callback]', e); - oauthError = 'Unable to complete OAuth login'; - } finally { + oauthError = (await getServerErrorMessage(e)) || 'Unable to complete OAuth login'; oauthLoading = false; } } try { - const { data } = await oauth.getConfig(window.location); - authConfig = data; - - const { enabled, url, autoLaunch } = authConfig; - - if (enabled && url && autoLaunch && !oauth.isAutoLaunchDisabled(window.location)) { + if ($featureFlags.oauthAutoLaunch && !oauth.isAutoLaunchDisabled(window.location)) { await goto(`${AppRoute.AUTH_LOGIN}?autoLaunch=0`, { replaceState: true }); - await goto(url); + await oauth.authorize(window.location); return; } } catch (error) { - authConfig.passwordLoginEnabled = true; await handleError(error, 'Unable to connect!'); } @@ -76,9 +74,15 @@ return; } }; + + const handleOAuthLogin = async () => { + oauthLoading = true; + oauthError = ''; + await oauth.authorize(window.location); + }; -{#if authConfig.passwordLoginEnabled} +{#if !oauthLoading && $featureFlags.passwordLogin}
{#if errorMessage}

@@ -113,7 +117,7 @@

- - +
{/if} -{#if !authConfig.enabled && !authConfig.passwordLoginEnabled} +{#if !$featureFlags.passwordLogin && !$featureFlags.oauth}

Login has been disabled.

{/if} diff --git a/web/src/lib/components/shared-components/fullscreen-container.svelte b/web/src/lib/components/shared-components/fullscreen-container.svelte index 75fd0bc4e7c..13903ccd486 100644 --- a/web/src/lib/components/shared-components/fullscreen-container.svelte +++ b/web/src/lib/components/shared-components/fullscreen-container.svelte @@ -5,7 +5,7 @@ export let showMessage = $$slots.message; -
+
diff --git a/web/src/lib/components/user-settings-page/oauth-settings.svelte b/web/src/lib/components/user-settings-page/oauth-settings.svelte index 15c1a7e9116..17e49d65f32 100644 --- a/web/src/lib/components/user-settings-page/oauth-settings.svelte +++ b/web/src/lib/components/user-settings-page/oauth-settings.svelte @@ -1,16 +1,16 @@ @@ -47,7 +40,7 @@ -{#if oauthEnabled} +{#if $featureFlags.loaded && $featureFlags.oauth} ({ + loaded: false, clipEncode: true, facialRecognition: true, sidecar: true, tagImage: true, search: true, - oauth: true, - oauthAutoLaunch: true, + oauth: false, + oauthAutoLaunch: false, passwordLogin: true, configFile: false, }); export const loadFeatureFlags = async () => { const { data } = await api.serverInfoApi.getServerFeatures(); - featureFlags.update(() => data); + featureFlags.update(() => ({ ...data, loaded: true })); }; diff --git a/web/src/routes/auth/login/+page.server.ts b/web/src/routes/auth/login/+page.server.ts index 5e10dcd55ef..f325c7cd2e9 100644 --- a/web/src/routes/auth/login/+page.server.ts +++ b/web/src/routes/auth/login/+page.server.ts @@ -1,5 +1,4 @@ import { AppRoute } from '$lib/constants'; -import type { OAuthConfigResponseDto } from '@api'; import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; @@ -10,23 +9,7 @@ export const load = (async ({ locals: { api } }) => { throw redirect(302, AppRoute.AUTH_REGISTER); } - let authConfig: OAuthConfigResponseDto = { - passwordLoginEnabled: true, - enabled: false, - }; - - try { - // TODO: Figure out how to get correct redirect URI server-side. - const { data } = await api.oauthApi.generateConfig({ oAuthConfigDto: { redirectUri: '/' } }); - data.url = undefined; - - authConfig = data; - } catch (err) { - console.error('[ERROR] login/+page.server.ts:', err); - } - return { - authConfig, meta: { title: 'Login', }, diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte index e958864fb2b..c3e4e8a4180 100644 --- a/web/src/routes/auth/login/+page.svelte +++ b/web/src/routes/auth/login/+page.svelte @@ -4,20 +4,22 @@ import FullscreenContainer from '$lib/components/shared-components/fullscreen-container.svelte'; import { AppRoute } from '$lib/constants'; import { loginPageMessage } from '$lib/constants'; + import { featureFlags } from '$lib/stores/feature-flags.store'; import type { PageData } from './$types'; export let data: PageData; - -

- - {@html loginPageMessage} -

+{#if $featureFlags.loaded} + +

+ + {@html loginPageMessage} +

- goto(AppRoute.PHOTOS, { invalidateAll: true })} - on:first-login={() => goto(AppRoute.AUTH_CHANGE_PASSWORD)} - /> -
+ goto(AppRoute.PHOTOS, { invalidateAll: true })} + on:first-login={() => goto(AppRoute.AUTH_CHANGE_PASSWORD)} + /> +
+{/if}