1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(server,web): OIDC Implementation (#884)

* chore: merge

* feat: nullable password

* feat: server debugger

* chore: regenerate api

* feat: auto-register flag

* refactor: oauth endpoints

* chore: regenerate api

* fix: default scope configuration

* refactor: pass in redirect uri from client

* chore: docs

* fix: bugs

* refactor: auth services and user repository

* fix: select password

* fix: tests

* fix: get signing algorithm from discovery document

* refactor: cookie constants

* feat: oauth logout

* test: auth services

* fix: query param check

* fix: regenerate open-api
This commit is contained in:
Jason Rasmussen 2022-11-14 21:24:25 -05:00 committed by GitHub
parent d476656789
commit d3c35ec9c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 1230 additions and 250 deletions

68
docs/docs/usage/oauth.md Normal file
View File

@ -0,0 +1,68 @@
---
sidebar_position: 5
---
# OAuth Authentication
This page contains details about using OAuth 2 in Immich.
## 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:
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
- [Authelia](https://www.authelia.com/configuration/identity-providers/open-id-connect/)
- [Okta](https://www.okta.com/openid-connect/)
- [Google](https://developers.google.com/identity/openid-connect/openid-connect)
## Prerequisites
Before enabling OAuth in Immich, a new client application needs to be configured in the 3rd-party authentication server. While the specifics of this setup vary from provider to provider, the general approach should be the same.
1. Create a new (Client) Application
1. The **Provider** type should be `OpenID Connect` or `OAuth2`
2. The **Client type** should be `Confidential`
3. The **Application** type should be `Web`
4. The **Grant** type should be `Authorization Code`
2. Configure Redirect URIs/Origins
1. The **Sign-in redirect URIs** should include:
- All URLs that will be used to access the login page of the Immich web client (eg. `http://localhost:2283/auth/login`, `http://192.168.0.200:2283/auth/login`, `https://immich.example.com/auth/login`)
## Enable OAuth
Once you have a new OAuth client application configured, Immich can be configured using the following environment variables:
| Key | Type | Default | Description |
| ------------------- | ------- | -------------------- | ------------------------------------------------------------------------- |
| OAUTH_ENABLED | boolean | false | Enable/disable OAuth2 |
| OAUTH_ISSUER_URL | URL | (required) | Required. Self-discovery URL for client (from previous step) |
| OAUTH_CLIENT_ID | string | (required) | Required. Client ID (from previous step) |
| OAUTH_CLIENT_SECRET | string | (required) | Required. Client Secret (previous step |
| OAUTH_SCOPE | string | openid email profile | Full list of scopes to send with the request (space delimited) |
| OAUTH_AUTO_REGISTER | boolean | true | When true, will automatically register a user the first time they sign in |
| OAUTH_BUTTON_TEXT | string | Login with OAuth | Text for the OAuth button on the web |
:::info
The Issuer URL should look something like the following, and return a valid json document.
- `https://accounts.google.com/.well-known/openid-configuration`
- `http://localhost:9000/application/o/immich/.well-known/openid-configuration`
The `.well-known/openid-configuration` part of the url is optional and will be automatically added during discovery.
:::
Here is an example of a valid configuration for setting up Immich to use OAuth with Authentik:
```
OAUTH_ENABLED=true
OAUTH_ISSUER_URL=http://192.168.0.187:9000/application/o/immich
OAUTH_CLIENT_ID=f08f9c5b4f77dcfd3916b1c032336b5544a7b368
OAUTH_CLIENT_SECRET=6fe2e697644da6ff6aef73387a457d819018189086fa54b151a6067fbb884e75f7e5c90be16d3c688cf902c6974817a85eab93007d76675041eaead8c39cf5a2
OAUTH_BUTTON_TEXT=Login with Authentik
```
[oidc]: https://openid.net/connect/

View File

@ -46,6 +46,10 @@ doc/JobStatusResponseDto.md
doc/LoginCredentialDto.md doc/LoginCredentialDto.md
doc/LoginResponseDto.md doc/LoginResponseDto.md
doc/LogoutResponseDto.md doc/LogoutResponseDto.md
doc/OAuthApi.md
doc/OAuthCallbackDto.md
doc/OAuthConfigDto.md
doc/OAuthConfigResponseDto.md
doc/RemoveAssetsDto.md doc/RemoveAssetsDto.md
doc/SearchAssetDto.md doc/SearchAssetDto.md
doc/ServerInfoApi.md doc/ServerInfoApi.md
@ -73,6 +77,7 @@ lib/api/asset_api.dart
lib/api/authentication_api.dart lib/api/authentication_api.dart
lib/api/device_info_api.dart lib/api/device_info_api.dart
lib/api/job_api.dart lib/api/job_api.dart
lib/api/o_auth_api.dart
lib/api/server_info_api.dart lib/api/server_info_api.dart
lib/api/user_api.dart lib/api/user_api.dart
lib/api_client.dart lib/api_client.dart
@ -122,6 +127,9 @@ lib/model/job_status_response_dto.dart
lib/model/login_credential_dto.dart lib/model/login_credential_dto.dart
lib/model/login_response_dto.dart lib/model/login_response_dto.dart
lib/model/logout_response_dto.dart lib/model/logout_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
lib/model/remove_assets_dto.dart lib/model/remove_assets_dto.dart
lib/model/search_asset_dto.dart lib/model/search_asset_dto.dart
lib/model/server_info_response_dto.dart lib/model/server_info_response_dto.dart

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,36 +1,31 @@
import { Body, Controller, Post, Res, ValidationPipe, Ip } from '@nestjs/common'; import { Body, Controller, Ip, Post, Req, Res, ValidationPipe } from '@nestjs/common';
import { ApiBadRequestResponse, ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBadRequestResponse, ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { AuthType, IMMICH_AUTH_TYPE_COOKIE } from '../../constants/jwt.constant';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { Authenticated } from '../../decorators/authenticated.decorator'; import { Authenticated } from '../../decorators/authenticated.decorator';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { LoginCredentialDto } from './dto/login-credential.dto'; import { LoginCredentialDto } from './dto/login-credential.dto';
import { LoginResponseDto } from './response-dto/login-response.dto';
import { SignUpDto } from './dto/sign-up.dto'; import { SignUpDto } from './dto/sign-up.dto';
import { AdminSignupResponseDto } from './response-dto/admin-signup-response.dto'; import { AdminSignupResponseDto } from './response-dto/admin-signup-response.dto';
import { ValidateAccessTokenResponseDto } from './response-dto/validate-asset-token-response.dto,'; import { LoginResponseDto } from './response-dto/login-response.dto';
import { Response } from 'express';
import { LogoutResponseDto } from './response-dto/logout-response.dto'; import { LogoutResponseDto } from './response-dto/logout-response.dto';
import { ValidateAccessTokenResponseDto } from './response-dto/validate-asset-token-response.dto,';
@ApiTags('Authentication') @ApiTags('Authentication')
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService, private readonly immichJwtService: ImmichJwtService) {}
@Post('/login') @Post('/login')
async login( async login(
@Body(new ValidationPipe({ transform: true })) loginCredential: LoginCredentialDto, @Body(new ValidationPipe({ transform: true })) loginCredential: LoginCredentialDto,
@Ip() clientIp: string, @Ip() clientIp: string,
@Res() response: Response, @Res({ passthrough: true }) response: Response,
): Promise<LoginResponseDto> { ): Promise<LoginResponseDto> {
const loginResponse = await this.authService.login(loginCredential, clientIp); const loginResponse = await this.authService.login(loginCredential, clientIp);
response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.PASSWORD));
// Set Cookies
const accessTokenCookie = this.authService.getCookieWithJwtToken(loginResponse);
const isAuthCookie = `immich_is_authenticated=true; Path=/; Max-Age=${7 * 24 * 3600}`;
response.setHeader('Set-Cookie', [accessTokenCookie, isAuthCookie]);
response.send(loginResponse);
return loginResponse; return loginResponse;
} }
@ -51,13 +46,14 @@ export class AuthController {
} }
@Post('/logout') @Post('/logout')
async logout(@Res() response: Response): Promise<LogoutResponseDto> { async logout(@Req() req: Request, @Res({ passthrough: true }) response: Response): Promise<LogoutResponseDto> {
response.clearCookie('immich_access_token'); const authType: AuthType = req.cookies[IMMICH_AUTH_TYPE_COOKIE];
response.clearCookie('immich_is_authenticated');
const status = new LogoutResponseDto(true); const cookies = this.immichJwtService.getCookieNames();
for (const cookie of cookies) {
response.clearCookie(cookie);
}
response.send(status); return this.authService.logout(authType);
return status;
} }
} }

