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 to reset email when updateEmail is used. #3096

Merged
merged 18 commits into from Feb 17, 2021
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/emulator/auth/handlers.ts
Expand Up @@ -36,6 +36,42 @@ export function registerHandlers(
const state = getProjectStateByApiKey(apiKey);

switch (mode) {
case "recoverEmail": {
const oob = state.validateOobCode(oobCode);
const RETRY_INSTRUCTIONS =
"If you're trying to test the reverting email flow, try changing the email again to generate a new link.";
if (oob?.requestType !== "RECOVER_EMAIL") {
return res.status(400).json({
authEmulator: {
error: `Requested mode does not match the OOB code provided.`,
instructions: RETRY_INSTRUCTIONS,
},
});
}
try {
const resp = setAccountInfoImpl(state, {
oobCode,
});
const email = resp.email;
return res.status(200).json({
authEmulator: { success: `The email has been successfully reset.`, email },
});
} catch (e) {
if (
e instanceof NotImplementedError ||
(e instanceof BadRequestError && e.message === "INVALID_OOB_CODE")
) {
return res.status(400).json({
authEmulator: {
error: `Your request to revert your email has expired or the link has already been used.`,
instructions: RETRY_INSTRUCTIONS,
},
});
} else {
throw e;
}
}
}
case "resetPassword": {
const oob = state.validateOobCode(oobCode);
if (oob?.requestType !== "PASSWORD_RESET") {
Expand Down
183 changes: 133 additions & 50 deletions src/emulator/auth/operations.ts
Expand Up @@ -18,13 +18,15 @@ import { Emulators } from "../types";
import { EmulatorLogger } from "../emulatorLogger";
import {
ProjectState,
OobRequestType,
UserInfo,
ProviderUserInfo,
PROVIDER_PASSWORD,
PROVIDER_ANONYMOUS,
PROVIDER_PHONE,
SIGNIN_METHOD_EMAIL_LINK,
PROVIDER_CUSTOM,
OobRecord,
} from "./state";

import * as schema from "./schema";
Expand Down Expand Up @@ -206,6 +208,7 @@ function lookup(

if (ctx.security?.Oauth2) {
if (reqBody.initialEmail) {
// TODO: This is now possible. See ProjectState.getUserByInitialEmail.
ssbushi marked this conversation as resolved.
Show resolved Hide resolved
throw new NotImplementedError("Lookup by initialEmail is not implemented.");
}
for (const localId of reqBody.localId ?? []) {
Expand Down Expand Up @@ -776,50 +779,22 @@ function sendOobCode(
);
}

const { oobCode, oobLink } = state.createOob(email, reqBody.requestType, (oobCode) => {
// TODO: Support custom handler links.
const url = authEmulatorUrl(ctx.req as express.Request);
url.pathname = "/emulator/action";
url.searchParams.set("mode", mode);
url.searchParams.set("lang", "en");
url.searchParams.set("oobCode", oobCode);

// This doesn't matter for now, since any API key works for defaultProject.
// TODO: What if reqBody.targetProjectId is set?
url.searchParams.set("apiKey", "fake-api-key");

if (reqBody.continueUrl) {
url.searchParams.set("continueUrl", reqBody.continueUrl);
}

return url.toString();
const url = authEmulatorUrl(ctx.req as express.Request);
const oobRecord = createOobRecord(state, email, url, {
requestType: reqBody.requestType,
mode,
continueUrl: reqBody.continueUrl,
});

if (reqBody.returnOobLink) {
return {
kind: "identitytoolkit#GetOobConfirmationCodeResponse",
email,
oobCode,
oobLink,
oobCode: oobRecord.oobCode,
oobLink: oobRecord.oobLink,
};
} else {
// Print out a developer-friendly log containing the link, in lieu of
// sending a real email out to the email address.
let message: string | undefined;
switch (reqBody.requestType) {
case "EMAIL_SIGNIN":
message = `To sign in as ${email}, follow this link: ${oobLink}`;
break;
case "PASSWORD_RESET":
message = `To reset the password for ${email}, follow this link: ${oobLink}&newPassword=NEW_PASSWORD_HERE`;
break;
case "VERIFY_EMAIL":
message = `To verify the email address ${email}, follow this link: ${oobLink}`;
break;
}
if (message) {
EmulatorLogger.forEmulator(Emulators.AUTH).log("BULLET", message);
}
logOobMessage(reqBody.requestType, oobRecord.oobLink, email);

return {
kind: "identitytoolkit#GetOobConfirmationCodeResponse",
Expand Down Expand Up @@ -860,7 +835,11 @@ function setAccountInfo(
reqBody: Schemas["GoogleCloudIdentitytoolkitV1SetAccountInfoRequest"],
ctx: ExegesisContext
): Schemas["GoogleCloudIdentitytoolkitV1SetAccountInfoResponse"] {
return setAccountInfoImpl(state, reqBody, { privileged: !!ctx.security?.Oauth2 });
const url = authEmulatorUrl(ctx.req as express.Request);
return setAccountInfoImpl(state, reqBody, {
privileged: !!ctx.security?.Oauth2,
emulatorUrl: url,
});
}

/**
Expand All @@ -869,12 +848,13 @@ function setAccountInfo(
* @param state the current project state
* @param reqBody request with fields to update
* @param privileged whether request is OAuth2 authenticated. Affects validation
* @param emulatorUrl url to the auth emulator instance. Needed for sending OOB link for email reset
* @return the HTTP response body
*/
export function setAccountInfoImpl(
state: ProjectState,
reqBody: Schemas["GoogleCloudIdentitytoolkitV1SetAccountInfoRequest"],
{ privileged = false }: { privileged?: boolean } = {}
{ privileged = false, emulatorUrl = undefined }: { privileged?: boolean; emulatorUrl?: URL } = {}
): Schemas["GoogleCloudIdentitytoolkitV1SetAccountInfoResponse"] {
// TODO: Implement these.
const unimplementedFields: (keyof typeof reqBody)[] = [
Expand Down Expand Up @@ -914,22 +894,40 @@ export function setAccountInfoImpl(
const updates: Omit<Partial<UserInfo>, "localId" | "providerUserInfo"> = {};
let user: UserInfo;
let signInProvider: string | undefined;
let isEmailUpdate: boolean = false;

if (reqBody.oobCode) {
const oob = state.validateOobCode(reqBody.oobCode);
assert(oob, "INVALID_OOB_CODE");
if (oob.requestType !== "VERIFY_EMAIL") {
throw new NotImplementedError(oob.requestType);
}
state.deleteOobCode(reqBody.oobCode);

signInProvider = PROVIDER_PASSWORD;
const maybeUser = state.getUserByEmail(oob.email);
assert(maybeUser, "INVALID_OOB_CODE");
user = maybeUser;
updates.emailVerified = true;
if (oob.email !== user.email) {
updates.email = oob.email;
switch (oob.requestType) {
case "VERIFY_EMAIL": {
state.deleteOobCode(reqBody.oobCode);
signInProvider = PROVIDER_PASSWORD;
const maybeUser = state.getUserByEmail(oob.email);
assert(maybeUser, "INVALID_OOB_CODE");
user = maybeUser;
updates.emailVerified = true;
if (oob.email !== user.email) {
updates.email = oob.email;
}
break;
}
case "RECOVER_EMAIL": {
state.deleteOobCode(reqBody.oobCode);
const maybeUser = state.getUserByInitialEmail(oob.email);
assert(maybeUser, "INVALID_OOB_CODE");
// Assert that we don't have any user with this initialEmail
assert(!state.getUserByEmail(oob.email), "EMAIL_EXISTS");
user = maybeUser;
if (oob.email !== user.email) {
updates.email = oob.email;
// Consider email verified, since this flow is initiated from the user's email
updates.emailVerified = true;
}
break;
}
default:
throw new NotImplementedError(oob.requestType);
}
} else {
if (reqBody.idToken) {
Expand All @@ -944,12 +942,21 @@ export function setAccountInfoImpl(

if (reqBody.email) {
assert(isValidEmailAddress(reqBody.email), "INVALID_EMAIL");

const newEmail = canonicalizeEmailAddress(reqBody.email);
if (newEmail !== user.email) {
assert(!state.getUserByEmail(newEmail), "EMAIL_EXISTS");
yuchenshi marked this conversation as resolved.
Show resolved Hide resolved
updates.email = newEmail;
// TODO: Set verified if email is verified by IDP linked to account.
updates.emailVerified = false;
isEmailUpdate = true;
// Only update initial email if the user is not anonymous and does not have an initial email.
// We need to check for an anonymous user through the signIn provider, rather than relying
// on an empty user.email field, because it is possible for an anonymous user to update their
// email address through the SetAccountInfo endpoint.
if (signInProvider !== PROVIDER_ANONYMOUS && user.email && !user.initialEmail) {
updates.initialEmail = user.email;
yuchenshi marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
if (reqBody.password) {
Expand Down Expand Up @@ -1033,6 +1040,14 @@ export function setAccountInfoImpl(
deleteProviders: reqBody.deleteProvider,
});

// Only initiate the recover email OOB flow for non-anonymous users
if (signInProvider !== PROVIDER_ANONYMOUS && user.initialEmail && isEmailUpdate) {
if (!emulatorUrl) {
throw new Error("Internal assertion error: missing emulatorUrl param");
}
sendOobForEmailReset(state, user.initialEmail, emulatorUrl);
}

return redactPasswordHash({
kind: "identitytoolkit#SetAccountInfoResponse",
localId: user.localId,
Expand All @@ -1048,6 +1063,74 @@ export function setAccountInfoImpl(
});
}

function sendOobForEmailReset(state: ProjectState, initialEmail: string, url: URL) {
const MODE: string = "recoverEmail";
const RECOVER_EMAIL_REQUEST_TYPE: OobRequestType = "RECOVER_EMAIL";
ssbushi marked this conversation as resolved.
Show resolved Hide resolved
const oobRecord = createOobRecord(state, initialEmail, url, {
requestType: RECOVER_EMAIL_REQUEST_TYPE,
mode: MODE,
});

// Print out a developer-friendly log
logOobMessage(RECOVER_EMAIL_REQUEST_TYPE, oobRecord.oobLink, initialEmail);
}

function createOobRecord(
state: ProjectState,
email: string,
url: URL,
params: {
requestType: OobRequestType;
mode: string;
continueUrl?: string;
}
): OobRecord {
// Encode the old email with the OOB code. The new email is stored in the UserInfo object.
ssbushi marked this conversation as resolved.
Show resolved Hide resolved
const oobRecord = state.createOob(email, params.requestType, (oobCode) => {
url.pathname = "/emulator/action";
url.searchParams.set("mode", params.mode);
url.searchParams.set("lang", "en");
url.searchParams.set("oobCode", oobCode);
// TODO: Support custom handler links.

// This doesn't matter for now, since any API key works for defaultProject.
// TODO: What if reqBody.targetProjectId is set?
url.searchParams.set("apiKey", "fake-api-key");

if (params.continueUrl) {
url.searchParams.set("continueUrl", params.continueUrl);
}

return url.toString();
});

return oobRecord;
}

function logOobMessage(requestType: OobRequestType, oobLink: string, email: string) {
yuchenshi marked this conversation as resolved.
Show resolved Hide resolved
// Generate a developer-friendly log containing the link, in lieu of
// sending a real email out to the email address.
let maybeMessage: string | undefined;
switch (requestType) {
case "EMAIL_SIGNIN":
maybeMessage = `To sign in as ${email}, follow this link: ${oobLink}`;
break;
case "PASSWORD_RESET":
maybeMessage = `To reset the password for ${email}, follow this link: ${oobLink}&newPassword=NEW_PASSWORD_HERE`;
break;
case "VERIFY_EMAIL":
maybeMessage = `To verify the email address ${email}, follow this link: ${oobLink}`;
break;
case "RECOVER_EMAIL":
maybeMessage = `To reset your email address to ${email}, follow this link: ${oobLink}`;
break;
}

if (maybeMessage) {
EmulatorLogger.forEmulator(Emulators.AUTH).log("BULLET", maybeMessage);
}
}

function signInWithCustomToken(
state: ProjectState,
reqBody: Schemas["GoogleCloudIdentitytoolkitV1SignInWithCustomTokenRequest"]
Expand Down
19 changes: 18 additions & 1 deletion src/emulator/auth/state.ts
Expand Up @@ -15,6 +15,7 @@ export const SIGNIN_METHOD_EMAIL_LINK = "emailLink";
export class ProjectState {
private users: Map<string, UserInfo> = new Map();
private localIdForEmail: Map<string, string> = new Map();
private localIdForInitialEmail: Map<string, string> = new Map();
private localIdForPhoneNumber: Map<string, string> = new Map();
private localIdsForProviderEmail: Map<string, Set<string>> = new Map();
private userIdForProviderRawId: Map<string, Map<string, string>> = new Map();
Expand Down Expand Up @@ -153,6 +154,10 @@ export class ProjectState {
deleteProviders.push(PROVIDER_PASSWORD);
}

if (user.initialEmail) {
this.localIdForInitialEmail.set(user.initialEmail, user.localId);
}

if (oldPhoneNumber && oldPhoneNumber !== user.phoneNumber) {
this.localIdForPhoneNumber.delete(oldPhoneNumber);
}
Expand Down Expand Up @@ -234,6 +239,14 @@ export class ProjectState {
return this.getUserByLocalIdAssertingExists(localId);
}

getUserByInitialEmail(initialEmail: string): UserInfo | undefined {
const localId = this.localIdForInitialEmail.get(initialEmail);
if (!localId) {
return undefined;
}
return this.getUserByLocalIdAssertingExists(localId);
}

private getUserByLocalIdAssertingExists(localId: string): UserInfo {
const userInfo = this.getUserByLocalId(localId);
if (!userInfo) {
Expand Down Expand Up @@ -471,6 +484,10 @@ export class ProjectState {
this.localIdForEmail.delete(user.email);
}

if (user.initialEmail) {
this.localIdForInitialEmail.delete(user.initialEmail);
}

if (user.phoneNumber) {
this.localIdForPhoneNumber.delete(user.phoneNumber);
}
Expand Down Expand Up @@ -501,7 +518,7 @@ interface RefreshTokenRecord {
extraClaims: Record<string, unknown>;
}

type OobRequestType = NonNullable<
export type OobRequestType = NonNullable<
Schemas["GoogleCloudIdentitytoolkitV1GetOobCodeRequest"]["requestType"]
>;

Expand Down