瀏覽代碼

feat(server): mobile oauth with custom scheme redirect uri (#1204)

* feat(server): support providers without support for custom schemas

* chore: unit tests

* chore: test mobile override

* chore: add details to the docs
Jason Rasmussen 2 年之前
父節點
當前提交
6974d4068b

+ 46 - 29
docs/docs/features/oauth.md

@@ -2,6 +2,10 @@
 
 This page contains details about using OAuth in Immich.
 
+:::tip
+Unable to set `app.immich:/` as a valid redirect URI? See [Mobile Redirect URI](#mobile-redirect-uri) for an alternative solution.
+:::
+
 ## Overview
 
 Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an identity layer built on top of OAuth2. OIDC is supported by most identity providers, including:
@@ -24,50 +28,47 @@ Before enabling OAuth in Immich, a new client application needs to be configured
 
 2. Configure Redirect URIs/Origins
 
-The **Sign-in redirect URIs** should include:
+   The **Sign-in redirect URIs** should include:
 
-- `app.immich:/` - for logging in with OAuth from the [Mobile App](/docs/features/mobile-app.mdx)
-- `http://DOMAIN:PORT/auth/login` - for logging in with OAuth from the Web Client
-- `http://DOMAIN:PORT/user-settings` - for manually linking OAuth in the Web Client
+   - `app.immich:/` - for logging in with OAuth from the [Mobile App](/docs/features/mobile-app.mdx)
+   - `http://DOMAIN:PORT/auth/login` - for logging in with OAuth from the Web Client
+   - `http://DOMAIN:PORT/user-settings` - for manually linking OAuth in the Web Client
 
-:::info Redirect URIs
+   Redirect URIs should contain all the domains you will be using to access Immich. Some examples include:
 
-Redirect URIs should contain all the domains you will be using to access Immich. Some examples include:
+   Mobile
 
-Mobile
+   - `app.immich:/` (You **MUST** include this for iOS and Android mobile apps to work properly)
 
-- `app.immich:/` (You **MUST** include this for iOS and Android mobile apps to work properly)
+   Localhost
 
-Localhost
+   - `http://localhost:2283/auth/login`
+   - `http://localhost:2283/user-settings`
 
-- `http://localhost:2283/auth/login`
-- `http://localhost:2283/user-settings`
+   Local IP
 
-Local IP
+   - `http://192.168.0.200:2283/auth/login`
+   - `http://192.168.0.200:2283/user-settings`
 
-- `http://192.168.0.200:2283/auth/login`
-- `http://192.168.0.200:2283/user-settings`
+   Hostname
 
-Hostname
-
-- `https://immich.example.com/auth/login`)
-- `https://immich.example.com/user-settings`)
-
-:::
+   - `https://immich.example.com/auth/login`)
+   - `https://immich.example.com/user-settings`)
 
 ## Enable OAuth
 
 Once you have a new OAuth client application configured, Immich can be configured using the Administration Settings page, available on the web (Administration -> Settings).
 
-| Setting       | Type    | Default              | Description                                                               |
-| ------------- | ------- | -------------------- | ------------------------------------------------------------------------- |
-| Enabled       | boolean | false                | Enable/disable OAuth                                                      |
-| Issuer URL    | URL     | (required)           | Required. Self-discovery URL for client (from previous step)              |
-| Client ID     | string  | (required)           | Required. Client ID (from 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)            |
-| 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 |
+| Setting                      | Type    | Default              | Description                                                               |
+| ---------------------------- | ------- | -------------------- | ------------------------------------------------------------------------- |
+| Enabled                      | boolean | false                | Enable/disable OAuth                                                      |
+| Issuer URL                   | URL     | (required)           | Required. Self-discovery URL for client (from previous step)              |
+| Client ID                    | string  | (required)           | Required. Client ID (from 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)            |
+| 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 |
+| Mobile Redirect URI Override | URL     | (empty)              | Http(s) alternative mobile redirect URI                                   |
 
 :::info
 The Issuer URL should look something like the following, and return a valid json document.
@@ -78,6 +79,22 @@ The Issuer URL should look something like the following, and return a valid json
 The `.well-known/openid-configuration` part of the url is optional and will be automatically added during discovery.
 :::
 
+## Mobile Redirect URI
+
+The redirect URI for the mobile app is `app.immich:/`, which is a [Custom Scheme](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app). If this custom scheme is an invalid redirect URI for your OAuth Provider, you can work around this by doing the following:
+
+1. Configure an http(s) endpoint to forwards requests to `app.immich:/`
+2. Whitelist the new endpoint as a valid redirect URI with your provider.
+3. Specify the new endpoint as the `Mobile Redirect URI Override`, in the OAuth settings.
+
+With these steps in place, you should be able to use OAuth from the [Mobile App](/docs/features/mobile-app.mdx) without a custom scheme redirect URI.
+
+:::info
+Immich has a route (`/api/oauth/mobile-redirect`) that is already configured to forward requests to `app.immich:/`, and can be used for step 1.
+:::
+
+## Example Configuration
+
 Here's an example of OAuth configured for Authentik:
 
 ![OAuth Settings](./img/oauth-settings.png)