View File

@ -1,16 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '@app/database/entities/user.entity';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module'; import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
import { JwtModule } from '@nestjs/jwt'; import { OAuthModule } from '../oauth/oauth.module';
import { jwtConfig } from '../../config/jwt.config'; import { UserModule } from '../user/user.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)], imports: [UserModule, ImmichJwtModule, OAuthModule],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, ImmichJwtService], providers: [AuthService],
}) })
export class AuthModule {} export class AuthModule {}

View File

@ -0,0 +1,147 @@
import { UserEntity } from '@app/database/entities/user.entity';
import { BadRequestException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as bcrypt from 'bcrypt';
import { AuthType } from '../../constants/jwt.constant';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { OAuthService } from '../oauth/oauth.service';
import { IUserRepository, USER_REPOSITORY } from '../user/user-repository';
import { AuthService } from './auth.service';
import { SignUpDto } from './dto/sign-up.dto';
import { LoginResponseDto } from './response-dto/login-response.dto';
const fixtures = {
login: {
email: 'test@immich.com',
password: 'password',
},
};
const CLIENT_IP = '127.0.0.1';
jest.mock('bcrypt');
describe('AuthService', () => {
let sut: AuthService;
let userRepositoryMock: jest.Mocked<IUserRepository>;
let immichJwtServiceMock: jest.Mocked<ImmichJwtService>;
let oauthServiceMock: jest.Mocked<OAuthService>;
let compare: jest.Mock;
afterEach(() => {
jest.resetModules();
});
beforeEach(async () => {
jest.mock('bcrypt');
compare = bcrypt.compare as jest.Mock;
userRepositoryMock = {
get: jest.fn(),
getAdmin: jest.fn(),
getByEmail: jest.fn(),
getList: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
restore: jest.fn(),
};
immichJwtServiceMock = {
getCookieNames: jest.fn(),
getCookies: jest.fn(),
createLoginResponse: jest.fn(),
validateToken: jest.fn(),
extractJwtFromHeader: jest.fn(),
extractJwtFromCookie: jest.fn(),
} as unknown as jest.Mocked<ImmichJwtService>;
oauthServiceMock = {
getLogoutEndpoint: jest.fn(),
} as unknown as jest.Mocked<OAuthService>;
const moduleRef = await Test.createTestingModule({
providers: [
AuthService,
{ provide: ImmichJwtService, useValue: immichJwtServiceMock },
{ provide: OAuthService, useValue: oauthServiceMock },
{
provide: USER_REPOSITORY,
useValue: userRepositoryMock,
},
],
}).compile();
sut = moduleRef.get(AuthService);
});
it('should be defined', () => {
expect(sut).toBeDefined();
});
describe('login', () => {
it('should check the user exists', async () => {
userRepositoryMock.getByEmail.mockResolvedValue(null);
await expect(sut.login(fixtures.login, CLIENT_IP)).rejects.toBeInstanceOf(BadRequestException);
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
});
it('should check the user has a password', async () => {
userRepositoryMock.getByEmail.mockResolvedValue({} as UserEntity);
await expect(sut.login(fixtures.login, CLIENT_IP)).rejects.toBeInstanceOf(BadRequestException);
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
});
it('should successfully log the user in', async () => {
userRepositoryMock.getByEmail.mockResolvedValue({ password: 'password' } as UserEntity);
compare.mockResolvedValue(true);
const dto = { firstName: 'test', lastName: 'immich' } as LoginResponseDto;
immichJwtServiceMock.createLoginResponse.mockResolvedValue(dto);
await expect(sut.login(fixtures.login, CLIENT_IP)).resolves.toEqual(dto);
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
expect(immichJwtServiceMock.createLoginResponse).toHaveBeenCalledTimes(1);
});
});
describe('logout', () => {
it('should return the end session endpoint', async () => {
oauthServiceMock.getLogoutEndpoint.mockResolvedValue('end-session-endpoint');
await expect(sut.logout(AuthType.OAUTH)).resolves.toEqual({
successful: true,
redirectUri: 'end-session-endpoint',
});
});
it('should return the default redirect', async () => {
await expect(sut.logout(AuthType.PASSWORD)).resolves.toEqual({
successful: true,
redirectUri: '/auth/login',
});
expect(oauthServiceMock.getLogoutEndpoint).not.toHaveBeenCalled();
});
});
describe('adminSignUp', () => {
const dto: SignUpDto = { email: 'test@immich.com', password: 'password', firstName: 'immich', lastName: 'admin' };
it('should only allow one admin', async () => {
userRepositoryMock.getAdmin.mockResolvedValue({} as UserEntity);
await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException);
expect(userRepositoryMock.getAdmin).toHaveBeenCalled();
});
it('should sign up the admin', async () => {
userRepositoryMock.getAdmin.mockResolvedValue(null);
userRepositoryMock.create.mockResolvedValue({ ...dto, id: 'admin', createdAt: 'today' } as UserEntity);
await expect(sut.adminSignUp(dto)).resolves.toEqual({
id: 'admin',
createdAt: 'today',
email: 'test@immich.com',
firstName: 'immich',
lastName: 'admin',
});
expect(userRepositoryMock.getAdmin).toHaveBeenCalled();
expect(userRepositoryMock.create).toHaveBeenCalled();
});
});
});

View File

@ -1,106 +1,80 @@
import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; import { BadRequestException, Inject, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserEntity } from '@app/database/entities/user.entity';
import { LoginCredentialDto } from './dto/login-credential.dto';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { JwtPayloadDto } from './dto/jwt-payload.dto';
import { SignUpDto } from './dto/sign-up.dto';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { LoginResponseDto, mapLoginResponse } from './response-dto/login-response.dto'; import { UserEntity } from '../../../../../libs/database/src/entities/user.entity';
import { AuthType } from '../../constants/jwt.constant';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { IUserRepository, USER_REPOSITORY } from '../user/user-repository';
import { LoginCredentialDto } from './dto/login-credential.dto';
import { SignUpDto } from './dto/sign-up.dto';
import { AdminSignupResponseDto, mapAdminSignupResponse } from './response-dto/admin-signup-response.dto'; import { AdminSignupResponseDto, mapAdminSignupResponse } from './response-dto/admin-signup-response.dto';
import { LoginResponseDto } from './response-dto/login-response.dto';
import { LogoutResponseDto } from './response-dto/logout-response.dto';
import { OAuthService } from '../oauth/oauth.service';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
constructor( constructor(
@InjectRepository(UserEntity) private oauthService: OAuthService,
private userRepository: Repository<UserEntity>,
private immichJwtService: ImmichJwtService, private immichJwtService: ImmichJwtService,
@Inject(USER_REPOSITORY) private userRepository: IUserRepository,
) {} ) {}
private async validateUser(loginCredential: LoginCredentialDto): Promise<UserEntity | null> { public async login(loginCredential: LoginCredentialDto, clientIp: string): Promise<LoginResponseDto> {
const user = await this.userRepository.findOne({ let user = await this.userRepository.getByEmail(loginCredential.email, true);
where: {
email: loginCredential.email, if (user) {
}, const isAuthenticated = await this.validatePassword(loginCredential.password, user);
select: [ if (!isAuthenticated) {
'id', user = null;
'email', }
'password', }
'salt',
'firstName',
'lastName',
'isAdmin',
'profileImagePath',
'shouldChangePassword',
],
});
if (!user) { if (!user) {
return null;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const isAuthenticated = await this.validatePassword(user.password!, loginCredential.password, user.salt!);
if (isAuthenticated) {
return user;
}
return null;
}
public async login(loginCredential: LoginCredentialDto, clientIp: string): Promise<LoginResponseDto> {
const validatedUser = await this.validateUser(loginCredential);
if (!validatedUser) {
Logger.warn(`Failed login attempt for user ${loginCredential.email} from ip address ${clientIp}`); Logger.warn(`Failed login attempt for user ${loginCredential.email} from ip address ${clientIp}`);
throw new BadRequestException('Incorrect email or password'); throw new BadRequestException('Incorrect email or password');
} }
const payload = new JwtPayloadDto(validatedUser.id, validatedUser.email); return this.immichJwtService.createLoginResponse(user);
const accessToken = await this.immichJwtService.generateToken(payload);
return mapLoginResponse(validatedUser, accessToken);
} }
public getCookieWithJwtToken(authLoginInfo: LoginResponseDto) { public async logout(authType: AuthType): Promise<LogoutResponseDto> {
const maxAge = 7 * 24 * 3600; // 7 days if (authType === AuthType.OAUTH) {
return `immich_access_token=${authLoginInfo.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}`; const url = await this.oauthService.getLogoutEndpoint();
if (url) {
return { successful: true, redirectUri: url };
}
}
return { successful: true, redirectUri: '/auth/login' };
} }
// !TODO: refactor this method to use the userService createUser method public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
public async adminSignUp(signUpCredential: SignUpDto): Promise<AdminSignupResponseDto> { const adminUser = await this.userRepository.getAdmin();
const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } });
if (adminUser) { if (adminUser) {
throw new BadRequestException('The server already has an admin'); throw new BadRequestException('The server already has an admin');
} }
const newAdminUser = new UserEntity();
newAdminUser.email = signUpCredential.email;
newAdminUser.salt = await bcrypt.genSalt();
newAdminUser.password = await this.hashPassword(signUpCredential.password, newAdminUser.salt);
newAdminUser.firstName = signUpCredential.firstName;
newAdminUser.lastName = signUpCredential.lastName;
newAdminUser.isAdmin = true;
try { try {
const savedNewAdminUserUser = await this.userRepository.save(newAdminUser); const admin = await this.userRepository.create({
isAdmin: true,
email: dto.email,
firstName: dto.firstName,
lastName: dto.lastName,
password: dto.password,
});
return mapAdminSignupResponse(savedNewAdminUserUser); return mapAdminSignupResponse(admin);
} catch (e) { } catch (e) {
Logger.error('e', 'signUp'); Logger.error('e', 'signUp');
throw new InternalServerErrorException('Failed to register new admin user'); throw new InternalServerErrorException('Failed to register new admin user');
} }
} }
private async hashPassword(password: string, salt: string): Promise<string> { private async validatePassword(inputPassword: string, user: UserEntity): Promise<boolean> {
return bcrypt.hash(password, salt); if (!user || !user.password) {
} return false;
}
private async validatePassword(hasedPassword: string, inputPassword: string, salt: string): Promise<boolean> { return await bcrypt.compare(inputPassword, user.password);
const hash = await bcrypt.hash(inputPassword, salt);
return hash === hasedPassword;
} }
} }

