diff --git a/i18n/en.json b/i18n/en.json
index 239936471d..f1ab30a6d0 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -192,26 +192,22 @@
"oauth_auto_register": "Auto register",
"oauth_auto_register_description": "Automatically register new users after signing in with OAuth",
"oauth_button_text": "Button text",
- "oauth_client_id": "Client ID",
- "oauth_client_secret": "Client Secret",
+ "oauth_client_secret_description": "Required if PKCE (Proof Key for Code Exchange) is not supported by the OAuth provider",
"oauth_enable_description": "Login with OAuth",
- "oauth_issuer_url": "Issuer URL",
"oauth_mobile_redirect_uri": "Mobile redirect URI",
"oauth_mobile_redirect_uri_override": "Mobile redirect URI override",
"oauth_mobile_redirect_uri_override_description": "Enable when OAuth provider does not allow a mobile URI, like '{callback}'",
- "oauth_profile_signing_algorithm": "Profile signing algorithm",
- "oauth_profile_signing_algorithm_description": "Algorithm used to sign the user profile.",
- "oauth_scope": "Scope",
"oauth_settings": "OAuth",
"oauth_settings_description": "Manage OAuth login settings",
"oauth_settings_more_details": "For more details about this feature, refer to the docs.",
- "oauth_signing_algorithm": "Signing algorithm",
"oauth_storage_label_claim": "Storage label claim",
"oauth_storage_label_claim_description": "Automatically set the user's storage label to the value of this claim.",
"oauth_storage_quota_claim": "Storage quota claim",
"oauth_storage_quota_claim_description": "Automatically set the user's storage quota to the value of this claim.",
"oauth_storage_quota_default": "Default storage quota (GiB)",
"oauth_storage_quota_default_description": "Quota in GiB to be used when no claim is provided (Enter 0 for unlimited quota).",
+ "oauth_timeout": "Request Timeout",
+ "oauth_timeout_description": "Timeout for requests in milliseconds",
"offline_paths": "Offline Paths",
"offline_paths_description": "These results may be due to manual deletion of files that are not part of an external library.",
"password_enable_description": "Login with email and password",
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index b8ea4b924c..d46945f640 100644
--- a/mobile/openapi/README.md
+++ b/mobile/openapi/README.md
@@ -377,6 +377,7 @@ Class | Method | HTTP request | Description
- [OAuthAuthorizeResponseDto](doc//OAuthAuthorizeResponseDto.md)
- [OAuthCallbackDto](doc//OAuthCallbackDto.md)
- [OAuthConfigDto](doc//OAuthConfigDto.md)
+ - [OAuthTokenEndpointAuthMethod](doc//OAuthTokenEndpointAuthMethod.md)
- [OnThisDayDto](doc//OnThisDayDto.md)
- [PartnerDirection](doc//PartnerDirection.md)
- [PartnerResponseDto](doc//PartnerResponseDto.md)
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index e845099bd2..ba64363c97 100644
--- a/mobile/openapi/lib/api.dart
+++ b/mobile/openapi/lib/api.dart
@@ -178,6 +178,7 @@ part 'model/notification_update_dto.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_token_endpoint_auth_method.dart';
part 'model/on_this_day_dto.dart';
part 'model/partner_direction.dart';
part 'model/partner_response_dto.dart';
diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart
index 7586cc1ae2..6abe576aca 100644
--- a/mobile/openapi/lib/api_client.dart
+++ b/mobile/openapi/lib/api_client.dart
@@ -410,6 +410,8 @@ class ApiClient {
return OAuthCallbackDto.fromJson(value);
case 'OAuthConfigDto':
return OAuthConfigDto.fromJson(value);
+ case 'OAuthTokenEndpointAuthMethod':
+ return OAuthTokenEndpointAuthMethodTypeTransformer().decode(value);
case 'OnThisDayDto':
return OnThisDayDto.fromJson(value);
case 'PartnerDirection':
diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart
index cc517d48ab..5f9d15c089 100644
--- a/mobile/openapi/lib/api_helper.dart
+++ b/mobile/openapi/lib/api_helper.dart
@@ -106,6 +106,9 @@ String parameterToString(dynamic value) {
if (value is NotificationType) {
return NotificationTypeTypeTransformer().encode(value).toString();
}
+ if (value is OAuthTokenEndpointAuthMethod) {
+ return OAuthTokenEndpointAuthMethodTypeTransformer().encode(value).toString();
+ }
if (value is PartnerDirection) {
return PartnerDirectionTypeTransformer().encode(value).toString();
}
diff --git a/mobile/openapi/lib/model/o_auth_token_endpoint_auth_method.dart b/mobile/openapi/lib/model/o_auth_token_endpoint_auth_method.dart
new file mode 100644
index 0000000000..fc528888b3
--- /dev/null
+++ b/mobile/openapi/lib/model/o_auth_token_endpoint_auth_method.dart
@@ -0,0 +1,85 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// 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 OAuthTokenEndpointAuthMethod {
+ /// Instantiate a new enum with the provided [value].
+ const OAuthTokenEndpointAuthMethod._(this.value);
+
+ /// The underlying value of this enum member.
+ final String value;
+
+ @override
+ String toString() => value;
+
+ String toJson() => value;
+
+ static const post = OAuthTokenEndpointAuthMethod._(r'client_secret_post');
+ static const basic = OAuthTokenEndpointAuthMethod._(r'client_secret_basic');
+
+ /// List of all possible values in this [enum][OAuthTokenEndpointAuthMethod].
+ static const values = [
+ post,
+ basic,
+ ];
+
+ static OAuthTokenEndpointAuthMethod? fromJson(dynamic value) => OAuthTokenEndpointAuthMethodTypeTransformer().decode(value);
+
+ static List listFromJson(dynamic json, {bool growable = false,}) {
+ final result = [];
+ if (json is List && json.isNotEmpty) {
+ for (final row in json) {
+ final value = OAuthTokenEndpointAuthMethod.fromJson(row);
+ if (value != null) {
+ result.add(value);
+ }
+ }
+ }
+ return result.toList(growable: growable);
+ }
+}
+
+/// Transformation class that can [encode] an instance of [OAuthTokenEndpointAuthMethod] to String,
+/// and [decode] dynamic data back to [OAuthTokenEndpointAuthMethod].
+class OAuthTokenEndpointAuthMethodTypeTransformer {
+ factory OAuthTokenEndpointAuthMethodTypeTransformer() => _instance ??= const OAuthTokenEndpointAuthMethodTypeTransformer._();
+
+ const OAuthTokenEndpointAuthMethodTypeTransformer._();
+
+ String encode(OAuthTokenEndpointAuthMethod data) => data.value;
+
+ /// Decodes a [dynamic value][data] to a OAuthTokenEndpointAuthMethod.
+ ///
+ /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
+ /// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
+ /// cannot be decoded successfully, then an [UnimplementedError] is thrown.
+ ///
+ /// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
+ /// and users are still using an old app with the old code.
+ OAuthTokenEndpointAuthMethod? decode(dynamic data, {bool allowNull = true}) {
+ if (data != null) {
+ switch (data) {
+ case r'client_secret_post': return OAuthTokenEndpointAuthMethod.post;
+ case r'client_secret_basic': return OAuthTokenEndpointAuthMethod.basic;
+ default:
+ if (!allowNull) {
+ throw ArgumentError('Unknown enum value to decode: $data');
+ }
+ }
+ }
+ return null;
+ }
+
+ /// Singleton [OAuthTokenEndpointAuthMethodTypeTransformer] instance.
+ static OAuthTokenEndpointAuthMethodTypeTransformer? _instance;
+}
+
diff --git a/mobile/openapi/lib/model/system_config_o_auth_dto.dart b/mobile/openapi/lib/model/system_config_o_auth_dto.dart
index 9125bb7bba..24384a47b1 100644
--- a/mobile/openapi/lib/model/system_config_o_auth_dto.dart
+++ b/mobile/openapi/lib/model/system_config_o_auth_dto.dart
@@ -28,6 +28,8 @@ class SystemConfigOAuthDto {
required this.signingAlgorithm,
required this.storageLabelClaim,
required this.storageQuotaClaim,
+ required this.timeout,
+ required this.tokenEndpointAuthMethod,
});
bool autoLaunch;
@@ -61,6 +63,11 @@ class SystemConfigOAuthDto {
String storageQuotaClaim;
+ /// Minimum value: 1
+ int timeout;
+
+ OAuthTokenEndpointAuthMethod tokenEndpointAuthMethod;
+
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigOAuthDto &&
other.autoLaunch == autoLaunch &&
@@ -77,7 +84,9 @@ class SystemConfigOAuthDto {
other.scope == scope &&
other.signingAlgorithm == signingAlgorithm &&
other.storageLabelClaim == storageLabelClaim &&
- other.storageQuotaClaim == storageQuotaClaim;
+ other.storageQuotaClaim == storageQuotaClaim &&
+ other.timeout == timeout &&
+ other.tokenEndpointAuthMethod == tokenEndpointAuthMethod;
@override
int get hashCode =>
@@ -96,10 +105,12 @@ class SystemConfigOAuthDto {
(scope.hashCode) +
(signingAlgorithm.hashCode) +
(storageLabelClaim.hashCode) +
- (storageQuotaClaim.hashCode);
+ (storageQuotaClaim.hashCode) +
+ (timeout.hashCode) +
+ (tokenEndpointAuthMethod.hashCode);
@override
- String toString() => 'SystemConfigOAuthDto[autoLaunch=$autoLaunch, autoRegister=$autoRegister, buttonText=$buttonText, clientId=$clientId, clientSecret=$clientSecret, defaultStorageQuota=$defaultStorageQuota, enabled=$enabled, issuerUrl=$issuerUrl, mobileOverrideEnabled=$mobileOverrideEnabled, mobileRedirectUri=$mobileRedirectUri, profileSigningAlgorithm=$profileSigningAlgorithm, scope=$scope, signingAlgorithm=$signingAlgorithm, storageLabelClaim=$storageLabelClaim, storageQuotaClaim=$storageQuotaClaim]';
+ String toString() => 'SystemConfigOAuthDto[autoLaunch=$autoLaunch, autoRegister=$autoRegister, buttonText=$buttonText, clientId=$clientId, clientSecret=$clientSecret, defaultStorageQuota=$defaultStorageQuota, enabled=$enabled, issuerUrl=$issuerUrl, mobileOverrideEnabled=$mobileOverrideEnabled, mobileRedirectUri=$mobileRedirectUri, profileSigningAlgorithm=$profileSigningAlgorithm, scope=$scope, signingAlgorithm=$signingAlgorithm, storageLabelClaim=$storageLabelClaim, storageQuotaClaim=$storageQuotaClaim, timeout=$timeout, tokenEndpointAuthMethod=$tokenEndpointAuthMethod]';
Map toJson() {
final json = {};
@@ -118,6 +129,8 @@ class SystemConfigOAuthDto {
json[r'signingAlgorithm'] = this.signingAlgorithm;
json[r'storageLabelClaim'] = this.storageLabelClaim;
json[r'storageQuotaClaim'] = this.storageQuotaClaim;
+ json[r'timeout'] = this.timeout;
+ json[r'tokenEndpointAuthMethod'] = this.tokenEndpointAuthMethod;
return json;
}
@@ -145,6 +158,8 @@ class SystemConfigOAuthDto {
signingAlgorithm: mapValueOfType(json, r'signingAlgorithm')!,
storageLabelClaim: mapValueOfType(json, r'storageLabelClaim')!,
storageQuotaClaim: mapValueOfType(json, r'storageQuotaClaim')!,
+ timeout: mapValueOfType(json, r'timeout')!,
+ tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.fromJson(json[r'tokenEndpointAuthMethod'])!,
);
}
return null;
@@ -207,6 +222,8 @@ class SystemConfigOAuthDto {
'signingAlgorithm',
'storageLabelClaim',
'storageQuotaClaim',
+ 'timeout',
+ 'tokenEndpointAuthMethod',
};
}
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index f4ec929373..826af5a2ec 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -10824,6 +10824,13 @@
],
"type": "object"
},
+ "OAuthTokenEndpointAuthMethod": {
+ "enum": [
+ "client_secret_post",
+ "client_secret_basic"
+ ],
+ "type": "string"
+ },
"OnThisDayDto": {
"properties": {
"year": {
@@ -13404,6 +13411,17 @@
},
"storageQuotaClaim": {
"type": "string"
+ },
+ "timeout": {
+ "minimum": 1,
+ "type": "integer"
+ },
+ "tokenEndpointAuthMethod": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/OAuthTokenEndpointAuthMethod"
+ }
+ ]
}
},
"required": [
@@ -13421,7 +13439,9 @@
"scope",
"signingAlgorithm",
"storageLabelClaim",
- "storageQuotaClaim"
+ "storageQuotaClaim",
+ "timeout",
+ "tokenEndpointAuthMethod"
],
"type": "object"
},
diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts
index 647c5c4ada..743eeadf03 100644
--- a/open-api/typescript-sdk/src/fetch-client.ts
+++ b/open-api/typescript-sdk/src/fetch-client.ts
@@ -1315,6 +1315,8 @@ export type SystemConfigOAuthDto = {
signingAlgorithm: string;
storageLabelClaim: string;
storageQuotaClaim: string;
+ timeout: number;
+ tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod;
};
export type SystemConfigPasswordLoginDto = {
enabled: boolean;
@@ -3859,6 +3861,10 @@ export enum LogLevel {
Error = "error",
Fatal = "fatal"
}
+export enum OAuthTokenEndpointAuthMethod {
+ ClientSecretPost = "client_secret_post",
+ ClientSecretBasic = "client_secret_basic"
+}
export enum TimeBucketSize {
Day = "DAY",
Month = "MONTH"
diff --git a/server/src/config.ts b/server/src/config.ts
index 566adbd693..a9fdffbd62 100644
--- a/server/src/config.ts
+++ b/server/src/config.ts
@@ -5,6 +5,7 @@ import {
CQMode,
ImageFormat,
LogLevel,
+ OAuthTokenEndpointAuthMethod,
QueueName,
ToneMapping,
TranscodeHWAccel,
@@ -96,6 +97,8 @@ export interface SystemConfig {
scope: string;
signingAlgorithm: string;
profileSigningAlgorithm: string;
+ tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod;
+ timeout: number;
storageLabelClaim: string;
storageQuotaClaim: string;
};
@@ -260,6 +263,8 @@ export const defaults = Object.freeze({
profileSigningAlgorithm: 'none',
storageLabelClaim: 'preferred_username',
storageQuotaClaim: 'immich_quota',
+ tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.CLIENT_SECRET_POST,
+ timeout: 30_000,
},
passwordLogin: {
enabled: true,
diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts
index eaef40a5e1..6991baf109 100644
--- a/server/src/dtos/system-config.dto.ts
+++ b/server/src/dtos/system-config.dto.ts
@@ -25,6 +25,7 @@ import {
Colorspace,
ImageFormat,
LogLevel,
+ OAuthTokenEndpointAuthMethod,
QueueName,
ToneMapping,
TranscodeHWAccel,
@@ -33,7 +34,7 @@ import {
VideoContainer,
} from 'src/enum';
import { ConcurrentQueueName } from 'src/types';
-import { IsCronExpression, ValidateBoolean } from 'src/validation';
+import { IsCronExpression, Optional, ValidateBoolean } from 'src/validation';
const isLibraryScanEnabled = (config: SystemConfigLibraryScanDto) => config.enabled;
const isOAuthEnabled = (config: SystemConfigOAuthDto) => config.enabled;
@@ -344,10 +345,19 @@ class SystemConfigOAuthDto {
clientId!: string;
@ValidateIf(isOAuthEnabled)
- @IsNotEmpty()
@IsString()
clientSecret!: string;
+ @IsEnum(OAuthTokenEndpointAuthMethod)
+ @ApiProperty({ enum: OAuthTokenEndpointAuthMethod, enumName: 'OAuthTokenEndpointAuthMethod' })
+ tokenEndpointAuthMethod!: OAuthTokenEndpointAuthMethod;
+
+ @IsInt()
+ @IsPositive()
+ @Optional()
+ @ApiProperty({ type: 'integer' })
+ timeout!: number;
+
@IsNumber()
@Min(0)
defaultStorageQuota!: number;
diff --git a/server/src/enum.ts b/server/src/enum.ts
index c88e2e942c..4e725e1c13 100644
--- a/server/src/enum.ts
+++ b/server/src/enum.ts
@@ -605,3 +605,8 @@ export enum NotificationType {
SystemMessage = 'SystemMessage',
Custom = 'Custom',
}
+
+export enum OAuthTokenEndpointAuthMethod {
+ CLIENT_SECRET_POST = 'client_secret_post',
+ CLIENT_SECRET_BASIC = 'client_secret_basic',
+}
diff --git a/server/src/repositories/logging.repository.ts b/server/src/repositories/logging.repository.ts
index 05d2d45f4d..2ac3715a50 100644
--- a/server/src/repositories/logging.repository.ts
+++ b/server/src/repositories/logging.repository.ts
@@ -5,7 +5,7 @@ import { Telemetry } from 'src/decorators';
import { LogLevel } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
-type LogDetails = any[];
+type LogDetails = any;
type LogFunction = () => string;
const LOG_LEVELS = [LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL];
diff --git a/server/src/repositories/oauth.repository.ts b/server/src/repositories/oauth.repository.ts
index d3e0372089..ea9f0b1901 100644
--- a/server/src/repositories/oauth.repository.ts
+++ b/server/src/repositories/oauth.repository.ts
@@ -1,16 +1,19 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import type { UserInfoResponse } from 'openid-client' with { 'resolution-mode': 'import' };
+import { OAuthTokenEndpointAuthMethod } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
export type OAuthConfig = {
clientId: string;
- clientSecret: string;
+ clientSecret?: string;
issuerUrl: string;
mobileOverrideEnabled: boolean;
mobileRedirectUri: string;
profileSigningAlgorithm: string;
scope: string;
signingAlgorithm: string;
+ tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod;
+ timeout: number;
};
export type OAuthProfile = UserInfoResponse;
@@ -76,12 +79,10 @@ export class OAuthRepository {
);
}
- if (error.code === 'OAUTH_INVALID_RESPONSE') {
- this.logger.warn(`Invalid response from authorization server. Cause: ${error.cause?.message}`);
- throw error.cause;
- }
+ this.logger.error(`OAuth login failed: ${error.message}`);
+ this.logger.error(error);
- throw error;
+ throw new Error('OAuth login failed', { cause: error });
}
}
@@ -103,6 +104,8 @@ export class OAuthRepository {
clientSecret,
profileSigningAlgorithm,
signingAlgorithm,
+ tokenEndpointAuthMethod,
+ timeout,
}: OAuthConfig) {
try {
const { allowInsecureRequests, discovery } = await import('openid-client');
@@ -114,14 +117,38 @@ export class OAuthRepository {
response_types: ['code'],
userinfo_signed_response_alg: profileSigningAlgorithm === 'none' ? undefined : profileSigningAlgorithm,
id_token_signed_response_alg: signingAlgorithm,
- timeout: 30_000,
},
- undefined,
- { execute: [allowInsecureRequests] },
+ await this.getTokenAuthMethod(tokenEndpointAuthMethod, clientSecret),
+ {
+ execute: [allowInsecureRequests],
+ timeout,
+ },
);
} catch (error: any | AggregateError) {
this.logger.error(`Error in OAuth discovery: ${error}`, error?.stack, error?.errors);
throw new InternalServerErrorException(`Error in OAuth discovery: ${error}`, { cause: error });
}
}
+
+ private async getTokenAuthMethod(tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod, clientSecret?: string) {
+ const { None, ClientSecretPost, ClientSecretBasic } = await import('openid-client');
+
+ if (!clientSecret) {
+ return None();
+ }
+
+ switch (tokenEndpointAuthMethod) {
+ case OAuthTokenEndpointAuthMethod.CLIENT_SECRET_POST: {
+ return ClientSecretPost(clientSecret);
+ }
+
+ case OAuthTokenEndpointAuthMethod.CLIENT_SECRET_BASIC: {
+ return ClientSecretBasic(clientSecret);
+ }
+
+ default: {
+ return None();
+ }
+ }
+ }
}
diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts
index 936acf27ad..176e6d6f04 100644
--- a/server/src/services/system-config.service.spec.ts
+++ b/server/src/services/system-config.service.spec.ts
@@ -6,6 +6,7 @@ import {
CQMode,
ImageFormat,
LogLevel,
+ OAuthTokenEndpointAuthMethod,
QueueName,
ToneMapping,
TranscodeHWAccel,
@@ -119,6 +120,8 @@ const updatedConfig = Object.freeze({
scope: 'openid email profile',
signingAlgorithm: 'RS256',
profileSigningAlgorithm: 'none',
+ tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.CLIENT_SECRET_POST,
+ timeout: 30_000,
storageLabelClaim: 'preferred_username',
storageQuotaClaim: 'immich_quota',
},
diff --git a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte
index 67da6bb7f2..b2454b06c3 100644
--- a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte
@@ -1,16 +1,17 @@