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

Adds rudimentary email enumeration protection for auth emulator #6702

Merged
merged 12 commits into from
Jan 29, 2024
49 changes: 37 additions & 12 deletions src/emulator/auth/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,10 +347,10 @@
const existingProviderAccounts = new Set<string>();
for (const userInfo of reqBody.users) {
for (const { providerId, rawId } of userInfo.providerUserInfo ?? []) {
const key = `${providerId}:${rawId}`;

Check warning on line 350 in src/emulator/auth/operations.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Invalid type "string | undefined" of template literal expression

Check warning on line 350 in src/emulator/auth/operations.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Invalid type "string | undefined" of template literal expression
assert(
!existingProviderAccounts.has(key),
`DUPLICATE_RAW_ID : Provider id(${providerId}), Raw id(${rawId})`

Check warning on line 353 in src/emulator/auth/operations.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Invalid type "string | undefined" of template literal expression

Check warning on line 353 in src/emulator/auth/operations.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Invalid type "string | undefined" of template literal expression
);
existingProviderAccounts.add(key);
}
Expand Down Expand Up @@ -495,7 +495,7 @@
);
}
state.overwriteUserWithLocalId(userInfo.localId, fields);
} catch (e: any) {

Check warning on line 498 in src/emulator/auth/operations.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
if (e instanceof BadRequestError) {
// Use friendlier messages for some codes, consistent with production.
let message = e.message;
Expand Down Expand Up @@ -556,11 +556,11 @@
ctx: ExegesisContext
): Schemas["GoogleCloudIdentitytoolkitV1DownloadAccountResponse"] {
assert(!state.disableAuth, "PROJECT_DISABLED");
const maxResults = Math.min(Math.floor(ctx.params.query.maxResults) || 20, 1000);

Check warning on line 559 in src/emulator/auth/operations.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe argument of type `any` assigned to a parameter of type `number`

const users = state.queryUsers(
{},
{ sortByField: "localId", order: "ASC", startToken: ctx.params.query.nextPageToken }

Check warning on line 563 in src/emulator/auth/operations.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
);
let newPageToken: string | undefined = undefined;

Expand Down Expand Up @@ -638,13 +638,20 @@
}
}

return {
kind: "identitytoolkit#CreateAuthUriResponse",
registered,
allProviders,
sessionId,
signinMethods,
};
if (state.enableImprovedEmailPrivacy) {
return {
kind: "identitytoolkit#CreateAuthUriResponse",
sessionId,
};
} else {
return {
kind: "identitytoolkit#CreateAuthUriResponse",
registered,
allProviders,
sessionId,
signinMethods,
};
}
}

const SESSION_COOKIE_MIN_VALID_DURATION = 5 * 60; /* 5 minutes in seconds */
Expand Down Expand Up @@ -793,7 +800,7 @@
}

