feat: manage link token via cookie instead

This commit is contained in:
bo0tzz
2026-04-18 12:27:02 +02:00
parent b8c373f0f1
commit d50ea005a1
22 changed files with 117 additions and 253 deletions
+7 -5
View File
@@ -363,11 +363,13 @@ describe(`/oauth`, () => {
password: 'password', password: 'password',
}); });
const callbackParams = await loginWithOAuth('oauth-user3'); const callbackParams = await loginWithOAuth('oauth-user3');
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams); const response = await request(app).post('/oauth/callback').send(callbackParams);
expect(status).toBe(403); expect(response.status).toBe(403);
expect(body.message).toBe('oauth_account_link_required'); expect(response.body.message).toBe('oauth_account_link_required');
expect(body.userEmail).toBe('oauth-user3@immich.app'); expect(response.body.userEmail).toBe('oauth-user3@immich.app');
expect(body.oauthLinkToken).toBeDefined(); expect(response.body.oauthLinkToken).toBeUndefined();
const setCookie = response.headers['set-cookie'] as unknown as string[];
expect(setCookie.some((cookie) => cookie.startsWith('immich_oauth_link_token='))).toBe(true);
}); });
}); });
}); });
-1
View File
@@ -122,7 +122,6 @@ Class | Method | HTTP request | Description
*AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code | Change pin code *AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code | Change pin code
*AuthenticationApi* | [**finishOAuth**](doc//AuthenticationApi.md#finishoauth) | **POST** /oauth/callback | Finish OAuth *AuthenticationApi* | [**finishOAuth**](doc//AuthenticationApi.md#finishoauth) | **POST** /oauth/callback | Finish OAuth
*AuthenticationApi* | [**getAuthStatus**](doc//AuthenticationApi.md#getauthstatus) | **GET** /auth/status | Retrieve auth status *AuthenticationApi* | [**getAuthStatus**](doc//AuthenticationApi.md#getauthstatus) | **GET** /auth/status | Retrieve auth status
*AuthenticationApi* | [**linkOAuthAccount**](doc//AuthenticationApi.md#linkoauthaccount) | **POST** /oauth/link | Link OAuth account
*AuthenticationApi* | [**lockAuthSession**](doc//AuthenticationApi.md#lockauthsession) | **POST** /auth/session/lock | Lock auth session *AuthenticationApi* | [**lockAuthSession**](doc//AuthenticationApi.md#lockauthsession) | **POST** /auth/session/lock | Lock auth session
*AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login | Login *AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login | Login
*AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | Logout *AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | Logout
-56
View File
@@ -224,62 +224,6 @@ class AuthenticationApi {
return null; return null;
} }
/// Link OAuth account
///
/// Link an OAuth account to the authenticated user.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [OAuthCallbackDto] oAuthCallbackDto (required):
Future<Response> linkOAuthAccountWithHttpInfo(OAuthCallbackDto oAuthCallbackDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/oauth/link';
// ignore: prefer_final_locals
Object? postBody = oAuthCallbackDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Link OAuth account
///
/// Link an OAuth account to the authenticated user.
///
/// Parameters:
///
/// * [OAuthCallbackDto] oAuthCallbackDto (required):
Future<UserAdminResponseDto?> linkOAuthAccount(OAuthCallbackDto oAuthCallbackDto,) async {
final response = await linkOAuthAccountWithHttpInfo(oAuthCallbackDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserAdminResponseDto',) as UserAdminResponseDto;
}
return null;
}
/// Lock auth session /// Lock auth session
/// ///
/// Remove elevated access to locked assets from the current session. /// Remove elevated access to locked assets from the current session.
+1 -19
View File
@@ -14,49 +14,32 @@ class LoginCredentialDto {
/// Returns a new [LoginCredentialDto] instance. /// Returns a new [LoginCredentialDto] instance.
LoginCredentialDto({ LoginCredentialDto({
required this.email, required this.email,
this.oauthLinkToken,
required this.password, required this.password,
}); });
/// User email /// User email
String email; String email;
/// OAuth link token to consume on successful login
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? oauthLinkToken;
/// User password /// User password
String password; String password;
@override @override
bool operator ==(Object other) => identical(this, other) || other is LoginCredentialDto && bool operator ==(Object other) => identical(this, other) || other is LoginCredentialDto &&
other.email == email && other.email == email &&
other.oauthLinkToken == oauthLinkToken &&
other.password == password; other.password == password;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(email.hashCode) + (email.hashCode) +
(oauthLinkToken == null ? 0 : oauthLinkToken!.hashCode) +
(password.hashCode); (password.hashCode);
@override @override
String toString() => 'LoginCredentialDto[email=$email, oauthLinkToken=$oauthLinkToken, password=$password]'; String toString() => 'LoginCredentialDto[email=$email, password=$password]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'email'] = this.email; json[r'email'] = this.email;
if (this.oauthLinkToken != null) {
json[r'oauthLinkToken'] = this.oauthLinkToken;
} else {
// json[r'oauthLinkToken'] = null;
}
json[r'password'] = this.password; json[r'password'] = this.password;
return json; return json;
} }
@@ -71,7 +54,6 @@ class LoginCredentialDto {
return LoginCredentialDto( return LoginCredentialDto(
email: mapValueOfType<String>(json, r'email')!, email: mapValueOfType<String>(json, r'email')!,
oauthLinkToken: mapValueOfType<String>(json, r'oauthLinkToken'),
password: mapValueOfType<String>(json, r'password')!, password: mapValueOfType<String>(json, r'password')!,
); );
} }
+1 -19
View File
@@ -15,7 +15,6 @@ class SignUpDto {
SignUpDto({ SignUpDto({
required this.email, required this.email,
required this.name, required this.name,
this.oauthLinkToken,
required this.password, required this.password,
}); });
@@ -25,15 +24,6 @@ class SignUpDto {
/// User name /// User name
String name; String name;
/// OAuth link token to consume on successful login
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? oauthLinkToken;
/// User password /// User password
String password; String password;
@@ -41,7 +31,6 @@ class SignUpDto {
bool operator ==(Object other) => identical(this, other) || other is SignUpDto && bool operator ==(Object other) => identical(this, other) || other is SignUpDto &&
other.email == email && other.email == email &&
other.name == name && other.name == name &&
other.oauthLinkToken == oauthLinkToken &&
other.password == password; other.password == password;
@override @override
@@ -49,21 +38,15 @@ class SignUpDto {
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(email.hashCode) + (email.hashCode) +
(name.hashCode) + (name.hashCode) +
(oauthLinkToken == null ? 0 : oauthLinkToken!.hashCode) +
(password.hashCode); (password.hashCode);
@override @override
String toString() => 'SignUpDto[email=$email, name=$name, oauthLinkToken=$oauthLinkToken, password=$password]'; String toString() => 'SignUpDto[email=$email, name=$name, password=$password]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'email'] = this.email; json[r'email'] = this.email;
json[r'name'] = this.name; json[r'name'] = this.name;
if (this.oauthLinkToken != null) {
json[r'oauthLinkToken'] = this.oauthLinkToken;
} else {
// json[r'oauthLinkToken'] = null;
}
json[r'password'] = this.password; json[r'password'] = this.password;
return json; return json;
} }
@@ -79,7 +62,6 @@ class SignUpDto {
return SignUpDto( return SignUpDto(
email: mapValueOfType<String>(json, r'email')!, email: mapValueOfType<String>(json, r'email')!,
name: mapValueOfType<String>(json, r'name')!, name: mapValueOfType<String>(json, r'name')!,
oauthLinkToken: mapValueOfType<String>(json, r'oauthLinkToken'),
password: mapValueOfType<String>(json, r'password')!, password: mapValueOfType<String>(json, r'password')!,
); );
} }
-8
View File
@@ -18013,10 +18013,6 @@
"pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$",
"type": "string" "type": "string"
}, },
"oauthLinkToken": {
"description": "OAuth link token to consume on successful login",
"type": "string"
},
"password": { "password": {
"description": "User password", "description": "User password",
"example": "password", "example": "password",
@@ -21804,10 +21800,6 @@
"example": "Admin", "example": "Admin",
"type": "string" "type": "string"
}, },
"oauthLinkToken": {
"description": "OAuth link token to consume on successful login",
"type": "string"
},
"password": { "password": {
"description": "User password", "description": "User password",
"example": "password", "example": "password",
@@ -1010,8 +1010,6 @@ export type SignUpDto = {
email: string; email: string;
/** User name */ /** User name */
name: string; name: string;
/** OAuth link token to consume on successful login */
oauthLinkToken?: string;
/** User password */ /** User password */
password: string; password: string;
}; };
@@ -1026,8 +1024,6 @@ export type ChangePasswordDto = {
export type LoginCredentialDto = { export type LoginCredentialDto = {
/** User email */ /** User email */
email: string; email: string;
/** OAuth link token to consume on successful login */
oauthLinkToken?: string;
/** User password */ /** User password */
password: string; password: string;
}; };
@@ -118,6 +118,7 @@ describe(AuthController.name, () => {
expect(service.login).toHaveBeenCalledWith( expect(service.login).toHaveBeenCalledWith(
expect.objectContaining({ email: 'admin@immich.app' }), expect.objectContaining({ email: 'admin@immich.app' }),
expect.anything(), expect.anything(),
expect.anything(),
); );
}); });
@@ -129,7 +130,11 @@ describe(AuthController.name, () => {
.send({ name: 'admin', email: 'admin@local', password: 'password' }); .send({ name: 'admin', email: 'admin@local', password: 'password' });
expect(status).toEqual(201); expect(status).toEqual(201);
expect(service.login).toHaveBeenCalledWith(expect.objectContaining({ email: 'admin@local' }), expect.anything()); expect(service.login).toHaveBeenCalledWith(
expect.objectContaining({ email: 'admin@local' }),
expect.anything(),
expect.anything(),
);
}); });
it('should auth cookies on a secure connection', async () => { it('should auth cookies on a secure connection', async () => {
+5 -1
View File
@@ -34,11 +34,15 @@ export class AuthController {
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
}) })
async login( async login(
@Req() request: Request,
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
@Body() loginCredential: LoginCredentialDto, @Body() loginCredential: LoginCredentialDto,
@GetLoginDetails() loginDetails: LoginDetails, @GetLoginDetails() loginDetails: LoginDetails,
): Promise<LoginResponseDto> { ): Promise<LoginResponseDto> {
const body = await this.service.login(loginCredential, loginDetails); const body = await this.service.login(loginCredential, loginDetails, request.headers);
if (request.cookies?.[ImmichCookie.OAuthLinkToken]) {
res.clearCookie(ImmichCookie.OAuthLinkToken);
}
return respondWithCookie(res, body, { return respondWithCookie(res, body, {
isSecure: loginDetails.isSecure, isSecure: loginDetails.isSecure,
values: [ values: [
+24 -12
View File
@@ -13,7 +13,7 @@ import {
import { UserAdminResponseDto } from 'src/dtos/user.dto'; import { UserAdminResponseDto } from 'src/dtos/user.dto';
import { ApiTag, AuthType, ImmichCookie } from 'src/enum'; import { ApiTag, AuthType, ImmichCookie } from 'src/enum';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { AuthService, LoginDetails } from 'src/services/auth.service'; import { AuthService, LoginDetails, OAuthLinkRequiredException } from 'src/services/auth.service';
import { respondWithCookie } from 'src/utils/response'; import { respondWithCookie } from 'src/utils/response';
@ApiTags(ApiTag.Authentication) @ApiTags(ApiTag.Authentication)
@@ -73,17 +73,29 @@ export class OAuthController {
@Body() dto: OAuthCallbackDto, @Body() dto: OAuthCallbackDto,
@GetLoginDetails() loginDetails: LoginDetails, @GetLoginDetails() loginDetails: LoginDetails,
): Promise<LoginResponseDto> { ): Promise<LoginResponseDto> {
const body = await this.service.callback(dto, request.headers, loginDetails); try {
res.clearCookie(ImmichCookie.OAuthState); const body = await this.service.callback(dto, request.headers, loginDetails);
res.clearCookie(ImmichCookie.OAuthCodeVerifier); res.clearCookie(ImmichCookie.OAuthState);
return respondWithCookie(res, body, { res.clearCookie(ImmichCookie.OAuthCodeVerifier);
isSecure: loginDetails.isSecure, return respondWithCookie(res, body, {
values: [ isSecure: loginDetails.isSecure,
{ key: ImmichCookie.AccessToken, value: body.accessToken }, values: [
{ key: ImmichCookie.AuthType, value: AuthType.OAuth }, { key: ImmichCookie.AccessToken, value: body.accessToken },
{ key: ImmichCookie.IsAuthenticated, value: 'true' }, { key: ImmichCookie.AuthType, value: AuthType.OAuth },
], { key: ImmichCookie.IsAuthenticated, value: 'true' },
}); ],
});
} catch (error) {
if (error instanceof OAuthLinkRequiredException) {
res.clearCookie(ImmichCookie.OAuthState);
res.clearCookie(ImmichCookie.OAuthCodeVerifier);
respondWithCookie(res, null, {
isSecure: loginDetails.isSecure,
values: [{ key: ImmichCookie.OAuthLinkToken, value: error.oauthLinkToken }],
});
}
throw error;
}
} }
@Post('unlink') @Post('unlink')
-1
View File
@@ -23,7 +23,6 @@ const LoginCredentialSchema = z
.object({ .object({
email: toEmail.describe('User email').meta({ example: 'testuser@email.com' }), email: toEmail.describe('User email').meta({ example: 'testuser@email.com' }),
password: z.string().describe('User password').meta({ example: 'password' }), password: z.string().describe('User password').meta({ example: 'password' }),
oauthLinkToken: z.string().optional().describe('OAuth link token to consume on successful login'),
}) })
.meta({ id: 'LoginCredentialDto' }); .meta({ id: 'LoginCredentialDto' });
+1
View File
@@ -15,6 +15,7 @@ export enum ImmichCookie {
SharedLinkToken = 'immich_shared_link_token', SharedLinkToken = 'immich_shared_link_token',
OAuthState = 'immich_oauth_state', OAuthState = 'immich_oauth_state',
OAuthCodeVerifier = 'immich_oauth_code_verifier', OAuthCodeVerifier = 'immich_oauth_code_verifier',
OAuthLinkToken = 'immich_oauth_link_token',
} }
export const ImmichCookieSchema = z.enum(ImmichCookie).describe('Immich cookie').meta({ id: 'ImmichCookie' }); export const ImmichCookieSchema = z.enum(ImmichCookie).describe('Immich cookie').meta({ id: 'ImmichCookie' });
@@ -6,6 +6,7 @@ export async function up(db: Kysely<any>): Promise<void> {
"id" uuid NOT NULL DEFAULT uuid_generate_v4(), "id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"token" bytea NOT NULL, "token" bytea NOT NULL,
"oauthSub" varchar NOT NULL, "oauthSub" varchar NOT NULL,
"oauthSid" varchar,
"userEmail" varchar NOT NULL, "userEmail" varchar NOT NULL,
"expiresAt" timestamp with time zone NOT NULL, "expiresAt" timestamp with time zone NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now() "createdAt" timestamp with time zone NOT NULL DEFAULT now()
@@ -11,6 +11,9 @@ export class OAuthLinkTokenTable {
@Column() @Column()
oauthSub!: string; oauthSub!: string;
@Column({ nullable: true })
oauthSid!: string | null;
@Column() @Column()
userEmail!: string; userEmail!: string;
+38 -73
View File
@@ -4,7 +4,7 @@ import { SALT_ROUNDS } from 'src/constants';
import { UserAdmin } from 'src/database'; import { UserAdmin } from 'src/database';
import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
import { AuthType, Permission } from 'src/enum'; import { AuthType, Permission } from 'src/enum';
import { AuthService } from 'src/services/auth.service'; import { AuthService, OAuthLinkRequiredException } from 'src/services/auth.service';
import { UserMetadataItem } from 'src/types'; import { UserMetadataItem } from 'src/types';
import { ApiKeyFactory } from 'test/factories/api-key.factory'; import { ApiKeyFactory } from 'test/factories/api-key.factory';
import { AuthFactory } from 'test/factories/auth.factory'; import { AuthFactory } from 'test/factories/auth.factory';
@@ -50,13 +50,13 @@ describe(AuthService.name, () => {
it('should throw an error if password login is disabled', async () => { it('should throw an error if password login is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled);
await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); await expect(sut.login(dto, loginDetails, {})).rejects.toBeInstanceOf(UnauthorizedException);
}); });
it('should check the user exists', async () => { it('should check the user exists', async () => {
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); await expect(sut.login(dto, loginDetails, {})).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
}); });
@@ -64,7 +64,7 @@ describe(AuthService.name, () => {
it('should check the user has a password', async () => { it('should check the user has a password', async () => {
mocks.user.getByEmail.mockResolvedValue({} as UserAdmin); mocks.user.getByEmail.mockResolvedValue({} as UserAdmin);
await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); await expect(sut.login(dto, loginDetails, {})).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
}); });
@@ -75,7 +75,7 @@ describe(AuthService.name, () => {
mocks.user.getByEmail.mockResolvedValue(user); mocks.user.getByEmail.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(session); mocks.session.create.mockResolvedValue(session);
await expect(sut.login(dto, loginDetails)).resolves.toEqual({ await expect(sut.login(dto, loginDetails, {})).resolves.toEqual({
accessToken: 'cmFuZG9tLWJ5dGVz', accessToken: 'cmFuZG9tLWJ5dGVz',
userId: user.id, userId: user.id,
userEmail: user.email, userEmail: user.email,
@@ -89,7 +89,7 @@ describe(AuthService.name, () => {
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
}); });
it('should link an OAuth account when oauthLinkToken is provided', async () => { it('should link an OAuth account when link token cookie is present', async () => {
const user = UserFactory.create({ password: 'immich_password' }); const user = UserFactory.create({ password: 'immich_password' });
const session = SessionFactory.create(); const session = SessionFactory.create();
mocks.user.getByEmail.mockResolvedValue(user); mocks.user.getByEmail.mockResolvedValue(user);
@@ -97,6 +97,7 @@ describe(AuthService.name, () => {
mocks.oauthLinkToken.consumeToken.mockResolvedValue({ mocks.oauthLinkToken.consumeToken.mockResolvedValue({
id: 'token-id', id: 'token-id',
oauthSub: 'oauth-sub-123', oauthSub: 'oauth-sub-123',
oauthSid: null,
userEmail: user.email, userEmail: user.email,
token: Buffer.from('hashed'), token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 600_000), expiresAt: new Date(Date.now() + 600_000),
@@ -104,20 +105,41 @@ describe(AuthService.name, () => {
}); });
mocks.user.update.mockResolvedValue(user); mocks.user.update.mockResolvedValue(user);
await sut.login({ email, password: 'password', oauthLinkToken: 'plain-token' }, loginDetails); await sut.login(dto, loginDetails, { cookie: 'immich_oauth_link_token=plain-token' });
expect(mocks.oauthLinkToken.consumeToken).toHaveBeenCalledTimes(1); expect(mocks.oauthLinkToken.consumeToken).toHaveBeenCalledTimes(1);
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: 'oauth-sub-123' }); expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: 'oauth-sub-123' });
}); });
it('should reject login with invalid oauthLinkToken', async () => { it('should propagate oauthSid from link token to the session', async () => {
const user = UserFactory.create({ password: 'immich_password' });
const session = SessionFactory.create();
mocks.user.getByEmail.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(session);
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
id: 'token-id',
oauthSub: 'oauth-sub-123',
oauthSid: 'idp-sid-456',
userEmail: user.email,
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 600_000),
createdAt: new Date(),
});
mocks.user.update.mockResolvedValue(user);
await sut.login(dto, loginDetails, { cookie: 'immich_oauth_link_token=plain-token' });
expect(mocks.session.create).toHaveBeenCalledWith(expect.objectContaining({ oauthSid: 'idp-sid-456' }));
});
it('should reject login with invalid link token cookie', async () => {
const user = UserFactory.create({ password: 'immich_password' }); const user = UserFactory.create({ password: 'immich_password' });
mocks.user.getByEmail.mockResolvedValue(user); mocks.user.getByEmail.mockResolvedValue(user);
mocks.oauthLinkToken.consumeToken.mockResolvedValue(null as any); mocks.oauthLinkToken.consumeToken.mockResolvedValue(null as any);
await expect( await expect(sut.login(dto, loginDetails, { cookie: 'immich_oauth_link_token=bad-token' })).rejects.toThrow(
sut.login({ email, password: 'password', oauthLinkToken: 'bad-token' }, loginDetails), 'Invalid or expired link token',
).rejects.toThrow('Invalid or expired link token'); );
}); });
}); });
@@ -738,7 +760,7 @@ describe(AuthService.name, () => {
const profile = OAuthProfileFactory.create(); const profile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile }); mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile, sid: 'idp-sid-789' });
mocks.user.getByEmail.mockResolvedValue(user); mocks.user.getByEmail.mockResolvedValue(user);
mocks.oauthLinkToken.deleteByEmail.mockResolvedValue(); mocks.oauthLinkToken.deleteByEmail.mockResolvedValue();
mocks.oauthLinkToken.create.mockResolvedValue({} as any); mocks.oauthLinkToken.create.mockResolvedValue({} as any);
@@ -749,12 +771,14 @@ describe(AuthService.name, () => {
{}, {},
loginDetails, loginDetails,
), ),
).rejects.toThrow(ForbiddenException); ).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
expect(mocks.user.update).not.toHaveBeenCalled(); expect(mocks.user.update).not.toHaveBeenCalled();
expect(mocks.oauthLinkToken.deleteByEmail).toHaveBeenCalledTimes(1); expect(mocks.oauthLinkToken.deleteByEmail).toHaveBeenCalledTimes(1);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledTimes(1); expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({ oauthSub: profile.sub, oauthSid: 'idp-sid-789' }),
);
}); });
it('should normalize the email from the OAuth profile before looking up user', async () => { it('should normalize the email from the OAuth profile before looking up user', async () => {
@@ -1136,65 +1160,6 @@ describe(AuthService.name, () => {
}); });
}); });
describe('link', () => {
it('should link an account', async () => {
const user = UserFactory.create();
const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build();
const profile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.user.update.mockResolvedValue(user);
await sut.link(
auth,
{ url: 'http://immich/user-settings?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
);
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: profile.sub });
});
it('should link an account and update the session with the oauthSid', async () => {
const user = UserFactory.create();
const session = SessionFactory.create();
const auth = AuthFactory.from(user).session(session).build();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: { sub: 'sub' },
sid: session.oauthSid ?? undefined,
});
mocks.user.update.mockResolvedValue(user);
mocks.session.update.mockResolvedValue(session);
await sut.link(
auth,
{ url: 'http://immich/user-settings?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
);
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: session.oauthSid });
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: 'sub' });
});
it('should not link an already linked oauth.sub', async () => {
const authUser = UserFactory.create();
const authApiKey = ApiKeyFactory.create({ permissions: [] });
const auth = { user: authUser, apiKey: authApiKey };
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin);
await expect(
sut.link(auth, { url: 'http://immich/user-settings?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, {}),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.user.update).not.toHaveBeenCalled();
});
});
describe('unlink', () => { describe('unlink', () => {
it('should unlink an account', async () => { it('should unlink an account', async () => {
const user = UserFactory.create(); const user = UserFactory.create();
+23 -39
View File
@@ -42,6 +42,15 @@ interface ClaimOptions<T> {
isValid: (value: unknown) => boolean; isValid: (value: unknown) => boolean;
} }
export class OAuthLinkRequiredException extends ForbiddenException {
constructor(
public readonly userEmail: string,
public readonly oauthLinkToken: string,
) {
super({ message: 'oauth_account_link_required', userEmail });
}
}
export type ValidateRequest = { export type ValidateRequest = {
headers: IncomingHttpHeaders; headers: IncomingHttpHeaders;
queryParams: Record<string, string>; queryParams: Record<string, string>;
@@ -56,7 +65,7 @@ export type ValidateRequest = {
@Injectable() @Injectable()
export class AuthService extends BaseService { export class AuthService extends BaseService {
async login(dto: LoginCredentialDto, details: LoginDetails) { async login(dto: LoginCredentialDto, details: LoginDetails, headers: IncomingHttpHeaders) {
const config = await this.getConfig({ withCache: false }); const config = await this.getConfig({ withCache: false });
if (!config.passwordLogin.enabled) { if (!config.passwordLogin.enabled) {
throw new UnauthorizedException('Password login has been disabled'); throw new UnauthorizedException('Password login has been disabled');
@@ -75,8 +84,10 @@ export class AuthService extends BaseService {
throw new UnauthorizedException('Incorrect email or password'); throw new UnauthorizedException('Incorrect email or password');
} }
if (dto.oauthLinkToken) { let linkedOAuthSid: string | undefined;
const hashedToken = this.cryptoRepository.hashSha256(dto.oauthLinkToken); const linkTokenCookie = this.getCookieOAuthLinkToken(headers);
if (linkTokenCookie) {
const hashedToken = this.cryptoRepository.hashSha256(linkTokenCookie);
const record = await this.oauthLinkTokenRepository.consumeToken(hashedToken); const record = await this.oauthLinkTokenRepository.consumeToken(hashedToken);
if (!record) { if (!record) {
throw new BadRequestException('Invalid or expired link token'); throw new BadRequestException('Invalid or expired link token');
@@ -88,9 +99,10 @@ export class AuthService extends BaseService {
} }
await this.userRepository.update(user.id, { oauthId: record.oauthSub }); await this.userRepository.update(user.id, { oauthId: record.oauthSub });
linkedOAuthSid = record.oauthSid ?? undefined;
} }
return this.createLoginResponse(user, details); return this.createLoginResponse(user, details, linkedOAuthSid);
} }
async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> { async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> {
@@ -344,14 +356,11 @@ export class AuthService extends BaseService {
await this.oauthLinkTokenRepository.create({ await this.oauthLinkTokenRepository.create({
token: hashedToken, token: hashedToken,
oauthSub: profile.sub, oauthSub: profile.sub,
oauthSid: oauthSid ?? null,
userEmail: emailUser.email, userEmail: emailUser.email,
expiresAt: DateTime.now().plus({ minutes: 10 }).toJSDate(), expiresAt: DateTime.now().plus({ minutes: 10 }).toJSDate(),
}); });
throw new ForbiddenException({ throw new OAuthLinkRequiredException(emailUser.email, plainToken);
message: 'oauth_account_link_required',
userEmail: emailUser.email,
oauthLinkToken: plainToken,
});
} }
} }
@@ -430,36 +439,6 @@ export class AuthService extends BaseService {
} }
} }
async link(auth: AuthDto, dto: OAuthCallbackDto, headers: IncomingHttpHeaders): Promise<UserAdminResponseDto> {
const expectedState = dto.state ?? this.getCookieOauthState(headers);
if (!expectedState?.length) {
throw new BadRequestException('OAuth state is missing');
}
const codeVerifier = dto.codeVerifier ?? this.getCookieCodeVerifier(headers);
if (!codeVerifier?.length) {
throw new BadRequestException('OAuth code verifier is missing');
}
const { oauth } = await this.getConfig({ withCache: false });
const {
profile: { sub: oauthId },
sid,
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, dto.url, expectedState, codeVerifier);
const duplicate = await this.userRepository.getByOAuthId(oauthId);
if (duplicate && duplicate.id !== auth.user.id) {
this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
throw new BadRequestException('This OAuth account has already been linked to another user.');
}
if (auth.session) {
await this.sessionRepository.update(auth.session.id, { oauthSid: sid });
}
const user = await this.userRepository.update(auth.user.id, { oauthId });
return mapUserAdmin(user);
}
async unlink(auth: AuthDto): Promise<UserAdminResponseDto> { async unlink(auth: AuthDto): Promise<UserAdminResponseDto> {
if (auth.session) { if (auth.session) {
await this.sessionRepository.update(auth.session.id, { oauthSid: null }); await this.sessionRepository.update(auth.session.id, { oauthSid: null });
@@ -510,6 +489,11 @@ export class AuthService extends BaseService {
return cookies[ImmichCookie.OAuthCodeVerifier] || null; return cookies[ImmichCookie.OAuthCodeVerifier] || null;
} }
private getCookieOAuthLinkToken(headers: IncomingHttpHeaders): string | null {
const cookies = parse(headers.cookie || '');
return cookies[ImmichCookie.OAuthLinkToken] || null;
}
async validateSharedLinkKey(key: string | string[]): Promise<AuthDto> { async validateSharedLinkKey(key: string | string[]): Promise<AuthDto> {
key = Array.isArray(key) ? key[0] : key; key = Array.isArray(key) ? key[0] : key;
+1
View File
@@ -18,6 +18,7 @@ export const respondWithCookie = <T>(res: Response, body: T, { isSecure, values
[ImmichCookie.MaintenanceToken]: { ...defaults, maxAge: Duration.fromObject({ days: 1 }).toMillis() }, [ImmichCookie.MaintenanceToken]: { ...defaults, maxAge: Duration.fromObject({ days: 1 }).toMillis() },
[ImmichCookie.OAuthState]: defaults, [ImmichCookie.OAuthState]: defaults,
[ImmichCookie.OAuthCodeVerifier]: defaults, [ImmichCookie.OAuthCodeVerifier]: defaults,
[ImmichCookie.OAuthLinkToken]: { ...defaults, maxAge: Duration.fromObject({ minutes: 10 }).toMillis() },
// no httpOnly so that the client can know the auth state // no httpOnly so that the client can know the auth state
[ImmichCookie.IsAuthenticated]: { ...defaults, httpOnly: false }, [ImmichCookie.IsAuthenticated]: { ...defaults, httpOnly: false },
[ImmichCookie.SharedLinkToken]: { ...defaults, maxAge: Duration.fromObject({ days: 1 }).toMillis() }, [ImmichCookie.SharedLinkToken]: { ...defaults, maxAge: Duration.fromObject({ days: 1 }).toMillis() },
@@ -77,7 +77,7 @@ describe(AuthService.name, () => {
const { user } = await ctx.newUser({ password: passwordHashed }); const { user } = await ctx.newUser({ password: passwordHashed });
const dto = { email: user.email, password: 'wrong-password' }; const dto = { email: user.email, password: 'wrong-password' };
await expect(sut.login(dto, mediumFactory.loginDetails())).rejects.toThrow('Incorrect email or password'); await expect(sut.login(dto, mediumFactory.loginDetails(), {})).rejects.toThrow('Incorrect email or password');
}); });
it('should accept a correct password and return a login response', async () => { it('should accept a correct password and return a login response', async () => {
@@ -87,7 +87,7 @@ describe(AuthService.name, () => {
const { user } = await ctx.newUser({ password: passwordHashed }); const { user } = await ctx.newUser({ password: passwordHashed });
const dto = { email: user.email, password }; const dto = { email: user.email, password };
await expect(sut.login(dto, mediumFactory.loginDetails())).resolves.toEqual({ await expect(sut.login(dto, mediumFactory.loginDetails(), {})).resolves.toEqual({
accessToken: expect.any(String), accessToken: expect.any(String),
isAdmin: user.isAdmin, isAdmin: user.isAdmin,
isOnboarded: false, isOnboarded: false,
@@ -147,7 +147,7 @@ describe(AuthService.name, () => {
expect((response as any).password).not.toBeDefined(); expect((response as any).password).not.toBeDefined();
await expect( await expect(
sut.login({ email: user.email, password: dto.newPassword }, mediumFactory.loginDetails()), sut.login({ email: user.email, password: dto.newPassword }, mediumFactory.loginDetails(), {}),
).resolves.toBeDefined(); ).resolves.toBeDefined();
}); });
+1 -1
View File
@@ -51,7 +51,7 @@ export const Docs = {
export const Route = { export const Route = {
// auth // auth
login: (params?: { continue?: string; autoLaunch?: 0 | 1 }) => '/auth/login' + asQueryString(params), login: (params?: { continue?: string; autoLaunch?: 0 | 1 }) => '/auth/login' + asQueryString(params),
authLink: (params?: { oauthLinkToken?: string; email?: string }) => '/auth/link' + asQueryString(params), authLink: (params?: { email?: string }) => '/auth/link' + asQueryString(params),
logout: (params?: { continue?: string }) => '/auth/logout' + asQueryString(params), logout: (params?: { continue?: string }) => '/auth/logout' + asQueryString(params),
register: () => '/auth/register', register: () => '/auth/register',
changePassword: () => '/auth/change-password', changePassword: () => '/auth/change-password',
+1 -7
View File
@@ -8,7 +8,6 @@
import { getServerErrorMessage } from '$lib/utils/handle-error'; import { getServerErrorMessage } from '$lib/utils/handle-error';
import { login } from '@immich/sdk'; import { login } from '@immich/sdk';
import { Alert, Button, Field, Input, PasswordInput, Stack, toastManager } from '@immich/ui'; import { Alert, Button, Field, Input, PasswordInput, Stack, toastManager } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
@@ -18,22 +17,17 @@
let { data }: Props = $props(); let { data }: Props = $props();
let oauthLinkToken = $state(data.oauthLinkToken);
let email = $state(data.email || authManager.user?.email || ''); let email = $state(data.email || authManager.user?.email || '');
let password = $state(''); let password = $state('');
let errorMessage = $state(''); let errorMessage = $state('');
let loading = $state(false); let loading = $state(false);
onMount(async () => {
await goto(Route.authLink(), { replaceState: true });
});
const handleSubmit = async (event: Event) => { const handleSubmit = async (event: Event) => {
event.preventDefault(); event.preventDefault();
try { try {
errorMessage = ''; errorMessage = '';
loading = true; loading = true;
const user = await login({ loginCredentialDto: { email, password, oauthLinkToken } }); const user = await login({ loginCredentialDto: { email, password } });
eventManager.emit('AuthLogin', user); eventManager.emit('AuthLogin', user);
await authManager.refresh(); await authManager.refresh();
toastManager.primary($t('linked_oauth_account')); toastManager.primary($t('linked_oauth_account'));
-2
View File
@@ -2,7 +2,6 @@ import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load = (async ({ url }) => { export const load = (async ({ url }) => {
const oauthLinkToken = url.searchParams.get('oauthLinkToken') || '';
const email = url.searchParams.get('email') || ''; const email = url.searchParams.get('email') || '';
const $t = await getFormatter(); const $t = await getFormatter();
@@ -10,7 +9,6 @@ export const load = (async ({ url }) => {
meta: { meta: {
title: $t('link_to_oauth'), title: $t('link_to_oauth'),
}, },
oauthLinkToken,
email, email,
}; };
}) satisfies PageLoad; }) satisfies PageLoad;
+1 -1
View File
@@ -72,7 +72,7 @@
} catch (error) { } catch (error) {
if (isHttpError(error) && error.data?.message === 'oauth_account_link_required') { if (isHttpError(error) && error.data?.message === 'oauth_account_link_required') {
const errorData = error.data as unknown as Record<string, string>; const errorData = error.data as unknown as Record<string, string>;
await goto(Route.authLink({ oauthLinkToken: errorData.oauthLinkToken, email: errorData.userEmail })); await goto(Route.authLink({ email: errorData.userEmail }));
return; return;
} }
console.error('Error [login-form] [oauth.callback]', error); console.error('Error [login-form] [oauth.callback]', error);