Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add OOB flow for VERIFY_AND_CHANGE_EMAIL #7618

Merged
merged 2 commits into from
Sep 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/emulator/auth/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
return res.status(200).json({
authEmulator: { success: `The email has been successfully reset.`, email },
});
} catch (e: any) {

Check warning on line 62 in src/emulator/auth/handlers.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
if (
e instanceof NotImplementedError ||
(e instanceof BadRequestError && e.message === "INVALID_OOB_CODE")
Expand Down Expand Up @@ -127,7 +127,7 @@
authEmulator: { success: `The email has been successfully verified.`, email },
});
}
} catch (e: any) {

Check warning on line 130 in src/emulator/auth/handlers.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
if (
e instanceof NotImplementedError ||
(e instanceof BadRequestError && e.message === "INVALID_OOB_CODE")
Expand All @@ -143,6 +143,32 @@
}
}
}
case "verifyAndChangeEmail": {
try {
const { newEmail } = setAccountInfoImpl(state, { oobCode });
if (continueUrl) {
return res.redirect(303, continueUrl);
} else {
return res.status(200).json({
authEmulator: { success: `The email has been successfully changed.`, newEmail },
});
}
} catch (e: any) {

Check warning on line 156 in src/emulator/auth/handlers.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
if (
e instanceof NotImplementedError ||
(e instanceof BadRequestError && e.message === "INVALID_OOB_CODE")
) {
return res.status(400).json({
authEmulator: {
error: `Your request to change your email has expired or the link has already been used.`,
instructions: `Try changing your email again.`,
},
});
} else {
throw e;
}
}
}
case "signIn": {
if (!continueUrl) {
return res.status(400).json({
Expand Down
241 changes: 237 additions & 4 deletions src/emulator/auth/oob.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
.send({ idToken, requestType: "VERIFY_EMAIL" })
.then((res) => {
expectStatusCode(200, res);
expect(res.body.email).to.equal(user.email);

Check warning on line 25 in src/emulator/auth/oob.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .email on an `any` value

// These fields should not be set since returnOobLink is not set.
expect(res.body).not.to.have.property("oobCode");
Expand All @@ -42,9 +42,9 @@
.send({ oobCode: oobs[0].oobCode })
.then((res) => {
expectStatusCode(200, res);
expect(res.body.localId).to.equal(localId);

Check warning on line 45 in src/emulator/auth/oob.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .localId on an `any` value
expect(res.body.email).to.equal(user.email);

Check warning on line 46 in src/emulator/auth/oob.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .email on an `any` value
expect(res.body.emailVerified).to.equal(true);

Check warning on line 47 in src/emulator/auth/oob.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .emailVerified on an `any` value
});

// oobCode is removed after redeemed.
Expand All @@ -61,9 +61,9 @@
.send({ email: user.email, requestType: "PASSWORD_RESET", returnOobLink: true })
.then((res) => {
expectStatusCode(200, res);
expect(res.body.email).to.equal(user.email);

Check warning on line 64 in src/emulator/auth/oob.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .email on an `any` value
expect(res.body.oobCode).to.be.a("string");

Check warning on line 65 in src/emulator/auth/oob.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .oobCode on an `any` value
expect(res.body.oobLink).to.be.a("string");

Check warning on line 66 in src/emulator/auth/oob.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .oobLink on an `any` value
});

await authApi()
Expand All @@ -76,6 +76,22 @@
expect(res.body.oobCode).to.be.a("string");
expect(res.body.oobLink).to.be.a("string");
});

await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
.set("Authorization", "Bearer owner")
.send({
email: user.email,
newEmail: "bob@example.com",
requestType: "VERIFY_AND_CHANGE_EMAIL",
returnOobLink: true,
})
.then((res) => {
expectStatusCode(200, res);
expect(res.body.email).to.equal(user.email);
expect(res.body.oobCode).to.be.a("string");
expect(res.body.oobLink).to.be.a("string");
});
});

it("should return OOB code by idToken for OAuth 2 requests as well", async () => {
Expand All @@ -91,6 +107,23 @@
expect(res.body.oobCode).to.be.a("string");
expect(res.body.oobLink).to.be.a("string");
});

await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
.set("Authorization", "Bearer owner")
.send({
email: user.email,
newEmail: "bob@example.com",
idToken,
requestType: "VERIFY_AND_CHANGE_EMAIL",
returnOobLink: true,
})
.then((res) => {
expectStatusCode(200, res);
expect(res.body.email).to.equal(user.email);
expect(res.body.oobCode).to.be.a("string");
expect(res.body.oobLink).to.be.a("string");
});
});