View File

@ -7,4 +7,7 @@ export class LogoutResponseDto {
@ApiResponseProperty() @ApiResponseProperty()
successful!: boolean; successful!: boolean;
@ApiResponseProperty()
redirectUri!: string;
} }

View File

@ -6,6 +6,8 @@ import { InjectRepository } from '@nestjs/typeorm';
import { UserEntity } from '@app/database/entities/user.entity'; import { UserEntity } from '@app/database/entities/user.entity';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import cookieParser from 'cookie'; import cookieParser from 'cookie';
import { IMMICH_ACCESS_COOKIE } from '../../constants/jwt.constant';
@WebSocketGateway({ cors: true }) @WebSocketGateway({ cors: true })
export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisconnect { export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisconnect {
constructor( constructor(
@ -30,8 +32,8 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco
if (client.handshake.headers.cookie != undefined) { if (client.handshake.headers.cookie != undefined) {
const cookies = cookieParser.parse(client.handshake.headers.cookie); const cookies = cookieParser.parse(client.handshake.headers.cookie);
if (cookies.immich_access_token) { if (cookies[IMMICH_ACCESS_COOKIE]) {
accessToken = cookies.immich_access_token; accessToken = cookies[IMMICH_ACCESS_COOKIE];
} else { } else {
client.emit('error', 'unauthorized'); client.emit('error', 'unauthorized');
client.disconnect(); client.disconnect();

View File

@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class OAuthCallbackDto {
@IsNotEmpty()
@IsString()
@ApiProperty()
url!: string;
}

View File

@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class OAuthConfigDto {
@IsNotEmpty()
@IsString()
@ApiProperty()
redirectUri!: string;
}

View File

@ -0,0 +1,27 @@
import { Body, Controller, Post, Res, ValidationPipe } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { AuthType } from '../../constants/jwt.constant';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
import { OAuthConfigDto } from './dto/oauth-config.dto';
import { OAuthService } from './oauth.service';
import { OAuthConfigResponseDto } from './response-dto/oauth-config-response.dto';
@ApiTags('OAuth')
@Controller('oauth')
export class OAuthController {
constructor(private readonly immichJwtService: ImmichJwtService, private readonly oauthService: OAuthService) {}
@Post('/config')
public generateConfig(@Body(ValidationPipe) dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
return this.oauthService.generateConfig(dto);
}
@Post('/callback')
public async callback(@Res({ passthrough: true }) response: Response, @Body(ValidationPipe) dto: OAuthCallbackDto) {
const loginResponse = await this.oauthService.callback(dto);
response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.OAUTH));
return loginResponse;
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
import { UserModule } from '../user/user.module';
import { OAuthController } from './oauth.controller';
import { OAuthService } from './oauth.service';
@Module({
imports: [UserModule, ImmichJwtModule],
controllers: [OAuthController],
providers: [OAuthService],
exports: [OAuthService],
})
export class OAuthModule {}

View File

@ -0,0 +1,169 @@
import { UserEntity } from '@app/database/entities/user.entity';
import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { generators, Issuer } from 'openid-client';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
import { OAuthService } from '../oauth/oauth.service';
import { IUserRepository } from '../user/user-repository';
interface OAuthConfig {
OAUTH_ENABLED: boolean;
OAUTH_AUTO_REGISTER: boolean;
OAUTH_ISSUER_URL: string;
OAUTH_SCOPE: string;
OAUTH_BUTTON_TEXT: string;
}
const mockConfig = (config: Partial<OAuthConfig>) => {
return (value: keyof OAuthConfig, defaultValue: any) => config[value] ?? defaultValue ?? null;
};
const email = 'user@immich.com';
const user = {
id: 'user',
email,
firstName: 'user',
lastName: 'imimch',
} as UserEntity;
const loginResponse = {
accessToken: 'access-token',
userId: 'user',
userEmail: 'user@immich.com,',
} as LoginResponseDto;
describe('OAuthService', () => {
let sut: OAuthService;
let userRepositoryMock: jest.Mocked<IUserRepository>;
let configServiceMock: jest.Mocked<ConfigService>;
let immichJwtServiceMock: jest.Mocked<ImmichJwtService>;
beforeEach(async () => {
jest.spyOn(generators, 'state').mockReturnValue('state');
jest.spyOn(Issuer, 'discover').mockResolvedValue({
id_token_signing_alg_values_supported: ['HS256'],
Client: jest.fn().mockResolvedValue({
issuer: {
metadata: {
end_session_endpoint: 'http://end-session-endpoint',
},
},
authorizationUrl: jest.fn().mockReturnValue('http://authorization-url'),
callbackParams: jest.fn().mockReturnValue({ state: 'state' }),
callback: jest.fn().mockReturnValue({ access_token: 'access-token' }),
userinfo: jest.fn().mockResolvedValue({ email }),
}),
} as any);
userRepositoryMock = {
get: jest.fn(),
getAdmin: jest.fn(),
getByEmail: jest.fn(),
getList: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
restore: jest.fn(),
};
immichJwtServiceMock = {
getCookieNames: jest.fn(),
getCookies: jest.fn(),
createLoginResponse: jest.fn(),
validateToken: jest.fn(),
extractJwtFromHeader: jest.fn(),
extractJwtFromCookie: jest.fn(),
} as unknown as jest.Mocked<ImmichJwtService>;
configServiceMock = {
get: jest.fn(),
} as unknown as jest.Mocked<ConfigService>;
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
});
it('should be defined', () => {
expect(sut).toBeDefined();
});
describe('generateConfig', () => {
it('should work when oauth is not configured', async () => {
await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({ enabled: false });
expect(configServiceMock.get).toHaveBeenCalled();
});
it('should generate the config', async () => {
configServiceMock.get.mockImplementation(
mockConfig({
OAUTH_ENABLED: true,
OAUTH_BUTTON_TEXT: 'OAuth',
}),
);
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
await expect(sut.generateConfig({ redirectUri: 'http://redirect' })).resolves.toEqual({
enabled: true,
buttonText: 'OAuth',
url: 'http://authorization-url',
});
});
});
describe('callback', () => {
it('should throw an error if OAuth is not enabled', async () => {
await expect(sut.callback({ url: '' })).rejects.toBeInstanceOf(BadRequestException);
});
it('should not allow auto registering', async () => {
configServiceMock.get.mockImplementation(
mockConfig({
OAUTH_ENABLED: true,
OAUTH_AUTO_REGISTER: false,
}),
);
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
jest.spyOn(sut['logger'], 'warn').mockImplementation(() => null);
userRepositoryMock.getByEmail.mockResolvedValue(null);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
});
it('should allow auto registering by default', async () => {
configServiceMock.get.mockImplementation(mockConfig({ OAUTH_ENABLED: true }));
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
jest.spyOn(sut['logger'], 'log').mockImplementation(() => null);
userRepositoryMock.getByEmail.mockResolvedValue(null);
userRepositoryMock.create.mockResolvedValue(user);
immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(loginResponse);
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
expect(userRepositoryMock.create).toHaveBeenCalledTimes(1);
expect(immichJwtServiceMock.createLoginResponse).toHaveBeenCalledTimes(1);
});
});
describe('getLogoutEndpoint', () => {
it('should return null if OAuth is not configured', async () => {
await expect(sut.getLogoutEndpoint()).resolves.toBeNull();
});
it('should get the session endpoint from the discovery document', async () => {
configServiceMock.get.mockImplementation(
mockConfig({
OAUTH_ENABLED: true,
OAUTH_ISSUER_URL: 'http://issuer',
}),
);
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
await expect(sut.getLogoutEndpoint()).resolves.toBe('http://end-session-endpoint');
});
});
});

View File

@ -0,0 +1,108 @@
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ClientMetadata, generators, Issuer, UserinfoResponse } from 'openid-client';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
import { IUserRepository, USER_REPOSITORY } from '../user/user-repository';
import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
import { OAuthConfigDto } from './dto/oauth-config.dto';
import { OAuthConfigResponseDto } from './response-dto/oauth-config-response.dto';
type OAuthProfile = UserinfoResponse & {
email: string;
};
@Injectable()
export class OAuthService {
private readonly logger = new Logger(OAuthService.name);
private readonly enabled: boolean;
private readonly autoRegister: boolean;
private readonly buttonText: string;
private readonly issuerUrl: string;
private readonly clientMetadata: ClientMetadata;
private readonly scope: string;
constructor(
private immichJwtService: ImmichJwtService,
configService: ConfigService,
@Inject(USER_REPOSITORY) private userRepository: IUserRepository,
) {
this.enabled = configService.get('OAUTH_ENABLED', false);
this.autoRegister = configService.get('OAUTH_AUTO_REGISTER', true);
this.issuerUrl = configService.get<string>('OAUTH_ISSUER_URL', '');
this.scope = configService.get<string>('OAUTH_SCOPE', '');
this.buttonText = configService.get<string>('OAUTH_BUTTON_TEXT', '');
this.clientMetadata = {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
client_id: configService.get('OAUTH_CLIENT_ID')!,
client_secret: configService.get('OAUTH_CLIENT_SECRET'),
response_types: ['code'],
};
}
public async generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
if (!this.enabled) {
return { enabled: false };
}
const url = (await this.getClient()).authorizationUrl({
redirect_uri: dto.redirectUri,
scope: this.scope,
state: generators.state(),
});
return { enabled: true, buttonText: this.buttonText, url };
}
public async callback(dto: OAuthCallbackDto): Promise<LoginResponseDto> {
const redirectUri = dto.url.split('?')[0];
const client = await this.getClient();
const params = client.callbackParams(dto.url);
const tokens = await client.callback(redirectUri, params, { state: params.state });
const profile = await client.userinfo<OAuthProfile>(tokens.access_token || '');
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
let user = await this.userRepository.getByEmail(profile.email);
if (!user) {
if (!this.autoRegister) {
this.logger.warn(
`Unable to register ${profile.email}. To enable auto registering, set OAUTH_AUTO_REGISTER=true.`,
);
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
}
this.logger.log(`Registering new user: ${profile.email}`);
user = await this.userRepository.create({
firstName: profile.given_name || '',
lastName: profile.family_name || '',
email: profile.email,
});
}
return this.immichJwtService.createLoginResponse(user);
}
public async getLogoutEndpoint(): Promise<string | null> {
if (!this.enabled) {
return null;
}
return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
}
private async getClient() {
if (!this.enabled) {
throw new BadRequestException('OAuth2 is not enabled');
}
const issuer = await Issuer.discover(this.issuerUrl);
const algorithms = (issuer.id_token_signing_alg_values_supported || []) as string[];
const metadata = { ...this.clientMetadata };
if (algorithms[0] === 'HS256') {
metadata.id_token_signed_response_alg = algorithms[0];
}
return new issuer.Client(metadata);
}
}

View File

@ -0,0 +1,12 @@
import { ApiResponseProperty } from '@nestjs/swagger';
export class OAuthConfigResponseDto {
@ApiResponseProperty()
enabled!: boolean;
@ApiResponseProperty()
url?: string;
@ApiResponseProperty()
buttonText?: string;
}

View File

@ -1,18 +1,16 @@
import { UserEntity } from '@app/database/entities/user.entity'; import { UserEntity } from '@app/database/entities/user.entity';
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
import { CreateUserDto } from './dto/create-user.dto';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { UpdateUserDto } from './dto/update-user.dto'; import { Not, Repository } from 'typeorm';
export interface IUserRepository { export interface IUserRepository {
get(userId: string, withDeleted?: boolean): Promise<UserEntity | null>; get(id: string, withDeleted?: boolean): Promise<UserEntity | null>;
getByEmail(email: string): Promise<UserEntity | null>; getAdmin(): Promise<UserEntity | null>;
getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null>;
getList(filter?: { excludeId?: string }): Promise<UserEntity[]>; getList(filter?: { excludeId?: string }): Promise<UserEntity[]>;
create(createUserDto: CreateUserDto): Promise<UserEntity>; create(user: Partial<UserEntity>): Promise<UserEntity>;
update(user: UserEntity, updateUserDto: UpdateUserDto): Promise<UserEntity>; update(id: string, user: Partial<UserEntity>): Promise<UserEntity>;
createProfileImage(user: UserEntity, fileInfo: Express.Multer.File): Promise<UserEntity>;
delete(user: UserEntity): Promise<UserEntity>; delete(user: UserEntity): Promise<UserEntity>;
restore(user: UserEntity): Promise<UserEntity>; restore(user: UserEntity): Promise<UserEntity>;
} }
@ -25,25 +23,29 @@ export class UserRepository implements IUserRepository {
private userRepository: Repository<UserEntity>, private userRepository: Repository<UserEntity>,
) {} ) {}
private async hashPassword(password: string, salt: string): Promise<string> { public async get(userId: string, withDeleted?: boolean): Promise<UserEntity | null> {
return bcrypt.hash(password, salt);
}
async get(userId: string, withDeleted?: boolean): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { id: userId }, withDeleted: withDeleted }); return this.userRepository.findOne({ where: { id: userId }, withDeleted: withDeleted });
} }
async getByEmail(email: string): Promise<UserEntity | null> { public async getAdmin(): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { email } }); return this.userRepository.findOne({ where: { isAdmin: true } });
} }
// TODO add DTO for filtering public async getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null> {
async getList({ excludeId }: { excludeId?: string } = {}): Promise<UserEntity[]> { let builder = this.userRepository.createQueryBuilder('user').where({ email });
if (withPassword) {
builder = builder.addSelect('user.password');
}
return builder.getOne();
}
public async getList({ excludeId }: { excludeId?: string } = {}): Promise<UserEntity[]> {
if (!excludeId) { if (!excludeId) {
return this.userRepository.find(); // TODO: this should also be ordered the same as below return this.userRepository.find(); // TODO: this should also be ordered the same as below
} }
return this.userRepository return this.userRepository.find({
.find({
where: { id: Not(excludeId) }, where: { id: Not(excludeId) },
withDeleted: true, withDeleted: true,
order: { order: {
@ -52,33 +54,27 @@ export class UserRepository implements IUserRepository {
}); });
} }
async create(createUserDto: CreateUserDto): Promise<UserEntity> { public async create(user: Partial<UserEntity>): Promise<UserEntity> {
const newUser = new UserEntity(); if (user.password) {
newUser.email = createUserDto.email; user.salt = await bcrypt.genSalt();
newUser.salt = await bcrypt.genSalt(); user.password = await this.hashPassword(user.password, user.salt);
newUser.password = await this.hashPassword(createUserDto.password, newUser.salt); }
newUser.firstName = createUserDto.firstName; user.isAdmin = false;
newUser.lastName = createUserDto.lastName;
newUser.isAdmin = false;
return this.userRepository.save(newUser); return this.userRepository.save(user);
} }
async update(user: UserEntity, updateUserDto: UpdateUserDto): Promise<UserEntity> { public async update(id: string, user: Partial<UserEntity>): Promise<UserEntity> {
user.lastName = updateUserDto.lastName || user.lastName; user.id = id;
user.firstName = updateUserDto.firstName || user.firstName;
user.profileImagePath = updateUserDto.profileImagePath || user.profileImagePath;
user.shouldChangePassword =
updateUserDto.shouldChangePassword != undefined ? updateUserDto.shouldChangePassword : user.shouldChangePassword;
// If payload includes password - Create new password for user // If payload includes password - Create new password for user
if (updateUserDto.password) { if (user.password) {
user.salt = await bcrypt.genSalt(); user.salt = await bcrypt.genSalt();
user.password = await this.hashPassword(updateUserDto.password, user.salt); user.password = await this.hashPassword(user.password, user.salt);
} }
// TODO: can this happen? If so we can move it to the service, otherwise remove it (also from DTO) // TODO: can this happen? If so we can move it to the service, otherwise remove it (also from DTO)
if (updateUserDto.isAdmin) { if (user.isAdmin) {
const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } }); const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } });
if (adminUser) { if (adminUser) {
@ -91,19 +87,18 @@ export class UserRepository implements IUserRepository {
return this.userRepository.save(user); return this.userRepository.save(user);
} }
async delete(user: UserEntity): Promise<UserEntity> { public async delete(user: UserEntity): Promise<UserEntity> {
if (user.isAdmin) { if (user.isAdmin) {
throw new BadRequestException('Cannot delete admin user! stay sane!'); throw new BadRequestException('Cannot delete admin user! stay sane!');
} }
return this.userRepository.softRemove(user); return this.userRepository.softRemove(user);
} }
async restore(user: UserEntity): Promise<UserEntity> { public async restore(user: UserEntity): Promise<UserEntity> {
return this.userRepository.recover(user); return this.userRepository.recover(user);
} }
async createProfileImage(user: UserEntity, fileInfo: Express.Multer.File): Promise<UserEntity> { private async hashPassword(password: string, salt: string): Promise<string> {
user.profileImagePath = fileInfo.path; return bcrypt.hash(password, salt);
return this.userRepository.save(user);
} }
} }