+ 2 - 1
mobile/openapi/README.md

@@ -3,7 +3,7 @@ Immich API
 
 This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
 
-- API version: 1.39.0
+- API version: 1.40.0
 - Build package: org.openapitools.codegen.languages.DartClientCodegen
 
 ## Requirements
@@ -109,6 +109,7 @@ Class | Method | HTTP request | Description
 *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 | 
+*OAuthApi* | [**mobileRedirect**](doc//OAuthApi.md#mobileredirect) | **GET** /oauth/mobile-redirect | 
 *OAuthApi* | [**unlink**](doc//OAuthApi.md#unlink) | **POST** /oauth/unlink | 
 *ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info | 
 *ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version | 

+ 37 - 0
mobile/openapi/doc/OAuthApi.md

@@ -12,6 +12,7 @@ Method | HTTP request | Description
 [**callback**](OAuthApi.md#callback) | **POST** /oauth/callback | 
 [**generateConfig**](OAuthApi.md#generateconfig) | **POST** /oauth/config | 
 [**link**](OAuthApi.md#link) | **POST** /oauth/link | 
+[**mobileRedirect**](OAuthApi.md#mobileredirect) | **GET** /oauth/mobile-redirect | 
 [**unlink**](OAuthApi.md#unlink) | **POST** /oauth/unlink | 
 
 
@@ -138,6 +139,42 @@ No authorization required
 
 [[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)
 
+# **mobileRedirect**
+> mobileRedirect()
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+
+final api_instance = OAuthApi();
+
+try {
+    api_instance.mobileRedirect();
+} catch (e) {
+    print('Exception when calling OAuthApi->mobileRedirect: $e\n');
+}
+```
+
+### Parameters
+This endpoint does not need any parameter.
+
+### Return type
+
+void (empty response body)
+
+### Authorization
+
+No authorization required
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: Not defined
+
+[[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)
+
 # **unlink**
 > UserResponseDto unlink()
 

+ 2 - 0
mobile/openapi/doc/SystemConfigOAuthDto.md

@@ -15,6 +15,8 @@ Name | Type | Description | Notes
 **scope** | **String** |  | 
 **buttonText** | **String** |  | 
 **autoRegister** | **bool** |  | 
+**mobileOverrideEnabled** | **bool** |  | 
+**mobileRedirectUri** | **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)
 

+ 33 - 0
mobile/openapi/lib/api/o_auth_api.dart

@@ -157,6 +157,39 @@ class OAuthApi {
     return null;
   }
 
+  /// Performs an HTTP 'GET /oauth/mobile-redirect' operation and returns the [Response].
+  Future<Response> mobileRedirectWithHttpInfo() async {
+    // ignore: prefer_const_declarations
+    final path = r'/oauth/mobile-redirect';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  Future<void> mobileRedirect() async {
+    final response = await mobileRedirectWithHttpInfo();
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+  }
+
   /// Performs an HTTP 'POST /oauth/unlink' operation and returns the [Response].
   Future<Response> unlinkWithHttpInfo() async {
     // ignore: prefer_const_declarations

+ 19 - 3
mobile/openapi/lib/model/system_config_o_auth_dto.dart

@@ -20,6 +20,8 @@ class SystemConfigOAuthDto {
     required this.scope,
     required this.buttonText,
     required this.autoRegister,
+    required this.mobileOverrideEnabled,
+    required this.mobileRedirectUri,
   });
 
   bool enabled;
@@ -36,6 +38,10 @@ class SystemConfigOAuthDto {
 
   bool autoRegister;
 
+  bool mobileOverrideEnabled;
+
+  String mobileRedirectUri;
+
   @override
   bool operator ==(Object other) => identical(this, other) || other is SystemConfigOAuthDto &&
      other.enabled == enabled &&
@@ -44,7 +50,9 @@ class SystemConfigOAuthDto {
      other.clientSecret == clientSecret &&
      other.scope == scope &&
      other.buttonText == buttonText &&
-     other.autoRegister == autoRegister;
+     other.autoRegister == autoRegister &&
+     other.mobileOverrideEnabled == mobileOverrideEnabled &&
+     other.mobileRedirectUri == mobileRedirectUri;
 
   @override
   int get hashCode =>
@@ -55,10 +63,12 @@ class SystemConfigOAuthDto {
     (clientSecret.hashCode) +
     (scope.hashCode) +
     (buttonText.hashCode) +
-    (autoRegister.hashCode);
+    (autoRegister.hashCode) +
+    (mobileOverrideEnabled.hashCode) +
+    (mobileRedirectUri.hashCode);
 
   @override
-  String toString() => 'SystemConfigOAuthDto[enabled=$enabled, issuerUrl=$issuerUrl, clientId=$clientId, clientSecret=$clientSecret, scope=$scope, buttonText=$buttonText, autoRegister=$autoRegister]';
+  String toString() => 'SystemConfigOAuthDto[enabled=$enabled, issuerUrl=$issuerUrl, clientId=$clientId, clientSecret=$clientSecret, scope=$scope, buttonText=$buttonText, autoRegister=$autoRegister, mobileOverrideEnabled=$mobileOverrideEnabled, mobileRedirectUri=$mobileRedirectUri]';
 
   Map<String, dynamic> toJson() {
     final _json = <String, dynamic>{};
@@ -69,6 +79,8 @@ class SystemConfigOAuthDto {
       _json[r'scope'] = scope;
       _json[r'buttonText'] = buttonText;
       _json[r'autoRegister'] = autoRegister;
+      _json[r'mobileOverrideEnabled'] = mobileOverrideEnabled;
+      _json[r'mobileRedirectUri'] = mobileRedirectUri;
     return _json;
   }
 
@@ -98,6 +110,8 @@ class SystemConfigOAuthDto {
         scope: mapValueOfType<String>(json, r'scope')!,
         buttonText: mapValueOfType<String>(json, r'buttonText')!,
         autoRegister: mapValueOfType<bool>(json, r'autoRegister')!,
+        mobileOverrideEnabled: mapValueOfType<bool>(json, r'mobileOverrideEnabled')!,
+        mobileRedirectUri: mapValueOfType<String>(json, r'mobileRedirectUri')!,
       );
     }
     return null;
@@ -154,6 +168,8 @@ class SystemConfigOAuthDto {
     'scope',
     'buttonText',
     'autoRegister',
+    'mobileOverrideEnabled',
+    'mobileRedirectUri',
   };
 }
 

+ 5 - 0
mobile/openapi/test/o_auth_api_test.dart

@@ -32,6 +32,11 @@ void main() {
       // TODO
     });
 
+    //Future mobileRedirect() async
+    test('test mobileRedirect', () async {
+      // TODO
+    });
+
     //Future<UserResponseDto> unlink() async
     test('test unlink', () async {
       // TODO

+ 10 - 0
mobile/openapi/test/system_config_o_auth_dto_test.dart

@@ -51,6 +51,16 @@ void main() {
       // TODO
     });
 
+    // bool mobileOverrideEnabled
+    test('to test the property `mobileOverrideEnabled`', () async {
+      // TODO
+    });
+
+    // String mobileRedirectUri
+    test('to test the property `mobileRedirectUri`', () async {
+      // TODO
+    });
+
 
   });
 

+ 12 - 5
server/apps/immich/src/api-v1/oauth/oauth.controller.ts

@@ -1,6 +1,6 @@
-import { Body, Controller, Post, Res, ValidationPipe } from '@nestjs/common';
+import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res, ValidationPipe } from '@nestjs/common';
 import { ApiTags } from '@nestjs/swagger';
-import { Response } from 'express';
+import { Request, Response } from 'express';
 import { AuthType } from '../../constants/jwt.constant';
 import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
 import { Authenticated } from '../../decorators/authenticated.decorator';
@@ -9,7 +9,7 @@ import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
 import { UserResponseDto } from '../user/response-dto/user-response.dto';
 import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
 import { OAuthConfigDto } from './dto/oauth-config.dto';
-import { OAuthService } from './oauth.service';
+import { MOBILE_REDIRECT, OAuthService } from './oauth.service';
 import { OAuthConfigResponseDto } from './response-dto/oauth-config-response.dto';
 
 @ApiTags('OAuth')
@@ -17,12 +17,19 @@ import { OAuthConfigResponseDto } from './response-dto/oauth-config-response.dto
 export class OAuthController {
   constructor(private readonly immichJwtService: ImmichJwtService, private readonly oauthService: OAuthService) {}
 
-  @Post('/config')
+  @Get('mobile-redirect')
+  @Redirect()
+  public mobileRedirect(@Req() req: Request) {
+    const url = `${MOBILE_REDIRECT}?${req.url.split('?')[1] || ''}`;
+    return { url, statusCode: HttpStatus.TEMPORARY_REDIRECT };
+  }
+
+  @Post('config')
   public generateConfig(@Body(ValidationPipe) dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
     return this.oauthService.generateConfig(dto);
   }
 
-  @Post('/callback')
+  @Post('callback')
   public async callback(
     @Res({ passthrough: true }) response: Response,
     @Body(ValidationPipe) dto: OAuthCallbackDto,

+ 57 - 56
server/apps/immich/src/api-v1/oauth/oauth.service.spec.ts

@@ -12,6 +12,38 @@ import { IUserRepository } from '../user/user-repository';
 const email = 'user@immich.com';
 const sub = 'my-auth-user-sub';
 
+const config = {
+  disabled: {
+    oauth: {
+      enabled: false,
+      buttonText: 'OAuth',
+      issuerUrl: 'http://issuer,',
+    },
+  } as SystemConfig,
+  enabled: {
+    oauth: {
+      enabled: true,
+      autoRegister: true,
+      buttonText: 'OAuth',
+    },
+  } as SystemConfig,
+  noAutoRegister: {
+    oauth: {
+      enabled: true,
+      autoRegister: false,
+    },
+  } as SystemConfig,
+  override: {
+    oauth: {
+      enabled: true,
+      autoRegister: true,
+      buttonText: 'OAuth',
+      mobileOverrideEnabled: true,
+      mobileRedirectUri: 'http://mobile-redirect',
+    },
+  } as SystemConfig,
+};
+
 const user = {
   id: 'user_id',
   email,
@@ -49,8 +81,11 @@ describe('OAuthService', () => {
   let userRepositoryMock: jest.Mocked<IUserRepository>;
   let immichConfigServiceMock: jest.Mocked<ImmichConfigService>;
   let immichJwtServiceMock: jest.Mocked<ImmichJwtService>;
+  let callbackMock: jest.Mock;
 
   beforeEach(async () => {
+    callbackMock = jest.fn().mockReturnValue({ access_token: 'access-token' });
+
     jest.spyOn(generators, 'state').mockReturnValue('state');
     jest.spyOn(Issuer, 'discover').mockResolvedValue({
       id_token_signing_alg_values_supported: ['HS256'],
@@ -62,7 +97,7 @@ describe('OAuthService', () => {
         },
         authorizationUrl: jest.fn().mockReturnValue('http://authorization-url'),
         callbackParams: jest.fn().mockReturnValue({ state: 'state' }),
-        callback: jest.fn().mockReturnValue({ access_token: 'access-token' }),
+        callback: callbackMock,
         userinfo: jest.fn().mockResolvedValue({ sub, email }),
       }),
     } as any);
@@ -89,10 +124,11 @@ describe('OAuthService', () => {
     } as unknown as jest.Mocked<ImmichJwtService>;
 
     immichConfigServiceMock = {
+      config$: { subscribe: jest.fn() },
       getConfig: jest.fn().mockResolvedValue({ oauth: { enabled: false } }),
     } as unknown as jest.Mocked<ImmichConfigService>;
 
-    sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
+    sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.disabled);
   });
 
   it('should be defined', () => {
@@ -102,17 +138,10 @@ describe('OAuthService', () => {
   describe('generateConfig', () => {
     it('should work when oauth is not configured', async () => {
       await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({ enabled: false });
-      expect(immichConfigServiceMock.getConfig).toHaveBeenCalled();
     });
 
     it('should generate the config', async () => {
-      immichConfigServiceMock.getConfig.mockResolvedValue({
-        oauth: {
-          enabled: true,
-          buttonText: 'OAuth',
-        },
-      } as SystemConfig);
-      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
+      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.enabled);
       await expect(sut.generateConfig({ redirectUri: 'http://redirect' })).resolves.toEqual({
         enabled: true,
         buttonText: 'OAuth',
@@ -127,13 +156,7 @@ describe('OAuthService', () => {
     });
 
     it('should not allow auto registering', async () => {
-      immichConfigServiceMock.getConfig.mockResolvedValue({
-        oauth: {
-          enabled: true,
-          autoRegister: false,
-        },
-      } as SystemConfig);
-      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
+      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.noAutoRegister);
       userRepositoryMock.getByEmail.mockResolvedValue(null);
       await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' })).rejects.toBeInstanceOf(
         BadRequestException,
@@ -142,13 +165,7 @@ describe('OAuthService', () => {
     });
 
     it('should link an existing user', async () => {
-      immichConfigServiceMock.getConfig.mockResolvedValue({
-        oauth: {
-          enabled: true,
-          autoRegister: false,
-        },
-      } as SystemConfig);
-      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
+      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.noAutoRegister);
       userRepositoryMock.getByEmail.mockResolvedValue(user);
       userRepositoryMock.update.mockResolvedValue(user);
       immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
@@ -160,13 +177,8 @@ describe('OAuthService', () => {
     });
 
     it('should allow auto registering by default', async () => {
-      immichConfigServiceMock.getConfig.mockResolvedValue({
-        oauth: {
-          enabled: true,
-          autoRegister: true,
-        },
-      } as SystemConfig);
-      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
+      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.enabled);
+
       userRepositoryMock.getByEmail.mockResolvedValue(null);
       userRepositoryMock.getAdmin.mockResolvedValue(user);
       userRepositoryMock.create.mockResolvedValue(user);
@@ -178,16 +190,21 @@ describe('OAuthService', () => {
       expect(userRepositoryMock.create).toHaveBeenCalledTimes(1);
       expect(immichJwtServiceMock.createLoginResponse).toHaveBeenCalledTimes(1);
     });
+
+    it('should use the mobile redirect override', async () => {
+      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.override);
+
+      userRepositoryMock.getByOAuthId.mockResolvedValue(user);
+
+      await sut.login({ url: `app.immich:/?code=abc123` });
+
+      expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
+    });
   });
 
   describe('link', () => {
     it('should link an account', async () => {
-      immichConfigServiceMock.getConfig.mockResolvedValue({
-        oauth: {
-          enabled: true,
-          autoRegister: true,
-        },
-      } as SystemConfig);
+      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.enabled);
 
       userRepositoryMock.update.mockResolvedValue(user);
 
@@ -197,12 +214,7 @@ describe('OAuthService', () => {
     });
 
     it('should not link an already linked oauth.sub', async () => {
-      immichConfigServiceMock.getConfig.mockResolvedValue({
-        oauth: {
-          enabled: true,
-          autoRegister: true,
-        },
-      } as SystemConfig);
+      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.enabled);
 
       userRepositoryMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity);
 
@@ -216,12 +228,7 @@ describe('OAuthService', () => {
 
   describe('unlink', () => {
     it('should unlink an account', async () => {
-      immichConfigServiceMock.getConfig.mockResolvedValue({
-        oauth: {
-          enabled: true,
-          autoRegister: true,
-        },
-      } as SystemConfig);
+      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.enabled);
 
       userRepositoryMock.update.mockResolvedValue(user);
 
@@ -237,13 +244,7 @@ describe('OAuthService', () => {
     });
 
     it('should get the session endpoint from the discovery document', async () => {
-      immichConfigServiceMock.getConfig.mockResolvedValue({
-        oauth: {
-          enabled: true,
-          issuerUrl: 'http://issuer,',
-        },
-      } as SystemConfig);
-      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
+      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.enabled);
 
       await expect(sut.getLogoutEndpoint()).resolves.toBe('http://end-session-endpoint');
     });

+ 24 - 15
server/apps/immich/src/api-v1/oauth/oauth.service.ts

@@ -1,4 +1,5 @@
-import { ImmichConfigService } from '@app/immich-config';
+import { SystemConfig } from '@app/database/entities/system-config.entity';
+import { ImmichConfigService, INITIAL_SYSTEM_CONFIG } from '@app/immich-config';
 import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
 import { ClientMetadata, custom, generators, Issuer, UserinfoResponse } from 'openid-client';
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
@@ -15,6 +16,8 @@ type OAuthProfile = UserinfoResponse & {
   email: string;
 };
 
+export const MOBILE_REDIRECT = 'app.immich:/';
+
 @Injectable()
 export class OAuthService {
   private readonly userCore: UserCore;
@@ -22,26 +25,29 @@ export class OAuthService {
 
   constructor(
     private immichJwtService: ImmichJwtService,
-    private immichConfigService: ImmichConfigService,
+    immichConfigService: ImmichConfigService,
     @Inject(USER_REPOSITORY) userRepository: IUserRepository,
+    @Inject(INITIAL_SYSTEM_CONFIG) private config: SystemConfig,
   ) {
     this.userCore = new UserCore(userRepository);
 
     custom.setHttpOptionsDefaults({
       timeout: 30000,
     });
+
+    immichConfigService.config$.subscribe((config) => (this.config = config));
   }
 
   public async generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
-    const config = await this.immichConfigService.getConfig();
-    const { enabled, scope, buttonText } = config.oauth;
+    const { enabled, scope, buttonText } = this.config.oauth;
+    const redirectUri = this.normalize(dto.redirectUri);
 
     if (!enabled) {
       return { enabled: false };
     }
 
     const url = (await this.getClient()).authorizationUrl({
-      redirect_uri: dto.redirectUri,
+      redirect_uri: redirectUri,
       scope,
       state: generators.state(),
     });
@@ -64,9 +70,7 @@ export class OAuthService {
 
     // register new user
     if (!user) {
-      const config = await this.immichConfigService.getConfig();
-      const { autoRegister } = config.oauth;
-      if (!autoRegister) {
+      if (!this.config.oauth.autoRegister) {
         this.logger.warn(
           `Unable to register ${profile.email}. To enable set OAuth Auto Register to true in admin settings.`,
         );
@@ -100,17 +104,14 @@ export class OAuthService {
   }
 
   public async getLogoutEndpoint(): Promise<string | null> {
-    const config = await this.immichConfigService.getConfig();
-    const { enabled } = config.oauth;
-
-    if (!enabled) {
+    if (!this.config.oauth.enabled) {
       return null;
     }
     return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
   }
 
   private async callback(url: string): Promise<any> {
-    const redirectUri = url.split('?')[0];
+    const redirectUri = this.normalize(url.split('?')[0]);
     const client = await this.getClient();
     const params = client.callbackParams(url);
     const tokens = await client.callback(redirectUri, params, { state: params.state });
@@ -118,8 +119,7 @@ export class OAuthService {
   }
 
   private async getClient() {
-    const config = await this.immichConfigService.getConfig();
-    const { enabled, clientId, clientSecret, issuerUrl } = config.oauth;
+    const { enabled, clientId, clientSecret, issuerUrl } = this.config.oauth;
 
     if (!enabled) {
       throw new BadRequestException('OAuth2 is not enabled');
@@ -139,4 +139,13 @@ export class OAuthService {
 
     return new issuer.Client(metadata);
   }
+
+  private normalize(redirectUri: string) {
+    const isMobile = redirectUri === MOBILE_REDIRECT;
+    const { mobileRedirectUri, mobileOverrideEnabled } = this.config.oauth;
+    if (isMobile && mobileOverrideEnabled && mobileRedirectUri) {
+      return mobileRedirectUri;
+    }
+    return redirectUri;
+  }
 }

+ 9 - 1
server/apps/immich/src/api-v1/system-config/dto/system-config-oauth.dto.ts

@@ -1,6 +1,7 @@
-import { IsBoolean, IsNotEmpty, IsString, ValidateIf } from 'class-validator';
+import { IsBoolean, IsNotEmpty, IsString, IsUrl, ValidateIf } from 'class-validator';
 
 const isEnabled = (config: SystemConfigOAuthDto) => config.enabled;
+const isOverrideEnabled = (config: SystemConfigOAuthDto) => config.mobileOverrideEnabled;
 
 export class SystemConfigOAuthDto {
   @IsBoolean()
@@ -29,4 +30,11 @@ export class SystemConfigOAuthDto {
 
   @IsBoolean()
   autoRegister!: boolean;
+
+  @IsBoolean()
+  mobileOverrideEnabled!: boolean;
+
+  @ValidateIf(isOverrideEnabled)
+  @IsUrl()
+  mobileRedirectUri!: string;
 }

+ 23 - 1
server/immich-openapi-specs.json

@@ -1764,6 +1764,20 @@
         ]
       }
     },
+    "/oauth/mobile-redirect": {
+      "get": {
+        "operationId": "mobileRedirect",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": ""
+          }
+        },
+        "tags": [
+          "OAuth"
+        ]
+      }
+    },
     "/oauth/config": {
       "post": {
         "operationId": "generateConfig",
@@ -3799,6 +3813,12 @@
           },
           "autoRegister": {
             "type": "boolean"
+          },
+          "mobileOverrideEnabled": {
+            "type": "boolean"
+          },
+          "mobileRedirectUri": {
+            "type": "string"
           }
         },
         "required": [
@@ -3808,7 +3828,9 @@
           "clientSecret",
           "scope",
           "buttonText",
-          "autoRegister"
+          "autoRegister",
+          "mobileOverrideEnabled",
+          "mobileRedirectUri"
         ]
       },
       "SystemConfigStorageTemplateDto": {

+ 4 - 0
server/libs/database/src/entities/system-config.entity.ts

@@ -25,6 +25,8 @@ export enum SystemConfigKey {
   OAUTH_SCOPE = 'oauth.scope',
   OAUTH_BUTTON_TEXT = 'oauth.buttonText',
   OAUTH_AUTO_REGISTER = 'oauth.autoRegister',
+  OAUTH_MOBILE_OVERRIDE_ENABLED = 'oauth.mobileOverrideEnabled',
+  OAUTH_MOBILE_REDIRECT_URI = 'oauth.mobileRedirectUri',
   STORAGE_TEMPLATE = 'storageTemplate.template',
 }
 
@@ -44,6 +46,8 @@ export interface SystemConfig {
     scope: string;
     buttonText: string;
     autoRegister: boolean;
+    mobileOverrideEnabled: boolean;
+    mobileRedirectUri: string;
   };
   storageTemplate: {
     template: string;

+ 2 - 0
server/libs/immich-config/src/immich-config.service.ts

@@ -20,6 +20,8 @@ const defaults: SystemConfig = Object.freeze({
     issuerUrl: '',
     clientId: '',
     clientSecret: '',
+    mobileOverrideEnabled: false,
+    mobileRedirectUri: '',
     scope: 'openid email profile',
     buttonText: 'Login with OAuth',
     autoRegister: true,

+ 69 - 1
web/src/api/open-api/api.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.39.0
+ * The version of the OpenAPI document: 1.40.0
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -1567,6 +1567,18 @@ export interface SystemConfigOAuthDto {
      * @memberof SystemConfigOAuthDto
      */
     'autoRegister': boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof SystemConfigOAuthDto
+     */
+    'mobileOverrideEnabled': boolean;
+    /**
+     * 
+     * @type {string}
+     * @memberof SystemConfigOAuthDto
+     */
+    'mobileRedirectUri': string;
 }
 /**
  * 
@@ -5111,6 +5123,35 @@ export const OAuthApiAxiosParamCreator = function (configuration?: Configuration
                 options: localVarRequestOptions,
             };
         },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        mobileRedirect: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            const localVarPath = `/oauth/mobile-redirect`;
+            // 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: 'GET', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+
+    
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
         /**
          * 
          * @param {*} [options] Override http request option.
@@ -5180,6 +5221,15 @@ export const OAuthApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.link(oAuthCallbackDto, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async mobileRedirect(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.mobileRedirect(options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
          * 
          * @param {*} [options] Override http request option.
@@ -5226,6 +5276,14 @@ export const OAuthApiFactory = function (configuration?: Configuration, basePath
         link(oAuthCallbackDto: OAuthCallbackDto, options?: any): AxiosPromise<UserResponseDto> {
             return localVarFp.link(oAuthCallbackDto, options).then((request) => request(axios, basePath));
         },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        mobileRedirect(options?: any): AxiosPromise<void> {
+            return localVarFp.mobileRedirect(options).then((request) => request(axios, basePath));
+        },
         /**
          * 
          * @param {*} [options] Override http request option.
@@ -5277,6 +5335,16 @@ export class OAuthApi extends BaseAPI {
         return OAuthApiFp(this.configuration).link(oAuthCallbackDto, options).then((request) => request(this.axios, this.basePath));
     }
 
+    /**
+     * 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof OAuthApi
+     */
+    public mobileRedirect(options?: AxiosRequestConfig) {
+        return OAuthApiFp(this.configuration).mobileRedirect(options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
      * 
      * @param {*} [options] Override http request option.

+ 1 - 1
web/src/api/open-api/base.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.39.0
+ * The version of the OpenAPI document: 1.40.0
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
web/src/api/open-api/common.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.39.0
+ * The version of the OpenAPI document: 1.40.0
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
web/src/api/open-api/configuration.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.39.0
+ * The version of the OpenAPI document: 1.40.0
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
web/src/api/open-api/index.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.39.0
+ * The version of the OpenAPI document: 1.40.0
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 101 - 73
web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte

@@ -3,18 +3,27 @@
 		notificationController,
 		NotificationType
 	} from '$lib/components/shared-components/notification/notification';
+	import { handleError } from '$lib/utils/handle-error';
 	import { api, SystemConfigOAuthDto } from '@api';
+	import _ from 'lodash';
+	import { fade } from 'svelte/transition';
 	import SettingButtonsRow from '../setting-buttons-row.svelte';
 	import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
 	import SettingSwitch from '../setting-switch.svelte';
-	import _ from 'lodash';
-	import { fade } from 'svelte/transition';
 
 	export let oauthConfig: SystemConfigOAuthDto;
 
 	let savedConfig: SystemConfigOAuthDto;
 	let defaultConfig: SystemConfigOAuthDto;
 
+	const handleToggleOverride = () => {
+		// click runs before bind
+		const previouslyEnabled = oauthConfig.mobileOverrideEnabled;
+		if (!previouslyEnabled && !oauthConfig.mobileRedirectUri) {
+			oauthConfig.mobileRedirectUri = window.location.origin + '/api/oauth/mobile-redirect';
+		}
+	};
+
 	async function getConfigs() {
 		[savedConfig, defaultConfig] = await Promise.all([
 			api.systemConfigApi.getConfig().then((res) => res.data.oauth),
@@ -38,6 +47,10 @@
 		try {
 			const { data: currentConfig } = await api.systemConfigApi.getConfig();
 
+			if (!oauthConfig.mobileOverrideEnabled) {
+				oauthConfig.mobileRedirectUri = '';
+			}
+
 			const result = await api.systemConfigApi.updateConfig({
 				...currentConfig,
 				oauth: oauthConfig
@@ -50,12 +63,8 @@
 				message: 'OAuth settings saved',
 				type: NotificationType.Info
 			});
-		} catch (e) {
-			console.error('Error [oauth-settings] [saveSetting]', e);
-			notificationController.show({
-				message: 'Unable to save settings',
-				type: NotificationType.Error
-			});
+		} catch (error) {
+			handleError(error, 'Unable to save OAuth settings');
 		}
 	}
 
@@ -74,76 +83,95 @@
 <div class="mt-2">
 	{#await getConfigs() then}
 		<div in:fade={{ duration: 500 }}>
-			<form autocomplete="off" on:submit|preventDefault>
-				<div class="mt-4">
-					<SettingSwitch title="Enable" bind:checked={oauthConfig.enabled} />
-				</div>
-
-				<hr class="m-4" />
-				<div class="flex flex-col gap-4 ml-4">
+			<form autocomplete="off" on:submit|preventDefault class="flex flex-col mx-4 gap-4 py-4">
+				<p class="text-sm dark:text-immich-dark-fg">
+					For more details about this feature, refer to the <a
+						href="http://immich.app/docs/features/oauth#mobile-redirect-uri"
+						class="underline"
+						target="_blank"
+						rel="noreferrer">docs</a
+					>.
+				</p>
+
+				<SettingSwitch title="Enable" bind:checked={oauthConfig.enabled} />
+				<hr />
+				<SettingInputField
+					inputType={SettingInputFieldType.TEXT}
+					label="ISSUER URL"
+					bind:value={oauthConfig.issuerUrl}
+					required={true}
+					disabled={!oauthConfig.enabled}
+					isEdited={!(oauthConfig.issuerUrl == savedConfig.issuerUrl)}
+				/>
+
+				<SettingInputField
+					inputType={SettingInputFieldType.TEXT}
+					label="CLIENT ID"
+					bind:value={oauthConfig.clientId}
+					required={true}
+					disabled={!oauthConfig.enabled}
+					isEdited={!(oauthConfig.clientId == savedConfig.clientId)}
+				/>
+
+				<SettingInputField
+					inputType={SettingInputFieldType.TEXT}
+					label="CLIENT SECRET"
+					bind:value={oauthConfig.clientSecret}
+					required={true}
+					disabled={!oauthConfig.enabled}
+					isEdited={!(oauthConfig.clientSecret == savedConfig.clientSecret)}
+				/>
+
+				<SettingInputField
+					inputType={SettingInputFieldType.TEXT}
+					label="SCOPE"
+					bind:value={oauthConfig.scope}
+					required={true}
+					disabled={!oauthConfig.enabled}
+					isEdited={!(oauthConfig.scope == savedConfig.scope)}
+				/>
+
+				<SettingInputField
+					inputType={SettingInputFieldType.TEXT}
+					label="BUTTON TEXT"
+					bind:value={oauthConfig.buttonText}
+					required={false}
+					disabled={!oauthConfig.enabled}
+					isEdited={!(oauthConfig.buttonText == savedConfig.buttonText)}
+				/>
+
+				<SettingSwitch
+					title="AUTO REGISTER"
+					subtitle="Automatically register new users after signing in with OAuth"
+					bind:checked={oauthConfig.autoRegister}
+					disabled={!oauthConfig.enabled}
+				/>
+
+				<SettingSwitch
+					title="MOBILE REDIRECT URI OVERRIDE"
+					subtitle="Enable when `app.immich:/` is an invalid redirect URI."
+					disabled={!oauthConfig.enabled}
+					on:click={() => handleToggleOverride()}
+					bind:checked={oauthConfig.mobileOverrideEnabled}
+				/>
+
+				{#if oauthConfig.mobileOverrideEnabled}
 					<SettingInputField
 						inputType={SettingInputFieldType.TEXT}
-						label="ISSUER URL"
-						bind:value={oauthConfig.issuerUrl}
+						label="MOBILE REDIRECT URI"
+						bind:value={oauthConfig.mobileRedirectUri}
 						required={true}
 						disabled={!oauthConfig.enabled}
-						isEdited={!(oauthConfig.issuerUrl == savedConfig.issuerUrl)}
-					/>
-
-					<SettingInputField
-						inputType={SettingInputFieldType.TEXT}
-						label="CLIENT ID"
-						bind:value={oauthConfig.clientId}
-						required={true}
-						disabled={!oauthConfig.enabled}
-						isEdited={!(oauthConfig.clientId == savedConfig.clientId)}
-					/>
-
-					<SettingInputField
-						inputType={SettingInputFieldType.TEXT}
-						label="CLIENT SECRET"
-						bind:value={oauthConfig.clientSecret}
-						required={true}
-						disabled={!oauthConfig.enabled}
-						isEdited={!(oauthConfig.clientSecret == savedConfig.clientSecret)}
-					/>
-
-					<SettingInputField
-						inputType={SettingInputFieldType.TEXT}
-						label="SCOPE"
-						bind:value={oauthConfig.scope}
-						required={true}
-						disabled={!oauthConfig.enabled}
-						isEdited={!(oauthConfig.scope == savedConfig.scope)}
-					/>
-
-					<SettingInputField
-						inputType={SettingInputFieldType.TEXT}
-						label="BUTTON TEXT"
-						bind:value={oauthConfig.buttonText}
-						required={false}
-						disabled={!oauthConfig.enabled}
-						isEdited={!(oauthConfig.buttonText == savedConfig.buttonText)}
-					/>
-				</div>
-
-				<div class="mt-4">
-					<SettingSwitch
-						title="AUTO REGISTER"
-						subtitle="Automatically register new users after signing in with OAuth"
-						bind:checked={oauthConfig.autoRegister}
-						disabled={!oauthConfig.enabled}
-					/>
-				</div>
-
-				<div class="ml-4">
-					<SettingButtonsRow
-						on:reset={reset}
-						on:save={saveSetting}
-						on:reset-to-default={resetToDefault}
-						showResetToDefault={!_.isEqual(savedConfig, defaultConfig)}
+						isEdited={!(oauthConfig.mobileRedirectUri == savedConfig.mobileRedirectUri)}
 					/>
-				</div>
+				{/if}
+
+				<SettingButtonsRow
+					on:reset={reset}
+					on:save={saveSetting}
+					on:reset-to-default={resetToDefault}
+					showResetToDefault={!_.isEqual(savedConfig, defaultConfig)}
+				/>
 			</form>
 		</div>
 	{/await}

+ 2 - 1
web/src/lib/components/admin-page/settings/setting-switch.svelte

@@ -5,7 +5,7 @@
 	export let disabled = false;
 </script>
 
-<div class="flex justify-between mx-4 place-items-center">
+<div class="flex justify-between place-items-center">
 	<div>
 		<h2 class="immich-form-label text-sm">
 			{title.toUpperCase()}
@@ -19,6 +19,7 @@
 			class="opacity-0 w-0 h-0 disabled::cursor-not-allowed"
 			type="checkbox"
 			bind:checked
+			on:click
 			{disabled}
 		/>