it("should error when trying to verify email without idToken or email", async () => {
Expand All @@ -100,7 +133,7 @@
await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
.query({ key: "fake-api-key" })
.send({ requestType: "VERIFY_EMAIL" })
.send({ idToken: "hoge", requestType: "VERIFY_EMAIL" })
.then((res) => {
expectStatusCode(400, res);
expect(res.body.error).to.have.property("message").equal("INVALID_ID_TOKEN");
Expand Down Expand Up @@ -281,6 +314,7 @@
it("should return purpose of oobCodes via resetPassword endpoint", async () => {
const user = { email: "alice@example.com", password: "notasecret" };
const { idToken } = await registerUser(authApi(), user);
const newEmail = "bob@example.com";

await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
Expand All @@ -297,11 +331,22 @@
await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
.query({ key: "fake-api-key" })
.send({ email: "bob@example.com", requestType: "EMAIL_SIGNIN" })
.send({ email: newEmail, requestType: "EMAIL_SIGNIN" })
.then((res) => expectStatusCode(200, res));

await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
.query({ key: "fake-api-key" })
.send({
email: user.email,
newEmail,
requestType: "VERIFY_AND_CHANGE_EMAIL",
idToken,
})
.then((res) => expectStatusCode(200, res));

const oobs = await inspectOobs(authApi());
expect(oobs).to.have.length(3);
expect(oobs).to.have.length(4);

for (const oob of oobs) {
await authApi()
Expand All @@ -322,12 +367,15 @@
} else {
expect(res.body.email).to.equal(oob.email);
}
if (oob.requestType === "VERIFY_AND_CHANGE_EMAIL") {
expect(res.body.newEmail).to.equal(newEmail);
}
});
}

// OOB codes are not consumed by the lookup above.
const oobs2 = await inspectOobs(authApi());
expect(oobs2).to.have.length(3);
expect(oobs2).to.have.length(4);
});

it("should error on resetPassword if auth is disabled", async () => {
Expand Down Expand Up @@ -395,4 +443,189 @@
expect(res.body).to.have.property("email").equals(user.email);
});
});

it("should generate OOB code for verify and change email", async () => {
const user = { email: "alice@example.com", password: "notasecret" };
const { idToken, localId } = await registerUser(authApi(), user);

await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
.query({ key: "fake-api-key" })
.send({
email: user.email,
newEmail: "bob@example.com",
idToken,
requestType: "VERIFY_AND_CHANGE_EMAIL",
})
.then((res) => {
expectStatusCode(200, res);
expect(res.body)
.to.have.property("kind")
.equals("identitytoolkit#GetOobConfirmationCodeResponse");
expect(res.body.email).to.equal(user.email);

// These fields should not be set since returnOobLink is not set.
expect(res.body).not.to.have.property("oobCode");
expect(res.body).not.to.have.property("oobLink");
});

const oobs = await inspectOobs(authApi());
expect(oobs).to.have.length(1);
expect(oobs[0].email).to.equal(user.email);
expect(oobs[0].requestType).to.equal("VERIFY_AND_CHANGE_EMAIL");

// The returned oobCode can be redeemed to verify and change the email.
await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:update")
.query({ key: "fake-api-key" })
// OOB code is enough, no idToken needed.
.send({ oobCode: oobs[0].oobCode })
.then((res) => {
expectStatusCode(200, res);
expect(res.body.localId).to.equal(localId);
expect(res.body.email).to.equal("bob@example.com");
expect(res.body.emailVerified).to.equal(true);
});

// oobCode is removed after redeemed.
const oobs2 = await inspectOobs(authApi());
expect(oobs2).to.have.length(0);
});