View File

@ -1,24 +1,23 @@
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '@app/database/entities/user.entity'; import { UserEntity } from '@app/database/entities/user.entity';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { jwtConfig } from '../../config/jwt.config';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module'; import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service'; import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { JwtModule } from '@nestjs/jwt';
import { jwtConfig } from '../../config/jwt.config';
import { UserRepository, USER_REPOSITORY } from './user-repository'; import { UserRepository, USER_REPOSITORY } from './user-repository';
import { UserController } from './user.controller';
import { UserService } from './user.service';
const USER_REPOSITORY_PROVIDER = {
provide: USER_REPOSITORY,
useClass: UserRepository,
};
@Module({ @Module({
imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)], imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)],
controllers: [UserController], controllers: [UserController],
providers: [ providers: [UserService, ImmichJwtService, USER_REPOSITORY_PROVIDER],
UserService, exports: [USER_REPOSITORY_PROVIDER],
ImmichJwtService,
{
provide: USER_REPOSITORY,
useClass: UserRepository,
},
],
}) })
export class UserModule {} export class UserModule {}

View File

@ -1,5 +1,6 @@
import { UserEntity } from '@app/database/entities/user.entity'; import { UserEntity } from '@app/database/entities/user.entity';
import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BadRequestException, NotFoundException } from '@nestjs/common';
import { newUserRepositoryMock } from '../../../test/test-utils';
import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { IUserRepository } from './user-repository'; import { IUserRepository } from './user-repository';
import { UserService } from './user.service'; import { UserService } from './user.service';
@ -58,16 +59,7 @@ describe('UserService', () => {
}); });
beforeAll(() => { beforeAll(() => {
userRepositoryMock = { userRepositoryMock = newUserRepositoryMock();
create: jest.fn(),
createProfileImage: jest.fn(),
get: jest.fn(),
getByEmail: jest.fn(),
getList: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
restore: jest.fn(),
};
sui = new UserService(userRepositoryMock); sui = new UserService(userRepositoryMock);
}); });

