[web] Replace token-link email verification with 6-digit code on SSO registration (ORCID) (#33889)

* Replace token-link email with 6-digit code on SSO registration

Unverified SSO emails previously received a long-lived token link
(90-day TTL) via UserEmailsConfirmationHandler. This replaces that
flow with the same 6-digit code verification used for password
registration, redirecting through /registration/confirm-email.

- SSOManager.registerSSO now always confirms email (caller must
  verify first); removes sendConfirmationEmail / _finishRegistration
- SSOController._signUp sends confirmation code and stores
  pendingSSORegistration in session when IdP email_verified is false
- New SSOConfirmEmailHandler completes registration after code check
  via completeSSOEmailConfirmation module hook
- OnboardingController confirm-email handlers accept
  pendingSSORegistration alongside pendingUserRegistration

confirmEmailFromToken (POST /user/emails/confirm) removal is deferred
to a follow-up PR to avoid breaking in-flight 90-day tokens.

Closes #28607

* Fix unverified-email edge cases; Add ORCID e2e tests;

* Rename `confirmEmail` parameter to `emailVerifiedByIdP` in _signUp function

* Remove `sendConfirmationEmail`

* Mock getUserByAnyEmail in tests

* Extract _finishSSORegistration helper to deduplicate the register →
set session flags → allocate referral → finishSaasLogin → finishLogin
sequence shared by both the direct and deferred (code-confirmed) paths.

* Stop duplicating session data in pendingSSORegistration

analyticsId, splitTests, and referal_* are already in the session at
confirmation time — no need to copy them into pendingSSORegistration.
Re-fetch splitTests fresh on completion instead.

* Simplify the code

* Remove dead confirmEmail template

No callers remain after sendConfirmationEmail was deleted. The token-link
flow (confirmEmailFromToken) only validates tokens, never sends email.

* Remove dead reconfirmEmail template

* Address comments from Copilot

* Clear stale pending registration when starting a new flow

* Add unit tests for completeSSOEmailConfirmation

* Add `verificationMethod` param

* Fix camelcase issues

* Extract _createSSOUser and _registerAndFinish helpers to deduplicate registration logic

* Remove obscure "registration_error"

* Prevent FormTextIcon from shrinking

* Enable "email_already_registered_sso" error

* Misc. improvements to confirm-email-form.tsx

* Remove `UserEmailsConfirmationHandler` mock

Co-authored-by: Olzhas Askar <olzhas.askar@overleaf.com>

* Add info on sso_email.pug page

---------

Co-authored-by: Olzhas Askar <olzhas.askar@overleaf.com>
GitOrigin-RevId: d0196ebc6d81ff61bcd27726d0b899b743d08d64
This commit is contained in:
Antoine Clausse
2026-06-05 08:06:34 +00:00
committed by Copybot
co-authored by Olzhas Askar
parent 2570b6559d
commit 3140e46e68
16 changed files with 49 additions and 235 deletions
@@ -271,32 +271,6 @@ templates.passwordResetRequested = ctaTemplate({
},
})
templates.confirmEmail = ctaTemplate({
subject() {
return `Confirm email - ${settings.appName}`
},
title() {
return 'Confirm email'
},
message(opts) {
return [
`Please confirm that you have added a new email, ${opts.to}, to your ${settings.appName} account.`,
]
},
secondaryMessage() {
return [
`If you did not request this, please let us know at <a href="mailto:${settings.adminEmail}">${settings.adminEmail}</a>.`,
`If you have any questions or trouble confirming your email address, please get in touch with our support team at ${settings.adminEmail}.`,
]
},
ctaText() {
return 'Confirm email'
},
ctaURL(opts) {
return opts.confirmEmailUrl
},
})
templates.confirmCode = NoCTAEmailTemplate({
greeting(opts) {
return ''
@@ -391,32 +365,6 @@ templates.projectInvite = ctaTemplate({
},
})
templates.reconfirmEmail = ctaTemplate({
subject() {
return `Reconfirm Email - ${settings.appName}`
},
title() {
return 'Reconfirm Email'
},
message(opts) {
return [
`Please reconfirm your email address, ${opts.to}, on your ${settings.appName} account.`,
]
},
secondaryMessage() {
return [
'If you did not request this, you can simply ignore this message.',
`If you have any questions or trouble confirming your email address, please get in touch with our support team at ${settings.adminEmail}.`,
]
},
ctaText() {
return 'Reconfirm email'
},
ctaURL(opts) {
return opts.confirmEmailUrl
},
})
templates.verifyEmailToJoinTeam = ctaTemplate({
subject(opts) {
return `${opts.reminder ? 'Reminder: ' : ''}${_.escape(
@@ -1,7 +1,6 @@
import EmailHelper from '../Helpers/EmailHelper.mjs'
import EmailHandler from '../Email/EmailHandler.mjs'
import OneTimeTokenHandler from '../Security/OneTimeTokenHandler.mjs'
import settings from '@overleaf/settings'
import Errors from '../Errors/Errors.js'
import UserUpdater from './UserUpdater.mjs'
import UserGetter from './UserGetter.mjs'
@@ -10,33 +9,9 @@ import crypto from 'node:crypto'
import SessionManager from '../Authentication/SessionManager.mjs'
// Reject email confirmation tokens after 90 days
const TOKEN_EXPIRY_IN_S = 90 * 24 * 60 * 60
const TOKEN_USE = 'email_confirmation'
const CONFIRMATION_CODE_EXPIRY_IN_S = 10 * 60
async function sendConfirmationEmail(
userId,
email,
emailTemplate = 'confirmEmail'
) {
email = EmailHelper.parseEmail(email)
if (!email) {
throw new Error('invalid email')
}
const data = { user_id: userId, email }
const token = await OneTimeTokenHandler.promises.getNewToken(
TOKEN_USE,
data,
{ expiresIn: TOKEN_EXPIRY_IN_S }
)
const emailOptions = {
to: email,
confirmEmailUrl: `${settings.siteUrl}/user/emails/confirm?token=${token}`,
sendingUser_id: userId,
}
await EmailHandler.promises.sendEmail(emailTemplate, emailOptions)
}
async function sendConfirmationCode(email, welcomeUser) {
if (!EmailHelper.parseEmail(email)) {
throw new Error('invalid email')
@@ -95,11 +70,9 @@ async function confirmEmailFromToken(req, token) {
const UserEmailsConfirmationHandler = {
confirmEmailFromToken: callbackify(confirmEmailFromToken),
sendConfirmationEmail: callbackify(sendConfirmationEmail),
}
UserEmailsConfirmationHandler.promises = {
sendConfirmationEmail,
confirmEmailFromToken,
sendConfirmationCode,
}
@@ -594,6 +594,7 @@
"email_address": "",
"email_address_is_invalid": "",
"email_already_registered": "",
"email_already_registered_sso": "",
"email_attribute": "",
"email_does_not_belong_to_university": "",
"email_limit_reached": "",
@@ -144,7 +144,6 @@ export function ConfirmEmailForm({
postJSON(resendEndpoint)
.then(data => {
setIsResending(false)
if (data?.message?.key) {
setFeedback({
type: 'alert',
@@ -334,7 +333,7 @@ function Title({
return outerErrorDisplay ? (
<div className="mt-4" />
) : (
<h3 className="h5">{outerErrorDisplay ? null : t('we_sent_code')}</h3>
<h3 className="h5">{t('we_sent_code')}</h3>
)
if (interstitial)
return <h1 className="h3 interstitial-header">{t('confirm_your_email')}</h1>
@@ -416,6 +415,9 @@ function ErrorMessage({ error }: { error: string }) {
case 'please_enter_confirmation_code':
return <span>{t('please_enter_confirmation_code')}</span>
case 'email_already_registered_sso':
return <span>{t('email_already_registered_sso')}</span>
default:
return <span>{t('generic_something_went_wrong')}</span>
}
@@ -26,9 +26,9 @@ export const getFormTextClass = (type?: TextType) =>
function FormTextIcon({ type }: { type?: TextType }) {
switch (type) {
case 'success':
return <CheckCircle className="ciam-form-text-icon" />
return <CheckCircle className="ciam-form-text-icon flex-shrink-0" />
case 'error':
return <WarningCircle className="ciam-form-text-icon" />
return <WarningCircle className="ciam-form-text-icon flex-shrink-0" />
default:
return null
}
-1
View File
@@ -1445,7 +1445,6 @@
"register_with_another_email": "<a href=\"__link__\">Bliv registreret hos __appName__</a> med en anden e-mailadresse.",
"registered": "Registreret",
"registering": "Registrerer",
"registration_error": "Registreringsfejl",
"reject": "Afvis",
"reject_change": "Afslå ændring",
"related_tags": "Relaterede tags",
-1
View File
@@ -1245,7 +1245,6 @@
"register_with_another_email": "<a href=\"__link__\">Registriere Dich bei __appName__</a> mit einer anderen E-Mail-Adresse.",
"registered": "Registriert",
"registering": "Registrieren",
"registration_error": "Registrierungs-Fehler",
"reject": "Verwerfen",
"related_tags": "Ähnliche Stichwörter",
"relink_your_account": "Verknüpfe dein Konto neu",
+2 -1
View File
@@ -485,6 +485,7 @@
"confirm_remove_sso_config_enter_email": "To confirm you want to remove your SSO configuration, enter your email address:",
"confirm_remove_user_type_email_address": "To confirm you want to remove __userName__ please type the email address associated with their account.",
"confirm_secondary_email": "Confirm secondary email",
"confirm_your_account": "Confirm your account",
"confirm_your_email": "Confirm your email address",
"confirmation_link_broken": "Sorry, something is wrong with your confirmation link. Please try copy and pasting the link from the bottom of your confirmation email.",
"confirmation_token_invalid": "Sorry, your confirmation token is invalid or has expired. Please request a new email confirmation link.",
@@ -807,6 +808,7 @@
"english_british": "English (British)",
"ensure_recover_account": "This will ensure that it can be used to recover your __appName__ account in case you lose access to your primary email address.",
"enter_any_size_including_units_or_valid_latex_command": "Enter any size (including units) or valid LaTeX command",
"enter_email_to_get_code": "Enter your email address to get a 6-digit confirmation code.",
"enter_emails_separated_by_commas": "Enter emails separated by commas",
"enter_manually": "Enter manually",
"enter_tax_id_number": "Enter tax ID number",
@@ -2110,7 +2112,6 @@
"register_with_another_email": "<a href=\"__link__\">Register with __appName__</a> using another email.",
"registered": "Registered",
"registering": "Registering",
"registration_error": "Registration error",
"reject": "Reject",
"reject_change": "Reject change",
"reject_selected_changes": "Reject selected changes",
-1
View File
@@ -899,7 +899,6 @@
"register_with_another_email": "<a href=\"__link__\">Inscrivez-vous avec __appName__</a> en utilisant une autre adresse courriel.",
"registered": "Inscrit·e",
"registering": "Inscription en cours",
"registration_error": "Erreur d’inscription",
"reject": "Rejeter",
"related_tags": "Étiquettes associées",
"reload_editor": "Actualiser l’éditeur",
-1
View File
@@ -474,7 +474,6 @@
"register_to_edit_template": "Por favor, registre-se para editar o modelo __templateName__",
"registered": "Registrado",
"registering": "Registrando",
"registration_error": "Erro de Registro",
"reject": "Rejeitar",
"remove": "remover",
"remove_from_group": "Remover do grupo",
-1
View File
@@ -668,7 +668,6 @@
"register_to_edit_template": "Vänligen registrera dig för att redigera __templateName__ mallen",
"registered": "Registrerad",
"registering": "Registrerar",
"registration_error": "Registreringsfel",
"reject": "Neka",
"reload_editor": "Ladda om redigeraren",
"remote_service_error": "Fjärrtjänsten producerade ett fel",
-1
View File
@@ -1652,7 +1652,6 @@
"register_with_another_email": "使用另一个邮件地址<a href=\"__link__\">注册 __appName__</a>",
"registered": "已注册",
"registering": "正在注册",
"registration_error": "注册错误",
"reject": "不要",
"reject_change": "拒绝修改",
"reject_selected_changes": "拒绝选定的更改",
@@ -0,0 +1,39 @@
import AbstractMockApi from './AbstractMockApi.mjs'
class MockOrcidApi extends AbstractMockApi {
reset() {
this.profiles = {}
this.tokens = {}
}
// profile: { orcid, name }
addProfile(profile, token, authorizationCode) {
this.profiles[token] = { ...profile }
this.tokens[authorizationCode] = token
}
applyRoutes() {
this.app.post('/oauth/token', (req, res) => {
const token = this.tokens[req.body.code]
if (!token) {
return res.sendStatus(400)
}
const profile = this.profiles[token]
res.json({
access_token: token,
token_type: 'bearer',
scope: '/authenticate',
...profile,
})
})
}
}
export default MockOrcidApi
/**
* @function instance
* @memberOf MockOrcidApi
* @static
* @returns {MockOrcidApi}
*/
@@ -371,41 +371,6 @@ describe('EmailBuilder', function () {
})
})
describe('confirmEmail', function () {
beforeEach(function (ctx) {
ctx.emailAddress = 'example@overleaf.com'
ctx.userId = 'abc123'
ctx.opts = {
to: ctx.emailAddress,
confirmEmailUrl: `${ctx.settings.siteUrl}/user/emails/confirm?token=aToken123`,
sendingUser_id: ctx.userId,
}
ctx.email = ctx.EmailBuilder.buildEmail('confirmEmail', ctx.opts)
})
it('should build the email', function (ctx) {
expect(ctx.email.html).to.exist
expect(ctx.email.text).to.exist
})
describe('HTML email', function () {
it('should include a CTA button and a fallback CTA link', function (ctx) {
const dom = cheerio.load(ctx.email.html)
const buttonLink = dom('a:contains("Confirm email")')
expect(buttonLink.length).to.equal(1)
expect(buttonLink.attr('href')).to.equal(ctx.opts.confirmEmailUrl)
expect(ctx.email.html).to.contain('copy and paste this link')
expect(ctx.email.html).to.contain(ctx.opts.confirmEmailUrl)
})
})
describe('plain text email', function () {
it('should contain the CTA link', function (ctx) {
expect(ctx.email.text).to.contain(ctx.opts.confirmEmailUrl)
})
})
})
describe('ownershipTransferConfirmationNewOwner', function () {
beforeEach(function (ctx) {
ctx.emailAddress = 'example@overleaf.com'
@@ -489,41 +454,6 @@ describe('EmailBuilder', function () {
})
})
describe('reconfirmEmail', function () {
beforeEach(function (ctx) {
ctx.emailAddress = 'example@overleaf.com'
ctx.userId = 'abc123'
ctx.opts = {
to: ctx.emailAddress,
confirmEmailUrl: `${ctx.settings.siteUrl}/user/emails/confirm?token=aToken123`,
sendingUser_id: ctx.userId,
}
ctx.email = ctx.EmailBuilder.buildEmail('reconfirmEmail', ctx.opts)
})
it('should build the email', function (ctx) {
expect(ctx.email.html).to.exist
expect(ctx.email.text).to.exist
})
describe('HTML email', function () {
it('should include a CTA button and a fallback CTA link', function (ctx) {
const dom = cheerio.load(ctx.email.html)
const buttonLink = dom('a:contains("Reconfirm email")')
expect(buttonLink.length).to.equal(1)
expect(buttonLink.attr('href')).to.equal(ctx.opts.confirmEmailUrl)
expect(ctx.email.html).to.contain('copy and paste this link')
expect(ctx.email.html).to.contain(ctx.opts.confirmEmailUrl)
})
})
describe('plain text email', function () {
it('should contain the CTA link', function (ctx) {
expect(ctx.email.text).to.contain(ctx.opts.confirmEmailUrl)
})
})
})
describe('verifyEmailToJoinTeam', function () {
beforeEach(function (ctx) {
ctx.emailAddress = 'example@overleaf.com'
@@ -30,12 +30,6 @@ describe('UserEmailsConfirmationHandler', function () {
ctx.email = ctx.mockUser.email
ctx.req = {}
vi.doMock('@overleaf/settings', () => ({
default: (ctx.settings = {
siteUrl: 'https://emails.example.com',
}),
}))
vi.doMock(
'../../../../app/src/Features/Security/OneTimeTokenHandler',
() => ({
@@ -83,72 +77,6 @@ describe('UserEmailsConfirmationHandler', function () {
return (ctx.callback = sinon.stub())
})
describe('sendConfirmationEmail', function () {
beforeEach(function (ctx) {
ctx.OneTimeTokenHandler.promises.getNewToken = sinon
.stub()
.resolves((ctx.token = 'new-token'))
return (ctx.EmailHandler.promises.sendEmail = sinon.stub().resolves())
})
describe('successfully', function () {
beforeEach(async function (ctx) {
await ctx.UserEmailsConfirmationHandler.promises.sendConfirmationEmail(
ctx.user_id,
ctx.email
)
})
it('should generate a token for the user which references their id and email', function (ctx) {
return ctx.OneTimeTokenHandler.promises.getNewToken
.calledWith(
'email_confirmation',
{ user_id: ctx.user_id, email: ctx.email },
{ expiresIn: 90 * 24 * 60 * 60 }
)
.should.equal(true)
})
it('should send an email to the user', function (ctx) {
return ctx.EmailHandler.promises.sendEmail
.calledWith('confirmEmail', {
to: ctx.email,
confirmEmailUrl:
'https://emails.example.com/user/emails/confirm?token=new-token',
sendingUser_id: ctx.user_id,
})
.should.equal(true)
})
})
describe('with invalid email', function () {
it('should reject with an error', async function (ctx) {
await expect(
ctx.UserEmailsConfirmationHandler.promises.sendConfirmationEmail(
ctx.user_id,
'!"£$%^&*()'
)
).to.be.rejectedWith(Error)
})
})
describe('a custom template', function () {
beforeEach(async function (ctx) {
await ctx.UserEmailsConfirmationHandler.promises.sendConfirmationEmail(
ctx.user_id,
ctx.email,
'myCustomTemplate'
)
})
it('should send an email with the given template', function (ctx) {
return ctx.EmailHandler.promises.sendEmail
.calledWith('myCustomTemplate')
.should.equal(true)
})
})
})
describe('confirmEmailFromToken', function () {
beforeEach(function (ctx) {
ctx.OneTimeTokenHandler.promises.peekValueFromToken = sinon
@@ -133,9 +133,7 @@ describe('UserEmailsController', function () {
'../../../../app/src/Features/User/UserEmailsConfirmationHandler',
() => ({
default: (ctx.UserEmailsConfirmationHandler = {
promises: {
sendConfirmationEmail: vi.fn().mockResolvedValue(undefined),
},
promises: {},
}),
})
)