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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added rudimentary email enumeration protection for auth emulator. (#6702)
9 changes: 9 additions & 0 deletions scripts/emulator-import-export-tests/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
function readConfig(): FrameworkOptions {
const filename = path.join(__dirname, "firebase.json");
const data = fs.readFileSync(filename, "utf8");
return JSON.parse(data);

Check warning on line 43 in scripts/emulator-import-export-tests/tests.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe return of an `any` typed value
}

function logIncludes(msg: string) {
Expand Down Expand Up @@ -124,7 +124,7 @@

// Write some data to export
const config = readConfig();
const port = config.emulators!.database.port;

Check warning on line 127 in scripts/emulator-import-export-tests/tests.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Forbidden non-null assertion
const host = await localhost();
const aApp = admin.initializeApp(
{
Expand Down Expand Up @@ -214,11 +214,11 @@

// Confirm the data exported is as expected
const aPath = path.join(dbExportPath, "namespace-a.json");
const aData = JSON.parse(fs.readFileSync(aPath).toString());

Check warning on line 217 in scripts/emulator-import-export-tests/tests.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
expect(aData).to.deep.equal({ ns: "namespace-a" });

const bPath = path.join(dbExportPath, "namespace-b.json");
const bData = JSON.parse(fs.readFileSync(bPath).toString());

Check warning on line 221 in scripts/emulator-import-export-tests/tests.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
expect(bData).to.equal(null);
});

Expand All @@ -239,7 +239,7 @@

// Create some accounts to export:
const config = readConfig();
const port = config.emulators!.auth.port;

Check warning on line 242 in scripts/emulator-import-export-tests/tests.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Forbidden non-null assertion
try {
process.env.FIREBASE_AUTH_EMULATOR_HOST = `${await localhost()}:${port}`;
const adminApp = admin.initializeApp(
Expand Down Expand Up @@ -272,17 +272,20 @@

// Confirm the data is exported as expected
const configPath = path.join(exportPath, "auth_export", "config.json");
const configData = JSON.parse(fs.readFileSync(configPath).toString());

Check warning on line 275 in scripts/emulator-import-export-tests/tests.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
expect(configData).to.deep.equal({
signIn: {
allowDuplicateEmails: false,
},
emailPrivacyConfig: {
enableImprovedEmailPrivacy: false,
},
});

const accountsPath = path.join(exportPath, "auth_export", "accounts.json");
const accountsData = JSON.parse(fs.readFileSync(accountsPath).toString());

Check warning on line 286 in scripts/emulator-import-export-tests/tests.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
expect(accountsData.users).to.have.length(2);

Check warning on line 287 in scripts/emulator-import-export-tests/tests.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .users on an `any` value
expect(accountsData.users[0]).to.deep.contain({

Check warning on line 288 in scripts/emulator-import-export-tests/tests.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .users on an `any` value
localId: "123",
email: "foo@example.com",
emailVerified: false,
Expand All @@ -295,7 +298,7 @@
},
],
});
expect(accountsData.users[0].passwordHash).to.match(/:password=testing$/);

Check warning on line 301 in scripts/emulator-import-export-tests/tests.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .users on an `any` value
expect(accountsData.users[1]).to.deep.contain({
localId: "456",
email: "bar@example.com",
Expand Down Expand Up @@ -382,6 +385,9 @@
signIn: {
allowDuplicateEmails: false,
},
emailPrivacyConfig: {
enableImprovedEmailPrivacy: false,
},
});

const accountsPath = path.join(exportPath, "auth_export", "accounts.json");
Expand Down Expand Up @@ -447,6 +453,9 @@
signIn: {
allowDuplicateEmails: false,
},
emailPrivacyConfig: {
enableImprovedEmailPrivacy: false,
},
});

const accountsPath = path.join(exportPath, "auth_export", "accounts.json");
Expand Down
57 changes: 44 additions & 13 deletions src/emulator/auth/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,13 +638,20 @@ function createAuthUri(
}
}

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 @@ -879,7 +886,14 @@ function sendOobCode(
mode = "resetPassword";
assert(reqBody.email, "MISSING_EMAIL");
email = canonicalizeEmailAddress(reqBody.email);
assert(state.getUserByEmail(email), "EMAIL_NOT_FOUND");
const maybeUser = state.getUserByEmail(email);
if (state.enableImprovedEmailPrivacy && !maybeUser) {
return {
kind: "identitytoolkit#GetOobConfirmationCodeResponse",
email,
};
}
assert(maybeUser, "EMAIL_NOT_FOUND");
break;
case "VERIFY_EMAIL":
mode = "verifyEmail";
Expand All @@ -898,7 +912,7 @@ function sendOobCode(
email = user.email;
}
break;

// TODO: implement case for requestType VERIFY_AND_CHANGE_EMAIL.
default:
throw new NotImplementedError(reqBody.requestType);
}
Expand Down Expand Up @@ -1726,10 +1740,21 @@ async function signInWithPassword(

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 Expand Up @@ -1904,6 +1929,9 @@ function getEmulatorProjectConfig(state: ProjectState): Schemas["EmulatorV1Proje
signIn: {
allowDuplicateEmails: !state.oneAccountPerEmail,
},
emailPrivacyConfig: {
enableImprovedEmailPrivacy: state.enableImprovedEmailPrivacy,
},
};
}