View File

@ -9,17 +9,17 @@ import {
StreamableFile, StreamableFile,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Response as Res } from 'express';
import { createReadStream } from 'fs';
import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateUserDto } from './dto/create-user.dto'; import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateUserDto } from './dto/update-user.dto';
import { createReadStream } from 'fs';
import { Response as Res } from 'express';
import { mapUser, UserResponseDto } from './response-dto/user-response.dto';
import { mapUserCountResponse, UserCountResponseDto } from './response-dto/user-count-response.dto';
import { import {
CreateProfileImageResponseDto, CreateProfileImageResponseDto,
mapCreateProfileImageResponse, mapCreateProfileImageResponse,
} from './response-dto/create-profile-image-response.dto'; } from './response-dto/create-profile-image-response.dto';
import { mapUserCountResponse, UserCountResponseDto } from './response-dto/user-count-response.dto';
import { mapUser, UserResponseDto } from './response-dto/user-response.dto';
import { IUserRepository, USER_REPOSITORY } from './user-repository'; import { IUserRepository, USER_REPOSITORY } from './user-repository';
@Injectable() @Injectable()
@ -98,7 +98,7 @@ export class UserService {
throw new NotFoundException('User not found'); throw new NotFoundException('User not found');
} }
try { try {
const updatedUser = await this.userRepository.update(user, updateUserDto); const updatedUser = await this.userRepository.update(user.id, updateUserDto);
return mapUser(updatedUser); return mapUser(updatedUser);
} catch (e) { } catch (e) {
@ -159,7 +159,7 @@ export class UserService {
} }
try { try {
await this.userRepository.createProfileImage(user, fileInfo); await this.userRepository.update(user.id, { profileImagePath: fileInfo.path });
return mapCreateProfileImageResponse(authUser.id, fileInfo.path); return mapCreateProfileImageResponse(authUser.id, fileInfo.path);
} catch (e) { } catch (e) {

View File

@ -16,6 +16,7 @@ import { ScheduleModule } from '@nestjs/schedule';
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module'; import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
import { DatabaseModule } from '@app/database'; import { DatabaseModule } from '@app/database';
import { JobModule } from './api-v1/job/job.module'; import { JobModule } from './api-v1/job/job.module';
import { OAuthModule } from './api-v1/oauth/oauth.module';
@Module({ @Module({
imports: [ imports: [
@ -27,6 +28,7 @@ import { JobModule } from './api-v1/job/job.module';
AssetModule, AssetModule,
AuthModule, AuthModule,
OAuthModule,
ImmichJwtModule, ImmichJwtModule,

View File

@ -1 +1,7 @@
export const jwtSecret = process.env.JWT_SECRET; export const jwtSecret = process.env.JWT_SECRET;
export const IMMICH_ACCESS_COOKIE = 'immich_access_token';
export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type';
export enum AuthType {
PASSWORD = 'password',
OAUTH = 'oauth',
}

View File

@ -1,53 +1,96 @@
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { Request } from 'express'; import { Request } from 'express';
import { UserEntity } from '../../../../../libs/database/src/entities/user.entity';
import { LoginResponseDto } from '../../api-v1/auth/response-dto/login-response.dto';
import { AuthType } from '../../constants/jwt.constant';
import { ImmichJwtService } from './immich-jwt.service'; import { ImmichJwtService } from './immich-jwt.service';
describe('ImmichJwtService', () => { describe('ImmichJwtService', () => {
let jwtService: JwtService; let jwtServiceMock: jest.Mocked<JwtService>;
let service: ImmichJwtService; let sut: ImmichJwtService;
beforeEach(() => { beforeEach(() => {
jwtService = new JwtService(); jwtServiceMock = {
service = new ImmichJwtService(jwtService); sign: jest.fn(),
verifyAsync: jest.fn(),
} as unknown as jest.Mocked<JwtService>;
sut = new ImmichJwtService(jwtServiceMock);
}); });
afterEach(() => { afterEach(() => {
jest.resetModules(); jest.resetModules();
}); });
describe('generateToken', () => { describe('getCookieNames', () => {
it('should generate the token', async () => { it('should return the cookie names', async () => {
const spy = jest.spyOn(jwtService, 'sign'); expect(sut.getCookieNames()).toEqual(['immich_access_token', 'immich_auth_type']);
spy.mockImplementation((value) => value as string); });
const dto = { userId: 'test-user', email: 'test-user@immich.com' }; });
const token = await service.generateToken(dto);
expect(token).toEqual(dto); describe('getCookies', () => {
it('should generate the cookie headers', async () => {
jwtServiceMock.sign.mockImplementation((value) => value as string);
const dto = { accessToken: 'test-user@immich.com', userId: 'test-user' };
const cookies = await sut.getCookies(dto as LoginResponseDto, AuthType.PASSWORD);
expect(cookies).toEqual([
'immich_access_token=test-user@immich.com; HttpOnly; Path=/; Max-Age=604800',
'immich_auth_type=password; Path=/; Max-Age=604800',
]);
});
});
describe('createLoginResponse', () => {
it('should create the login response', async () => {
jwtServiceMock.sign.mockReturnValue('fancy-token');
const user: UserEntity = {
id: 'user',
firstName: 'immich',
lastName: 'user',
isAdmin: false,
email: 'test@immich.com',
password: 'changeme',
salt: '123',
profileImagePath: '',
shouldChangePassword: false,
createdAt: 'today',
};
const dto: LoginResponseDto = {
accessToken: 'fancy-token',
firstName: 'immich',
isAdmin: false,
lastName: 'user',
profileImagePath: '',
shouldChangePassword: false,
userEmail: 'test@immich.com',
userId: 'user',
};
await expect(sut.createLoginResponse(user)).resolves.toEqual(dto);
}); });
}); });
describe('validateToken', () => { describe('validateToken', () => {
it('should validate the token', async () => { it('should validate the token', async () => {
const dto = { userId: 'test-user', email: 'test-user@immich.com' }; const dto = { userId: 'test-user', email: 'test-user@immich.com' };
const spy = jest.spyOn(jwtService, 'verifyAsync'); jwtServiceMock.verifyAsync.mockImplementation(() => dto as any);
spy.mockImplementation(() => dto as any); const response = await sut.validateToken('access-token');
const response = await service.validateToken('access-token');
expect(spy).toHaveBeenCalledTimes(1); expect(jwtServiceMock.verifyAsync).toHaveBeenCalledTimes(1);
expect(response).toEqual({ userId: 'test-user', status: true }); expect(response).toEqual({ userId: 'test-user', status: true });
}); });
it('should handle an invalid token', async () => { it('should handle an invalid token', async () => {
const verifyAsync = jest.spyOn(jwtService, 'verifyAsync'); jwtServiceMock.verifyAsync.mockImplementation(() => {
verifyAsync.mockImplementation(() => {
throw new Error('Invalid token!'); throw new Error('Invalid token!');
}); });
const error = jest.spyOn(Logger, 'error'); const error = jest.spyOn(Logger, 'error');
error.mockImplementation(() => null); error.mockImplementation(() => null);
const response = await service.validateToken('access-token'); const response = await sut.validateToken('access-token');
expect(verifyAsync).toHaveBeenCalledTimes(1); expect(jwtServiceMock.verifyAsync).toHaveBeenCalledTimes(1);
expect(error).toHaveBeenCalledTimes(1); expect(error).toHaveBeenCalledTimes(1);
expect(response).toEqual({ userId: null, status: false }); expect(response).toEqual({ userId: null, status: false });
}); });
@ -58,7 +101,7 @@ describe('ImmichJwtService', () => {
const request = { const request = {
headers: {}, headers: {},
} as Request; } as Request;
const token = service.extractJwtFromHeader(request); const token = sut.extractJwtFromHeader(request);
expect(token).toBe(null); expect(token).toBe(null);
}); });
@ -75,15 +118,15 @@ describe('ImmichJwtService', () => {
}, },
} as Request; } as Request;
expect(service.extractJwtFromHeader(upper)).toBe('token'); expect(sut.extractJwtFromHeader(upper)).toBe('token');
expect(service.extractJwtFromHeader(lower)).toBe('token'); expect(sut.extractJwtFromHeader(lower)).toBe('token');
}); });
}); });
describe('extracJwtFromCookie', () => { describe('extracJwtFromCookie', () => {
it('should handle no cookie', () => { it('should handle no cookie', () => {
const request = {} as Request; const request = {} as Request;
const token = service.extractJwtFromCookie(request); const token = sut.extractJwtFromCookie(request);
expect(token).toBe(null); expect(token).toBe(null);
}); });
@ -93,7 +136,7 @@ describe('ImmichJwtService', () => {
immich_access_token: 'cookie', immich_access_token: 'cookie',
}, },
} as Request; } as Request;
const token = service.extractJwtFromCookie(request); const token = sut.extractJwtFromCookie(request);
expect(token).toBe('cookie'); expect(token).toBe('cookie');
}); });
}); });

