Skip to content
Draft
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
34 changes: 33 additions & 1 deletion web/api/v1/precheck/[app_id]/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import {
} from "@/api/helpers/verify";
import { APPS_WITH_CUSTOM_EXTERNAL_NULLIFIER } from "@/lib/constants";
import { generateExternalNullifier } from "@/lib/hashing";
import { CanUserVerifyType, EngineType } from "@/lib/types";
import {
CanUserVerifyType,
EngineType,
ExperimentalFaceAuthConfig,
} from "@/lib/types";
import { getCDNImageUrl } from "@/lib/utils";
import { NextRequest, NextResponse } from "next/server";
import * as yup from "yup";
Expand All @@ -24,6 +28,11 @@ const APPS_TO_SHOW_UNVERIFIED_LOGO = [
"app_staging_79640e8c674aedb3f5969a30f80ff6f9",
];

const EXPERIMENTAL_FACE_AUTH_CONFIG_V1_PARAMETERS = {
[ExperimentalFaceAuthConfig.V1]:
"precheck/experimental-face-auth-config/enabled",
} satisfies Partial<Record<ExperimentalFaceAuthConfig, string>>;

const schema = yup
.object()
.shape({
Expand All @@ -49,6 +58,28 @@ const schema = yup

const corsMethods = ["POST", "OPTIONS"];

async function getExperimentalFaceAuthConfig(
app_id: string,
): Promise<ExperimentalFaceAuthConfig> {
try {
const enabledAppIds =
(await global.ParameterStore?.getParameter<string[]>(
EXPERIMENTAL_FACE_AUTH_CONFIG_V1_PARAMETERS[
ExperimentalFaceAuthConfig.V1
],
[],
)) ?? [];

if (Array.isArray(enabledAppIds) && enabledAppIds.includes(app_id)) {
return ExperimentalFaceAuthConfig.V1;
}
} catch {
return ExperimentalFaceAuthConfig.Disabled;
}

return ExperimentalFaceAuthConfig.Disabled;
}

/**
* Fetches public metadata for an app & action.
* Can be used to check whether a user can verify for a particular action.
Expand Down Expand Up @@ -176,6 +207,7 @@ export async function POST(
unverified_app_metadata?.integration_url ??
"",
enable_face_check: true, // Default to true now this is GA
experimental_face_auth_config: await getExperimentalFaceAuthConfig(app_id),
actions: rawAppValues.actions,
};

Expand Down
8 changes: 8 additions & 0 deletions web/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ export enum CanUserVerifyType {
OnChain = "on-chain",
}

// Options for the `experimental_face_auth_config` attribute in the /precheck endpoint
export enum ExperimentalFaceAuthConfig {
// Versions are mapped on the client side to the corresponding experimental face auth config. The server only returns the version string.
V1 = "v1",
// Default to Disabled and clients should use their default face auth config
Disabled = "disabled",
}

export interface JwtConfig {
key: string;
type: "HS512" | "HS384" | "HS256";
Expand Down
152 changes: 152 additions & 0 deletions web/tests/api/v1/precheck.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ const exampleValidRequestPayload = {
"0x2a6f11552fe9073280e1dc38358aa6b23ec4c14ab56046d4d97695b21b166690",
};

const mockParameterStoreAppIds = (appIds: string[]) => {
global.ParameterStore = {
getParameter: jest.fn().mockResolvedValue(appIds),
} as unknown as NonNullable<typeof global.ParameterStore>;
};

jest.mock("@/api/helpers/graphql", () => ({
getAPIServiceGraphqlClient: jest.fn(),
}));
Expand All @@ -50,6 +56,20 @@ jest.mock("@/api/v1/precheck/[app_id]/graphql/app-precheck.generated", () => ({
AppPrecheckQuery,
}),
}));
const FetchRpRegistrationForPrecheck = jest.fn();
jest.mock(
"@/api/v1/precheck/[app_id]/graphql/fetch-rp-registration-for-precheck.generated",
() => ({
getSdk: () => ({
FetchRpRegistrationForPrecheck,
}),
}),
);

beforeEach(() => {
jest.clearAllMocks();
global.ParameterStore = undefined;
});

describe("/api/v1/precheck/[app_id]", () => {
test("can fetch precheck response verified", async () => {
Expand Down Expand Up @@ -82,6 +102,7 @@ describe("/api/v1/precheck/[app_id]", () => {
verified_app_logo:
"https://cdn.test.com/app_staging_6d1c9fb86751a40d952749022db1c1/logo_img.png",
enable_face_check: true,
experimental_face_auth_config: "disabled",
sign_in_with_world_id: false,
can_user_verify: "undetermined", // Because no `nullifier_hash` was provided
action: {
Expand Down Expand Up @@ -123,6 +144,7 @@ describe("/api/v1/precheck/[app_id]", () => {
engine: "cloud",
verified_app_logo: "",
enable_face_check: true,
experimental_face_auth_config: "disabled",
sign_in_with_world_id: false,
can_user_verify: "undetermined", // Because no `nullifier_hash` was provided
action: {
Expand All @@ -139,6 +161,136 @@ describe("/api/v1/precheck/[app_id]", () => {
// TODO
});

test("returns enabled face auth config for allowlisted apps", async () => {
mockParameterStoreAppIds([appPayload.id]);

const request = new NextRequest(
"http://localhost:3000/api/v1/precheck/app_staging_6d1c9fb86751a40d952749022db1c1",
{
method: "POST",
body: JSON.stringify(exampleValidRequestPayload),
},
);

AppPrecheckQuery.mockResolvedValue({
app: [{ ...appPayload }],
});

const response = await POST(request, {
params: Promise.resolve({
app_id: "app_staging_6d1c9fb86751a40d952749022db1c1",
}),
});

expect(response.status).toBe(200);
const responseBody = await response.json();
expect(responseBody).toMatchObject({
id: "app_staging_6d1c9fb86751a40d952749022db1c1",
experimental_face_auth_config: "v1",
});
expect(global.ParameterStore?.getParameter).toHaveBeenCalledWith(
"precheck/experimental-face-auth-config/enabled",
[],
);
});

test("returns disabled face auth config for apps outside the allowlist", async () => {
mockParameterStoreAppIds(["app_staging_00000000000000000000000000000000"]);

const request = new NextRequest(
"http://localhost:3000/api/v1/precheck/app_staging_6d1c9fb86751a40d952749022db1c1",
{
method: "POST",
body: JSON.stringify(exampleValidRequestPayload),
},
);

AppPrecheckQuery.mockResolvedValue({
app: [{ ...appPayload }],
});

const response = await POST(request, {
params: Promise.resolve({
app_id: "app_staging_6d1c9fb86751a40d952749022db1c1",
}),
});

expect(response.status).toBe(200);
const responseBody = await response.json();
expect(responseBody).toMatchObject({
id: "app_staging_6d1c9fb86751a40d952749022db1c1",
experimental_face_auth_config: "disabled",
});
});

test("returns disabled face auth config when SSM fails", async () => {
global.ParameterStore = {
getParameter: jest.fn().mockRejectedValue(new Error("SSM error")),
} as unknown as NonNullable<typeof global.ParameterStore>;

const request = new NextRequest(
"http://localhost:3000/api/v1/precheck/app_staging_6d1c9fb86751a40d952749022db1c1",
{
method: "POST",
body: JSON.stringify(exampleValidRequestPayload),
},
);

AppPrecheckQuery.mockResolvedValue({
app: [{ ...appPayload }],
});

const response = await POST(request, {
params: Promise.resolve({
app_id: "app_staging_6d1c9fb86751a40d952749022db1c1",
}),
});

expect(response.status).toBe(200);
const responseBody = await response.json();
expect(responseBody).toMatchObject({
id: "app_staging_6d1c9fb86751a40d952749022db1c1",
experimental_face_auth_config: "disabled",
});
});

test("returns face auth config for synthetic RP registration actions", async () => {
mockParameterStoreAppIds([appPayload.id]);

const request = new NextRequest(
"http://localhost:3000/api/v1/precheck/app_staging_6d1c9fb86751a40d952749022db1c1",
{
method: "POST",
body: JSON.stringify(exampleValidRequestPayload),
},
);

AppPrecheckQuery.mockResolvedValue({
app: [{ ...appPayload, actions: [] }],
});
FetchRpRegistrationForPrecheck.mockResolvedValue({
rp_registration: [{ status: "registered" }],
});

const response = await POST(request, {
params: Promise.resolve({
app_id: "app_staging_6d1c9fb86751a40d952749022db1c1",
}),
});

expect(response.status).toBe(200);
const responseBody = await response.json();
expect(responseBody).toMatchObject({
id: "app_staging_6d1c9fb86751a40d952749022db1c1",
experimental_face_auth_config: "v1",
action: {
action: "swag_pack_2022",
status: "active",
},
can_user_verify: "yes",
});
});

test("can fetch precheck response with nullifier", async () => {
// This is used to check if a specific person has already verified for an action
const request = new NextRequest(
Expand Down
Loading