feat(server)!: oauth encryption algorithm setting (#6818)

* feat: add oauth signing algorithm setting

* chore: open api

* chore: change default to RS256

* feat: test and clean up

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Daniel Dietzler 2024-02-02 06:27:54 +01:00 committed by GitHub
parent 8a643e5e48
commit d3404f927c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 189 additions and 127 deletions

View File

@ -66,8 +66,10 @@ Once you have a new OAuth client application configured, Immich can be configure
| Client ID | string | (required) | Required. Client ID (from previous step) | | Client ID | string | (required) | Required. Client ID (from previous step) |
| Client Secret | string | (required) | Required. Client Secret (previous step) | | Client Secret | string | (required) | Required. Client Secret (previous step) |
| Scope | string | openid email profile | Full list of scopes to send with the request (space delimited) | | Scope | string | openid email profile | Full list of scopes to send with the request (space delimited) |
| Signing Algorithm | string | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) |
| Button Text | string | Login with OAuth | Text for the OAuth button on the web | | Button Text | string | Login with OAuth | Text for the OAuth button on the web |
| Auto Register | boolean | true | When true, will automatically register a user the first time they sign in | | Auto Register | boolean | true | When true, will automatically register a user the first time they sign in |
| Storage Claim | string | preferred_username | Claim mapping for the user's storage label |
| [Auto Launch](#auto-launch) | boolean | false | When true, will skip the login page and automatically start the OAuth login process | | [Auto Launch](#auto-launch) | boolean | false | When true, will skip the login page and automatically start the OAuth login process |
| [Mobile Redirect URI Override](#mobile-redirect-uri) | URL | (empty) | Http(s) alternative mobile redirect URI | | [Mobile Redirect URI Override](#mobile-redirect-uri) | URL | (empty) | Http(s) alternative mobile redirect URI |

View File

@ -18,6 +18,7 @@ Name | Type | Description | Notes
**mobileOverrideEnabled** | **bool** | | **mobileOverrideEnabled** | **bool** | |
**mobileRedirectUri** | **String** | | **mobileRedirectUri** | **String** | |
**scope** | **String** | | **scope** | **String** | |
**signingAlgorithm** | **String** | |
**storageLabelClaim** | **String** | | **storageLabelClaim** | **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) [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -23,6 +23,7 @@ class SystemConfigOAuthDto {
required this.mobileOverrideEnabled, required this.mobileOverrideEnabled,
required this.mobileRedirectUri, required this.mobileRedirectUri,
required this.scope, required this.scope,
required this.signingAlgorithm,
required this.storageLabelClaim, required this.storageLabelClaim,
}); });
@ -46,6 +47,8 @@ class SystemConfigOAuthDto {
String scope; String scope;
String signingAlgorithm;
String storageLabelClaim; String storageLabelClaim;
@override @override
@ -60,6 +63,7 @@ class SystemConfigOAuthDto {
other.mobileOverrideEnabled == mobileOverrideEnabled && other.mobileOverrideEnabled == mobileOverrideEnabled &&
other.mobileRedirectUri == mobileRedirectUri && other.mobileRedirectUri == mobileRedirectUri &&
other.scope == scope && other.scope == scope &&
other.signingAlgorithm == signingAlgorithm &&
other.storageLabelClaim == storageLabelClaim; other.storageLabelClaim == storageLabelClaim;
@override @override
@ -75,10 +79,11 @@ class SystemConfigOAuthDto {
(mobileOverrideEnabled.hashCode) + (mobileOverrideEnabled.hashCode) +
(mobileRedirectUri.hashCode) + (mobileRedirectUri.hashCode) +
(scope.hashCode) + (scope.hashCode) +
(signingAlgorithm.hashCode) +
(storageLabelClaim.hashCode); (storageLabelClaim.hashCode);
@override @override
String toString() => 'SystemConfigOAuthDto[autoLaunch=$autoLaunch, autoRegister=$autoRegister, buttonText=$buttonText, clientId=$clientId, clientSecret=$clientSecret, enabled=$enabled, issuerUrl=$issuerUrl, mobileOverrideEnabled=$mobileOverrideEnabled, mobileRedirectUri=$mobileRedirectUri, scope=$scope, storageLabelClaim=$storageLabelClaim]'; String toString() => 'SystemConfigOAuthDto[autoLaunch=$autoLaunch, autoRegister=$autoRegister, buttonText=$buttonText, clientId=$clientId, clientSecret=$clientSecret, enabled=$enabled, issuerUrl=$issuerUrl, mobileOverrideEnabled=$mobileOverrideEnabled, mobileRedirectUri=$mobileRedirectUri, scope=$scope, signingAlgorithm=$signingAlgorithm, storageLabelClaim=$storageLabelClaim]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -92,6 +97,7 @@ class SystemConfigOAuthDto {
json[r'mobileOverrideEnabled'] = this.mobileOverrideEnabled; json[r'mobileOverrideEnabled'] = this.mobileOverrideEnabled;
json[r'mobileRedirectUri'] = this.mobileRedirectUri; json[r'mobileRedirectUri'] = this.mobileRedirectUri;
json[r'scope'] = this.scope; json[r'scope'] = this.scope;
json[r'signingAlgorithm'] = this.signingAlgorithm;
json[r'storageLabelClaim'] = this.storageLabelClaim; json[r'storageLabelClaim'] = this.storageLabelClaim;
return json; return json;
} }
@ -114,6 +120,7 @@ class SystemConfigOAuthDto {
mobileOverrideEnabled: mapValueOfType<bool>(json, r'mobileOverrideEnabled')!, mobileOverrideEnabled: mapValueOfType<bool>(json, r'mobileOverrideEnabled')!,
mobileRedirectUri: mapValueOfType<String>(json, r'mobileRedirectUri')!, mobileRedirectUri: mapValueOfType<String>(json, r'mobileRedirectUri')!,
scope: mapValueOfType<String>(json, r'scope')!, scope: mapValueOfType<String>(json, r'scope')!,
signingAlgorithm: mapValueOfType<String>(json, r'signingAlgorithm')!,
storageLabelClaim: mapValueOfType<String>(json, r'storageLabelClaim')!, storageLabelClaim: mapValueOfType<String>(json, r'storageLabelClaim')!,
); );
} }
@ -172,6 +179,7 @@ class SystemConfigOAuthDto {
'mobileOverrideEnabled', 'mobileOverrideEnabled',
'mobileRedirectUri', 'mobileRedirectUri',
'scope', 'scope',
'signingAlgorithm',
'storageLabelClaim', 'storageLabelClaim',
}; };
} }