View File

@ -1,8 +1,10 @@
import { UserEntity } from '@app/database/entities/user.entity';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { Request } from 'express'; import { Request } from 'express';
import { JwtPayloadDto } from '../../api-v1/auth/dto/jwt-payload.dto'; import { JwtPayloadDto } from '../../api-v1/auth/dto/jwt-payload.dto';
import { jwtSecret } from '../../constants/jwt.constant'; import { LoginResponseDto, mapLoginResponse } from '../../api-v1/auth/response-dto/login-response.dto';
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE, jwtSecret } from '../../constants/jwt.constant';
export type JwtValidationResult = { export type JwtValidationResult = {
status: boolean; status: boolean;
@ -13,10 +15,24 @@ export type JwtValidationResult = {
export class ImmichJwtService { export class ImmichJwtService {
constructor(private jwtService: JwtService) {} constructor(private jwtService: JwtService) {}
public async generateToken(payload: JwtPayloadDto) { public getCookieNames() {
return this.jwtService.sign({ return [IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE];
...payload, }
});
public getCookies(loginResponse: LoginResponseDto, authType: AuthType) {
const maxAge = 7 * 24 * 3600; // 7 days
const accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}`;
const authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; Path=/; Max-Age=${maxAge}`;
return [accessTokenCookie, authTypeCookie];
}
public async createLoginResponse(user: UserEntity): Promise<LoginResponseDto> {
const payload = new JwtPayloadDto(user.id, user.email);
const accessToken = await this.generateToken(payload);
return mapLoginResponse(user, accessToken);
} }
public async validateToken(accessToken: string): Promise<JwtValidationResult> { public async validateToken(accessToken: string): Promise<JwtValidationResult> {
@ -48,10 +64,12 @@ export class ImmichJwtService {
} }
public extractJwtFromCookie(req: Request) { public extractJwtFromCookie(req: Request) {
if (req.cookies?.immich_access_token) { return req.cookies?.[IMMICH_ACCESS_COOKIE] || null;
return req.cookies.immich_access_token; }
}
return null; private async generateToken(payload: JwtPayloadDto) {
return this.jwtService.sign({
...payload,
});
} }
} }

View File

@ -1,6 +1,7 @@
import { DataSource } from 'typeorm';
import { CanActivate, ExecutionContext } from '@nestjs/common'; import { CanActivate, ExecutionContext } from '@nestjs/common';
import { TestingModuleBuilder } from '@nestjs/testing'; import { TestingModuleBuilder } from '@nestjs/testing';
import { DataSource } from 'typeorm';
import { IUserRepository } from '../src/api-v1/user/user-repository';
import { AuthUserDto } from '../src/decorators/auth-user.decorator'; import { AuthUserDto } from '../src/decorators/auth-user.decorator';
import { JwtAuthGuard } from '../src/modules/immich-jwt/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../src/modules/immich-jwt/guards/jwt-auth.guard';
@ -14,6 +15,19 @@ export async function clearDb(db: DataSource) {
} }
} }
export function newUserRepositoryMock(): jest.Mocked<IUserRepository> {
return {
get: jest.fn(),
getAdmin: jest.fn(),
getByEmail: jest.fn(),
getList: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
restore: jest.fn(),
};
}
export function getAuthUser(): AuthUserDto { export function getAuthUser(): AuthUserDto {
return { return {
id: '3108ac14-8afb-4b7e-87fd-39ebb6b79750', id: '3108ac14-8afb-4b7e-87fd-39ebb6b79750',

File diff suppressed because one or more lines are too long

View File

@ -16,6 +16,12 @@ const jwtSecretValidator: Joi.CustomValidator<string> = (value) => {
return value; return value;
}; };
const WHEN_OAUTH_ENABLED = Joi.when('OAUTH_ENABLED', {
is: true,
then: Joi.string().required(),
otherwise: Joi.string().optional(),
});
export const immichAppConfig: ConfigModuleOptions = { export const immichAppConfig: ConfigModuleOptions = {
envFilePath: '.env', envFilePath: '.env',
isGlobal: true, isGlobal: true,
@ -28,5 +34,12 @@ export const immichAppConfig: ConfigModuleOptions = {
DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false), DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false),
REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0, 1, 2, 3).default(3), REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0, 1, 2, 3).default(3),
LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'), LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'),
OAUTH_ENABLED: Joi.bool().valid(true, false).default(false),
OAUTH_BUTTON_TEXT: Joi.string().optional().default('Login with OAuth'),
OAUTH_AUTO_REGISTER: Joi.bool().valid(true, false).default(true),
OAUTH_ISSUER_URL: WHEN_OAUTH_ENABLED,
OAUTH_SCOPE: Joi.string().optional().default('openid email profile'),
OAUTH_CLIENT_ID: WHEN_OAUTH_ENABLED,
OAUTH_CLIENT_SECRET: WHEN_OAUTH_ENABLED,
}), }),
}; };

