From d89e88bb3f04bee8434cfb06b7a07b4f4b789b0f Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 29 Apr 2025 15:17:48 -0400 Subject: [PATCH] feat: configure token endpoint auth method (#17968) --- i18n/en.json | 10 ++-- mobile/openapi/README.md | Bin 34964 -> 35036 bytes mobile/openapi/lib/api.dart | Bin 12684 -> 12737 bytes mobile/openapi/lib/api_client.dart | Bin 32146 -> 32269 bytes mobile/openapi/lib/api_helper.dart | Bin 6822 -> 6956 bytes .../o_auth_token_endpoint_auth_method.dart | Bin 0 -> 3026 bytes .../lib/model/system_config_o_auth_dto.dart | Bin 7381 -> 8097 bytes open-api/immich-openapi-specs.json | 22 +++++++- open-api/typescript-sdk/src/fetch-client.ts | 6 +++ server/src/config.ts | 5 ++ server/src/dtos/system-config.dto.ts | 14 ++++- server/src/enum.ts | 5 ++ server/src/repositories/logging.repository.ts | 2 +- server/src/repositories/oauth.repository.ts | 45 ++++++++++++---- .../services/system-config.service.spec.ts | 3 ++ .../settings/auth/auth-settings.svelte | 48 +++++++++++++----- .../settings/setting-input-field.svelte | 10 ++-- .../settings/setting-select.svelte | 8 +-- 18 files changed, 137 insertions(+), 41 deletions(-) create mode 100644 mobile/openapi/lib/model/o_auth_token_endpoint_auth_method.dart diff --git a/i18n/en.json b/i18n/en.json index 239936471..f1ab30a6d 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 b8ea4b924c183f1ca137072126a95e874c474582..d46945f6409aa8623e45ed43c580030c4e2b238d 100644 GIT binary patch delta 77 zcmbO-k?GDvrVVacc8;Yb86o-Esd=t>DFykNc_knL-_(+f{FGRYl>B6UeSbVkHrr%H G$N>PM%N_av delta 14 VcmcaJk!i|ArVVacoBOi-`Pr#?@u_(!1^JnICGm-+B^mL#sU;ctDU&zyi)?-(u|fg> D>K7CA delta 12 TcmX?@+>^ZFsl;X}$>kCNC^rRW diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 7586cc1ae2a4f5cf3c059ec026c10af3e1cae3ed..6abe576acaf3ed409bef3e4581aa67b5a0c9dd79 100644 GIT binary patch delta 74 zcmbRAo3Zx~@OEU6PCQo#f#3{GYQ-AYi H$Nkm-a6}$? delta 14 WcmeDE!#L?TRWKtuk7)Fu*-g8Mw zNjy(U1H_ih`+d(nm*eqZJf^do`Snk~&8}vDoy}+GbaDB4Hlp)uy1Jgzm+SM(i@%Q` z#*&|MVg2;iHSeX{ariCiyglb(onWwUp8>#)MeOH^xSbtDMlv}ZLX;u5w z%71HRp{~USpOvuq_R=~Sx9+fdX0-D{`&8v{s0t-*P>AW&Zd;Z~z^lOvTw*k=@)wqnI zDB&du(sD~u*Bja@zb3zyR9SP6Fmqbmi?Wu>1bQL8rP$WH9r|+)HZ_5k#6pZ{ox7gr*;((IDC#|KW_@ zKSTq(%9>Ov<2+4Y%E%sPr9#6}Co?O3%38-rj_8@Om2fcp!`6Ztn`>(ISs^CWMwBJN zlT)iw#^f9+9ZR<0UM&}WjSdZa7GJ2kLx3Op^gb>S2eD^A3Qr+Eeq4qS8gxk~+cPzjNX{XqT!Bu4aY zWlTx8)@%h=cS?mQog6)!gz;!GXr-%5#z()S1q#C@Qw0s#JOR_8iErN$zbDFF89&=p ze%IQE2V-d%p^hlHu`K#{-o_9|KpZl z9zsIxcYFi#YpYu=Z#czaTf#|K%;5iF@W7NirmmY9I#ed|6S&M%CNu%Y+}GTz(y5}4m8`J zVv#AVo7(Ztg<(^|SXDjV2KWaaRgSU`E~iZwhAkc#2rb`sy#=LmXFIxO=43(o4^KJV zN3wN1eac>$aFP!0+37{X69w)mgXvyCFYc-S}7QEDJTF{DnN`VvE~Bu{XzOdup6X+%K{-*KIQ}k zH8r4q1%>>QjMO4MsF@13wlL$-9pVyV{*}fImA{4Y5-6KyH!9H*d;dm@x(FLD=1_Y=jTNisYA_Ck5vG=IwP|f z=4)%Pa}dg~8-}C}y8?(YMX4pFMR{N=K?1d0oA>gcUz_3<9&-4PFDYrV-f%vm+Qv2L^pR H3VjL+!PgJ- diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f4ec92937..826af5a2e 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 647c5c4ad..743eeadf0 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 566adbd69..a9fdffbd6 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 eaef40a5e..6991baf10 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 c88e2e942..4e725e1c1 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 05d2d45f4..2ac3715a5 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 d3e037208..ea9f0b190 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 936acf27a..176e6d6f0 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 67da6bb7f..b2454b06c 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 @@