Skip to content
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
49 changes: 28 additions & 21 deletions src/emulator/auth/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ function signUp(
if (reqBody.idToken) {
assert(!reqBody.localId, "UNEXPECTED_PARAMETER : User ID");
}
if (reqBody.localId) {
// Fail fast if localId is taken (matching production behavior).
assert(!state.getUserByLocalId(reqBody.localId), "DUPLICATE_LOCAL_ID");
}

updates.displayName = reqBody.displayName;
updates.photoUrl = reqBody.photoUrl;
updates.emailVerified = reqBody.emailVerified || false;
Expand Down Expand Up @@ -189,34 +194,33 @@ function lookup(
reqBody: Schemas["GoogleCloudIdentitytoolkitV1GetAccountInfoRequest"],
ctx: ExegesisContext
): Schemas["GoogleCloudIdentitytoolkitV1GetAccountInfoResponse"] {
const seenLocalIds = new Set<string>();
const users: UserInfo[] = [];
function tryAddUser(maybeUser: UserInfo | undefined): void {
if (maybeUser && !seenLocalIds.has(maybeUser.localId)) {
users.push(maybeUser);
seenLocalIds.add(maybeUser.localId);
}
}

if (ctx.security?.Oauth2) {
if (reqBody.initialEmail) {
throw new NotImplementedError("Lookup by initialEmail is not implemented.");
}
if (reqBody.localId) {
for (const localId of reqBody.localId) {
const maybeUser = state.getUserByLocalId(localId);
if (maybeUser) {
users.push(maybeUser);
}
}
for (const localId of reqBody.localId ?? []) {
tryAddUser(state.getUserByLocalId(localId));
}
if (reqBody.email) {
for (const email of reqBody.email) {
const maybeUser = state.getUserByEmail(email);
if (maybeUser) {
users.push(maybeUser);
}
}
for (const email of reqBody.email ?? []) {
tryAddUser(state.getUserByEmail(email));
}
if (reqBody.phoneNumber) {
for (const phoneNumber of reqBody.phoneNumber) {
const maybeUser = state.getUserByPhoneNumber(phoneNumber);
if (maybeUser) {
users.push(maybeUser);
}
for (const phoneNumber of reqBody.phoneNumber ?? []) {
tryAddUser(state.getUserByPhoneNumber(phoneNumber));
}
for (const { providerId, rawId } of reqBody.federatedUserId ?? []) {
if (!providerId || !rawId) {
continue;
}
tryAddUser(state.getUserByProviderRawId(providerId, rawId));
}
} else {
assert(reqBody.idToken, "MISSING_ID_TOKEN");
Expand Down Expand Up @@ -340,7 +344,7 @@ function batchCreate(
// TODO: Support MFA.

fields.validSince = toUnixTimestamp(uploadTime).toString();
fields.createdAt = uploadTime.toString();
fields.createdAt = uploadTime.getTime().toString();
if (fields.createdAt && !isNaN(Number(userInfo.createdAt))) {
fields.createdAt = userInfo.createdAt;
}
Expand Down Expand Up @@ -938,6 +942,7 @@ export function setAccountInfoImpl(
if (reqBody.phoneNumber && reqBody.phoneNumber !== user.phoneNumber) {
assert(isValidPhoneNumber(reqBody.phoneNumber), "INVALID_PHONE_NUMBER : Invalid format.");
assert(!state.getUserByPhoneNumber(reqBody.phoneNumber), "PHONE_NUMBER_EXISTS");
updates.phoneNumber = reqBody.phoneNumber;
}
fieldsToCopy.push(
"emailVerified",
Expand Down Expand Up @@ -1479,6 +1484,8 @@ function issueTokens(
signInProvider: string,
extraClaims: Record<string, unknown> = {}
): { idToken: string; refreshToken: string; expiresIn: string } {
state.updateUserByLocalId(user.localId, { lastRefreshAt: new Date().toISOString() });

const expiresInSeconds = 60 * 60;
const idToken = generateJwt(state.projectId, user, signInProvider, expiresInSeconds, extraClaims);
const refreshToken = state.createRefreshTokenFor(user, signInProvider, extraClaims);
Expand Down
50 changes: 25 additions & 25 deletions src/emulator/auth/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,44 +504,44 @@ function validateAndFixRestMappingRequestBody(
): ReturnType<ValidatorFunction> {
body = convertKeysToCamelCase(body);

// Protobuf JSON parser accepts enum values as either string or integer, but
// Protobuf JSON parser accepts enum values as either string or int index, but
// the JSON schema only accepts strings, causing validation errors. We catch
// these errors and fix the paths. This is needed for e.g. Android SDK.
// Similarly, convert numbers to strings for e.g. Node Admin SDK.
let result: ReturnType<ValidatorFunction>;
let fixedErrors = false;
let keepFixing = false; // Keep fixing issues as long as we can.
const fixedPaths = new Set<string>();
do {
result = validate(body);
if (!result.errors) return result;
fixedErrors = false;
keepFixing = false;
for (const error of result.errors) {
const path = error.location?.path;
if (path && !fixedPaths.has(path) && error.ajvError?.message === "should be string") {
let schema = api.requestBodyMediaTypeObject.schema;
if (schema.$ref) {
schema = _.get(api.openApiDoc, jsonPointerToPath(schema.$ref));
const ajvError = error.ajvError;
if (!path || fixedPaths.has(path) || !ajvError) {
continue;
}
const dataPath = jsonPointerToPath(path);
const value = _.get(body, dataPath);
if (ajvError.keyword === "type" && (ajvError.params as { type: string }).type === "string") {
if (typeof value === "number") {
// Coerce numbers to strings.
// Ideally, we should handle enums differently right now, but we don't
// know if it is an enum yet (ajvError.schema is somehow undefined).
// So we'll just leave it to the next iteration and handle it below.
_.set(body, dataPath, value.toString());
keepFixing = true;
}
const schemaPath = jsonPointerToPath(error.ajvError.schemaPath);
if (
schemaPath[0] === "properties" &&
schemaPath[1] === "value" &&
schemaPath[schemaPath.length - 1] === "type"
) {
const enumValues = _.get(schema, schemaPath.slice(2, schemaPath.length - 1))?.enum;
if (Array.isArray(enumValues)) {
const dataPath = jsonPointerToPath(path);
const value = _.get(body, dataPath);
const normalizedValue = enumValues[value];
if (normalizedValue) {
_.set(body, dataPath, normalizedValue);
fixedPaths.add(path);
fixedErrors = true;
}
}
} else if (ajvError.keyword === "enum") {
const params = ajvError.params as { allowedValues: string[] };
const enumValue = params.allowedValues[value];
if (enumValue) {
_.set(body, dataPath, enumValue);
keepFixing = true;
}
}
}
} while (fixedErrors);
} while (keepFixing);
return result;
}

Expand Down
2 changes: 1 addition & 1 deletion src/emulator/auth/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export class ProjectState {
this.localIdForPhoneNumber.set(user.phoneNumber, user.localId);
upsertProviders.push({
providerId: PROVIDER_PHONE,
federatedId: user.phoneNumber,
phoneNumber: user.phoneNumber,
rawId: user.phoneNumber,
});
} else {
Expand Down
36 changes: 35 additions & 1 deletion src/test/emulators/auth/misc.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect } from "chai";
import { UserInfo } from "../../../emulator/auth/state";
import { PROJECT_ID } from "./helpers";
import { PROJECT_ID, signInWithPhoneNumber, TEST_PHONE_NUMBER } from "./helpers";
import { describeAuthEmulator } from "./setup";
import {
expectStatusCode,
Expand Down Expand Up @@ -64,6 +64,40 @@ describeAuthEmulator("accounts:lookup", ({ authApi }) => {
});
});

it("should deduplicate users", async () => {
const { localId } = await registerAnonUser(authApi());

await authApi()
.post(`/identitytoolkit.googleapis.com/v1/projects/${PROJECT_ID}/accounts:lookup`)
.set("Authorization", "Bearer owner")
.send({ localId: [localId, localId] /* two with the same id */ })
.then((res) => {
expectStatusCode(200, res);
expect(res.body.users).to.have.length(1);
expect(res.body.users[0].localId).to.equal(localId);
});
});

it("should return providerUserInfo for phone auth users", async () => {
const { localId } = await signInWithPhoneNumber(authApi(), TEST_PHONE_NUMBER);

await authApi()
.post(`/identitytoolkit.googleapis.com/v1/projects/${PROJECT_ID}/accounts:lookup`)
.set("Authorization", "Bearer owner")
.send({ localId: [localId] })
.then((res) => {
expectStatusCode(200, res);
expect(res.body.users).to.have.length(1);
expect(res.body.users[0].providerUserInfo).to.eql([
{
phoneNumber: TEST_PHONE_NUMBER,
rawId: TEST_PHONE_NUMBER,
providerId: "phone",
},
]);
});
});

it("should return empty result when localId is not found", async () => {
await authApi()
.post(`/identitytoolkit.googleapis.com/v1/projects/${PROJECT_ID}/accounts:lookup`)
Expand Down
15 changes: 15 additions & 0 deletions src/test/emulators/auth/rest.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ describeAuthEmulator("REST API mapping", ({ authApi }) => {
expect(res.body.oobLink).to.include("mode=signIn");
});
});

it("should convert numbers to strings for type:string fields", async () => {
// validSince should be an int64-formatted string, but Node.js Admin SDK
// sends it as a plain number (without quotes).
const validSince = 1611780718;
await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:update")
.set("Authorization", "Bearer owner")
.send({ localId: "nosuch", validSince })
.then((res) => {
expectStatusCode(400, res);
// It should pass JSON schema validation and get into handler logic.
expect(res.body.error.message).to.equal("USER_NOT_FOUND");
});
});
});

describeAuthEmulator("authentication", ({ authApi }) => {
Expand Down
15 changes: 15 additions & 0 deletions src/test/emulators/auth/setAccountInfo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,21 @@ describeAuthEmulator("accounts:update", ({ authApi, getClock }) => {
});
});

it("should update phoneNumber if specified", async () => {
const phoneNumber = TEST_PHONE_NUMBER;
const { localId, idToken } = await signInWithPhoneNumber(authApi(), phoneNumber);

const newPhoneNumber = "+15555550123";
await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:update")
.set("Authorization", "Bearer owner")
.send({ localId, phoneNumber: newPhoneNumber })
.then((res) => expectStatusCode(200, res));

const info = await getAccountInfoByIdToken(authApi(), idToken);
expect(info.phoneNumber).to.equal(newPhoneNumber);
});

it("should noop when setting phoneNumber to the same as before", async () => {
const phoneNumber = TEST_PHONE_NUMBER;
const { localId, idToken } = await signInWithPhoneNumber(authApi(), phoneNumber);
Expand Down