Skip to content

Commit 1bdde08

Browse files
committed
feat(auth): implement unified email accounts for OAuth and local users
Unify account handling so OAuth users and local users share the same email record. When a user signs up with OAuth, if their email already exists as a local account, the accounts are linked. This allows users to: - Sign in with either OAuth or local credentials - Have a single user profile regardless of auth method Includes null checks and proper user object handling for JWT activation.
1 parent 41090bd commit 1bdde08

File tree

6 files changed

+218
-49
lines changed

6 files changed

+218
-49
lines changed

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ CLOUDFLARE_REGION="auto"
3030
#EMAIL_FROM_NAME=""
3131
#DISABLE_REGISTRATION=false
3232

33+
## When enabled, accounts are unified by email address across providers.
34+
## Users can sign in with any provider (Google, GitHub, email/password) to the same account.
35+
## OAuth users can add a password, and email/password users can sign in with OAuth.
36+
#UNIFIED_EMAIL_ACCOUNTS=false
37+
3338
# Where will social media icons be saved - local or cloudflare.
3439
STORAGE_PROVIDER="local"
3540

.github/dependabot.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ updates:
88
- package-ecosystem: "npm" # See documentation for possible values
99
directory: "/" # Location of package manifests
1010
schedule:
11-
interval: "weekly"
11+
interval: "daily"

apps/backend/src/api/routes/auth.controller.ts

Lines changed: 37 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -51,17 +51,14 @@ export class AuthController {
5151
req?.cookies?.org
5252
);
5353

