mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	refactor(web,server): use feature flags for oauth (#3928)
* refactor: oauth to use feature flags * chore: open api * chore: e2e test for authorize endpoint
This commit is contained in:
		@@ -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": {
 | 
			
		||||
 
 | 
			
		||||
@@ -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<OAuthAuthorizeResponseDto> {
 | 
			
		||||
    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<OAuthProfile>(tokens.access_token || '');
 | 
			
		||||
    try {
 | 
			
		||||
      const tokens = await client.callback(redirectUri, params, { state: params.state });
 | 
			
		||||
      return client.userinfo<OAuthProfile>(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) {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,7 @@
 | 
			
		||||
import { ApiProperty } from '@nestjs/swagger';
 | 
			
		||||
import { IsNotEmpty, IsString } from 'class-validator';
 | 
			
		||||
 | 
			
		||||
export class OAuthConfigDto {
 | 
			
		||||
  @IsNotEmpty()
 | 
			
		||||
  @IsString()
 | 
			
		||||
  @ApiProperty()
 | 
			
		||||
  redirectUri!: string;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -5,3 +5,7 @@ export class OAuthConfigResponseDto {
 | 
			
		||||
  buttonText?: string;
 | 
			
		||||
  autoLaunch?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class OAuthAuthorizeResponseDto {
 | 
			
		||||
  url!: string;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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<OAuthConfigResponseDto> {
 | 
			
		||||
    return this.service.generateConfig(dto);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @PublicRoute()
 | 
			
		||||
  @Post('authorize')
 | 
			
		||||
  authorizeOAuth(@Body() dto: OAuthConfigDto): Promise<OAuthAuthorizeResponseDto> {
 | 
			
		||||
    return this.service.authorize(dto);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @PublicRoute()
 | 
			
		||||
  @Post('callback')
 | 
			
		||||
  async callback(
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										42
									
								
								server/test/e2e/oauth.e2e-spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								server/test/e2e/oauth.e2e-spec.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
		Reference in New Issue
	
	Block a user