diff --git a/web/api/v1/precheck/[app_id]/index.ts b/web/api/v1/precheck/[app_id]/index.ts index 13f14472bd..9852a436cc 100644 --- a/web/api/v1/precheck/[app_id]/index.ts +++ b/web/api/v1/precheck/[app_id]/index.ts @@ -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"; @@ -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>; + const schema = yup .object() .shape({ @@ -49,6 +58,28 @@ const schema = yup const corsMethods = ["POST", "OPTIONS"]; +async function getExperimentalFaceAuthConfig( + app_id: string, +): Promise { + try { + const enabledAppIds = + (await global.ParameterStore?.getParameter( + 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. @@ -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, }; diff --git a/web/lib/types.ts b/web/lib/types.ts index b077779616..00adf354e4 100644 --- a/web/lib/types.ts +++ b/web/lib/types.ts @@ -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"; diff --git a/web/tests/api/v1/precheck.test.ts b/web/tests/api/v1/precheck.test.ts index cbae8a8de8..8afe0c5970 100644 --- a/web/tests/api/v1/precheck.test.ts +++ b/web/tests/api/v1/precheck.test.ts @@ -41,6 +41,12 @@ const exampleValidRequestPayload = { "0x2a6f11552fe9073280e1dc38358aa6b23ec4c14ab56046d4d97695b21b166690", }; +const mockParameterStoreAppIds = (appIds: string[]) => { + global.ParameterStore = { + getParameter: jest.fn().mockResolvedValue(appIds), + } as unknown as NonNullable; +}; + jest.mock("@/api/helpers/graphql", () => ({ getAPIServiceGraphqlClient: jest.fn(), })); @@ -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 () => { @@ -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: { @@ -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: { @@ -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; + + 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(