From 6974d4068b4ccd4add677439d0497e10cb35f5a9 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 29 Dec 2022 15:47:30 -0500 Subject: [PATCH] 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 --- docs/docs/features/oauth.md | 75 +++++--- mobile/openapi/README.md | Bin 12219 -> 12322 bytes mobile/openapi/doc/OAuthApi.md | Bin 4092 -> 4859 bytes mobile/openapi/doc/SystemConfigOAuthDto.md | Bin 613 -> 697 bytes mobile/openapi/lib/api/o_auth_api.dart | Bin 6681 -> 7582 bytes .../lib/model/system_config_o_auth_dto.dart | Bin 4998 -> 5746 bytes mobile/openapi/test/o_auth_api_test.dart | Bin 983 -> 1085 bytes .../test/system_config_o_auth_dto_test.dart | Bin 1200 -> 1444 bytes .../src/api-v1/oauth/oauth.controller.ts | 17 +- .../src/api-v1/oauth/oauth.service.spec.ts | 113 ++++++------ .../immich/src/api-v1/oauth/oauth.service.ts | 39 ++-- .../dto/system-config-oauth.dto.ts | 10 +- server/immich-openapi-specs.json | 24 ++- .../src/entities/system-config.entity.ts | 4 + .../src/immich-config.service.ts | 2 + web/src/api/open-api/api.ts | 70 +++++++- web/src/api/open-api/base.ts | 2 +- web/src/api/open-api/common.ts | 2 +- web/src/api/open-api/configuration.ts | 2 +- web/src/api/open-api/index.ts | 2 +- .../settings/oauth/oauth-settings.svelte | 170 ++++++++++-------- .../admin-page/settings/setting-switch.svelte | 3 +- 22 files changed, 351 insertions(+), 184 deletions(-) diff --git a/docs/docs/features/oauth.md b/docs/docs/features/oauth.md index e4c9ac9272..406772c935 100644 --- a/docs/docs/features/oauth.md +++ b/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) diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 0a6b958f8fe20550b1f46526ac9462bc2411523d..e0dccdbf7ca0c61c0b5da44c6f77dfc4a6c18e13 100644 GIT binary patch delta 86 zcmdlTzbIkCOePb9jk8ya^X29zW#*&?rKV&Sr6!k54ipzPfCv>qg)|jv6tuM5T|>0A X6!h~GOG`5JAqsS1DmEKQbczE2eB>V- delta 18 acmZ3KuseRjOeSN?jk8yaZ(b`=Dh>co0|&+c diff --git a/mobile/openapi/doc/OAuthApi.md b/mobile/openapi/doc/OAuthApi.md index 1ed0598830f3c1863d9ceba4a53d4c3c75e94116..836cd845567d725d6aadb1ca31b02016a4dbb80d 100644 GIT binary patch delta 236 zcmew(|66s#Tt>g#{G`mB)S%Rq%%arf5-qJ*4S&bdk_^X!OugI`Wr%DMR8~`=MnOx< z-8Do@OF=(B5vWWbqCgj>V)Hb{S=@@~W^&mnpbAY68ui4SDtAVNW@DVas7$t9shnOa&3H40i< P!6ikRdFhk4Gp+*w0B9G~ delta 11 ScmdnV`jlnE0mjK9OiKYA;{;~_ diff --git a/mobile/openapi/lib/api/o_auth_api.dart b/mobile/openapi/lib/api/o_auth_api.dart index e61b047cc4f3257ac7ef03eb7c5d6010c3c4dc0f..b8778596a7148e5fd06f943e54dee66425c09eed 100644 GIT binary patch delta 134 zcmbPfGS7O$TM;RD*ANB${KV3d4E@~vq|BUD-J;Z#%%arflF9!?toa~fK`N-{FJyH4ICHcO-|KQqNn0Zmh$xEi`Sv&0u}z9iz#3IIrKGG+h( delta 16 YcmbPdJ=0{vTan4T7}al1W}cp(wSmG_xo*MWG}kvsf=TKPfXO)xRvYs3?kT6cmC>iZb)kp$d@nZhpm-%V@5krUq04 z1oP|yZjqk+|(dKrnu8P54BshX(D zkW85z%z7_G2i+ME_t~moH5H~B$rPAMB%#fQY{`uE3JURA#rb*BMe10MQjb-@2m)9T zgF_EZJCgZGT9HJp!J$!{5H$b* diff --git a/mobile/openapi/test/o_auth_api_test.dart b/mobile/openapi/test/o_auth_api_test.dart index 5940bb6f18e9a10786016edd4369d12fe96d1d78..bc8b5f3810ebbe2979b606ee146f8ee7006d030f 100644 GIT binary patch delta 50 scmcc4zL#S|2{XS!Zhlf`PHIqUN@h`Na>?W#W(httq5VwGliQdB0ob$>ng9R* delta 15 XcmdnXah-ib3G?I+OhS|2Fb4tvGx-KJ diff --git a/mobile/openapi/test/system_config_o_auth_dto_test.dart b/mobile/openapi/test/system_config_o_auth_dto_test.dart index d3bfed19ef14c11da960fb9d5c26098c2d12b8d9..744f55dd045900a7f8d3ec37c6f2e8b544a57fbb 100644 GIT binary patch delta 101 zcmdnMxrBSeGM35KEIgvQ`AL~Mss3fDMMaq@sjhj6Nja%0lLMI)`O#!1^D$WoLd1en VQ! { return this.oauthService.generateConfig(dto); } - @Post('/callback') + @Post('callback') public async callback( @Res({ passthrough: true }) response: Response, @Body(ValidationPipe) dto: OAuthCallbackDto, diff --git a/server/apps/immich/src/api-v1/oauth/oauth.service.spec.ts b/server/apps/immich/src/api-v1/oauth/oauth.service.spec.ts index ecc7290e24..c222bfebcc 100644 --- a/server/apps/immich/src/api-v1/oauth/oauth.service.spec.ts +++ b/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; let immichConfigServiceMock: jest.Mocked; let immichJwtServiceMock: jest.Mocked; + 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; immichConfigServiceMock = { + config$: { subscribe: jest.fn() }, getConfig: jest.fn().mockResolvedValue({ oauth: { enabled: false } }), } as unknown as jest.Mocked; - 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'); }); diff --git a/server/apps/immich/src/api-v1/oauth/oauth.service.ts b/server/apps/immich/src/api-v1/oauth/oauth.service.ts index 4b3663a54f..892f877533 100644 --- a/server/apps/immich/src/api-v1/oauth/oauth.service.ts +++ b/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 { - 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 { - 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 { - 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; + } } diff --git a/server/apps/immich/src/api-v1/system-config/dto/system-config-oauth.dto.ts b/server/apps/immich/src/api-v1/system-config/dto/system-config-oauth.dto.ts index 1df0e0cd69..722e6c199b 100644 --- a/server/apps/immich/src/api-v1/system-config/dto/system-config-oauth.dto.ts +++ b/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; } diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 18c89a968d..4ed31ace75 100644 --- a/server/immich-openapi-specs.json +++ b/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": { diff --git a/server/libs/database/src/entities/system-config.entity.ts b/server/libs/database/src/entities/system-config.entity.ts index 646a89f9f4..40378b5bcf 100644 --- a/server/libs/database/src/entities/system-config.entity.ts +++ b/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; diff --git a/server/libs/immich-config/src/immich-config.service.ts b/server/libs/immich-config/src/immich-config.service.ts index ce30ce91c8..2efe03112a 100644 --- a/server/libs/immich-config/src/immich-config.service.ts +++ b/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, diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index ebca7211bd..4876d051b4 100644 --- a/web/src/api/open-api/api.ts +++ b/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 => { + 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> { + 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 { return localVarFp.link(oAuthCallbackDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + mobileRedirect(options?: any): AxiosPromise { + 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. diff --git a/web/src/api/open-api/base.ts b/web/src/api/open-api/base.ts index 5cb76e4478..4d4ced1519 100644 --- a/web/src/api/open-api/base.ts +++ b/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). diff --git a/web/src/api/open-api/common.ts b/web/src/api/open-api/common.ts index 79a5fd9335..d51fd5cd23 100644 --- a/web/src/api/open-api/common.ts +++ b/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). diff --git a/web/src/api/open-api/configuration.ts b/web/src/api/open-api/configuration.ts index 3d13f6f92c..14b5a1c7a9 100644 --- a/web/src/api/open-api/configuration.ts +++ b/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). diff --git a/web/src/api/open-api/index.ts b/web/src/api/open-api/index.ts index c9ec6d14c1..0ce2e4a1fd 100644 --- a/web/src/api/open-api/index.ts +++ b/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). diff --git a/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte b/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte index f444a0a963..29ded0c599 100644 --- a/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte +++ b/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 @@
{#await getConfigs() then}
-
-
- -
+ +

+ For more details about this feature, refer to the docs. +

-
-
+ +
+ + + + + + + + + + + + + handleToggleOverride()} + bind:checked={oauthConfig.mobileOverrideEnabled} + /> + + {#if oauthConfig.mobileOverrideEnabled} + {/if} - - - - - - - -
- -
- -
- -
- -
+
{/await} diff --git a/web/src/lib/components/admin-page/settings/setting-switch.svelte b/web/src/lib/components/admin-page/settings/setting-switch.svelte index ff314bcdf1..83163bd28b 100644 --- a/web/src/lib/components/admin-page/settings/setting-switch.svelte +++ b/web/src/lib/components/admin-page/settings/setting-switch.svelte @@ -5,7 +5,7 @@ export let disabled = false; -
+

{title.toUpperCase()} @@ -19,6 +19,7 @@ class="opacity-0 w-0 h-0 disabled::cursor-not-allowed" type="checkbox" bind:checked + on:click {disabled} />