/**
* Reset password for a user account.

Check warning on line 803 in src/emulator/auth/operations.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Expected only 0 line after block description
*
* @param state the current project state
* @param reqBody request with oobCode and passwords
Expand Down Expand Up @@ -879,7 +886,14 @@
mode = "resetPassword";
assert(reqBody.email, "MISSING_EMAIL");
email = canonicalizeEmailAddress(reqBody.email);
assert(state.getUserByEmail(email), "EMAIL_NOT_FOUND");
const maybeUser = state.getUserByEmail(email);

Check warning on line 889 in src/emulator/auth/operations.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected lexical declaration in case block
if (state.enableImprovedEmailPrivacy && !maybeUser) {
return {
kind: "identitytoolkit#GetOobConfirmationCodeResponse",
email,
};
}
assert(maybeUser, "EMAIL_NOT_FOUND");
break;
case "VERIFY_EMAIL":
mode = "verifyEmail";
Expand Down Expand Up @@ -983,7 +997,7 @@
}

/**
* Updates an account based on localId, idToken, or oobCode.

Check warning on line 1000 in src/emulator/auth/operations.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Expected only 0 line after block description
*
* @param state the current project state
* @param reqBody request with fields to update
Expand Down Expand Up @@ -1726,10 +1740,21 @@

const email = canonicalizeEmailAddress(reqBody.email);
let user = state.getUserByEmail(email);
assert(user, "EMAIL_NOT_FOUND");
assert(!user.disabled, "USER_DISABLED");
assert(user.passwordHash && user.salt, "INVALID_PASSWORD");
assert(user.passwordHash === hashPassword(reqBody.password, user.salt), "INVALID_PASSWORD");

if (state.enableImprovedEmailPrivacy) {
assert(user, "INVALID_LOGIN_CREDENTIALS");
assert(!user.disabled, "USER_DISABLED");
assert(user.passwordHash && user.salt, "INVALID_LOGIN_CREDENTIALS");
assert(
user.passwordHash === hashPassword(reqBody.password, user.salt),
"INVALID_LOGIN_CREDENTIALS"
);
} else {
assert(user, "EMAIL_NOT_FOUND");
assert(!user.disabled, "USER_DISABLED");
assert(user.passwordHash && user.salt, "INVALID_PASSWORD");
assert(user.passwordHash === hashPassword(reqBody.password, user.salt), "INVALID_PASSWORD");
}

const response = {
kind: "identitytoolkit#VerifyPasswordResponse",
Expand Down
20 changes: 20 additions & 0 deletions src/emulator/auth/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export abstract class ProjectState {

abstract get oneAccountPerEmail(): boolean;

abstract get enableImprovedEmailPrivacy(): boolean;

abstract get authCloudFunction(): AuthCloudFunction;

abstract get allowPasswordSignup(): boolean;
Expand Down Expand Up @@ -578,6 +580,9 @@ export class AgentProjectState extends ProjectState {
private _config: Config = {
signIn: { allowDuplicateEmails: false },
blockingFunctions: {},
emailPrivacyConfig: {
enableImprovedEmailPrivacy: false,
},
};

constructor(projectId: string) {
Expand All @@ -596,6 +601,14 @@ export class AgentProjectState extends ProjectState {
this._config.signIn.allowDuplicateEmails = !oneAccountPerEmail;
}

get enableImprovedEmailPrivacy() {
return !!this._config.emailPrivacyConfig.enableImprovedEmailPrivacy;
}

set enableImprovedEmailPrivacy(improveEmailPrivacy: boolean) {
this._config.emailPrivacyConfig.enableImprovedEmailPrivacy = improveEmailPrivacy;
}

get allowPasswordSignup() {
return true;
}
Expand Down Expand Up @@ -748,6 +761,10 @@ export class TenantProjectState extends ProjectState {
return this.parentProject.oneAccountPerEmail;
}

get enableImprovedEmailPrivacy() {
return this.parentProject.enableImprovedEmailPrivacy;
}

get authCloudFunction() {
return this.parentProject.authCloudFunction;
}
Expand Down Expand Up @@ -853,13 +870,16 @@ export type SignInConfig = MakeRequired<
export type BlockingFunctionsConfig =
Schemas["GoogleCloudIdentitytoolkitAdminV2BlockingFunctionsConfig"];

export type EmailPrivacyConfig = Schemas["GoogleCloudIdentitytoolkitAdminV2EmailPrivacyConfig"];

// Serves as a substitute for Schemas["GoogleCloudIdentitytoolkitAdminV2Config"],
// i.e. the configuration object for top-level AgentProjectStates. Emulator
// fixes certain configurations for ease of use / testing, so as non-standard
// behavior, Config only stores the configurable fields.
export type Config = {
signIn: SignInConfig;
blockingFunctions: BlockingFunctionsConfig;
emailPrivacyConfig: EmailPrivacyConfig;
};

export interface RefreshTokenRecord {
Expand Down
49 changes: 49 additions & 0 deletions src/test/emulators/auth/createAuthUri.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
signInWithEmailLink,
updateProjectConfig,
registerTenant,
updateConfig,
} from "./helpers";

describeAuthEmulator("accounts:createAuthUri", ({ authApi }) => {
Expand All @@ -23,6 +24,28 @@ describeAuthEmulator("accounts:createAuthUri", ({ authApi }) => {
});
});

it("should not show registered field with improved email privacy enabled", async () => {
await updateConfig(
authApi(),
PROJECT_ID,
{
emailPrivacyConfig: {
enableImprovedEmailPrivacy: true,
},
},
"emailPrivacyConfig"
);
await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:createAuthUri")
.send({ continueUri: "http://example.com/", identifier: "notregistered@example.com" })
.query({ key: "fake-api-key" })
.then((res) => {
expectStatusCode(200, res);
expect(res.body).to.not.have.property("registered");
expect(res.body).to.have.property("sessionId").that.is.a("string");
});
});

it("should return providers for a registered user", async () => {
const user = { email: "alice@example.com", password: "notasecret" };
await registerUser(authApi(), user);
Expand All @@ -39,6 +62,32 @@ describeAuthEmulator("accounts:createAuthUri", ({ authApi }) => {
});
});

it("should not return providers for a registered user with improved email privacy enabled", async () => {
const user = { email: "alice@example.com", password: "notasecret" };
await updateConfig(
authApi(),
PROJECT_ID,
{
emailPrivacyConfig: {
enableImprovedEmailPrivacy: true,
},
},
"emailPrivacyConfig"
);
await registerUser(authApi(), user);
await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:createAuthUri")
.send({ continueUri: "http://example.com/", identifier: user.email })
.query({ key: "fake-api-key" })
.then((res) => {
expectStatusCode(200, res);
expect(res.body).to.not.have.property("registered");
expect(res.body).to.not.have.property("allProviders").eql(["password"]);
expect(res.body).to.not.have.property("signinMethods").eql(["password"]);
expect(res.body).to.have.property("sessionId").that.is.a("string");
});
});

it("should return existing sessionId if provided", async () => {
await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:createAuthUri")
Expand Down
39 changes: 39 additions & 0 deletions src/test/emulators/auth/oob.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
expectIdTokenExpired,
inspectOobs,
registerTenant,
updateConfig,
} from "./helpers";

describeAuthEmulator("accounts:sendOobCode", ({ authApi, getClock }) => {
Expand Down Expand Up @@ -357,4 +358,42 @@
expect(res.body.error).to.have.property("message").equals("PASSWORD_LOGIN_DISABLED");
});
});

it("should error when sending a password reset with improved email privacy disabled", async () => {
const user = { email: "alice@example.com", password: "notasecret" };
// await registerUser(authApi(), user);
await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
.set("Authorization", "Bearer owner")
Dismissed Show dismissed Hide dismissed
.send({ email: user.email, requestType: "PASSWORD_RESET", returnOobLink: true })
.then((res) => {
expectStatusCode(400, res);
expect(res.body.error).to.have.property("message").equals("EMAIL_NOT_FOUND");
});
});

it("should error when sending a password reset with improved email privacy enabled", async () => {
const user = { email: "alice@example.com", password: "notasecret" };
await updateConfig(
authApi(),
PROJECT_ID,
{
emailPrivacyConfig: {
enableImprovedEmailPrivacy: true,
},
},
"emailPrivacyConfig"
);
await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:sendOobCode")
.set("Authorization", "Bearer owner")
Dismissed Show dismissed Hide dismissed
.send({ email: user.email, requestType: "PASSWORD_RESET", returnOobLink: true })
.then((res) => {
expectStatusCode(200, res);
expect(res.body)
.to.have.property("kind")
.equals("identitytoolkit#GetOobConfirmationCodeResponse");
expect(res.body).to.have.property("email").equals(user.email);
});
});
});
45 changes: 45 additions & 0 deletions src/test/emulators/auth/password.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,27 @@ describeAuthEmulator("accounts:signInWithPassword", ({ authApi, getClock }) => {
});
});

it("should error if email is not found with improved email privacy enabled", async () => {
await updateConfig(
authApi(),
PROJECT_ID,
{
emailPrivacyConfig: {
enableImprovedEmailPrivacy: true,
},
},
"emailPrivacyConfig"
);
await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:signInWithPassword")
.query({ key: "fake-api-key" })
.send({ email: "nosuchuser@example.com", password: "notasecret" })
.then((res) => {
expectStatusCode(400, res);
expect(res.body.error.message).equals("INVALID_LOGIN_CREDENTIALS");
});
});

it("should error if password is wrong", async () => {
const user = { email: "alice@example.com", password: "notasecret" };
await registerUser(authApi(), user);
Expand All @@ -145,6 +166,30 @@ describeAuthEmulator("accounts:signInWithPassword", ({ authApi, getClock }) => {
});
});

it("should error if password is wrong with improved email privacy enabled", async () => {
const user = { email: "alice@example.com", password: "notasecret" };
await updateConfig(
authApi(),
PROJECT_ID,
{
emailPrivacyConfig: {
enableImprovedEmailPrivacy: true,
},
},
"emailPrivacyConfig"
);
await registerUser(authApi(), user);
await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:signInWithPassword")
.query({ key: "fake-api-key" })
// Passwords are case sensitive. The uppercase one below doesn't match.
.send({ email: user.email, password: "NOTASECRET" })
.then((res) => {
expectStatusCode(400, res);
expect(res.body.error.message).equals("INVALID_LOGIN_CREDENTIALS");
});
});

it("should error if user is disabled", async () => {
const user = { email: "alice@example.com", password: "notasecret" };
const { localId } = await registerUser(authApi(), user);
Expand Down