Skip to content

Commit 19b5f49

Browse files
naoinajoehan
andauthored
Add OOB flow for VERIFY_AND_CHANGE_EMAIL (#7618)
Co-authored-by: joehan <[email protected]>
1 parent 626bdce commit 19b5f49

File tree

5 files changed

+380
-7
lines changed

5 files changed

+380
-7
lines changed

src/emulator/auth/handlers.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,32 @@ export function registerHandlers(
143143
}
144144
}
145145
}
146+
case "verifyAndChangeEmail": {
147+
try {
148+
const { newEmail } = setAccountInfoImpl(state, { oobCode });
149+
if (continueUrl) {
150+
return res.redirect(303, continueUrl);
151+
} else {
152+
return res.status(200).json({
153+
authEmulator: { success: `The email has been successfully changed.`, newEmail },
154+
});
155+
}
156+
} catch (e: any) {
157+
if (
158+
e instanceof NotImplementedError ||
159+
(e instanceof BadRequestError && e.message === "INVALID_OOB_CODE")
160+
) {
161+
return res.status(400).json({
162+
authEmulator: {
163+
error: `Your request to change your email has expired or the link has already been used.`,
164+
instructions: `Try changing your email again.`,
165+
},
166+
});
167+
} else {
168+
throw e;
169+
}
170+
}
171+
}
146172
case "signIn": {
147173
if (!continueUrl) {
148174
return res.status(400).json({

src/emulator/auth/oob.spec.ts

Lines changed: 237 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,22 @@ describeAuthEmulator("accounts:sendOobCode", ({ authApi, getClock }) => {
7676
expect(res.body.oobCode).to.be.a("string");
7777
expect(res.body.oobLink).to.be.a("string");
7878
});
79+
80+
await authApi()
81+
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
82+
.set("Authorization", "Bearer owner")
83+
.send({
84+
email: user.email,
85+
newEmail: "[email protected]",
86+
requestType: "VERIFY_AND_CHANGE_EMAIL",
87+
returnOobLink: true,
88+
})
89+
.then((res) => {
90+
expectStatusCode(200, res);
91+
expect(res.body.email).to.equal(user.email);
92+
expect(res.body.oobCode).to.be.a("string");
93+
expect(res.body.oobLink).to.be.a("string");
94+
});
7995
});
8096

8197
it("should return OOB code by idToken for OAuth 2 requests as well", async () => {
@@ -91,6 +107,23 @@ describeAuthEmulator("accounts:sendOobCode", ({ authApi, getClock }) => {
91107
expect(res.body.oobCode).to.be.a("string");
92108
expect(res.body.oobLink).to.be.a("string");
93109
});
110+
111+
await authApi()
112+
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
113+
.set("Authorization", "Bearer owner")
114+
.send({
115+
email: user.email,
116+
newEmail: "[email protected]",
117+
idToken,
118+
requestType: "VERIFY_AND_CHANGE_EMAIL",
119+
returnOobLink: true,
120+
})
121+
.then((res) => {
122+
expectStatusCode(200, res);
123+
expect(res.body.email).to.equal(user.email);
124+
expect(res.body.oobCode).to.be.a("string");
125+
expect(res.body.oobLink).to.be.a("string");
126+
});
94127
});
95128

96129
it("should error when trying to verify email without idToken or email", async () => {
@@ -100,7 +133,7 @@ describeAuthEmulator("accounts:sendOobCode", ({ authApi, getClock }) => {
100133
await authApi()
101134
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
102135
.query({ key: "fake-api-key" })
103-
.send({ requestType: "VERIFY_EMAIL" })
136+
.send({ idToken: "hoge", requestType: "VERIFY_EMAIL" })
104137
.then((res) => {
105138
expectStatusCode(400, res);
106139
expect(res.body.error).to.have.property("message").equal("INVALID_ID_TOKEN");
@@ -281,6 +314,7 @@ describeAuthEmulator("accounts:sendOobCode", ({ authApi, getClock }) => {
281314
it("should return purpose of oobCodes via resetPassword endpoint", async () => {
282315
const user = { email: "[email protected]", password: "notasecret" };
283316
const { idToken } = await registerUser(authApi(), user);
317+
const newEmail = "[email protected]";
284318

285319
await authApi()
286320
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
@@ -297,11 +331,22 @@ describeAuthEmulator("accounts:sendOobCode", ({ authApi, getClock }) => {
297331
await authApi()
298332
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
299333
.query({ key: "fake-api-key" })
300-
.send({ email: "[email protected]", requestType: "EMAIL_SIGNIN" })
334+
.send({ email: newEmail, requestType: "EMAIL_SIGNIN" })
335+
.then((res) => expectStatusCode(200, res));
336+
337+
await authApi()
338+
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
339+
.query({ key: "fake-api-key" })
340+
.send({
341+
email: user.email,
342+
newEmail,
343+
requestType: "VERIFY_AND_CHANGE_EMAIL",
344+
idToken,
345+
})
301346
.then((res) => expectStatusCode(200, res));
302347

303348
const oobs = await inspectOobs(authApi());
304-
expect(oobs).to.have.length(3);
349+
expect(oobs).to.have.length(4);
305350

306351
for (const oob of oobs) {
307352
await authApi()
@@ -322,12 +367,15 @@ describeAuthEmulator("accounts:sendOobCode", ({ authApi, getClock }) => {
322367
} else {
323368
expect(res.body.email).to.equal(oob.email);
324369
}
370+
if (oob.requestType === "VERIFY_AND_CHANGE_EMAIL") {
371+
expect(res.body.newEmail).to.equal(newEmail);
372+
}
325373
});
326374
}
327375

328376
// OOB codes are not consumed by the lookup above.
329377
const oobs2 = await inspectOobs(authApi());
330-
expect(oobs2).to.have.length(3);
378+
expect(oobs2).to.have.length(4);
331379
});
332380

333381
it("should error on resetPassword if auth is disabled", async () => {
@@ -395,4 +443,189 @@ describeAuthEmulator("accounts:sendOobCode", ({ authApi, getClock }) => {
395443
expect(res.body).to.have.property("email").equals(user.email);
396444
});
397445
});
446+
447+
it("should generate OOB code for verify and change email", async () => {
448+
const user = { email: "[email protected]", password: "notasecret" };
449+
const { idToken, localId } = await registerUser(authApi(), user);
450+
451+
await authApi()
452+
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
453+
.query({ key: "fake-api-key" })
454+
.send({
455+
email: user.email,
456+
newEmail: "[email protected]",
457+
idToken,
458+
requestType: "VERIFY_AND_CHANGE_EMAIL",
459+
})
460+
.then((res) => {
461+
expectStatusCode(200, res);
462+
expect(res.body)
463+
.to.have.property("kind")
464+
.equals("identitytoolkit#GetOobConfirmationCodeResponse");
465+
expect(res.body.email).to.equal(user.email);
466+
467+
// These fields should not be set since returnOobLink is not set.
468+
expect(res.body).not.to.have.property("oobCode");
469+
expect(res.body).not.to.have.property("oobLink");
470+
});
471+
472+
const oobs = await inspectOobs(authApi());
473+
expect(oobs).to.have.length(1);
474+
expect(oobs[0].email).to.equal(user.email);
475+
expect(oobs[0].requestType).to.equal("VERIFY_AND_CHANGE_EMAIL");
476+
477+
// The returned oobCode can be redeemed to verify and change the email.
478+
await authApi()
479+
.post("/identitytoolkit.googleapis.com/v1/accounts:update")
480+
.query({ key: "fake-api-key" })
481+
// OOB code is enough, no idToken needed.
482+
.send({ oobCode: oobs[0].oobCode })
483+
.then((res) => {
484+
expectStatusCode(200, res);
485+
expect(res.body.localId).to.equal(localId);
486+
expect(res.body.email).to.equal("[email protected]");
487+
expect(res.body.emailVerified).to.equal(true);
488+
});
489+
490+
// oobCode is removed after redeemed.
491+
const oobs2 = await inspectOobs(authApi());
492+
expect(oobs2).to.have.length(0);
493+
});
494+
495+
it("should error when trying to verify and change email without idToken or email or newEmail", async () => {
496+
const user = { email: "[email protected]", password: "notasecret" };
497+
await registerUser(authApi(), user);
498+
499+
await authApi()
500+
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
501+
.query({ key: "fake-api-key" })
502+
.send({ newEmail: "[email protected]", requestType: "VERIFY_AND_CHANGE_EMAIL" })
503+
.then((res) => {
504+
expectStatusCode(400, res);
505+
expect(res.body.error).to.have.property("message").equal("MISSING_ID_TOKEN");
506+
});
507+
508+
await authApi()
509+
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
510+
.query({ key: "fake-api-key" })
511+
.send({ email: user.email, requestType: "VERIFY_AND_CHANGE_EMAIL" })
512+
.then((res) => {
513+
expectStatusCode(400, res);
514+
expect(res.body.error).to.have.property("message").equal("MISSING_NEW_EMAIL");
515+
});
516+
517+
await authApi()
518+
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
519+
.set("Authorization", "Bearer owner")
520+
.send({
521+
newEmail: "[email protected]",
522+
returnOobLink: true,
523+
requestType: "VERIFY_AND_CHANGE_EMAIL",
524+
})
525+
.then((res) => {
526+
expectStatusCode(400, res);
527+
expect(res.body.error).to.have.property("message").equal("MISSING_EMAIL");
528+
});
529+
530+
await authApi()
531+
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
532+
.set("Authorization", "Bearer owner")
533+
.send({
534+
email: user.email,
535+
returnOobLink: true,
536+
requestType: "VERIFY_AND_CHANGE_EMAIL",
537+
})
538+
.then((res) => {
539+
expectStatusCode(400, res);
540+
expect(res.body.error).to.have.property("message").equal("MISSING_NEW_EMAIL");
541+
});
542+
543+
const oobs = await inspectOobs(authApi());
544+
expect(oobs).to.have.length(0);
545+
});
546+
547+
it("should error when trying to verify and change email without idToken if not returnOobLink", async () => {
548+
const user = await registerUser(authApi(), {
549+
550+
password: "notasecret",
551+
});
552+
553+
await authApi()
554+
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
555+
.query({ key: "fake-api-key" })
556+
.send({
557+
email: user.email,
558+
newEmail: "[email protected]",
559+
requestType: "VERIFY_AND_CHANGE_EMAIL",
560+
})
561+
.then((res) => {
562+
expectStatusCode(400, res);
563+
expect(res.body.error).to.have.property("message").equal("MISSING_ID_TOKEN");
564+
});
565+
566+
const oobs = await inspectOobs(authApi());
567+
expect(oobs).to.have.length(0);
568+
});
569+
570+
it("should error when trying to verify and change email not associated with any user", async () => {
571+
await authApi()
572+
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
573+
.set("Authorization", "Bearer owner")
574+
.send({
575+
576+
newEmail: "[email protected]",
577+
returnOobLink: true,
578+
requestType: "VERIFY_AND_CHANGE_EMAIL",
579+
})
580+
.then((res) => {
581+
expectStatusCode(400, res);
582+
expect(res.body.error).to.have.property("message").equal("USER_NOT_FOUND");
583+
});
584+
585+
const oobs = await inspectOobs(authApi());
586+
expect(oobs).to.have.length(0);
587+
});
588+
589+
it("should error if newEmail is already associated to another user", async () => {
590+
const user = {
591+
592+
password: "notasecret",
593+
};
594+
const { idToken } = await registerUser(authApi(), user);
595+
const anotherUser = await registerUser(authApi(), {
596+
597+
password: "notasecret",
598+
});
599+
600+
await authApi()
601+
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
602+
.query({ key: "fake-api-key" })
603+
.send({
604+
idToken,
605+
email: user.email,
606+
newEmail: anotherUser.email,
607+
requestType: "VERIFY_AND_CHANGE_EMAIL",
608+
})
609+
.then((res) => {
610+
expectStatusCode(400, res);
611+
expect(res.body.error).to.have.property("message").equal("EMAIL_EXISTS");
612+
});
613+
614+
await authApi()
615+
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
616+
.set("Authorization", "Bearer owner")
617+
.send({
618+
email: user.email,
619+
newEmail: anotherUser.email,
620+
returnOobLink: true,
621+
requestType: "VERIFY_AND_CHANGE_EMAIL",
622+
})
623+
.then((res) => {
624+
expectStatusCode(400, res);
625+
expect(res.body.error).to.have.property("message").equal("EMAIL_EXISTS");
626+
});
627+
628+
const oobs = await inspectOobs(authApi());
629+
expect(oobs).to.have.length(0);
630+
});
398631
});

0 commit comments

Comments
 (0)