View File

@ -1,4 +1,4 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, DeleteDateColumn } from 'typeorm'; import { Column, CreateDateColumn, DeleteDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('users') @Entity('users')
export class UserEntity { export class UserEntity {
@ -17,10 +17,10 @@ export class UserEntity {
@Column() @Column()
email!: string; email!: string;
@Column({ select: false }) @Column({ default: '', select: false })
password?: string; password?: string;
@Column({ select: false }) @Column({ default: '', select: false })
salt?: string; salt?: string;
@Column({ default: '' }) @Column({ default: '' })

View File

@ -41,6 +41,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"luxon": "^3.0.3", "luxon": "^3.0.3",
"nest-commander": "^3.3.0", "nest-commander": "^3.3.0",
"openid-client": "^5.2.1",
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-jwt": "^4.0.0", "passport-jwt": "^4.0.0",
"pg": "^8.7.1", "pg": "^8.7.1",
@ -7449,6 +7450,14 @@
"@sideway/pinpoint": "^2.0.0" "@sideway/pinpoint": "^2.0.0"
} }
}, },
"node_modules/jose": {
"version": "4.10.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.10.3.tgz",
"integrity": "sha512-3S4wQnaoJKSAx9uHSoyf8B/lxjs1qCntHWL6wNFszJazo+FtWe+qD0zVfY0BlqJ5HHK4jcnM98k3BQzVLbzE4g==",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -8439,6 +8448,14 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/oidc-token-hash": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.1.tgz",
"integrity": "sha512-EvoOtz6FIEBzE+9q253HsLCVRiK/0doEJ2HCvvqMQb3dHZrP3WlJKYtJ55CRTw4jmYomzH4wkPuCj/I3ZvpKxQ==",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -8472,6 +8489,28 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/openid-client": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.2.1.tgz",
"integrity": "sha512-KPxqWnxobG/70Cxqyvd43RWfCfHedFnCdHSBpw5f7WnTnuBAeBnvot/BIo+brrcTr0wyAYUlL/qejQSGwWtdIg==",
"dependencies": {
"jose": "^4.10.0",
"lru-cache": "^6.0.0",
"object-hash": "^2.0.1",
"oidc-token-hash": "^5.0.1"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client/node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"engines": {
"node": ">= 6"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.1", "version": "0.9.1",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
@ -17131,6 +17170,11 @@
"@sideway/pinpoint": "^2.0.0" "@sideway/pinpoint": "^2.0.0"
} }
}, },
"jose": {
"version": "4.10.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.10.3.tgz",
"integrity": "sha512-3S4wQnaoJKSAx9uHSoyf8B/lxjs1qCntHWL6wNFszJazo+FtWe+qD0zVfY0BlqJ5HHK4jcnM98k3BQzVLbzE4g=="
},
"js-tokens": { "js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -17939,6 +17983,11 @@
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz",
"integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==" "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g=="
}, },
"oidc-token-hash": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.1.tgz",
"integrity": "sha512-EvoOtz6FIEBzE+9q253HsLCVRiK/0doEJ2HCvvqMQb3dHZrP3WlJKYtJ55CRTw4jmYomzH4wkPuCj/I3ZvpKxQ=="
},
"on-finished": { "on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -17963,6 +18012,24 @@
"mimic-fn": "^2.1.0" "mimic-fn": "^2.1.0"
} }
}, },
"openid-client": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.2.1.tgz",
"integrity": "sha512-KPxqWnxobG/70Cxqyvd43RWfCfHedFnCdHSBpw5f7WnTnuBAeBnvot/BIo+brrcTr0wyAYUlL/qejQSGwWtdIg==",
"requires": {
"jose": "^4.10.0",
"lru-cache": "^6.0.0",
"object-hash": "^2.0.1",
"oidc-token-hash": "^5.0.1"
},
"dependencies": {
"object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="
}
}
},
"optionator": { "optionator": {
"version": "0.9.1", "version": "0.9.1",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",

View File

@ -62,6 +62,7 @@
"local-reverse-geocoder": "^0.12.5", "local-reverse-geocoder": "^0.12.5",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"luxon": "^3.0.3", "luxon": "^3.0.3",
"openid-client": "^5.2.1",
"nest-commander": "^3.3.0", "nest-commander": "^3.3.0",
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-jwt": "^4.0.0", "passport-jwt": "^4.0.0",

View File

@ -6,6 +6,7 @@ import {
Configuration, Configuration,
DeviceInfoApi, DeviceInfoApi,
JobApi, JobApi,
OAuthApi,
ServerInfoApi, ServerInfoApi,
UserApi UserApi
} from './open-api'; } from './open-api';
@ -15,6 +16,7 @@ class ImmichApi {
public albumApi: AlbumApi; public albumApi: AlbumApi;
public assetApi: AssetApi; public assetApi: AssetApi;
public authenticationApi: AuthenticationApi; public authenticationApi: AuthenticationApi;
public oauthApi: OAuthApi;
public deviceInfoApi: DeviceInfoApi; public deviceInfoApi: DeviceInfoApi;
public serverInfoApi: ServerInfoApi; public serverInfoApi: ServerInfoApi;
public jobApi: JobApi; public jobApi: JobApi;
@ -26,6 +28,7 @@ class ImmichApi {
this.albumApi = new AlbumApi(this.config); this.albumApi = new AlbumApi(this.config);
this.assetApi = new AssetApi(this.config); this.assetApi = new AssetApi(this.config);
this.authenticationApi = new AuthenticationApi(this.config); this.authenticationApi = new AuthenticationApi(this.config);
this.oauthApi = new OAuthApi(this.config);
this.deviceInfoApi = new DeviceInfoApi(this.config); this.deviceInfoApi = new DeviceInfoApi(this.config);
this.serverInfoApi = new ServerInfoApi(this.config); this.serverInfoApi = new ServerInfoApi(this.config);
this.jobApi = new JobApi(this.config); this.jobApi = new JobApi(this.config);

View File

@ -1125,6 +1125,63 @@ export interface LogoutResponseDto {
* @memberof LogoutResponseDto * @memberof LogoutResponseDto
*/ */
'successful': boolean; 'successful': boolean;
/**
*
* @type {string}
* @memberof LogoutResponseDto
*/
'redirectUri': string;
}
/**
*
* @export
* @interface OAuthCallbackDto
*/
export interface OAuthCallbackDto {
/**
*
* @type {string}
* @memberof OAuthCallbackDto
*/
'url': string;
}
/**
*
* @export
* @interface OAuthConfigDto
*/
export interface OAuthConfigDto {
/**
*
* @type {string}
* @memberof OAuthConfigDto
*/
'redirectUri': string;
}
/**
*
* @export
* @interface OAuthConfigResponseDto
*/
export interface OAuthConfigResponseDto {
/**
*
* @type {boolean}
* @memberof OAuthConfigResponseDto
*/
'enabled': boolean;
/**
*
* @type {string}
* @memberof OAuthConfigResponseDto
*/
'url'?: string;
/**
*
* @type {string}
* @memberof OAuthConfigResponseDto
*/
'buttonText'?: string;
} }
/** /**
* *
@ -4459,6 +4516,174 @@ export class JobApi extends BaseAPI {
} }
/**
* OAuthApi - axios parameter creator
* @export
*/
export const OAuthApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @param {OAuthCallbackDto} oAuthCallbackDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
callback: async (oAuthCallbackDto: OAuthCallbackDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'oAuthCallbackDto' is not null or undefined
assertParamExists('callback', 'oAuthCallbackDto', oAuthCallbackDto)
const localVarPath = `/oauth/callback`;
// 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(oAuthCallbackDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {OAuthConfigDto} oAuthConfigDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
generateConfig: async (oAuthConfigDto: OAuthConfigDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'oAuthConfigDto' is not null or undefined
assertParamExists('generateConfig', 'oAuthConfigDto', oAuthConfigDto)
const localVarPath = `/oauth/config`;
// 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,
};
},
}
};
/**
* OAuthApi - functional programming interface
* @export
*/
export const OAuthApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = OAuthApiAxiosParamCreator(configuration)
return {
/**
*
* @param {OAuthCallbackDto} oAuthCallbackDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async callback(oAuthCallbackDto: OAuthCallbackDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<LoginResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.callback(oAuthCallbackDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {OAuthConfigDto} oAuthConfigDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async generateConfig(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<OAuthConfigResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.generateConfig(oAuthConfigDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};
/**
* OAuthApi - factory interface
* @export
*/
export const OAuthApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = OAuthApiFp(configuration)
return {
/**
*
* @param {OAuthCallbackDto} oAuthCallbackDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
callback(oAuthCallbackDto: OAuthCallbackDto, options?: any): AxiosPromise<LoginResponseDto> {
return localVarFp.callback(oAuthCallbackDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {OAuthConfigDto} oAuthConfigDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
generateConfig(oAuthConfigDto: OAuthConfigDto, options?: any): AxiosPromise<OAuthConfigResponseDto> {
return localVarFp.generateConfig(oAuthConfigDto, options).then((request) => request(axios, basePath));
},
};
};
/**
* OAuthApi - object-oriented interface
* @export
* @class OAuthApi
* @extends {BaseAPI}
*/
export class OAuthApi extends BaseAPI {
/**
*
* @param {OAuthCallbackDto} oAuthCallbackDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof OAuthApi
*/
public callback(oAuthCallbackDto: OAuthCallbackDto, options?: AxiosRequestConfig) {
return OAuthApiFp(this.configuration).callback(oAuthCallbackDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {OAuthConfigDto} oAuthConfigDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof OAuthApi
*/
public generateConfig(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig) {
return OAuthApiFp(this.configuration).generateConfig(oAuthConfigDto, options).then((request) => request(this.axios, this.basePath));
}
}
/** /**
* ServerInfoApi - axios parameter creator * ServerInfoApi - axios parameter creator
* @export * @export

View File

@ -1,17 +1,49 @@
<script lang="ts"> <script lang="ts">
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { loginPageMessage } from '$lib/constants'; import { loginPageMessage } from '$lib/constants';
import { api } from '@api'; import { api, OAuthConfigResponseDto } from '@api';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
let error: string; let error: string;
let email = ''; let email = '';
let password = ''; let password = '';
let oauthError: string;
let oauthConfig: OAuthConfigResponseDto = { enabled: false };
let loading = true;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
onMount(async () => {
const search = window.location.search;
if (search.includes('code=') || search.includes('error=')) {
try {
loading = true;
await api.oauthApi.callback({ url: window.location.href });
dispatch('success');
return;
} catch (e) {
console.error('Error [login-form] [oauth.callback]', e);
oauthError = 'Unable to complete OAuth login';
loading = false;
}
}
try {
const redirectUri = window.location.href.split('?')[0];
console.log(`OAuth Redirect URI: ${redirectUri}`);
const { data } = await api.oauthApi.generateConfig({ redirectUri });
oauthConfig = data;
} catch (e) {
console.error('Error [login-form] [oauth.generateConfig]', e);
}
loading = false;
});
const login = async () => { const login = async () => {
try { try {
error = ''; error = '';
loading = true;
const { data } = await api.authenticationApi.login({ const { data } = await api.authenticationApi.login({
email, email,
@ -27,6 +59,7 @@
return; return;
} catch (e) { } catch (e) {
error = 'Incorrect email or password'; error = 'Incorrect email or password';
loading = false;
return; return;
} }
}; };
@ -48,41 +81,65 @@
</p> </p>
{/if} {/if}
<form on:submit|preventDefault={login} autocomplete="off"> {#if loading}
<div class="m-4 flex flex-col gap-2"> <div class="flex place-items-center place-content-center">
<label class="immich-form-label" for="email">Email</label> <LoadingSpinner />
<input
class="immich-form-input"
id="email"
name="email"
type="email"
bind:value={email}
required
/>
</div> </div>
{:else}
<form on:submit|preventDefault={login} autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">Email</label>
<input
class="immich-form-input"
id="email"
name="email"
type="email"
bind:value={email}
required
/>
</div>
<div class="m-4 flex flex-col gap-2"> <div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="password">Password</label> <label class="immich-form-label" for="password">Password</label>
<input <input
class="immich-form-input" class="immich-form-input"
id="password" id="password"
name="password" name="password"
type="password" type="password"
bind:value={password} bind:value={password}
required required
/> />
</div> </div>
{#if error} {#if error}
<p class="text-red-400 pl-4">{error}</p> <p class="text-red-400 pl-4">{error}</p>
{/if} {/if}
<div class="flex w-full"> <div class="flex w-full">
<button <button
type="submit" type="submit"
class="m-4 p-2 bg-immich-primary dark:bg-immich-dark-primary dark:text-immich-dark-gray dark:hover:bg-immich-dark-primary/80 hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full font-semibold" disabled={loading}
>Login</button class="m-4 p-2 bg-immich-primary dark:bg-immich-dark-primary dark:text-immich-dark-gray dark:hover:bg-immich-dark-primary/80 hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full font-semibold"
> >Login</button
</div> >
</form> </div>
{#if oauthConfig.enabled}
<div class="flex flex-col gap-4 px-4">
<hr />
{#if oauthError}
<p class="text-red-400">{oauthError}</p>
{/if}
<a href={oauthConfig.url} class="flex w-full">
<button
type="button"
disabled={loading}
class="bg-immich-primary dark:bg-immich-dark-primary dark:text-immich-dark-gray dark:hover:bg-immich-dark-primary/80 hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full font-semibold"
>{oauthConfig.buttonText || 'Login with OAuth'}</button
>
</a>
</div>
{/if}
</form>
{/if}
</div> </div>

View File

@ -38,8 +38,11 @@
}; };
const logOut = async () => { const logOut = async () => {
const { data } = await api.authenticationApi.logout();
await fetch('auth/logout', { method: 'POST' }); await fetch('auth/logout', { method: 'POST' });
goto('/auth/login');
goto(data.redirectUri || '/auth/login');
}; };
</script> </script>

View File

@ -10,7 +10,7 @@ export const POST: RequestHandler = async () => {
headers.append( headers.append(
'set-cookie', 'set-cookie',
'immich_is_authenticated=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT;' 'immich_auth_type=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT;'
); );
headers.append( headers.append(
'set-cookie', 'set-cookie',