Expand All @@ -1918,6 +1946,9 @@ function updateEmulatorProjectConfig(
if (reqBody.signIn?.allowDuplicateEmails != null) {
updateMask.push("signIn.allowDuplicateEmails");
}
if (reqBody.emailPrivacyConfig?.enableImprovedEmailPrivacy != null) {
updateMask.push("emailPrivacyConfig.enableImprovedEmailPrivacy");
}
ctx.params.query.updateMask = updateMask.join();

updateConfig(state, reqBody, ctx);
Expand Down
3 changes: 3 additions & 0 deletions src/emulator/auth/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4144,6 +4144,9 @@ export interface components {
signIn?: {
allowDuplicateEmails?: boolean;
};
emailPrivacyConfig?: {
enableImprovedEmailPrivacy?: boolean,
},
};
/** @description Details of all pending confirmation codes. */
EmulatorV1ProjectsOobCodes: {
Expand Down
22 changes: 22 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 @@ -659,6 +672,8 @@ export class AgentProjectState extends ProjectState {
if (!updateMask) {
this.oneAccountPerEmail = !update.signIn?.allowDuplicateEmails ?? true;
this.blockingFunctionsConfig = update.blockingFunctions ?? {};
this.enableImprovedEmailPrivacy =
update.emailPrivacyConfig?.enableImprovedEmailPrivacy ?? false;
return this.config;
}
return applyMask(updateMask, this.config, update);
Expand Down Expand Up @@ -748,6 +763,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 +872,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
71 changes: 70 additions & 1 deletion src/test/emulators/auth/misc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,9 @@ describeAuthEmulator("emulator utility APIs", ({ authApi }) => {
expect(res.body).to.have.property("signIn").eql({
allowDuplicateEmails: false /* default value */,
});
expect(res.body).to.have.property("emailPrivacyConfig").eql({
enableImprovedEmailPrivacy: false /* default value */,
});
});
});

Expand All @@ -564,7 +567,7 @@ describeAuthEmulator("emulator utility APIs", ({ authApi }) => {
});
});

it("should update allowDuplicateEmails on PATCH /emulator/v1/projects/{PROJECT_ID}/config", async () => {
it("should only update allowDuplicateEmails on PATCH /emulator/v1/projects/{PROJECT_ID}/config", async () => {
await authApi()
.patch(`/emulator/v1/projects/${PROJECT_ID}/config`)
.send({ signIn: { allowDuplicateEmails: true } })
Expand All @@ -573,6 +576,9 @@ describeAuthEmulator("emulator utility APIs", ({ authApi }) => {
expect(res.body).to.have.property("signIn").eql({
allowDuplicateEmails: true,
});
expect(res.body).to.have.property("emailPrivacyConfig").eql({
enableImprovedEmailPrivacy: false,
});
});
await authApi()
.patch(`/emulator/v1/projects/${PROJECT_ID}/config`)
Expand All @@ -582,6 +588,69 @@ describeAuthEmulator("emulator utility APIs", ({ authApi }) => {
expect(res.body).to.have.property("signIn").eql({
allowDuplicateEmails: false,
});
expect(res.body).to.have.property("emailPrivacyConfig").eql({
enableImprovedEmailPrivacy: false,
});
});
});

it("should only update enableImprovedEmailPrivacy on PATCH /emulator/v1/projects/{PROJECT_ID}/config", async () => {
await authApi()
.patch(`/emulator/v1/projects/${PROJECT_ID}/config`)
.send({ emailPrivacyConfig: { enableImprovedEmailPrivacy: true } })
.then((res) => {
expectStatusCode(200, res);
expect(res.body).to.have.property("signIn").eql({
allowDuplicateEmails: false,
});
expect(res.body).to.have.property("emailPrivacyConfig").eql({
enableImprovedEmailPrivacy: true,
});
});
await authApi()
.patch(`/emulator/v1/projects/${PROJECT_ID}/config`)
.send({ emailPrivacyConfig: { enableImprovedEmailPrivacy: false } })
.then((res) => {
expectStatusCode(200, res);
expect(res.body).to.have.property("signIn").eql({
allowDuplicateEmails: false,
});
expect(res.body).to.have.property("emailPrivacyConfig").eql({
enableImprovedEmailPrivacy: false,
});
});
});

it("should update both allowDuplicateEmails and enableImprovedEmailPrivacy on PATCH /emulator/v1/projects/{PROJECT_ID}/config", async () => {
await authApi()
.patch(`/emulator/v1/projects/${PROJECT_ID}/config`)
.send({
signIn: { allowDuplicateEmails: true },
emailPrivacyConfig: { enableImprovedEmailPrivacy: true },
})
.then((res) => {
expectStatusCode(200, res);
expect(res.body).to.have.property("signIn").eql({
allowDuplicateEmails: true,
});
expect(res.body).to.have.property("emailPrivacyConfig").eql({
enableImprovedEmailPrivacy: true,
});
});
await authApi()
.patch(`/emulator/v1/projects/${PROJECT_ID}/config`)
.send({
signIn: { allowDuplicateEmails: false },
emailPrivacyConfig: { enableImprovedEmailPrivacy: false },
})
.then((res) => {
expectStatusCode(200, res);
expect(res.body).to.have.property("signIn").eql({
allowDuplicateEmails: false,
});
expect(res.body).to.have.property("emailPrivacyConfig").eql({
enableImprovedEmailPrivacy: false,
});
});
});
});
Expand Down