it("should error when trying to verify and change email without idToken or email or newEmail", async () => {
const user = { email: "alice@example.com", password: "notasecret" };
await registerUser(authApi(), user);

await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
.query({ key: "fake-api-key" })
.send({ newEmail: "bob@example.com", requestType: "VERIFY_AND_CHANGE_EMAIL" })
.then((res) => {
expectStatusCode(400, res);
expect(res.body.error).to.have.property("message").equal("MISSING_ID_TOKEN");
});

await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
.query({ key: "fake-api-key" })
.send({ email: user.email, requestType: "VERIFY_AND_CHANGE_EMAIL" })
.then((res) => {
expectStatusCode(400, res);
expect(res.body.error).to.have.property("message").equal("MISSING_NEW_EMAIL");
});

await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
.set("Authorization", "Bearer owner")
.send({
newEmail: "bob@example.com",
returnOobLink: true,
requestType: "VERIFY_AND_CHANGE_EMAIL",
})
.then((res) => {
expectStatusCode(400, res);
expect(res.body.error).to.have.property("message").equal("MISSING_EMAIL");
});

await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
.set("Authorization", "Bearer owner")
.send({
email: user.email,
returnOobLink: true,
requestType: "VERIFY_AND_CHANGE_EMAIL",
})
.then((res) => {
expectStatusCode(400, res);
expect(res.body.error).to.have.property("message").equal("MISSING_NEW_EMAIL");
});

const oobs = await inspectOobs(authApi());
expect(oobs).to.have.length(0);
});

it("should error when trying to verify and change email without idToken if not returnOobLink", async () => {
const user = await registerUser(authApi(), {
email: "alice@example.com",
password: "notasecret",
});

await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
.query({ key: "fake-api-key" })
.send({
email: user.email,
newEmail: "bob@example.com",
requestType: "VERIFY_AND_CHANGE_EMAIL",
})
.then((res) => {
expectStatusCode(400, res);
expect(res.body.error).to.have.property("message").equal("MISSING_ID_TOKEN");
});

const oobs = await inspectOobs(authApi());
expect(oobs).to.have.length(0);
});

it("should error when trying to verify and change email not associated with any user", async () => {
await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
.set("Authorization", "Bearer owner")
.send({
email: "nosuchuser@example.com",
newEmail: "bob@example.com",
returnOobLink: true,
requestType: "VERIFY_AND_CHANGE_EMAIL",
})
.then((res) => {
expectStatusCode(400, res);
expect(res.body.error).to.have.property("message").equal("USER_NOT_FOUND");
});

const oobs = await inspectOobs(authApi());
expect(oobs).to.have.length(0);
});

it("should error if newEmail is already associated to another user", async () => {
const user = {
email: "alice@example.com",
password: "notasecret",
};
const { idToken } = await registerUser(authApi(), user);
const anotherUser = await registerUser(authApi(), {
email: "bob@example.com",
password: "notasecret",
});

await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
.query({ key: "fake-api-key" })
.send({
idToken,
email: user.email,
newEmail: anotherUser.email,
requestType: "VERIFY_AND_CHANGE_EMAIL",
})
.then((res) => {
expectStatusCode(400, res);
expect(res.body.error).to.have.property("message").equal("EMAIL_EXISTS");
});

await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
.set("Authorization", "Bearer owner")
.send({
email: user.email,
newEmail: anotherUser.email,
returnOobLink: true,
requestType: "VERIFY_AND_CHANGE_EMAIL",
})
.then((res) => {
expectStatusCode(400, res);
expect(res.body.error).to.have.property("message").equal("EMAIL_EXISTS");
});

const oobs = await inspectOobs(authApi());
expect(oobs).to.have.length(0);
});
});
Loading
Loading