View File

@ -66,6 +66,11 @@ void main() {
// TODO // TODO
}); });
// String signingAlgorithm
test('to test the property `signingAlgorithm`', () async {
// TODO
});
// String storageLabelClaim // String storageLabelClaim
test('to test the property `storageLabelClaim`', () async { test('to test the property `storageLabelClaim`', () async {
// TODO // TODO

View File

@ -9679,6 +9679,9 @@
"scope": { "scope": {
"type": "string" "type": "string"
}, },
"signingAlgorithm": {
"type": "string"
},
"storageLabelClaim": { "storageLabelClaim": {
"type": "string" "type": "string"
} }
@ -9694,6 +9697,7 @@
"mobileOverrideEnabled", "mobileOverrideEnabled",
"mobileRedirectUri", "mobileRedirectUri",
"scope", "scope",
"signingAlgorithm",
"storageLabelClaim" "storageLabelClaim"
], ],
"type": "object" "type": "object"

View File

@ -4073,6 +4073,12 @@ export interface SystemConfigOAuthDto {
* @memberof SystemConfigOAuthDto * @memberof SystemConfigOAuthDto
*/ */
'scope': string; 'scope': string;
/**
*
* @type {string}
* @memberof SystemConfigOAuthDto
*/
'signingAlgorithm': string;
/** /**
* *
* @type {string} * @type {string}

View File

@ -73,7 +73,7 @@ describe('AuthService', () => {
jest.spyOn(generators, 'state').mockReturnValue('state'); jest.spyOn(generators, 'state').mockReturnValue('state');
jest.spyOn(Issuer, 'discover').mockResolvedValue({ jest.spyOn(Issuer, 'discover').mockResolvedValue({
id_token_signing_alg_values_supported: ['HS256'], id_token_signing_alg_values_supported: ['RS256'],
Client: jest.fn().mockResolvedValue({ Client: jest.fn().mockResolvedValue({
issuer: { issuer: {
metadata: { metadata: {

View File

@ -318,12 +318,25 @@ export class AuthService {
const redirectUri = this.normalize(config, url.split('?')[0]); const redirectUri = this.normalize(config, url.split('?')[0]);
const client = await this.getOAuthClient(config); const client = await this.getOAuthClient(config);
const params = client.callbackParams(url); const params = client.callbackParams(url);
const tokens = await client.callback(redirectUri, params, { state: params.state }); try {
return client.userinfo<OAuthProfile>(tokens.access_token || ''); const tokens = await client.callback(redirectUri, params, { state: params.state });
return client.userinfo<OAuthProfile>(tokens.access_token || '');
} catch (error: Error | any) {
if (error.message.includes('unexpected JWT alg received')) {
this.logger.warn(
[
'Algorithm mismatch. Make sure the signing algorithm is set correctly in the OAuth settings.',
'Or, that you have specified a signing key in your OAuth provider.',
].join(' '),
);
}
throw error;
}
} }
private async getOAuthClient(config: SystemConfig) { private async getOAuthClient(config: SystemConfig) {
const { enabled, clientId, clientSecret, issuerUrl } = config.oauth; const { enabled, clientId, clientSecret, issuerUrl, signingAlgorithm } = config.oauth;
if (!enabled) { if (!enabled) {
throw new BadRequestException('OAuth2 is not enabled'); throw new BadRequestException('OAuth2 is not enabled');
@ -337,10 +350,7 @@ export class AuthService {
try { try {
const issuer = await Issuer.discover(issuerUrl); const issuer = await Issuer.discover(issuerUrl);
const algorithms = (issuer.id_token_signing_alg_values_supported || []) as string[]; metadata.id_token_signed_response_alg = signingAlgorithm;
if (algorithms[0] === 'HS256') {
metadata.id_token_signed_response_alg = algorithms[0];
}
return new issuer.Client(metadata); return new issuer.Client(metadata);
} catch (error: any | AggregateError) { } catch (error: any | AggregateError) {

View File

@ -5,12 +5,13 @@ const isOverrideEnabled = (config: SystemConfigOAuthDto) => config.mobileOverrid
export class SystemConfigOAuthDto { export class SystemConfigOAuthDto {
@IsBoolean() @IsBoolean()
enabled!: boolean; autoLaunch!: boolean;
@IsBoolean()
autoRegister!: boolean;
@ValidateIf(isEnabled)
@IsNotEmpty()
@IsString() @IsString()
issuerUrl!: string; buttonText!: string;
@ValidateIf(isEnabled) @ValidateIf(isEnabled)
@IsNotEmpty() @IsNotEmpty()
@ -22,20 +23,13 @@ export class SystemConfigOAuthDto {
@IsString() @IsString()
clientSecret!: string; clientSecret!: string;
@IsString()
scope!: string;
@IsString()
storageLabelClaim!: string;
@IsString()
buttonText!: string;
@IsBoolean() @IsBoolean()
autoRegister!: boolean; enabled!: boolean;
@IsBoolean() @ValidateIf(isEnabled)
autoLaunch!: boolean; @IsNotEmpty()
@IsString()
issuerUrl!: string;
@IsBoolean() @IsBoolean()
mobileOverrideEnabled!: boolean; mobileOverrideEnabled!: boolean;
@ -43,4 +37,14 @@ export class SystemConfigOAuthDto {
@ValidateIf(isOverrideEnabled) @ValidateIf(isOverrideEnabled)
@IsUrl() @IsUrl()
mobileRedirectUri!: string; mobileRedirectUri!: string;
@IsString()
scope!: string;
@IsString()
@IsNotEmpty()
signingAlgorithm!: string;
@IsString()
storageLabelClaim!: string;
} }

View File

@ -88,17 +88,18 @@ export const defaults = Object.freeze<SystemConfig>({
enabled: true, enabled: true,
}, },
oauth: { oauth: {
enabled: false, autoLaunch: false,
issuerUrl: '', autoRegister: true,
buttonText: 'Login with OAuth',
clientId: '', clientId: '',
clientSecret: '', clientSecret: '',
enabled: false,
issuerUrl: '',
mobileOverrideEnabled: false, mobileOverrideEnabled: false,
mobileRedirectUri: '', mobileRedirectUri: '',
scope: 'openid email profile', scope: 'openid email profile',
signingAlgorithm: 'RS256',
storageLabelClaim: 'preferred_username', storageLabelClaim: 'preferred_username',
buttonText: 'Login with OAuth',
autoRegister: true,
autoLaunch: false,
}, },
passwordLogin: { passwordLogin: {
enabled: true, enabled: true,

View File

@ -98,6 +98,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
mobileOverrideEnabled: false, mobileOverrideEnabled: false,
mobileRedirectUri: '', mobileRedirectUri: '',
scope: 'openid email profile', scope: 'openid email profile',
signingAlgorithm: 'RS256',
storageLabelClaim: 'preferred_username', storageLabelClaim: 'preferred_username',
}, },
passwordLogin: { passwordLogin: {

View File

@ -77,17 +77,18 @@ export enum SystemConfigKey {
NEW_VERSION_CHECK_ENABLED = 'newVersionCheck.enabled', NEW_VERSION_CHECK_ENABLED = 'newVersionCheck.enabled',
OAUTH_ENABLED = 'oauth.enabled', OAUTH_AUTO_LAUNCH = 'oauth.autoLaunch',
OAUTH_ISSUER_URL = 'oauth.issuerUrl', OAUTH_AUTO_REGISTER = 'oauth.autoRegister',
OAUTH_BUTTON_TEXT = 'oauth.buttonText',
OAUTH_CLIENT_ID = 'oauth.clientId', OAUTH_CLIENT_ID = 'oauth.clientId',
OAUTH_CLIENT_SECRET = 'oauth.clientSecret', OAUTH_CLIENT_SECRET = 'oauth.clientSecret',
OAUTH_SCOPE = 'oauth.scope', OAUTH_ENABLED = 'oauth.enabled',
OAUTH_STORAGE_LABEL_CLAIM = 'oauth.storageLabelClaim', OAUTH_ISSUER_URL = 'oauth.issuerUrl',
OAUTH_AUTO_LAUNCH = 'oauth.autoLaunch',
OAUTH_BUTTON_TEXT = 'oauth.buttonText',
OAUTH_AUTO_REGISTER = 'oauth.autoRegister',
OAUTH_MOBILE_OVERRIDE_ENABLED = 'oauth.mobileOverrideEnabled', OAUTH_MOBILE_OVERRIDE_ENABLED = 'oauth.mobileOverrideEnabled',
OAUTH_MOBILE_REDIRECT_URI = 'oauth.mobileRedirectUri', OAUTH_MOBILE_REDIRECT_URI = 'oauth.mobileRedirectUri',
OAUTH_SCOPE = 'oauth.scope',
OAUTH_SIGNING_ALGORITHM = 'oauth.signingAlgorithm',
OAUTH_STORAGE_LABEL_CLAIM = 'oauth.storageLabelClaim',
PASSWORD_LOGIN_ENABLED = 'passwordLogin.enabled', PASSWORD_LOGIN_ENABLED = 'passwordLogin.enabled',
@ -216,17 +217,18 @@ export interface SystemConfig {
enabled: boolean; enabled: boolean;
}; };
oauth: { oauth: {
enabled: boolean; autoLaunch: boolean;
issuerUrl: string; autoRegister: boolean;
buttonText: string;
clientId: string; clientId: string;
clientSecret: string; clientSecret: string;
scope: string; enabled: boolean;
storageLabelClaim: string; issuerUrl: string;
buttonText: string;
autoRegister: boolean;
autoLaunch: boolean;
mobileOverrideEnabled: boolean; mobileOverrideEnabled: boolean;
mobileRedirectUri: string; mobileRedirectUri: string;
scope: string;
signingAlgorithm: string;
storageLabelClaim: string;
}; };
passwordLogin: { passwordLogin: {
enabled: boolean; enabled: boolean;

View File

@ -45,8 +45,10 @@ export const oauth = {
const redirectUri = location.href.split('?')[0]; const redirectUri = location.href.split('?')[0];
const { data } = await api.oauthApi.startOAuth({ oAuthConfigDto: { redirectUri } }); const { data } = await api.oauthApi.startOAuth({ oAuthConfigDto: { redirectUri } });
window.location.href = data.url; window.location.href = data.url;
return true;
} catch (error) { } catch (error) {
handleError(error, 'Unable to login with OAuth'); handleError(error, 'Unable to login with OAuth');
return false;
} }
}, },
login: (location: Location) => { login: (location: Location) => {

View File

@ -69,94 +69,106 @@
>. >.
</p> </p>
<SettingSwitch {disabled} title="ENABLE" bind:checked={config.oauth.enabled} /> <SettingSwitch {disabled} title="ENABLE" subtitle="Login with OAuth" bind:checked={config.oauth.enabled} />
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ISSUER URL"
bind:value={config.oauth.issuerUrl}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.issuerUrl == savedConfig.oauth.issuerUrl)}
/>
<SettingInputField {#if config.oauth.enabled}
inputType={SettingInputFieldType.TEXT} <hr />
label="CLIENT ID"
bind:value={config.oauth.clientId}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.clientId == savedConfig.oauth.clientId)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT SECRET"
bind:value={config.oauth.clientSecret}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.clientSecret == savedConfig.oauth.clientSecret)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SCOPE"
bind:value={config.oauth.scope}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.scope == savedConfig.oauth.scope)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="STORAGE LABEL CLAIM"
desc="Automatically set the user's storage label to the value of this claim."
bind:value={config.oauth.storageLabelClaim}
required={true}
disabled={disabled || !config.oauth.storageLabelClaim}
isEdited={!(config.oauth.storageLabelClaim == savedConfig.oauth.storageLabelClaim)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="BUTTON TEXT"
bind:value={config.oauth.buttonText}
required={false}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.buttonText == savedConfig.oauth.buttonText)}
/>
<SettingSwitch
title="AUTO REGISTER"
subtitle="Automatically register new users after signing in with OAuth"
bind:checked={config.oauth.autoRegister}
disabled={disabled || !config.oauth.enabled}
/>
<SettingSwitch
title="AUTO LAUNCH"
subtitle="Start the OAuth login flow automatically upon navigating to the login page"
disabled={disabled || !config.oauth.enabled}
bind:checked={config.oauth.autoLaunch}
/>
<SettingSwitch
title="MOBILE REDIRECT URI OVERRIDE"
subtitle="Enable when 'app.immich:/' is an invalid redirect URI."
disabled={disabled || !config.oauth.enabled}
on:click={() => handleToggleOverride()}
bind:checked={config.oauth.mobileOverrideEnabled}
/>
{#if config.oauth.mobileOverrideEnabled}
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label="MOBILE REDIRECT URI" label="ISSUER URL"
bind:value={config.oauth.mobileRedirectUri} bind:value={config.oauth.issuerUrl}
required={true} required={true}
disabled={disabled || !config.oauth.enabled} disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.mobileRedirectUri == savedConfig.oauth.mobileRedirectUri)} isEdited={!(config.oauth.issuerUrl == savedConfig.oauth.issuerUrl)}
/> />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT ID"
bind:value={config.oauth.clientId}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.clientId == savedConfig.oauth.clientId)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT SECRET"
bind:value={config.oauth.clientSecret}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.clientSecret == savedConfig.oauth.clientSecret)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SCOPE"
bind:value={config.oauth.scope}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.scope == savedConfig.oauth.scope)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SIGNING ALGORITHM"
bind:value={config.oauth.signingAlgorithm}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.signingAlgorithm == savedConfig.oauth.signingAlgorithm)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="STORAGE LABEL CLAIM"
desc="Automatically set the user's storage label to the value of this claim."
bind:value={config.oauth.storageLabelClaim}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.storageLabelClaim == savedConfig.oauth.storageLabelClaim)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="BUTTON TEXT"
bind:value={config.oauth.buttonText}
required={false}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.buttonText == savedConfig.oauth.buttonText)}
/>
<SettingSwitch
title="AUTO REGISTER"
subtitle="Automatically register new users after signing in with OAuth"
bind:checked={config.oauth.autoRegister}
disabled={disabled || !config.oauth.enabled}
/>
<SettingSwitch
title="AUTO LAUNCH"
subtitle="Start the OAuth login flow automatically upon navigating to the login page"
disabled={disabled || !config.oauth.enabled}
bind:checked={config.oauth.autoLaunch}
/>
<SettingSwitch
title="MOBILE REDIRECT URI OVERRIDE"
subtitle="Enable when 'app.immich:/' is an invalid redirect URI."
disabled={disabled || !config.oauth.enabled}
on:click={() => handleToggleOverride()}
bind:checked={config.oauth.mobileOverrideEnabled}
/>
{#if config.oauth.mobileOverrideEnabled}
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="MOBILE REDIRECT URI"
bind:value={config.oauth.mobileRedirectUri}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.mobileRedirectUri == savedConfig.oauth.mobileRedirectUri)}
/>
{/if}
{/if} {/if}
<SettingButtonsRow <SettingButtonsRow

View File

@ -89,7 +89,11 @@
const handleOAuthLogin = async () => { const handleOAuthLogin = async () => {
oauthLoading = true; oauthLoading = true;
oauthError = ''; oauthError = '';
await oauth.authorize(window.location); const success = await oauth.authorize(window.location);
if (!success) {
oauthLoading = false;
oauthError = 'Unable to login with OAuth';
}
}; };
</script> </script>