chore: rename linkToken to oauthLinkToken

This commit is contained in:
bo0tzz
2026-04-17 02:22:15 +02:00
parent b3e5ec48e6
commit b8c373f0f1
12 changed files with 63 additions and 27 deletions
+1 -1
View File
@@ -367,7 +367,7 @@ describe(`/oauth`, () => {
expect(status).toBe(403); expect(status).toBe(403);
expect(body.message).toBe('oauth_account_link_required'); expect(body.message).toBe('oauth_account_link_required');
expect(body.userEmail).toBe('oauth-user3@immich.app'); expect(body.userEmail).toBe('oauth-user3@immich.app');
expect(body.linkToken).toBeDefined(); expect(body.oauthLinkToken).toBeDefined();
}); });
}); });
}); });
+19 -1
View File
@@ -14,32 +14,49 @@ 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, password=$password]'; String toString() => 'LoginCredentialDto[email=$email, oauthLinkToken=$oauthLinkToken, 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;
} }
@@ -54,6 +71,7 @@ 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')!,
); );
} }
+19 -1
View File
@@ -15,6 +15,7 @@ class SignUpDto {
SignUpDto({ SignUpDto({
required this.email, required this.email,
required this.name, required this.name,
this.oauthLinkToken,
required this.password, required this.password,
}); });
@@ -24,6 +25,15 @@ 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;
@@ -31,6 +41,7 @@ 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
@@ -38,15 +49,21 @@ 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, password=$password]'; String toString() => 'SignUpDto[email=$email, name=$name, oauthLinkToken=$oauthLinkToken, 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;
} }
@@ -62,6 +79,7 @@ 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')!,
); );
} }
+5 -5
View File
@@ -18013,7 +18013,7 @@
"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"
}, },
"linkToken": { "oauthLinkToken": {
"description": "OAuth link token to consume on successful login", "description": "OAuth link token to consume on successful login",
"type": "string" "type": "string"
}, },
@@ -21799,15 +21799,15 @@
"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"
}, },
"linkToken": {
"description": "OAuth link token to consume on successful login",
"type": "string"
},
"name": { "name": {
"description": "User name", "description": "User name",
"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",
+3 -3
View File
@@ -1008,10 +1008,10 @@ export type AssetOcrResponseDto = {
export type SignUpDto = { export type SignUpDto = {
/** User email */ /** User email */
email: string; email: string;
/** OAuth link token to consume on successful login */
linkToken?: 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;
}; };
@@ -1027,7 +1027,7 @@ export type LoginCredentialDto = {
/** User email */ /** User email */
email: string; email: string;
/** OAuth link token to consume on successful login */ /** OAuth link token to consume on successful login */
linkToken?: string; oauthLinkToken?: string;
/** User password */ /** User password */
password: string; password: string;
}; };
+1 -1
View File
@@ -23,7 +23,7 @@ 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' }),
linkToken: z.string().optional().describe('OAuth link token to consume on successful login'), oauthLinkToken: z.string().optional().describe('OAuth link token to consume on successful login'),
}) })
.meta({ id: 'LoginCredentialDto' }); .meta({ id: 'LoginCredentialDto' });
+6 -6
View File
@@ -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 linkToken is provided', async () => { it('should link an OAuth account when oauthLinkToken is provided', 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);
@@ -104,20 +104,20 @@ describe(AuthService.name, () => {
}); });
mocks.user.update.mockResolvedValue(user); mocks.user.update.mockResolvedValue(user);
await sut.login({ email, password: 'password', linkToken: 'plain-token' }, loginDetails); await sut.login({ email, password: 'password', oauthLinkToken: 'plain-token' }, loginDetails);
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 linkToken', async () => { it('should reject login with invalid oauthLinkToken', 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(sut.login({ email, password: 'password', linkToken: 'bad-token' }, loginDetails)).rejects.toThrow( await expect(
'Invalid or expired link token', sut.login({ email, password: 'password', oauthLinkToken: 'bad-token' }, loginDetails),
); ).rejects.toThrow('Invalid or expired link token');
}); });
}); });
+3 -3
View File
@@ -75,8 +75,8 @@ export class AuthService extends BaseService {
throw new UnauthorizedException('Incorrect email or password'); throw new UnauthorizedException('Incorrect email or password');
} }
if (dto.linkToken) { if (dto.oauthLinkToken) {
const hashedToken = this.cryptoRepository.hashSha256(dto.linkToken); const hashedToken = this.cryptoRepository.hashSha256(dto.oauthLinkToken);
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');
@@ -350,7 +350,7 @@ export class AuthService extends BaseService {
throw new ForbiddenException({ throw new ForbiddenException({
message: 'oauth_account_link_required', message: 'oauth_account_link_required',
userEmail: emailUser.email, userEmail: emailUser.email,
linkToken: plainToken, oauthLinkToken: plainToken,
}); });
} }
} }
+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?: { linkToken?: string; email?: string }) => '/auth/link' + asQueryString(params), authLink: (params?: { oauthLinkToken?: string; 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',
+2 -2
View File
@@ -18,7 +18,7 @@
let { data }: Props = $props(); let { data }: Props = $props();
let linkToken = $state(data.linkToken); 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('');
@@ -33,7 +33,7 @@
try { try {
errorMessage = ''; errorMessage = '';
loading = true; loading = true;
const user = await login({ loginCredentialDto: { email, password, linkToken } }); const user = await login({ loginCredentialDto: { email, password, oauthLinkToken } });
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 -2
View File
@@ -2,7 +2,7 @@ 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 linkToken = url.searchParams.get('linkToken') || ''; 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 +10,7 @@ export const load = (async ({ url }) => {
meta: { meta: {
title: $t('link_to_oauth'), title: $t('link_to_oauth'),
}, },
linkToken, 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({ linkToken: errorData.linkToken, email: errorData.userEmail })); await goto(Route.authLink({ oauthLinkToken: errorData.oauthLinkToken, email: errorData.userEmail }));
return; return;
} }
console.error('Error [login-form] [oauth.callback]', error); console.error('Error [login-form] [oauth.callback]', error);