From a26ed3d1a6d7364726dcbd2880ce552254c408d9 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 1 Sep 2023 07:08:42 -0400 Subject: [PATCH] refactor(web,server): use feature flags for oauth (#3928) * refactor: oauth to use feature flags * chore: open api * chore: e2e test for authorize endpoint --- cli/src/api/open-api/api.ts | 104 +++++++++++++++++- mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | Bin 18984 -> 19148 bytes mobile/openapi/doc/OAuthApi.md | Bin 6913 -> 8044 bytes .../openapi/doc/OAuthAuthorizeResponseDto.md | Bin 0 -> 418 bytes mobile/openapi/lib/api.dart | Bin 6318 -> 6367 bytes mobile/openapi/lib/api/o_auth_api.dart | Bin 7582 -> 9354 bytes mobile/openapi/lib/api_client.dart | Bin 19515 -> 19617 bytes .../model/o_auth_authorize_response_dto.dart | Bin 0 -> 2916 bytes mobile/openapi/test/o_auth_api_test.dart | Bin 1085 -> 1308 bytes .../o_auth_authorize_response_dto_test.dart | Bin 0 -> 589 bytes server/immich-openapi-specs.json | 44 ++++++++ server/src/domain/auth/auth.service.ts | 35 +++++- .../src/domain/auth/dto/oauth-config.dto.ts | 2 - .../response-dto/oauth-config-response.dto.ts | 4 + .../immich/controllers/oauth.controller.ts | 8 ++ server/test/e2e/oauth.e2e-spec.ts | 42 +++++++ web/src/api/open-api/api.ts | 104 +++++++++++++++++- web/src/api/utils.ts | 11 +- .../lib/components/forms/login-form.svelte | 75 +++++++------ .../fullscreen-container.svelte | 2 +- .../user-settings-page/oauth-settings.svelte | 19 +--- .../user-settings-list.svelte | 17 +-- web/src/lib/stores/feature-flags.store.ts | 9 +- web/src/routes/auth/login/+page.server.ts | 17 --- web/src/routes/auth/login/+page.svelte | 24 ++-- 26 files changed, 411 insertions(+), 109 deletions(-) create mode 100644 mobile/openapi/doc/OAuthAuthorizeResponseDto.md create mode 100644 mobile/openapi/lib/model/o_auth_authorize_response_dto.dart create mode 100644 mobile/openapi/test/o_auth_authorize_response_dto_test.dart create mode 100644 server/test/e2e/oauth.e2e-spec.ts diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 8d15a19d7d..70cd8fa297 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -1861,6 +1861,19 @@ export const ModelType = { export type ModelType = typeof ModelType[keyof typeof ModelType]; +/** + * + * @export + * @interface OAuthAuthorizeResponseDto + */ +export interface OAuthAuthorizeResponseDto { + /** + * + * @type {string} + * @memberof OAuthAuthorizeResponseDto + */ + 'url': string; +} /** * * @export @@ -8890,6 +8903,41 @@ export class JobApi extends BaseAPI { */ export const OAuthApiAxiosParamCreator = function (configuration?: Configuration) { return { + /** + * + * @param {OAuthConfigDto} oAuthConfigDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + authorizeOAuth: async (oAuthConfigDto: OAuthConfigDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'oAuthConfigDto' is not null or undefined + assertParamExists('authorizeOAuth', 'oAuthConfigDto', oAuthConfigDto) + const localVarPath = `/oauth/authorize`; + // 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: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(oAuthConfigDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {OAuthCallbackDto} oAuthCallbackDto @@ -8926,9 +8974,10 @@ export const OAuthApiAxiosParamCreator = function (configuration?: Configuration }; }, /** - * + * @deprecated use feature flags and /oauth/authorize * @param {OAuthConfigDto} oAuthConfigDto * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ generateConfig: async (oAuthConfigDto: OAuthConfigDto, options: AxiosRequestConfig = {}): Promise => { @@ -9081,6 +9130,16 @@ export const OAuthApiAxiosParamCreator = function (configuration?: Configuration export const OAuthApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = OAuthApiAxiosParamCreator(configuration) return { + /** + * + * @param {OAuthConfigDto} oAuthConfigDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async authorizeOAuth(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.authorizeOAuth(oAuthConfigDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {OAuthCallbackDto} oAuthCallbackDto @@ -9092,9 +9151,10 @@ export const OAuthApiFp = function(configuration?: Configuration) { return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** - * + * @deprecated use feature flags and /oauth/authorize * @param {OAuthConfigDto} oAuthConfigDto * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ async generateConfig(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { @@ -9139,6 +9199,15 @@ export const OAuthApiFp = function(configuration?: Configuration) { export const OAuthApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { const localVarFp = OAuthApiFp(configuration) return { + /** + * + * @param {OAuthApiAuthorizeOAuthRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + authorizeOAuth(requestParameters: OAuthApiAuthorizeOAuthRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.authorizeOAuth(requestParameters.oAuthConfigDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {OAuthApiCallbackRequest} requestParameters Request parameters. @@ -9149,9 +9218,10 @@ export const OAuthApiFactory = function (configuration?: Configuration, basePath return localVarFp.callback(requestParameters.oAuthCallbackDto, options).then((request) => request(axios, basePath)); }, /** - * + * @deprecated use feature flags and /oauth/authorize * @param {OAuthApiGenerateConfigRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ generateConfig(requestParameters: OAuthApiGenerateConfigRequest, options?: AxiosRequestConfig): AxiosPromise { @@ -9185,6 +9255,20 @@ export const OAuthApiFactory = function (configuration?: Configuration, basePath }; }; +/** + * Request parameters for authorizeOAuth operation in OAuthApi. + * @export + * @interface OAuthApiAuthorizeOAuthRequest + */ +export interface OAuthApiAuthorizeOAuthRequest { + /** + * + * @type {OAuthConfigDto} + * @memberof OAuthApiAuthorizeOAuth + */ + readonly oAuthConfigDto: OAuthConfigDto +} + /** * Request parameters for callback operation in OAuthApi. * @export @@ -9234,6 +9318,17 @@ export interface OAuthApiLinkRequest { * @extends {BaseAPI} */ export class OAuthApi extends BaseAPI { + /** + * + * @param {OAuthApiAuthorizeOAuthRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof OAuthApi + */ + public authorizeOAuth(requestParameters: OAuthApiAuthorizeOAuthRequest, options?: AxiosRequestConfig) { + return OAuthApiFp(this.configuration).authorizeOAuth(requestParameters.oAuthConfigDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {OAuthApiCallbackRequest} requestParameters Request parameters. @@ -9246,9 +9341,10 @@ export class OAuthApi extends BaseAPI { } /** - * + * @deprecated use feature flags and /oauth/authorize * @param {OAuthApiGenerateConfigRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} * @memberof OAuthApi */ diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 517c33f249..5c6b65f46f 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -73,6 +73,7 @@ doc/MemoryLaneResponseDto.md doc/MergePersonDto.md doc/ModelType.md doc/OAuthApi.md +doc/OAuthAuthorizeResponseDto.md doc/OAuthCallbackDto.md doc/OAuthConfigDto.md doc/OAuthConfigResponseDto.md @@ -225,6 +226,7 @@ lib/model/map_marker_response_dto.dart lib/model/memory_lane_response_dto.dart lib/model/merge_person_dto.dart lib/model/model_type.dart +lib/model/o_auth_authorize_response_dto.dart lib/model/o_auth_callback_dto.dart lib/model/o_auth_config_dto.dart lib/model/o_auth_config_response_dto.dart @@ -352,6 +354,7 @@ test/memory_lane_response_dto_test.dart test/merge_person_dto_test.dart test/model_type_test.dart test/o_auth_api_test.dart +test/o_auth_authorize_response_dto_test.dart test/o_auth_callback_dto_test.dart test/o_auth_config_dto_test.dart test/o_auth_config_response_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 76503a4149935e17a2f915ff44f97ea99fb83322..5972ea91cf3dd2c0060a4c7f148c9707ee0ab1b8 100644 GIT binary patch delta 129 zcmZ26h4IW(#toZg{1Qt`GV+Tut5W?Pfh;YpSdEnYWPN=w&#@p=FE>RQt|TAC(p0EX w(9#O<4-V1NQqTtr>BCiQo-cDufdga`$jr$PZG?FtCc#7+Ekrjb*q-1405-8OIsgCw delta 19 bcmX>zm2t%s#toZgHjBz`QrOICcbEqNQ?v)4 diff --git a/mobile/openapi/doc/OAuthApi.md b/mobile/openapi/doc/OAuthApi.md index 519a96d9ee725bc2ebf2ecb4d19f7174616aa9e8..5a5afc2a15ea1421ef2f10c8a84d93eb24894740 100644 GIT binary patch delta 279 zcmZoPdt*1@nonYBNk)EAW>u=cBao$~6{`VeI2L5;<)$dZW%EHSO@$f-Ev*3m;1De> z1%0rPK3oM*ZsVl{Mr*hL)Icsf1&E0-i-J;%3-a@dQ(a2(6;KsV)?>TGhbF|w9*-_m z&3>QL5oX%t1P&1dZ}S|MD8|i3Oad%M4k@VxMXAY&C8;S2rNyZVX{m`NrA0tGCo#QP QAu%rn!v~uk*ba&S0J-XB`~Uy| delta 29 lcmaE3*Jw83+GaJz7{<++YyvEkm$Ic!He-(5oXyQB0syFQ3A+FQ diff --git a/mobile/openapi/doc/OAuthAuthorizeResponseDto.md b/mobile/openapi/doc/OAuthAuthorizeResponseDto.md new file mode 100644 index 0000000000000000000000000000000000000000..fe32d2b85feabbcee620290b708dbfb1b7c35e6b GIT binary patch literal 418 zcma)&K}!QM5QXpaD+cyZ8_4#qrD1-{TG8$ zW9*g6I;EZ^I)9oVGr(?lpCh0!u1b3BU6TF&rmh#;Wz(*f^IqE5V dn&WqOvw8SuZ~h8R-UfDFepq}B{55(~SdLc!jC>5wbuUMfZ xBUQm8BqTr~8s?=~Jru10iA9OIlkf7$z(Q^FUXCOt3vwMa`68djW(V##Apn!{j@keK delta 52 zcmeD3oM*k^7~^Jd4j;D3x*YPX`FUxX>5~iOwI&}BRh=xyWU)DmKZ$8_t%M?wqqjLq H$Uq1H)mRW( diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index f900ea54b4feccd38dfc8742794f204d2ba66495..f660f07e95e90862c078b4bae552ddc450ebb111 100644 GIT binary patch delta 45 vcmdlzgK^q} diff --git a/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart b/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..1a1c09287365cc9b236af921f884106f1e3e47a8 GIT binary patch literal 2916 zcmbVOZExE)5dQ98aRG){0aSVGry`l$28%QFEe29=z+e~xBT*JRnbb(ChMW4o?~ard z8MTtM25d{z-SO^uo;zwX8BHc|`PX9p;?LR5?DldoyMpVtA7&w3&EaOgfRFR5x7Yui zpcz@d$(XkB%jmb4Bf1r9sWeY=rITEg{2Xdo8=fV+Dr6n^4=9jfC=&Q8&%5S-;a9e(572J;iCWyo>x3!iA z7)ZW_<)Fg~giAQ(5qb`q1r!&cMFeaiOjPSK#O3`N<^}M{Y9ZHvi|+P~`ogtF6!6iV zQYF=)nN=F%IGEpk<|$TZ3$D)~o`Od!Wo1B-!}iIyZ~h9zV>O?iy5zhmWrbTD%|l2F zYi^-nruai;9Cvn}opMMplBe7lrgua*a#(O9j57-`bkzF#qWe`g09fTO0$1305Yi<^ zi+{nN)z|^{kyXEY&k(X5N=~Fxu(jMXTXt7sb3o2Y!^3@x ziAO|pQ#iiNWcq(_JLYjk2>%Wp{0G1RQ5W(CneMIG3F1!}FFB zPggt)b42=c^_ed^Yo~<=pS&NE|D75NNg5h}WZ0IwBnLJ8}V=nfD4M`s-M|0|)Lw7(Xct+t- zOh>MtoCoCMs@}EqMsz2*Y0Dy>d}%*Gvx^uxhaPE9(2dY7z2m=pM^n=Id)-nNk8qBz zUE5*(cuRxL!KM7kwiUL(C2BT0!v}eJ;qB4A8J;-t(U0D0C@ePn5!H~=9%;TQd6Lah z{eqNyauS1&5T>f}+&q#FEq$h0@|wg|yVflF}j|os*betdN+OqM)A-(ytFUgi8Sk M^z}D;FnwhN0ALX`W&i*H delta 20 ccmbQkwU=YVNyf?POl^}BnAkU)GCyYo08!KjHvj+t diff --git a/mobile/openapi/test/o_auth_authorize_response_dto_test.dart b/mobile/openapi/test/o_auth_authorize_response_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..76b016642ad9f84933d089b03214caef4d6f47bf GIT binary patch literal 589 zcmZ{gK~DlP5QXpk72|0kK~^~#2#F*MhD6x}*Mq0hEW^^c+tz7UgNFa^bd{5EXgia< zZ{C}xsYp|py%hQN!+bSg&x&~li{;~d09g*Jynv@XTP|L&SQeG{TSZ|y8s3gXY;|QU zq1;)LJ6+=#TGM*mO02O#69%VVH_lQxOQ`x&9lUgHkc988CdQ3w_;!l;4M?7clRuA_ z*7%@Ikb298dZ&>(+@Ce8wLvdyYbut7y)yFdMwWX;UWng}kT_MosS3x|p;1n!e1>uB zWb#iE(1$c$CqXogy$Ajh$Y*WX$74x<;TpZ;u$ToaKCIqCqqOPu;adPOw$nz{pu25K zgz06v*PjSxDzdHXCDHD(HW|R5MQ8FHNSg{;s(Ao(QF(&x3J7}yN4z$L18i8c`K6UV Wn6ir*8ID@yS>Ex-n1rkDJ>nVZ)*T literal 0 HcmV?d00001 diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 00765a38f2..4178f39eba 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -2477,6 +2477,37 @@ ] } }, + "/oauth/authorize": { + "post": { + "operationId": "authorizeOAuth", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthConfigDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthAuthorizeResponseDto" + } + } + }, + "description": "" + } + }, + "tags": [ + "OAuth" + ] + } + }, "/oauth/callback": { "post": { "operationId": "callback", @@ -2510,6 +2541,8 @@ }, "/oauth/config": { "post": { + "deprecated": true, + "description": "@deprecated use feature flags and /oauth/authorize", "operationId": "generateConfig", "parameters": [], "requestBody": { @@ -6202,6 +6235,17 @@ ], "type": "string" }, + "OAuthAuthorizeResponseDto": { + "properties": { + "url": { + "type": "string" + } + }, + "required": [ + "url" + ], + "type": "object" + }, "OAuthCallbackDto": { "properties": { "url": { diff --git a/server/src/domain/auth/auth.service.ts b/server/src/domain/auth/auth.service.ts index cebb3d4626..bf7891ed0e 100644 --- a/server/src/domain/auth/auth.service.ts +++ b/server/src/domain/auth/auth.service.ts @@ -1,5 +1,12 @@ import { SystemConfig, UserEntity } from '@app/infra/entities'; -import { BadRequestException, Inject, Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { + BadRequestException, + Inject, + Injectable, + InternalServerErrorException, + Logger, + UnauthorizedException, +} from '@nestjs/common'; import cookieParser from 'cookie'; import { IncomingHttpHeaders } from 'http'; import { DateTime } from 'luxon'; @@ -27,6 +34,7 @@ import { mapAdminSignupResponse, mapLoginResponse, mapUserToken, + OAuthAuthorizeResponseDto, OAuthConfigResponseDto, } from './response-dto'; import { IUserTokenRepository } from './user-token.repository'; @@ -201,6 +209,22 @@ export class AuthService { return { ...response, buttonText, url, autoLaunch }; } + async authorize(dto: OAuthConfigDto): Promise { + const config = await this.configCore.getConfig(); + if (!config.oauth.enabled) { + throw new BadRequestException('OAuth is not enabled'); + } + + const client = await this.getOAuthClient(config); + const url = await client.authorizationUrl({ + redirect_uri: this.normalize(config, dto.redirectUri), + scope: config.oauth.scope, + state: generators.state(), + }); + + return { url }; + } + async callback( dto: OAuthCallbackDto, loginDetails: LoginDetails, @@ -280,8 +304,13 @@ export class AuthService { const redirectUri = this.normalize(config, url.split('?')[0]); const client = await this.getOAuthClient(config); const params = client.callbackParams(url); - const tokens = await client.callback(redirectUri, params, { state: params.state }); - return client.userinfo(tokens.access_token || ''); + try { + const tokens = await client.callback(redirectUri, params, { state: params.state }); + return client.userinfo(tokens.access_token || ''); + } catch (error: Error | any) { + this.logger.error(`Unable to complete OAuth login: ${error}`, error?.stack); + throw new InternalServerErrorException(`Unable to complete OAuth login: ${error}`, { cause: error }); + } } private async getOAuthClient(config: SystemConfig) { diff --git a/server/src/domain/auth/dto/oauth-config.dto.ts b/server/src/domain/auth/dto/oauth-config.dto.ts index a15a963bfa..a14fc19dc2 100644 --- a/server/src/domain/auth/dto/oauth-config.dto.ts +++ b/server/src/domain/auth/dto/oauth-config.dto.ts @@ -1,9 +1,7 @@ -import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsString } from 'class-validator'; export class OAuthConfigDto { @IsNotEmpty() @IsString() - @ApiProperty() redirectUri!: string; } diff --git a/server/src/domain/auth/response-dto/oauth-config-response.dto.ts b/server/src/domain/auth/response-dto/oauth-config-response.dto.ts index c239405fc0..dadd66f59a 100644 --- a/server/src/domain/auth/response-dto/oauth-config-response.dto.ts +++ b/server/src/domain/auth/response-dto/oauth-config-response.dto.ts @@ -5,3 +5,7 @@ export class OAuthConfigResponseDto { buttonText?: string; autoLaunch?: boolean; } + +export class OAuthAuthorizeResponseDto { + url!: string; +} diff --git a/server/src/immich/controllers/oauth.controller.ts b/server/src/immich/controllers/oauth.controller.ts index 24cc8afe45..eec8627199 100644 --- a/server/src/immich/controllers/oauth.controller.ts +++ b/server/src/immich/controllers/oauth.controller.ts @@ -3,6 +3,7 @@ import { AuthUserDto, LoginDetails, LoginResponseDto, + OAuthAuthorizeResponseDto, OAuthCallbackDto, OAuthConfigDto, OAuthConfigResponseDto, @@ -31,12 +32,19 @@ export class OAuthController { }; } + /** @deprecated use feature flags and /oauth/authorize */ @PublicRoute() @Post('config') generateConfig(@Body() dto: OAuthConfigDto): Promise { return this.service.generateConfig(dto); } + @PublicRoute() + @Post('authorize') + authorizeOAuth(@Body() dto: OAuthConfigDto): Promise { + return this.service.authorize(dto); + } + @PublicRoute() @Post('callback') async callback( diff --git a/server/test/e2e/oauth.e2e-spec.ts b/server/test/e2e/oauth.e2e-spec.ts new file mode 100644 index 0000000000..a9f0027819 --- /dev/null +++ b/server/test/e2e/oauth.e2e-spec.ts @@ -0,0 +1,42 @@ +import { AppModule, OAuthController } from '@app/immich'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { errorStub } from '../fixtures'; +import { api, db } from '../test-utils'; + +describe(`${OAuthController.name} (e2e)`, () => { + let app: INestApplication; + let server: any; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = await moduleFixture.createNestApplication().init(); + server = app.getHttpServer(); + }); + + beforeEach(async () => { + await db.reset(); + await api.adminSignUp(server); + }); + + afterAll(async () => { + await db.disconnect(); + await app.close(); + }); + + describe('POST /oauth/authorize', () => { + beforeEach(async () => { + await db.reset(); + }); + + it(`should throw an error if a redirect uri is not provided`, async () => { + const { status, body } = await request(server).post('/oauth/authorize').send({}); + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest); + }); + }); +}); diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 8d15a19d7d..70cd8fa297 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1861,6 +1861,19 @@ export const ModelType = { export type ModelType = typeof ModelType[keyof typeof ModelType]; +/** + * + * @export + * @interface OAuthAuthorizeResponseDto + */ +export interface OAuthAuthorizeResponseDto { + /** + * + * @type {string} + * @memberof OAuthAuthorizeResponseDto + */ + 'url': string; +} /** * * @export @@ -8890,6 +8903,41 @@ export class JobApi extends BaseAPI { */ export const OAuthApiAxiosParamCreator = function (configuration?: Configuration) { return { + /** + * + * @param {OAuthConfigDto} oAuthConfigDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + authorizeOAuth: async (oAuthConfigDto: OAuthConfigDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'oAuthConfigDto' is not null or undefined + assertParamExists('authorizeOAuth', 'oAuthConfigDto', oAuthConfigDto) + const localVarPath = `/oauth/authorize`; + // 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: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(oAuthConfigDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {OAuthCallbackDto} oAuthCallbackDto @@ -8926,9 +8974,10 @@ export const OAuthApiAxiosParamCreator = function (configuration?: Configuration }; }, /** - * + * @deprecated use feature flags and /oauth/authorize * @param {OAuthConfigDto} oAuthConfigDto * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ generateConfig: async (oAuthConfigDto: OAuthConfigDto, options: AxiosRequestConfig = {}): Promise => { @@ -9081,6 +9130,16 @@ export const OAuthApiAxiosParamCreator = function (configuration?: Configuration export const OAuthApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = OAuthApiAxiosParamCreator(configuration) return { + /** + * + * @param {OAuthConfigDto} oAuthConfigDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async authorizeOAuth(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.authorizeOAuth(oAuthConfigDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {OAuthCallbackDto} oAuthCallbackDto @@ -9092,9 +9151,10 @@ export const OAuthApiFp = function(configuration?: Configuration) { return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** - * + * @deprecated use feature flags and /oauth/authorize * @param {OAuthConfigDto} oAuthConfigDto * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ async generateConfig(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { @@ -9139,6 +9199,15 @@ export const OAuthApiFp = function(configuration?: Configuration) { export const OAuthApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { const localVarFp = OAuthApiFp(configuration) return { + /** + * + * @param {OAuthApiAuthorizeOAuthRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + authorizeOAuth(requestParameters: OAuthApiAuthorizeOAuthRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.authorizeOAuth(requestParameters.oAuthConfigDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {OAuthApiCallbackRequest} requestParameters Request parameters. @@ -9149,9 +9218,10 @@ export const OAuthApiFactory = function (configuration?: Configuration, basePath return localVarFp.callback(requestParameters.oAuthCallbackDto, options).then((request) => request(axios, basePath)); }, /** - * + * @deprecated use feature flags and /oauth/authorize * @param {OAuthApiGenerateConfigRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ generateConfig(requestParameters: OAuthApiGenerateConfigRequest, options?: AxiosRequestConfig): AxiosPromise { @@ -9185,6 +9255,20 @@ export const OAuthApiFactory = function (configuration?: Configuration, basePath }; }; +/** + * Request parameters for authorizeOAuth operation in OAuthApi. + * @export + * @interface OAuthApiAuthorizeOAuthRequest + */ +export interface OAuthApiAuthorizeOAuthRequest { + /** + * + * @type {OAuthConfigDto} + * @memberof OAuthApiAuthorizeOAuth + */ + readonly oAuthConfigDto: OAuthConfigDto +} + /** * Request parameters for callback operation in OAuthApi. * @export @@ -9234,6 +9318,17 @@ export interface OAuthApiLinkRequest { * @extends {BaseAPI} */ export class OAuthApi extends BaseAPI { + /** + * + * @param {OAuthApiAuthorizeOAuthRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof OAuthApi + */ + public authorizeOAuth(requestParameters: OAuthApiAuthorizeOAuthRequest, options?: AxiosRequestConfig) { + return OAuthApiFp(this.configuration).authorizeOAuth(requestParameters.oAuthConfigDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {OAuthApiCallbackRequest} requestParameters Request parameters. @@ -9246,9 +9341,10 @@ export class OAuthApi extends BaseAPI { } /** - * + * @deprecated use feature flags and /oauth/authorize * @param {OAuthApiGenerateConfigRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} * @memberof OAuthApi */ diff --git a/web/src/api/utils.ts b/web/src/api/utils.ts index 26b6550666..f82596eef2 100644 --- a/web/src/api/utils.ts +++ b/web/src/api/utils.ts @@ -1,3 +1,4 @@ +import { goto } from '$app/navigation'; import type { AxiosError, AxiosPromise } from 'axios'; import { notificationController, @@ -32,9 +33,17 @@ export const oauth = { } return false; }, + authorize: async (location: Location) => { + try { + const redirectUri = location.href.split('?')[0]; + const { data } = await api.oauthApi.authorizeOAuth({ oAuthConfigDto: { redirectUri } }); + goto(data.url); + } catch (error) { + handleError(error, 'Unable to login with OAuth'); + } + }, getConfig: (location: Location) => { const redirectUri = location.href.split('?')[0]; - console.log(`OAuth Redirect URI: ${redirectUri}`); return api.oauthApi.generateConfig({ oAuthConfigDto: { redirectUri } }); }, login: (location: Location) => { diff --git a/web/src/lib/components/forms/login-form.svelte b/web/src/lib/components/forms/login-form.svelte index 8ededa6b7f..0dbc069f35 100644 --- a/web/src/lib/components/forms/login-form.svelte +++ b/web/src/lib/components/forms/login-form.svelte @@ -2,8 +2,9 @@ import { goto } from '$app/navigation'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import { AppRoute } from '$lib/constants'; + import { featureFlags } from '$lib/stores/feature-flags.store'; import { getServerErrorMessage, handleError } from '$lib/utils/handle-error'; - import { OAuthConfigResponseDto, api, oauth } from '@api'; + import { api, oauth } from '@api'; import { createEventDispatcher, onMount } from 'svelte'; import { fade } from 'svelte/transition'; import Button from '../elements/buttons/button.svelte'; @@ -11,14 +12,18 @@ let errorMessage: string; let email = ''; let password = ''; - let oauthError: string; - export let authConfig: OAuthConfigResponseDto; + let oauthError = ''; let loading = false; let oauthLoading = true; const dispatch = createEventDispatcher(); onMount(async () => { + if (!$featureFlags.oauth) { + oauthLoading = false; + return; + } + if (oauth.isCallback(window.location)) { try { await oauth.login(window.location); @@ -26,25 +31,18 @@ return; } catch (e) { console.error('Error [login-form] [oauth.callback]', e); - oauthError = 'Unable to complete OAuth login'; - } finally { + oauthError = (await getServerErrorMessage(e)) || 'Unable to complete OAuth login'; oauthLoading = false; } } try { - const { data } = await oauth.getConfig(window.location); - authConfig = data; - - const { enabled, url, autoLaunch } = authConfig; - - if (enabled && url && autoLaunch && !oauth.isAutoLaunchDisabled(window.location)) { + if ($featureFlags.oauthAutoLaunch && !oauth.isAutoLaunchDisabled(window.location)) { await goto(`${AppRoute.AUTH_LOGIN}?autoLaunch=0`, { replaceState: true }); - await goto(url); + await oauth.authorize(window.location); return; } } catch (error) { - authConfig.passwordLoginEnabled = true; await handleError(error, 'Unable to connect!'); } @@ -76,9 +74,15 @@ return; } }; + + const handleOAuthLogin = async () => { + oauthLoading = true; + oauthError = ''; + await oauth.authorize(window.location); + }; -{#if authConfig.passwordLoginEnabled} +{#if !oauthLoading && $featureFlags.passwordLogin}
{#if errorMessage}

@@ -113,7 +117,7 @@

- - +
{/if} -{#if !authConfig.enabled && !authConfig.passwordLoginEnabled} +{#if !$featureFlags.passwordLogin && !$featureFlags.oauth}

Login has been disabled.

{/if} diff --git a/web/src/lib/components/shared-components/fullscreen-container.svelte b/web/src/lib/components/shared-components/fullscreen-container.svelte index 75fd0bc4e7..13903ccd48 100644 --- a/web/src/lib/components/shared-components/fullscreen-container.svelte +++ b/web/src/lib/components/shared-components/fullscreen-container.svelte @@ -5,7 +5,7 @@ export let showMessage = $$slots.message; -
+
diff --git a/web/src/lib/components/user-settings-page/oauth-settings.svelte b/web/src/lib/components/user-settings-page/oauth-settings.svelte index 15c1a7e911..17e49d65f3 100644 --- a/web/src/lib/components/user-settings-page/oauth-settings.svelte +++ b/web/src/lib/components/user-settings-page/oauth-settings.svelte @@ -1,16 +1,16 @@ @@ -47,7 +40,7 @@ -{#if oauthEnabled} +{#if $featureFlags.loaded && $featureFlags.oauth} ({ + loaded: false, clipEncode: true, facialRecognition: true, sidecar: true, tagImage: true, search: true, - oauth: true, - oauthAutoLaunch: true, + oauth: false, + oauthAutoLaunch: false, passwordLogin: true, configFile: false, }); export const loadFeatureFlags = async () => { const { data } = await api.serverInfoApi.getServerFeatures(); - featureFlags.update(() => data); + featureFlags.update(() => ({ ...data, loaded: true })); }; diff --git a/web/src/routes/auth/login/+page.server.ts b/web/src/routes/auth/login/+page.server.ts index 5e10dcd55e..f325c7cd2e 100644 --- a/web/src/routes/auth/login/+page.server.ts +++ b/web/src/routes/auth/login/+page.server.ts @@ -1,5 +1,4 @@ import { AppRoute } from '$lib/constants'; -import type { OAuthConfigResponseDto } from '@api'; import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; @@ -10,23 +9,7 @@ export const load = (async ({ locals: { api } }) => { throw redirect(302, AppRoute.AUTH_REGISTER); } - let authConfig: OAuthConfigResponseDto = { - passwordLoginEnabled: true, - enabled: false, - }; - - try { - // TODO: Figure out how to get correct redirect URI server-side. - const { data } = await api.oauthApi.generateConfig({ oAuthConfigDto: { redirectUri: '/' } }); - data.url = undefined; - - authConfig = data; - } catch (err) { - console.error('[ERROR] login/+page.server.ts:', err); - } - return { - authConfig, meta: { title: 'Login', }, diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte index e958864fb2..c3e4e8a418 100644 --- a/web/src/routes/auth/login/+page.svelte +++ b/web/src/routes/auth/login/+page.svelte @@ -4,20 +4,22 @@ import FullscreenContainer from '$lib/components/shared-components/fullscreen-container.svelte'; import { AppRoute } from '$lib/constants'; import { loginPageMessage } from '$lib/constants'; + import { featureFlags } from '$lib/stores/feature-flags.store'; import type { PageData } from './$types'; export let data: PageData; - -

- - {@html loginPageMessage} -

+{#if $featureFlags.loaded} + +

+ + {@html loginPageMessage} +

- goto(AppRoute.PHOTOS, { invalidateAll: true })} - on:first-login={() => goto(AppRoute.AUTH_CHANGE_PASSWORD)} - /> -
+ goto(AppRoute.PHOTOS, { invalidateAll: true })} + on:first-login={() => goto(AppRoute.AUTH_CHANGE_PASSWORD)} + /> +
+{/if}