54-
const { jwt, addedOrg } = await this._authService.routeAuth(
54+
const { jwt, addedOrg, activationRequired } = await this._authService.routeAuth(
5555
body.provider,
5656
body,
5757
ip,
5858
userAgent,
5959
getOrgFromCookie
6060
);
6161

62-
const activationRequired =
63-
body.provider === 'LOCAL' && this._emailService.hasProvider();
64-
6562
if (activationRequired) {
6663
response.header('activate', 'true');
6764
response.status(200).json({ activate: true });
@@ -179,7 +176,16 @@ export class AuthController {
179176
@Post('/forgot')
180177
async forgot(@Body() body: ForgotPasswordDto) {
181178
try {
182-
await this._authService.forgot(body.email);
179+
const result = await this._authService.forgot(body.email);
180+
181+
if (!result.success && result.message) {
182+
// OAuth user without password - return specific message
183+
return {
184+
forgot: false,
185+
message: result.message,
186+
};
187+
}
188+
183189
return {
184190
forgot: true,
185191
};
@@ -240,32 +246,36 @@ export class AuthController {
240246
@Param('provider') provider: string,
241247
@Res({ passthrough: false }) response: Response
242248
) {
243-
const { jwt, token } = await this._authService.checkExists(provider, code);
249+
try {
250+
const { jwt, token } = await this._authService.checkExists(provider, code);
244251

245-
if (token) {
246-
return response.json({ token });
247-
}
252+
if (token) {
253+
return response.json({ token });
254+
}
248255

249-
response.cookie('auth', jwt, {
250-
domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!),
251-
...(!process.env.NOT_SECURED
252-
? {
253-
secure: true,
254-
httpOnly: true,
255-
sameSite: 'none',
256-
}
257-
: {}),
258-
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
259-
});
256+
response.cookie('auth', jwt, {
257+
domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!),
258+
...(!process.env.NOT_SECURED
259+
? {
260+
secure: true,
261+
httpOnly: true,
262+
sameSite: 'none',
263+
}
264+
: {}),
265+
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
266+
});
260267

261-
if (process.env.NOT_SECURED) {
262-
response.header('auth', jwt);
263-
}
268+
if (process.env.NOT_SECURED) {
269+
response.header('auth', jwt);
270+
}
264271

265-
response.header('reload', 'true');
272+
response.header('reload', 'true');
266273

267-
response.status(200).json({
268-
login: true,
269-
});
274+
response.status(200).json({
275+
login: true,
276+
});
277+
} catch (e: any) {
278+
response.status(400).send(e.message);
279+
}
270280
}
271281
}

apps/backend/src/services/auth/auth.service.ts

Lines changed: 125 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ export class AuthService {
2020
private _notificationService: NotificationService,
2121
private _emailService: EmailService
2222
) {}
23+
24+
private isUnifiedEmailEnabled(): boolean {
25+
return process.env.UNIFIED_EMAIL_ACCOUNTS === 'true';
26+
}
2327
async canRegister(provider: string) {
2428
if (process.env.DISABLE_REGISTRATION !== 'true' || provider === Provider.GENERIC) {
2529
return true;
@@ -45,6 +49,30 @@ export class AuthService {
4549
throw new Error('Email already exists');
4650
}
4751

52+
// Check if email exists with any other provider (e.g., Google, GitHub)
53+
// If UNIFIED_EMAIL_ACCOUNTS is enabled, require email verification before adding password
54+
if (this.isUnifiedEmailEnabled()) {
55+
const existingUserAnyProvider = await this._userService.getUserByEmailAnyProvider(body.email);
56+
if (existingUserAnyProvider) {
57+
// Don't set password immediately - require email verification first
58+
// This prevents account hijacking by someone who knows an email
59+
const addPasswordToken = AuthChecker.signJWT({
60+
id: existingUserAnyProvider.id,
61+
email: existingUserAnyProvider.email,
62+
passwordHash: AuthChecker.hashPassword(body.password),
63+
type: 'add-password',
64+
});
65+
66+
await this._emailService.sendEmail(
67+
existingUserAnyProvider.email,
68+
'Verify to add password to your account',
69+
`Someone is trying to add a password to your account. If this was you, click <a href="${process.env.FRONTEND_URL}/auth/activate/${addPasswordToken}">here</a> to verify and set your password. If you did not request this, please ignore this email.`
70+
);
71+
72+
return { addedOrg: false, jwt: '', activationRequired: true };
73+
}
74+
}
75+
4876
if (!(await this.canRegister(provider))) {
4977
throw new Error('Registration is disabled');
5078
}
@@ -65,24 +93,36 @@ export class AuthService {
6593
)
6694
: false;
6795

68-
const obj = { addedOrg, jwt: await this.jwt(create.users[0].user) };
96+
const jwt = await this.jwt(create.users[0].user);
6997
await this._emailService.sendEmail(
7098
body.email,
7199
'Activate your account',
72-
`Click <a href="${process.env.FRONTEND_URL}/auth/activate/${obj.jwt}">here</a> to activate your account`
100+
`Click <a href="${process.env.FRONTEND_URL}/auth/activate/${jwt}">here</a> to activate your account`
73101
);
74-
return obj;
102+
// Activation required if email provider is configured
103+
const activationRequired = this._emailService.hasProvider();
104+
return { addedOrg, jwt, activationRequired };
105+
}
106+
107+
// For login: check LOCAL account first, then any provider with password (if unified emails enabled)
108+
let loginUser = user;
109+
if (!loginUser && this.isUnifiedEmailEnabled()) {
110+
// Check if there's an OAuth account with this email that has a password set
111+
const oauthUser = await this._userService.getUserByEmailAnyProvider(body.email);
112+
if (oauthUser && oauthUser.password) {
113+
loginUser = oauthUser;
114+
}
75115
}
76116

77-
if (!user || !AuthChecker.comparePassword(body.password, user.password)) {
117+
if (!loginUser || !loginUser.password || !AuthChecker.comparePassword(body.password, loginUser.password)) {
78118
throw new Error('Invalid user name or password');
79119
}
80120

81-
if (!user.activated) {
121+
if (!loginUser.activated) {
82122
throw new Error('User is not activated');
83123
}
84124

85-
return { addedOrg: false, jwt: await this.jwt(user) };
125+
return { addedOrg: false, jwt: await this.jwt(loginUser), activationRequired: false };
86126
}
87127

88128
const user = await this.loginOrRegisterProvider(
@@ -101,7 +141,7 @@ export class AuthService {
101141
addToOrg.role
102142
)
103143
: false;
104-
return { addedOrg, jwt: await this.jwt(user) };
144+
return { addedOrg, jwt: await this.jwt(user), activationRequired: false };
105145
}
106146

107147
public getOrgFromCookie(cookie?: string) {
@@ -147,6 +187,21 @@ export class AuthService {
147187
return user;
148188
}
149189

190+
// Check if there's an existing account with the same email (any provider)
191+
// This allows users with email/password accounts to also sign in via OAuth (if unified emails enabled)
192+
if (this.isUnifiedEmailEnabled()) {
193+
const existingUserByEmail = await this._userService.getUserByEmailAnyProvider(
194+
providerUser.email
195+
);
196+
if (existingUserByEmail) {
197+
// If user is not activated, activate them now since OAuth provider verified the email
198+
if (!existingUserByEmail.activated) {
199+
await this._userService.activateUser(existingUserByEmail.id);
200+
}
201+
return existingUserByEmail;
202+
}
203+
}
204+
150205
if (!(await this.canRegister(provider))) {
151206
throw new Error('Registration is disabled');
152207
}
@@ -168,10 +223,24 @@ export class AuthService {
168223
return create.users[0].user;
169224
}
170225

171-
async forgot(email: string) {
172-
const user = await this._userService.getUserByEmail(email);
173-
if (!user || user.providerName !== Provider.LOCAL) {
174-
return false;
226+
async forgot(email: string): Promise<{ success: boolean; message?: string }> {
227+
// Check for user with this email
228+
const user = this.isUnifiedEmailEnabled()
229+
? await this._userService.getUserByEmailAnyProvider(email)
230+
: await this._userService.getUserByEmail(email);
231+
232+
if (!user) {
233+
// Don't reveal if email exists for security
234+
return { success: true };
235+
}
236+
237+
// Check if user has a password set (only relevant when unified emails enabled)
238+
if (this.isUnifiedEmailEnabled() && !user.password) {
239+
// User registered with OAuth and hasn't set a password
240+
return {
241+
success: false,
242+
message: 'This account was registered with OAuth (Google, GitHub, etc.). Please sign in using your OAuth provider, or register with email/password to set a password.'
243+
};
175244
}
176245

177246
const resetValues = AuthChecker.signJWT({
@@ -182,8 +251,10 @@ export class AuthService {
182251
await this._notificationService.sendEmail(
183252
user.email,
184253
'Reset your password',
185-
`You have requested to reset your passsord. <br />Click <a href="${process.env.FRONTEND_URL}/auth/forgot/${resetValues}">here</a> to reset your password<br />The link will expire in 20 minutes`
254+
`You have requested to reset your password. <br />Click <a href="${process.env.FRONTEND_URL}/auth/forgot/${resetValues}">here</a> to reset your password<br />The link will expire in 20 minutes`
186255
);
256+
257+
return { success: true };
187258
}
188259

189260
forgotReturn(body: ForgotReturnPasswordDto) {
@@ -195,24 +266,42 @@ export class AuthService {
195266
return false;
196267
}
197268

198-
return this._userService.updatePassword(user.id, body.password);
269+
// Use setPassword (works with any provider) if unified emails enabled, otherwise updatePassword (LOCAL only)
270+
return this.isUnifiedEmailEnabled()
271+
? this._userService.setPassword(user.id, body.password)
272+
: this._userService.updatePassword(user.id, body.password);
199273
}
200274

201275
async activate(code: string) {
202-
const user = AuthChecker.verifyJWT(code) as {
276+
const tokenData = AuthChecker.verifyJWT(code) as {
203277
id: string;
204278
activated: boolean;
205279
email: string;
280+
passwordHash?: string;
281+
type?: string;
206282
};
207-
if (user.id && !user.activated) {
208-
const getUserAgain = await this._userService.getUserByEmail(user.email);
209-
if (getUserAgain.activated) {
283+
284+
// Handle add-password flow (OAuth user adding password) - only when unified emails enabled
285+
if (this.isUnifiedEmailEnabled() && tokenData.type === 'add-password' && tokenData.passwordHash) {
286+
const user = await this._userService.getUserById(tokenData.id);
287+
if (!user) {
210288
return false;
211289
}
212-
await this._userService.activateUser(user.id);
213-
user.activated = true;
214-
await NewsletterService.register(user.email);
215-
return this.jwt(user as any);
290+
// Set the password (passwordHash is already hashed)
291+
await this._userService.setPasswordHash(tokenData.id, tokenData.passwordHash);
292+
return this.jwt(user);
293+
}
294+
295+
// Handle normal activation flow (new LOCAL user)
296+
if (tokenData.id && !tokenData.activated && tokenData.email) {
297+
const getUserAgain = await this._userService.getUserByEmail(tokenData.email);
298+
if (!getUserAgain || getUserAgain.activated) {
299+
return false;
300+
}
301+
await this._userService.activateUser(tokenData.id);
302+
getUserAgain.activated = true; // Reflect DB change in local object
303+
await NewsletterService.register(tokenData.email);
304+
return this.jwt(getUserAgain);
216305
}
217306

218307
return false;
@@ -242,6 +331,21 @@ export class AuthService {
242331
return { jwt: await this.jwt(checkExists) };
243332
}
244333

334+
// Check if there's an existing account with the same email (any provider) - only when unified emails enabled
335+
// This allows users with email/password accounts to also sign in via OAuth
336+
if (this.isUnifiedEmailEnabled()) {
337+
const existingUserByEmail = await this._userService.getUserByEmailAnyProvider(
338+
user.email
339+
);
340+
if (existingUserByEmail) {
341+
// If user is not activated, activate them now since OAuth provider verified the email
342+
if (!existingUserByEmail.activated) {
343+
await this._userService.activateUser(existingUserByEmail.id);
344+
}
345+
return { jwt: await this.jwt(existingUserByEmail) };
346+
}
347+
}
348+
245349
return { token };
246350
}
247351

libraries/nestjs-libraries/src/database/prisma/users/users.repository.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,22 @@ export class UsersRepository {
8686
});
8787
}
8888

89+
getUserByEmailAnyProvider(email: string) {
90+
return this._user.model.user.findFirst({
91+
where: {
92+
email,
93+
},
94+
include: {
95+
picture: {
96+
select: {
97+
id: true,
98+
path: true,
99+
},
100+
},
101+
},
102+
});
103+
}
104+
89105
updatePassword(id: string, password: string) {
90106
return this._user.model.user.update({
91107
where: {
@@ -98,6 +114,28 @@ export class UsersRepository {
98114
});
99115
}
100116

117+
setPassword(id: string, password: string) {
118+
return this._user.model.user.update({
119+
where: {
120+
id,
121+
},
122+
data: {
123+
password: AuthService.hashPassword(password),
124+
},
125+
});
126+
}
127+
128+
setPasswordHash(id: string, passwordHash: string) {
129+
return this._user.model.user.update({
130+
where: {
131+
id,
132+
},
133+
data: {
134+
password: passwordHash,
135+
},
136+
});
137+
}
138+
101139
changeAudienceSize(userId: string, audience: number) {
102140
return this._user.model.user.update({
103141
where: {

0 commit comments

Comments
 (0)