diff --git a/js/examples/nextjs/app/ui.tsx b/js/examples/nextjs/app/ui.tsx index a8ca40aa..ca91afcf 100644 --- a/js/examples/nextjs/app/ui.tsx +++ b/js/examples/nextjs/app/ui.tsx @@ -152,6 +152,7 @@ export function DemoClient(): ReactElement { const [useReturnTo, setUseReturnTo] = useState(false); const [returnTo, setReturnTo] = useState(""); const [isReturnToTooltipOpen, setIsReturnToTooltipOpen] = useState(false); + const [requireUserPresence, setRequireUserPresence] = useState(false); const genesisIssuedAtMin = genesisEnabled && genesisDate @@ -485,6 +486,17 @@ export function DemoClient(): ReactElement { placeholder="googlechromes://" /> +
+ + setRequireUserPresence(e.target.checked)} + /> +
@@ -514,6 +526,7 @@ export function DemoClient(): ReactElement { action={action || "test-action"} rp_context={widgetRpContext} allow_legacy_proofs={true} + require_user_presence={requireUserPresence} {...widgetConstraintsOrPreset} onSuccess={(result) => { setWidgetIdkitResult(result); diff --git a/js/packages/core/src/__tests__/smoke.test.ts b/js/packages/core/src/__tests__/smoke.test.ts index 39f75fc8..7b7b744b 100644 --- a/js/packages/core/src/__tests__/smoke.test.ts +++ b/js/packages/core/src/__tests__/smoke.test.ts @@ -105,6 +105,35 @@ describe("IDKitRequest API", () => { ).toThrow("rp_context is required"); }); + it("should default require_user_presence to false in request config", () => { + const builder = IDKit.request({ + app_id: "app_staging_test", + action: "test-action", + rp_context: TEST_SESSION_CONFIG.rp_context, + allow_legacy_proofs: false, + }); + + expect((builder as any).config.require_user_presence).toBe(false); + }); + + it("should preserve require_user_presence in request and session configs", () => { + const requestBuilder = IDKit.request({ + app_id: "app_staging_test", + action: "test-action", + rp_context: TEST_SESSION_CONFIG.rp_context, + allow_legacy_proofs: false, + require_user_presence: true, + }); + + const sessionBuilder = IDKit.createSession({ + ...TEST_SESSION_CONFIG, + require_user_presence: true, + }); + + expect((requestBuilder as any).config.require_user_presence).toBe(true); + expect((sessionBuilder as any).config.require_user_presence).toBe(true); + }); + it("should reject malformed session_id values in proveSession", () => { expect(() => IDKit.proveSession( @@ -173,6 +202,7 @@ describe("IDKitRequest API", () => { null, null, true, + false, null, null, "production", @@ -196,6 +226,7 @@ describe("Enums", () => { expect(IDKitErrorCodes.CredentialUnavailable).toBe( "credential_unavailable", ); + expect(IDKitErrorCodes.UserPresenceFailed).toBe("user_presence_failed"); expect(IDKitErrorCodes.WorldId4NotAvailable).toBe( "world_id_4_not_available", ); diff --git a/js/packages/core/src/request.ts b/js/packages/core/src/request.ts index 303eacc5..aeb878a8 100644 --- a/js/packages/core/src/request.ts +++ b/js/packages/core/src/request.ts @@ -372,6 +372,7 @@ function createWasmBuilderFromConfig( config.action_description ?? null, config.bridge_url ?? null, config.allow_legacy_proofs ?? false, + config.require_user_presence ?? false, config.override_connect_base_url ?? null, config.return_to ?? null, config.environment ?? null, @@ -385,6 +386,7 @@ function createWasmBuilderFromConfig( rpContext, config.action_description ?? null, config.bridge_url ?? null, + config.require_user_presence ?? false, config.override_connect_base_url ?? null, config.return_to ?? null, config.environment ?? null, @@ -397,6 +399,7 @@ function createWasmBuilderFromConfig( rpContext, config.action_description ?? null, config.bridge_url ?? null, + config.require_user_presence ?? false, config.override_connect_base_url ?? null, config.return_to ?? null, config.environment ?? null, @@ -618,6 +621,7 @@ function createRequest(config: IDKitRequestConfig): IDKitBuilder { bridge_url: config.bridge_url, return_to: config.return_to, allow_legacy_proofs: config.allow_legacy_proofs, + require_user_presence: config.require_user_presence ?? false, override_connect_base_url: config.override_connect_base_url, environment: config.environment, }); @@ -667,6 +671,7 @@ function createSession(config: IDKitSessionConfig): IDKitBuilder { action_description: config.action_description, bridge_url: config.bridge_url, return_to: config.return_to, + require_user_presence: config.require_user_presence ?? false, override_connect_base_url: config.override_connect_base_url, environment: config.environment, }); @@ -728,6 +733,7 @@ function proveSession( action_description: config.action_description, bridge_url: config.bridge_url, return_to: config.return_to, + require_user_presence: config.require_user_presence ?? false, override_connect_base_url: config.override_connect_base_url, environment: config.environment, }); diff --git a/js/packages/core/src/transports/native.test.ts b/js/packages/core/src/transports/native.test.ts index 0fa1e8b6..7c88a9e4 100644 --- a/js/packages/core/src/transports/native.test.ts +++ b/js/packages/core/src/transports/native.test.ts @@ -106,6 +106,85 @@ describe("native transport request lifecycle", () => { expect(completion.success).toBe(true); }); + it("defaults missing user_presence_completed to false on success", async () => { + const req = createNativeRequest({ payload: 1 }, baseConfig, {}, ""); + activeRequest = req; + + const completionPromise = req.pollUntilCompletion({ timeout: 1000 }); + + miniKitHandlers["miniapp-verify-action"]?.({ + status: "success", + protocol_version: "3.0", + verification_level: "orb", + signal_hash: "0xabc", + proof: "0x01", + merkle_root: "0x02", + nullifier_hash: "0x03", + }); + + const completion = await completionPromise; + expect(completion.success).toBe(true); + if (completion.success) { + expect(completion.result.user_presence_completed).toBe(false); + } + }); + + it("fails when user presence is required but not completed", async () => { + const req = createNativeRequest( + { payload: 1 }, + { ...baseConfig, require_user_presence: true }, + {}, + "", + ); + activeRequest = req; + + const completionPromise = req.pollUntilCompletion({ timeout: 1000 }); + + miniKitHandlers["miniapp-verify-action"]?.({ + status: "success", + protocol_version: "3.0", + verification_level: "orb", + signal_hash: "0xabc", + proof: "0x01", + merkle_root: "0x02", + nullifier_hash: "0x03", + }); + + await expect(completionPromise).resolves.toEqual({ + success: false, + error: IDKitErrorCodes.UserPresenceFailed, + }); + }); + + it("preserves completed user presence on success", async () => { + const req = createNativeRequest( + { payload: 1 }, + { ...baseConfig, require_user_presence: true }, + {}, + "", + ); + activeRequest = req; + + const completionPromise = req.pollUntilCompletion({ timeout: 1000 }); + + miniKitHandlers["miniapp-verify-action"]?.({ + status: "success", + user_presence_completed: true, + protocol_version: "3.0", + verification_level: "orb", + signal_hash: "0xabc", + proof: "0x01", + merkle_root: "0x02", + nullifier_hash: "0x03", + }); + + const completion = await completionPromise; + expect(completion.success).toBe(true); + if (completion.success) { + expect(completion.result.user_presence_completed).toBe(true); + } + }); + it("uses per-identifier signal hashes when response omits signal_hash", async () => { const signalHashes = { proof_of_human: hashSignal("poh-signal"), diff --git a/js/packages/core/src/transports/native.ts b/js/packages/core/src/transports/native.ts index 4d56b003..b0b031d3 100644 --- a/js/packages/core/src/transports/native.ts +++ b/js/packages/core/src/transports/native.ts @@ -76,6 +76,7 @@ export interface BuilderConfig { bridge_url?: string; return_to?: string; allow_legacy_proofs?: boolean; + require_user_presence?: boolean; override_connect_base_url?: string; environment?: string; } @@ -167,6 +168,16 @@ class NativeIDKitRequest implements IDKitRequest { return; } + const userPresenceCompleted = getUserPresenceCompleted(responsePayload); + + if (config.require_user_presence === true && !userPresenceCompleted) { + this.complete({ + success: false, + error: IDKitErrorCodes.UserPresenceFailed, + }); + return; + } + this.complete({ success: true, result: nativeResultToIDKitResult( @@ -174,6 +185,7 @@ class NativeIDKitRequest implements IDKitRequest { config, signalHashes, legacySignalHash, + userPresenceCompleted, ), }); }; @@ -349,6 +361,7 @@ function nativeResultToIDKitResult( config: BuilderConfig, signalHashes: Record, legacySignalHash: string, + userPresenceCompleted: boolean, ): IDKitResult { const p = payload as Record; const rpNonce = config.rp_context?.nonce ?? ""; @@ -372,6 +385,7 @@ function nativeResultToIDKitResult( issuer_schema_id: item.issuer_schema_id, expires_at_min: item.expires_at_min, })), + user_presence_completed: userPresenceCompleted, environment: config.environment ?? "production", } satisfies IDKitResultSession; } @@ -389,6 +403,7 @@ function nativeResultToIDKitResult( issuer_schema_id: item.issuer_schema_id, expires_at_min: item.expires_at_min, })), + user_presence_completed: userPresenceCompleted, environment: config.environment ?? "production", } satisfies IDKitResultV4; } @@ -414,6 +429,7 @@ function nativeResultToIDKitResult( merkle_root: v.merkle_root, nullifier: v.nullifier_hash, })), + user_presence_completed: userPresenceCompleted, environment: config.environment ?? "production", } satisfies IDKitResultV3; } @@ -435,6 +451,16 @@ function nativeResultToIDKitResult( nullifier: p.nullifier_hash, }, ], + user_presence_completed: userPresenceCompleted, environment: config.environment ?? "production", } satisfies IDKitResultV3; } + +function getUserPresenceCompleted(payload: unknown): boolean { + const p = payload as Record; + return ( + p?.user_presence_completed === true || + (p?.proof_response as Record | undefined) + ?.user_presence_completed === true + ); +} diff --git a/js/packages/core/src/types/config.ts b/js/packages/core/src/types/config.ts index 0fa77403..994ae987 100644 --- a/js/packages/core/src/types/config.ts +++ b/js/packages/core/src/types/config.ts @@ -58,6 +58,9 @@ export type IDKitRequestConfig = { */ allow_legacy_proofs: boolean; + /** Require World App to perform a user-presence check before verification. Defaults to false. */ + require_user_presence?: boolean; + /** Optional override for the connect base URL (e.g., for staging environments) */ override_connect_base_url?: string; @@ -84,6 +87,8 @@ export type IDKitSessionConfig = { bridge_url?: string; /** Optional deep-link callback URL appended as `return_to` on the connector URL. */ return_to?: string; + /** Require World App to perform a user-presence check before verification. Defaults to false. */ + require_user_presence?: boolean; /** Optional override for the connect base URL (e.g., for staging environments) */ override_connect_base_url?: string; diff --git a/js/packages/core/src/types/index.ts b/js/packages/core/src/types/index.ts index 5a543a1e..287f5d08 100644 --- a/js/packages/core/src/types/index.ts +++ b/js/packages/core/src/types/index.ts @@ -1,3 +1,2 @@ -export * from "./bridge"; export * from "./config"; export * from "./result"; diff --git a/js/packages/core/src/types/result.ts b/js/packages/core/src/types/result.ts index a38c8336..fecf4ea6 100644 --- a/js/packages/core/src/types/result.ts +++ b/js/packages/core/src/types/result.ts @@ -42,6 +42,7 @@ export enum IDKitErrorCodes { ConnectionFailed = "connection_failed", MaxVerificationsReached = "max_verifications_reached", FailedByHostApp = "failed_by_host_app", + UserPresenceFailed = "user_presence_failed", InvalidRpSignature = "invalid_rp_signature", NullifierReplayed = "nullifier_replayed", DuplicateNonce = "duplicate_nonce", diff --git a/js/packages/react/src/__tests__/hooks.test.tsx b/js/packages/react/src/__tests__/hooks.test.tsx index 3135c84b..2af1acce 100644 --- a/js/packages/react/src/__tests__/hooks.test.tsx +++ b/js/packages/react/src/__tests__/hooks.test.tsx @@ -141,6 +141,7 @@ describe("request/session hooks", () => { bridge_url: undefined, return_to: undefined, allow_legacy_proofs: false, + require_user_presence: false, override_connect_base_url: undefined, environment: undefined, }); @@ -163,7 +164,7 @@ describe("request/session hooks", () => { useIDKitSession({ app_id: "app_test", rp_context: baseRpContext, - constraints: { type: "All", children: [] }, + constraints: { all: [] }, }), ); @@ -181,6 +182,7 @@ describe("request/session hooks", () => { rp_context: baseRpContext, action_description: undefined, bridge_url: undefined, + require_user_presence: false, override_connect_base_url: undefined, return_to: undefined, environment: undefined, @@ -205,7 +207,7 @@ describe("request/session hooks", () => { app_id: "app_test", rp_context: baseRpContext, existing_session_id: SESSION_ID_2, - preset: { type: "OrbLegacy" }, + constraints: { all: [] }, }), ); @@ -222,6 +224,7 @@ describe("request/session hooks", () => { rp_context: baseRpContext, action_description: undefined, bridge_url: undefined, + require_user_presence: false, override_connect_base_url: undefined, return_to: undefined, environment: undefined, @@ -266,11 +269,46 @@ describe("request/session hooks", () => { bridge_url: undefined, return_to: "idkit://callback?step=proof", allow_legacy_proofs: false, + require_user_presence: false, override_connect_base_url: undefined, environment: undefined, }); }); + it("request hook forwards require_user_presence to core", async () => { + requestMock.mockReturnValue({ + preset: vi.fn(async () => + makeRequest(async () => ({ + type: "confirmed", + result: { proof: "ok" }, + })), + ), + }); + + const { result } = renderHook(() => + useIDKitRequest({ + app_id: "app_test", + action: "test-action", + rp_context: baseRpContext, + allow_legacy_proofs: false, + require_user_presence: true, + preset: { type: "OrbLegacy" }, + }), + ); + + act(() => { + result.current.open(); + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(requestMock).toHaveBeenCalledWith( + expect.objectContaining({ require_user_presence: true }), + ); + }); + it("session hook forwards return_to to createSession", async () => { createSessionMock.mockReturnValue({ constraints: vi.fn(async () => ({ @@ -287,7 +325,8 @@ describe("request/session hooks", () => { app_id: "app_test", rp_context: baseRpContext, return_to: "idkit://callback?step=create", - constraints: { type: "All", children: [] }, + require_user_presence: true, + constraints: { all: [] }, }), ); @@ -304,6 +343,7 @@ describe("request/session hooks", () => { rp_context: baseRpContext, action_description: undefined, bridge_url: undefined, + require_user_presence: true, override_connect_base_url: undefined, return_to: "idkit://callback?step=create", environment: undefined, @@ -328,7 +368,7 @@ describe("request/session hooks", () => { rp_context: baseRpContext, existing_session_id: validSessionId, return_to: "idkit://callback?step=prove", - constraints: { type: "All", children: [] }, + constraints: { all: [] }, }), ); @@ -345,6 +385,7 @@ describe("request/session hooks", () => { rp_context: baseRpContext, action_description: undefined, bridge_url: undefined, + require_user_presence: false, override_connect_base_url: undefined, return_to: "idkit://callback?step=prove", environment: undefined, @@ -357,7 +398,7 @@ describe("request/session hooks", () => { app_id: "app_test", rp_context: baseRpContext, existing_session_id: " " as unknown as `session_${string}`, - constraints: { type: "All", children: [] }, + constraints: { all: [] }, }), ); @@ -378,7 +419,7 @@ describe("request/session hooks", () => { app_id: "app_test", rp_context: baseRpContext, existing_session_id: "session_2" as `session_${string}`, - constraints: { type: "All", children: [] }, + constraints: { all: [] }, }), ); diff --git a/js/packages/react/src/__tests__/widgets.test.tsx b/js/packages/react/src/__tests__/widgets.test.tsx index a9faae2d..cdd5986e 100644 --- a/js/packages/react/src/__tests__/widgets.test.tsx +++ b/js/packages/react/src/__tests__/widgets.test.tsx @@ -76,7 +76,7 @@ function createRequestProps( allow_legacy_proofs: false, preset: { type: "OrbLegacy" }, ...overrides, - }; + } as IDKitRequestWidgetProps; } function createSessionProps( @@ -88,7 +88,7 @@ function createSessionProps( onSuccess: vi.fn(), app_id: "app_test", rp_context: baseRpContext, - preset: { type: "OrbLegacy" }, + constraints: { all: [] }, ...overrides, }; } diff --git a/js/packages/react/src/hooks/useIDKitRequest.ts b/js/packages/react/src/hooks/useIDKitRequest.ts index 22ee05e8..916439ec 100644 --- a/js/packages/react/src/hooks/useIDKitRequest.ts +++ b/js/packages/react/src/hooks/useIDKitRequest.ts @@ -17,6 +17,7 @@ export function useIDKitRequest( bridge_url: config.bridge_url, return_to: config.return_to, allow_legacy_proofs: config.allow_legacy_proofs, + require_user_presence: config.require_user_presence ?? false, override_connect_base_url: config.override_connect_base_url, environment: config.environment, }); diff --git a/js/packages/react/src/hooks/useIDKitSession.ts b/js/packages/react/src/hooks/useIDKitSession.ts index 9e826128..97bd300c 100644 --- a/js/packages/react/src/hooks/useIDKitSession.ts +++ b/js/packages/react/src/hooks/useIDKitSession.ts @@ -36,6 +36,7 @@ export function useIDKitSession( rp_context: config.rp_context, action_description: config.action_description, bridge_url: config.bridge_url, + require_user_presence: config.require_user_presence ?? false, override_connect_base_url: config.override_connect_base_url, return_to: config.return_to, environment: config.environment, @@ -45,6 +46,7 @@ export function useIDKitSession( rp_context: config.rp_context, action_description: config.action_description, bridge_url: config.bridge_url, + require_user_presence: config.require_user_presence ?? false, override_connect_base_url: config.override_connect_base_url, return_to: config.return_to, environment: config.environment, diff --git a/kotlin/Examples/IDKitSampleApp/app/src/main/java/com/worldcoin/idkit/sample/MainActivity.kt b/kotlin/Examples/IDKitSampleApp/app/src/main/java/com/worldcoin/idkit/sample/MainActivity.kt index 57957a5d..dae800be 100644 --- a/kotlin/Examples/IDKitSampleApp/app/src/main/java/com/worldcoin/idkit/sample/MainActivity.kt +++ b/kotlin/Examples/IDKitSampleApp/app/src/main/java/com/worldcoin/idkit/sample/MainActivity.kt @@ -346,6 +346,7 @@ private class SampleModel { actionDescription = "Local Android sample", bridgeUrl = null, allowLegacyProofs = false, + requireUserPresence = false, overrideConnectBaseUrl = null, returnTo = returnToURL, environment = when (environment) { diff --git a/kotlin/README.md b/kotlin/README.md index 06b56991..4da56b4d 100644 --- a/kotlin/README.md +++ b/kotlin/README.md @@ -84,6 +84,7 @@ val config = IDKitRequestConfig( actionDescription = "Log in", bridgeUrl = null, allowLegacyProofs = false, + requireUserPresence = false, overrideConnectBaseUrl = null, returnTo = null, environment = Environment.STAGING, diff --git a/kotlin/bindings/src/main/kotlin/com/worldcoin/idkit/IdKit.kt b/kotlin/bindings/src/main/kotlin/com/worldcoin/idkit/IdKit.kt index a4927d2e..1eacde7e 100644 --- a/kotlin/bindings/src/main/kotlin/com/worldcoin/idkit/IdKit.kt +++ b/kotlin/bindings/src/main/kotlin/com/worldcoin/idkit/IdKit.kt @@ -13,11 +13,10 @@ import uniffi.idkit_core.AppError // import uniffi.idkit_core.CredentialRequest // import uniffi.idkit_core.CredentialType import uniffi.idkit_core.IdKitBuilder -import uniffi.idkit_core.IdKitRequestConfig +import uniffi.idkit_core.IdKitRequestConfig as NativeIDKitRequestConfig import uniffi.idkit_core.IdKitRequestWrapper import uniffi.idkit_core.IdKitResult -// TODO: Re-enable when World ID 4.0 is live -// import uniffi.idkit_core.IdKitSessionConfig +import uniffi.idkit_core.IdKitSessionConfig as NativeIDKitSessionConfig import uniffi.idkit_core.Preset import uniffi.idkit_core.Signal import uniffi.idkit_core.StatusWrapper @@ -31,12 +30,62 @@ import uniffi.idkit_core.idkitResultToJson as nativeIdkitResultToJson // import uniffi.idkit_core.proveSession as nativeProveSession import uniffi.idkit_core.request as nativeRequest -typealias IDKitRequestConfig = IdKitRequestConfig -// TODO: Re-enable when World ID 4.0 is live -// typealias IDKitSessionConfig = IdKitSessionConfig typealias IDKitResult = IdKitResult typealias RpContext = uniffi.idkit_core.RpContext typealias Environment = uniffi.idkit_core.Environment +typealias ConnectUrlMode = uniffi.idkit_core.ConnectUrlMode + +data class IDKitRequestConfig( + val appId: String, + val action: String, + val rpContext: RpContext, + val actionDescription: String? = null, + val bridgeUrl: String? = null, + val allowLegacyProofs: Boolean = false, + val requireUserPresence: Boolean = false, + val overrideConnectBaseUrl: String? = null, + val returnTo: String? = null, + val environment: Environment? = null, + val connectUrlMode: ConnectUrlMode? = null, +) { + internal fun toNative(): NativeIDKitRequestConfig = + NativeIDKitRequestConfig( + appId = appId, + action = action, + rpContext = rpContext, + actionDescription = actionDescription, + bridgeUrl = bridgeUrl, + allowLegacyProofs = allowLegacyProofs, + requireUserPresence = requireUserPresence, + overrideConnectBaseUrl = overrideConnectBaseUrl, + returnTo = returnTo, + environment = environment, + connectUrlMode = connectUrlMode, + ) +} + +data class IDKitSessionConfig( + val appId: String, + val rpContext: RpContext, + val actionDescription: String? = null, + val bridgeUrl: String? = null, + val requireUserPresence: Boolean = false, + val overrideConnectBaseUrl: String? = null, + val returnTo: String? = null, + val environment: Environment? = null, +) { + internal fun toNative(): NativeIDKitSessionConfig = + NativeIDKitSessionConfig( + appId = appId, + rpContext = rpContext, + actionDescription = actionDescription, + bridgeUrl = bridgeUrl, + requireUserPresence = requireUserPresence, + overrideConnectBaseUrl = overrideConnectBaseUrl, + returnTo = returnTo, + environment = environment, + ) +} class IDKitClientError(message: String) : IllegalArgumentException(message) @@ -54,6 +103,7 @@ enum class IDKitErrorCode(val rawValue: String) { CONNECTION_FAILED("connection_failed"), MAX_VERIFICATIONS_REACHED("max_verifications_reached"), FAILED_BY_HOST_APP("failed_by_host_app"), + USER_PRESENCE_FAILED("user_presence_failed"), INVALID_RP_SIGNATURE("invalid_rp_signature"), NULLIFIER_REPLAYED("nullifier_replayed"), DUPLICATE_NONCE("duplicate_nonce"), @@ -82,6 +132,7 @@ enum class IDKitErrorCode(val rawValue: String) { AppError.CONNECTION_FAILED -> CONNECTION_FAILED AppError.MAX_VERIFICATIONS_REACHED -> MAX_VERIFICATIONS_REACHED AppError.FAILED_BY_HOST_APP -> FAILED_BY_HOST_APP + AppError.USER_PRESENCE_FAILED -> USER_PRESENCE_FAILED AppError.INVALID_RP_SIGNATURE -> INVALID_RP_SIGNATURE AppError.NULLIFIER_REPLAYED -> NULLIFIER_REPLAYED AppError.DUPLICATE_NONCE -> DUPLICATE_NONCE @@ -201,19 +252,19 @@ object IDKit { fun request(config: IDKitRequestConfig): IDKitBuilder { require(config.appId.isNotBlank()) { "app_id is required" } require(config.action.isNotBlank()) { "action is required" } - return IDKitBuilder(nativeRequest(config)) + return IDKitBuilder(nativeRequest(config.toNative())) } // TODO: Re-enable when World ID 4.0 is live // fun createSession(config: IDKitSessionConfig): IDKitBuilder { // require(config.appId.isNotBlank()) { "app_id is required" } - // return IDKitBuilder(nativeCreateSession(config)) + // return IDKitBuilder(nativeCreateSession(config.toNative())) // } // fun proveSession(sessionId: String, config: IDKitSessionConfig): IDKitBuilder { // require(sessionId.isNotBlank()) { "session_id is required" } // require(config.appId.isNotBlank()) { "app_id is required" } - // return IDKitBuilder(nativeProveSession(sessionId, config)) + // return IDKitBuilder(nativeProveSession(sessionId, config.toNative())) // } fun hashSignal(signal: String): String = hashSignalFfi(Signal.fromString(signal)) diff --git a/kotlin/bindings/src/test/kotlin/com/worldcoin/idkit/IDKitTests.kt b/kotlin/bindings/src/test/kotlin/com/worldcoin/idkit/IDKitTests.kt index da50115e..c24f5165 100644 --- a/kotlin/bindings/src/test/kotlin/com/worldcoin/idkit/IDKitTests.kt +++ b/kotlin/bindings/src/test/kotlin/com/worldcoin/idkit/IDKitTests.kt @@ -16,7 +16,10 @@ import uniffi.idkit_core.RpContext import uniffi.idkit_core.StatusWrapper class IDKitTests { - private fun sampleResult(sessionId: String? = null): IDKitResult = + private fun sampleResult( + sessionId: String? = null, + userPresenceCompleted: Boolean = false, + ): IDKitResult = IDKitResult( protocolVersion = "4.0", nonce = "0x1234", @@ -24,6 +27,7 @@ class IDKitTests { actionDescription = "Sample action", sessionId = sessionId, responses = emptyList(), + userPresenceCompleted = userPresenceCompleted, environment = "production", ) @@ -47,6 +51,7 @@ class IDKitTests { actionDescription = null, bridgeUrl = null, allowLegacyProofs = false, + requireUserPresence = false, overrideConnectBaseUrl = null, returnTo = null, environment = Environment.STAGING, @@ -58,6 +63,7 @@ class IDKitTests { // rpContext = sampleRpContext(), // actionDescription = null, // bridgeUrl = null, + // requireUserPresence = false, // overrideConnectBaseUrl = null, // returnTo = null, // environment = Environment.STAGING, @@ -89,6 +95,10 @@ class IDKitTests { IDKitStatus.Failed(IDKitErrorCode.INVALID_NETWORK), IDKitRequest.mapStatus(StatusWrapper.Failed(AppError.INVALID_NETWORK)), ) + assertEquals( + IDKitStatus.Failed(IDKitErrorCode.USER_PRESENCE_FAILED), + IDKitRequest.mapStatus(StatusWrapper.Failed(AppError.USER_PRESENCE_FAILED)), + ) assertEquals( IDKitStatus.Failed(IDKitErrorCode.INVALID_RP_SIGNATURE), IDKitRequest.mapStatus(StatusWrapper.Failed(AppError.INVALID_RP_SIGNATURE)), diff --git a/rust/core/src/bridge.rs b/rust/core/src/bridge.rs index 5d4363a7..843f1ced 100644 --- a/rust/core/src/bridge.rs +++ b/rust/core/src/bridge.rs @@ -13,7 +13,10 @@ use crate::{ }; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use world_id_primitives::{FieldElement, ProofRequest, SessionId}; +use world_id_primitives::{ + FieldElement, ProofRequest, ProofResponse, RequestVersion, + ResponseItem as ProtocolResponseItem, SessionId, +}; #[cfg(feature = "native-crypto")] use crate::crypto::CryptoKey; @@ -122,6 +125,9 @@ struct BridgeRequestPayload { /// - `false`: Only accept v4 proofs. Use after migration cutoff or for new apps. allow_legacy_proofs: bool, + /// Whether World App should require a user-presence check before verification. + require_user_presence: bool, + /// Environment for the bridge request environment: Environment, } @@ -225,21 +231,76 @@ enum BridgeResponse { Error { error_code: AppError }, /// World ID 4.0 protocol response - ResponseV2(world_id_primitives::ProofResponse), + ResponseV2(BridgeResponseV2), /// World ID 4.0 protocol response with extensions ResponseV2_1 { proof_response: world_id_primitives::ProofResponse, identity_attested: Option, + #[serde(default)] + user_presence_completed: bool, }, /// Multi-credential legacy: bridge sends v3 proofs via `legacy_responses` MultiLegacyResponse { legacy_responses: Vec, + #[serde(default)] + user_presence_completed: bool, }, /// V1 legacy (old World App 3.0, single credential) - ResponseV1(BridgeResponseV1), + ResponseV1(BridgeResponseV1WithMetadata), +} + +#[derive(Debug, Deserialize)] +struct BridgeResponseV2 { + /// The response id references request id + id: String, + /// Version corresponding to request version + version: RequestVersion, + /// Optional session identifier for session proofs + #[serde(default)] + session_id: Option, + /// Protocol-level proof response error + #[serde(default)] + error: Option, + /// Per-credential proof responses + responses: Vec, + /// Whether World App completed the requested user-presence check. + #[serde(default)] + user_presence_completed: bool, +} + +impl BridgeResponseV2 { + fn into_proof_response(self) -> ProofResponse { + ProofResponse { + id: self.id, + version: self.version, + session_id: self.session_id, + error: self.error, + responses: self.responses, + } + } +} + +#[derive(Debug, Deserialize)] +struct BridgeResponseV1WithMetadata { + #[serde(flatten)] + response: BridgeResponseV1, + /// Whether World App completed the requested user-presence check. + #[serde(default)] + user_presence_completed: bool, +} + +fn user_presence_failure_status( + require_user_presence: bool, + user_presence_completed: bool, +) -> Option { + if require_user_presence && !user_presence_completed { + Some(Status::Failed(AppError::UserPresenceFailed)) + } else { + None + } } /// Status of a verification request @@ -271,6 +332,7 @@ pub struct BridgeConnectionParams { pub legacy_signal: String, pub bridge_url: Option, pub allow_legacy_proofs: bool, + pub require_user_presence: bool, /// Optional override for the connect base URL (e.g., for staging environments) pub override_connect_base_url: Option, /// Optional deep-link callback URL appended as `return_to` on the connector URL @@ -360,6 +422,8 @@ pub struct BridgeConnection { return_to: Option, /// Resolved environment for this connection environment: Environment, + /// Whether a successful response must prove user presence was completed. + require_user_presence: bool, } /// Builds a `BridgeRequestPayload` from params without connecting to the bridge. @@ -459,6 +523,7 @@ pub fn build_request_payload( signal: legacy_signal_hash, timestamp, allow_legacy_proofs: params.allow_legacy_proofs, + require_user_presence: params.require_user_presence, environment: params.environment.unwrap_or_default(), }; @@ -603,6 +668,7 @@ impl BridgeConnection { override_connect_base_url: params.override_connect_base_url, return_to: params.return_to, environment: params.environment.unwrap_or_default(), + require_user_presence: params.require_user_presence, }) } @@ -681,14 +747,33 @@ impl BridgeConnection { match bridge_response { BridgeResponse::Error { error_code } => Ok(Status::Failed(error_code)), - BridgeResponse::ResponseV2(proof_response) => { - self.handle_bridge_v2_response(proof_response, None) + BridgeResponse::ResponseV2(response) => { + let user_presence_completed = response.user_presence_completed; + self.handle_bridge_v2_response( + response.into_proof_response(), + None, + user_presence_completed, + ) } BridgeResponse::ResponseV2_1 { proof_response, identity_attested, - } => self.handle_bridge_v2_response(proof_response, identity_attested), - BridgeResponse::MultiLegacyResponse { legacy_responses } => { + user_presence_completed, + } => self.handle_bridge_v2_response( + proof_response, + identity_attested, + user_presence_completed, + ), + BridgeResponse::MultiLegacyResponse { + legacy_responses, + user_presence_completed, + } => { + if let Some(status) = user_presence_failure_status( + self.require_user_presence, + user_presence_completed, + ) { + return Ok(status); + } let responses: Vec = legacy_responses .into_iter() .map(|item| { @@ -707,20 +792,30 @@ impl BridgeConnection { self.action.clone(), self.action_description.clone(), responses, + user_presence_completed, self.environment.as_ref(), ))) } BridgeResponse::ResponseV1(response) => { + if let Some(status) = user_presence_failure_status( + self.require_user_presence, + response.user_presence_completed, + ) { + return Ok(status); + } + // V1 responses are always protocol 3.0 // For V1 we don't have identifier, use verification_level as key let signal_hash = self.cached_signal_hashes.legacy(); - let item = response.into_response_item(signal_hash); + let user_presence_completed = response.user_presence_completed; + let item = response.response.into_response_item(signal_hash); Ok(Status::Confirmed(IDKitResult::new( "3.0", self.nonce.clone(), self.action.clone(), self.action_description.clone(), vec![item], + user_presence_completed, self.environment.as_ref(), ))) } @@ -734,12 +829,19 @@ impl BridgeConnection { &self, proof_response: world_id_primitives::ProofResponse, identity_attested: Option, + user_presence_completed: bool, ) -> Result { - // Check for protocol-level error + // Protocol-level errors take precedence over user-presence enforcement. if let Some(error_code) = proof_response.error.as_deref() { return Ok(Status::Failed(AppError::from_code(error_code))); } + if let Some(status) = + user_presence_failure_status(self.require_user_presence, user_presence_completed) + { + return Ok(status); + } + let responses: Vec = proof_response .responses .into_iter() @@ -763,6 +865,7 @@ impl BridgeConnection { .to_owned(), self.action_description.clone(), responses, + user_presence_completed, self.environment.as_ref(), ) } else { @@ -772,6 +875,7 @@ impl BridgeConnection { self.action.clone(), self.action_description.clone(), responses, + user_presence_completed, self.environment.as_ref(), ); result.identity_attested = identity_attested; @@ -809,6 +913,8 @@ pub struct IDKitRequestConfig { /// - `true`: Accept both v3 and v4 proofs. Use during migration. /// - `false`: Only accept v4 proofs. Use after migration cutoff or for new apps. pub allow_legacy_proofs: bool, + /// Optional user-presence requirement. Defaults to false when omitted. + pub require_user_presence: Option, /// Optional override for the connect base URL (e.g., for staging environments) pub override_connect_base_url: Option, /// Optional deep-link callback URL appended as `return_to` on the connector URL @@ -833,6 +939,8 @@ pub struct IDKitSessionConfig { pub action_description: Option, /// Optional bridge URL (defaults to production) pub bridge_url: Option, + /// Optional user-presence requirement. Defaults to false when omitted. + pub require_user_presence: Option, /// Optional override for the connect base URL (e.g., for staging environments) pub override_connect_base_url: Option, /// Optional deep-link callback URL appended as `return_to` on the connector URL @@ -894,6 +1002,7 @@ impl IDKitConfig { legacy_signal: String::new(), bridge_url, allow_legacy_proofs: config.allow_legacy_proofs, + require_user_presence: config.require_user_presence.unwrap_or(false), override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, @@ -919,6 +1028,7 @@ impl IDKitConfig { legacy_signal: String::new(), bridge_url, allow_legacy_proofs: false, + require_user_presence: config.require_user_presence.unwrap_or(false), override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, @@ -946,6 +1056,7 @@ impl IDKitConfig { legacy_signal: String::new(), bridge_url, allow_legacy_proofs: false, + require_user_presence: config.require_user_presence.unwrap_or(false), override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, @@ -1006,6 +1117,7 @@ impl IDKitConfig { legacy_signal: bridge_params.legacy_signal.unwrap_or_default(), bridge_url, allow_legacy_proofs, + require_user_presence: config.require_user_presence.unwrap_or(false), override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, @@ -1030,6 +1142,7 @@ impl IDKitConfig { legacy_signal: bridge_params.legacy_signal.unwrap_or_default(), bridge_url, allow_legacy_proofs: false, + require_user_presence: config.require_user_presence.unwrap_or(false), override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, @@ -1056,6 +1169,7 @@ impl IDKitConfig { legacy_signal: bridge_params.legacy_signal.unwrap_or_default(), bridge_url, allow_legacy_proofs: false, + require_user_presence: config.require_user_presence.unwrap_or(false), override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, @@ -1369,6 +1483,7 @@ mod tests { proof_request: Some(proof_request), identity_attributes: None, allow_legacy_proofs: false, + require_user_presence: false, environment: Environment::Production, }; @@ -1379,6 +1494,7 @@ mod tests { assert!(json.contains("proof_request")); assert!(json.contains("rp_1234567890abcdef")); assert!(json.contains("allow_legacy_proofs")); + assert!(json.contains("require_user_presence")); } #[test] @@ -1409,6 +1525,7 @@ mod tests { legacy_signal: String::new(), bridge_url: None, allow_legacy_proofs: false, + require_user_presence: false, override_connect_base_url: None, return_to: None, environment: Some(Environment::Production), @@ -1541,10 +1658,10 @@ mod tests { let response: BridgeResponse = serde_json::from_str(json).unwrap(); match response { BridgeResponse::ResponseV1(v1) => { - assert_eq!(v1.verification_level, VerificationLevel::Device); - assert_eq!(v1.proof, "0xproof"); - assert_eq!(v1.merkle_root, "0xroot"); - assert_eq!(v1.nullifier_hash, "0xnull"); + assert_eq!(v1.response.verification_level, VerificationLevel::Device); + assert_eq!(v1.response.proof, "0xproof"); + assert_eq!(v1.response.merkle_root, "0xroot"); + assert_eq!(v1.response.nullifier_hash, "0xnull"); } other => panic!("Expected ResponseV1, got: {other:?}"), } @@ -1564,12 +1681,77 @@ mod tests { let response: BridgeResponse = serde_json::from_str(json).unwrap(); match response { BridgeResponse::ResponseV1(v1) => { - assert_eq!(v1.verification_level, VerificationLevel::Orb); + assert_eq!(v1.response.verification_level, VerificationLevel::Orb); } other => panic!("Expected ResponseV1, got: {other:?}"), } } + #[test] + fn test_bridge_response_v2_user_presence_defaults_false() { + let json = r#"{ + "id": "request-id", + "version": 1, + "responses": [] + }"#; + + let response: BridgeResponse = serde_json::from_str(json).unwrap(); + match response { + BridgeResponse::ResponseV2(v2) => { + assert!(!v2.user_presence_completed); + } + other => panic!("Expected ResponseV2, got: {other:?}"), + } + } + + #[test] + fn test_bridge_response_v2_user_presence_explicit_true() { + let json = r#"{ + "id": "request-id", + "version": 1, + "responses": [], + "user_presence_completed": true + }"#; + + let response: BridgeResponse = serde_json::from_str(json).unwrap(); + match response { + BridgeResponse::ResponseV2(v2) => { + assert!(v2.user_presence_completed); + } + other => panic!("Expected ResponseV2, got: {other:?}"), + } + } + + #[test] + fn test_required_user_presence_fails_when_not_completed() { + assert_eq!( + user_presence_failure_status(true, false), + Some(Status::Failed(AppError::UserPresenceFailed)) + ); + assert_eq!(user_presence_failure_status(true, true), None); + assert_eq!(user_presence_failure_status(false, false), None); + } + + #[test] + fn test_protocol_error_takes_precedence_over_user_presence_failure() { + let mut connection = sample_connection(None); + connection.require_user_presence = true; + + let proof_response = ProofResponse { + id: "req_failed".to_string(), + version: RequestVersion::V1, + session_id: None, + error: Some("invalid_rp_signature".to_string()), + responses: vec![], + }; + + let status = connection + .handle_bridge_v2_response(proof_response, None, false) + .unwrap(); + + assert_eq!(status, Status::Failed(AppError::InvalidRpSignature)); + } + #[test] fn test_bridge_response_error_deserialization() { let json = r#"{"error_code": "user_rejected"}"#; @@ -1696,8 +1878,12 @@ mod tests { let response: BridgeResponse = serde_json::from_str(json).unwrap(); match response { - BridgeResponse::MultiLegacyResponse { legacy_responses } => { + BridgeResponse::MultiLegacyResponse { + legacy_responses, + user_presence_completed, + } => { assert_eq!(legacy_responses.len(), 1); + assert!(!user_presence_completed); assert_eq!( legacy_responses[0].verification_level, VerificationLevel::Orb @@ -1729,8 +1915,12 @@ mod tests { let response: BridgeResponse = serde_json::from_str(json).unwrap(); match response { - BridgeResponse::MultiLegacyResponse { legacy_responses } => { + BridgeResponse::MultiLegacyResponse { + legacy_responses, + user_presence_completed, + } => { assert_eq!(legacy_responses.len(), 2); + assert!(!user_presence_completed); assert_eq!( legacy_responses[0].verification_level, VerificationLevel::Orb @@ -1789,6 +1979,39 @@ mod tests { } } + #[cfg(feature = "ffi")] + #[test] + fn test_request_config_defaults_user_presence_requirement_to_false() { + let signature = "0x".to_string() + &"00".repeat(64) + "1b"; + let rp_context = RpContext::new( + "rp_1234567890abcdef", + "0x0000000000000000000000000000000000000000000000000000000000000001", + 1_700_000_000, + 1_700_003_600, + &signature, + ) + .unwrap(); + let config = IDKitConfig::Request(IDKitRequestConfig { + app_id: "app_test".to_string(), + action: "test-action".to_string(), + rp_context: std::sync::Arc::new(rp_context), + action_description: None, + bridge_url: None, + allow_legacy_proofs: false, + require_user_presence: None, + override_connect_base_url: None, + return_to: None, + environment: None, + connect_url_mode: None, + }); + + let params = config + .to_params(ConstraintNode::Any { any: Vec::new() }) + .unwrap(); + + assert!(!params.require_user_presence); + } + #[test] fn test_selfie_check_legacy_preset_serializes_face_verification_level() { let preset = crate::preset::Preset::selfie_check_legacy(Some("face-signal".to_string())); @@ -1819,6 +2042,7 @@ mod tests { legacy_signal: bridge_params.legacy_signal.unwrap_or_default(), bridge_url: None, allow_legacy_proofs: false, + require_user_presence: false, override_connect_base_url: None, return_to: None, @@ -1860,6 +2084,7 @@ mod tests { legacy_signal: bridge_params.legacy_signal.unwrap_or_default(), bridge_url: None, allow_legacy_proofs: false, + require_user_presence: false, override_connect_base_url: None, return_to: None, @@ -1923,6 +2148,7 @@ mod tests { legacy_signal: "test-signal".to_string(), bridge_url: None, allow_legacy_proofs: false, + require_user_presence: false, override_connect_base_url: None, return_to: None, @@ -1969,6 +2195,7 @@ mod tests { legacy_signal: "test-signal".to_string(), bridge_url: None, allow_legacy_proofs: false, + require_user_presence: false, override_connect_base_url: None, return_to: None, environment: None, @@ -1978,10 +2205,66 @@ mod tests { // native=true includes timestamp let payload = build_request_payload(¶ms, true).unwrap(); assert_eq!(payload["timestamp"], "2023-11-14T22:13:20Z"); + assert_eq!(payload["require_user_presence"], serde_json::json!(false)); // native=false omits timestamp let payload_bridge = build_request_payload(¶ms, false).unwrap(); assert!(payload_bridge.get("timestamp").is_none()); + assert_eq!( + payload_bridge["require_user_presence"], + serde_json::json!(false) + ); + } + + #[test] + fn test_build_request_payload_serializes_user_presence_requirement() { + let app_id = AppId::new("app_test").unwrap(); + let sig = "0x".to_string() + &"00".repeat(64) + "1b"; + let rp_context = RpContext::new( + "rp_1234567890abcdef", + "0x0000000000000000000000000000000000000000000000000000000000000001", + 1_700_000_000, + 1_700_003_600, + &sig, + ) + .unwrap(); + + let item = CredentialRequest::new( + CredentialType::ProofOfHuman, + Some(Signal::from_string("test")), + ); + let constraints = ConstraintNode::item(item); + + let params = BridgeConnectionParams { + app_id, + kind: RequestKind::Uniqueness { + action: "my-action".to_string(), + }, + constraints: Some(constraints), + rp_context, + action_description: None, + legacy_verification_level: VerificationLevel::Orb, + legacy_signal: "test-signal".to_string(), + bridge_url: None, + allow_legacy_proofs: false, + require_user_presence: true, + override_connect_base_url: None, + return_to: None, + environment: None, + identity_attributes: None, + }; + + let bridge_payload = build_request_payload(¶ms, false).unwrap(); + assert_eq!( + bridge_payload["require_user_presence"], + serde_json::json!(true) + ); + + let native_payload = build_request_payload(¶ms, true).unwrap(); + assert_eq!( + native_payload["require_user_presence"], + serde_json::json!(true) + ); } #[test] @@ -2015,6 +2298,7 @@ mod tests { legacy_signal: address.to_string(), bridge_url: None, allow_legacy_proofs: false, + require_user_presence: false, override_connect_base_url: None, return_to: None, @@ -2053,6 +2337,7 @@ mod tests { override_connect_base_url: None, return_to, environment: Environment::Production, + require_user_presence: false, } } diff --git a/rust/core/src/error.rs b/rust/core/src/error.rs index b11ad3be..55c0c9d3 100644 --- a/rust/core/src/error.rs +++ b/rust/core/src/error.rs @@ -118,6 +118,10 @@ pub enum AppError { #[error("Verification failed by host app")] FailedByHostApp, + /// User presence check failed or was not completed + #[error("User presence check failed")] + UserPresenceFailed, + /// RP signature is invalid #[error("Invalid RP signature")] InvalidRpSignature, @@ -178,6 +182,7 @@ impl AppError { "connection_failed" => Self::ConnectionFailed, "max_verifications_reached" => Self::MaxVerificationsReached, "failed_by_host_app" => Self::FailedByHostApp, + "user_presence_failed" => Self::UserPresenceFailed, "invalid_rp_signature" => Self::InvalidRpSignature, "nullifier_replayed" => Self::NullifierReplayed, "duplicate_nonce" => Self::DuplicateNonce, diff --git a/rust/core/src/types.rs b/rust/core/src/types.rs index faa9f589..24eea44d 100644 --- a/rust/core/src/types.rs +++ b/rust/core/src/types.rs @@ -711,6 +711,9 @@ pub struct IDKitResult { /// Array of credential responses (always successful - errors at `BridgeResponse` level) pub responses: Vec, + /// Whether World App completed the requested user-presence check. + pub user_presence_completed: bool, + /// The environment used for this request ("production" or "staging") pub environment: String, @@ -729,6 +732,7 @@ impl IDKitResult { action: Option, action_description: Option, responses: Vec, + user_presence_completed: bool, environment: impl Into, ) -> Self { Self { @@ -738,6 +742,7 @@ impl IDKitResult { action_description, session_id: None, responses, + user_presence_completed, environment: environment.into(), identity_attested: None, } @@ -751,6 +756,7 @@ impl IDKitResult { session_id: String, action_description: Option, responses: Vec, + user_presence_completed: bool, environment: impl Into, ) -> Self { Self { @@ -760,6 +766,7 @@ impl IDKitResult { action_description, session_id: Some(session_id), responses, + user_presence_completed, environment: environment.into(), identity_attested: None, } @@ -1353,6 +1360,7 @@ mod tests { None, None, responses, + false, "production", ); assert_eq!(result.protocol_version, "3.0"); diff --git a/rust/core/src/wasm_bindings.rs b/rust/core/src/wasm_bindings.rs index ac52f001..4fe8d6dc 100644 --- a/rust/core/src/wasm_bindings.rs +++ b/rust/core/src/wasm_bindings.rs @@ -478,6 +478,7 @@ enum IDKitConfigWasm { action_description: Option, bridge_url: Option, allow_legacy_proofs: bool, + require_user_presence: bool, override_connect_base_url: Option, return_to: Option, environment: Option, @@ -487,6 +488,7 @@ enum IDKitConfigWasm { rp_context: RpContext, action_description: Option, bridge_url: Option, + require_user_presence: bool, override_connect_base_url: Option, return_to: Option, environment: Option, @@ -497,6 +499,7 @@ enum IDKitConfigWasm { rp_context: RpContext, action_description: Option, bridge_url: Option, + require_user_presence: bool, override_connect_base_url: Option, return_to: Option, environment: Option, @@ -517,6 +520,7 @@ impl IDKitConfigWasm { action_description, bridge_url, allow_legacy_proofs, + require_user_presence, override_connect_base_url, return_to, environment, @@ -542,6 +546,7 @@ impl IDKitConfigWasm { legacy_signal: String::new(), bridge_url, allow_legacy_proofs: *allow_legacy_proofs, + require_user_presence: *require_user_presence, override_connect_base_url: override_connect_base_url.clone(), return_to: return_to.clone(), @@ -557,6 +562,7 @@ impl IDKitConfigWasm { rp_context, action_description, bridge_url, + require_user_presence, override_connect_base_url, return_to, environment, @@ -580,6 +586,7 @@ impl IDKitConfigWasm { legacy_signal: String::new(), bridge_url, allow_legacy_proofs: false, + require_user_presence: *require_user_presence, override_connect_base_url: override_connect_base_url.clone(), return_to: return_to.clone(), @@ -596,6 +603,7 @@ impl IDKitConfigWasm { rp_context, action_description, bridge_url, + require_user_presence, override_connect_base_url, return_to, environment, @@ -621,6 +629,7 @@ impl IDKitConfigWasm { legacy_signal: String::new(), bridge_url, allow_legacy_proofs: false, + require_user_presence: *require_user_presence, override_connect_base_url: override_connect_base_url.clone(), return_to: return_to.clone(), @@ -676,6 +685,7 @@ impl IDKitBuilderWasm { action_description: Option, bridge_url: Option, allow_legacy_proofs: bool, + require_user_presence: bool, override_connect_base_url: Option, return_to: Option, environment: Option, @@ -688,6 +698,7 @@ impl IDKitBuilderWasm { action_description, bridge_url, allow_legacy_proofs, + require_user_presence, override_connect_base_url, return_to, environment, @@ -703,6 +714,7 @@ impl IDKitBuilderWasm { rp_context: RpContextWasm, action_description: Option, bridge_url: Option, + require_user_presence: bool, override_connect_base_url: Option, return_to: Option, environment: Option, @@ -713,6 +725,7 @@ impl IDKitBuilderWasm { rp_context: rp_context.into_inner(), action_description, bridge_url, + require_user_presence, override_connect_base_url, return_to, environment, @@ -729,6 +742,7 @@ impl IDKitBuilderWasm { rp_context: RpContextWasm, action_description: Option, bridge_url: Option, + require_user_presence: bool, override_connect_base_url: Option, return_to: Option, environment: Option, @@ -740,6 +754,7 @@ impl IDKitBuilderWasm { rp_context: rp_context.into_inner(), action_description, bridge_url, + require_user_presence, override_connect_base_url, return_to, environment, @@ -952,6 +967,7 @@ pub fn request( action_description: Option, bridge_url: Option, allow_legacy_proofs: bool, + require_user_presence: bool, override_connect_base_url: Option, return_to: Option, environment: Option, @@ -963,6 +979,7 @@ pub fn request( action_description, bridge_url, allow_legacy_proofs, + require_user_presence, override_connect_base_url, return_to, environment, @@ -975,11 +992,13 @@ pub fn request( /// `session_`. #[must_use] #[wasm_bindgen(js_name = createSession)] +#[allow(clippy::too_many_arguments)] pub fn create_session( app_id: String, rp_context: RpContextWasm, action_description: Option, bridge_url: Option, + require_user_presence: bool, override_connect_base_url: Option, return_to: Option, environment: Option, @@ -989,6 +1008,7 @@ pub fn create_session( rp_context, action_description, bridge_url, + require_user_presence, override_connect_base_url, return_to, environment, @@ -1008,6 +1028,7 @@ pub fn prove_session( rp_context: RpContextWasm, action_description: Option, bridge_url: Option, + require_user_presence: bool, override_connect_base_url: Option, return_to: Option, environment: Option, @@ -1018,6 +1039,7 @@ pub fn prove_session( rp_context, action_description, bridge_url, + require_user_presence, override_connect_base_url, return_to, environment, @@ -1211,6 +1233,8 @@ export interface IDKitResultV3 { action_description?: string; /** Array of V3 credential responses */ responses: ResponseItemV3[]; + /** Whether World App completed the requested user-presence check. */ + user_presence_completed: boolean; /** The environment used for this request ("production" or "staging") */ environment: string; } @@ -1227,6 +1251,8 @@ export interface IDKitResultV4 { action_description?: string; /** Array of V4 credential responses */ responses: ResponseItemV4[]; + /** Whether World App completed the requested user-presence check. */ + user_presence_completed: boolean; /** The environment used for this request ("production" or "staging") */ environment: string; } @@ -1243,6 +1269,8 @@ export interface IDKitResultSession { session_id: `session_${string}`; /** Array of session credential responses */ responses: ResponseItemSession[]; + /** Whether World App completed the requested user-presence check. */ + user_presence_completed: boolean; /** The environment used for this request ("production" or "staging") */ environment: string; } @@ -1267,6 +1295,8 @@ export interface IDKitSessionConfig { bridge_url?: string; /** Optional deep-link callback URL appended as `return_to` on the connector URL */ return_to?: string; + /** Require World App to perform a user-presence check before verification. Defaults to false. */ + require_user_presence?: boolean; } /** RpContext for proof requests */ @@ -1298,6 +1328,7 @@ export type IDKitErrorCode = | "connection_failed" | "max_verifications_reached" | "failed_by_host_app" + | "user_presence_failed" | "invalid_rp_signature" | "nullifier_replayed" | "duplicate_nonce" @@ -1409,6 +1440,7 @@ export function createSession( rp_context: RpContextWasm, action_description?: string, bridge_url?: string, + require_user_presence?: boolean, override_connect_base_url?: string, return_to?: string, environment?: string @@ -1427,6 +1459,7 @@ export function proveSession( rp_context: RpContextWasm, action_description?: string, bridge_url?: string, + require_user_presence?: boolean, override_connect_base_url?: string, return_to?: string, environment?: string @@ -1451,6 +1484,7 @@ mod tests { action_description: None, bridge_url: None, allow_legacy_proofs: false, + require_user_presence: false, override_connect_base_url: None, return_to: Some("idkit://callback?step=request".to_string()), environment: None, @@ -1466,6 +1500,28 @@ mod tests { ); } + #[test] + fn request_params_preserve_user_presence_requirement() { + let config = IDKitConfigWasm::Request { + app_id: "app_staging_test".to_string(), + action: "test-action".to_string(), + rp_context: sample_rp_context(), + action_description: None, + bridge_url: None, + allow_legacy_proofs: false, + require_user_presence: true, + override_connect_base_url: None, + return_to: None, + environment: None, + }; + + let params = config + .to_params(Some(ConstraintNode::Any { any: Vec::new() })) + .expect("request params"); + + assert!(params.require_user_presence); + } + #[test] fn create_session_params_preserve_return_to() { let config = IDKitConfigWasm::CreateSession { @@ -1473,6 +1529,7 @@ mod tests { rp_context: sample_rp_context(), action_description: None, bridge_url: None, + require_user_presence: false, override_connect_base_url: None, return_to: Some("idkit://callback?step=create".to_string()), environment: None, @@ -1496,6 +1553,7 @@ mod tests { rp_context: sample_rp_context(), action_description: None, bridge_url: None, + require_user_presence: false, override_connect_base_url: None, return_to: Some("idkit://callback?step=prove".to_string()), environment: None, diff --git a/swift/Examples/BasicVerification.swift b/swift/Examples/BasicVerification.swift index 478abfd6..22d774b7 100644 --- a/swift/Examples/BasicVerification.swift +++ b/swift/Examples/BasicVerification.swift @@ -27,6 +27,7 @@ func basicVerification() async throws { actionDescription: "Example verification", bridgeUrl: nil, allowLegacyProofs: false, + requireUserPresence: false, overrideConnectBaseUrl: nil, returnTo: nil, environment: .staging, diff --git a/swift/Examples/IDKitSampleApp/IDKitSampleApp/ContentView.swift b/swift/Examples/IDKitSampleApp/IDKitSampleApp/ContentView.swift index 38b94838..79fb84bb 100644 --- a/swift/Examples/IDKitSampleApp/IDKitSampleApp/ContentView.swift +++ b/swift/Examples/IDKitSampleApp/IDKitSampleApp/ContentView.swift @@ -175,6 +175,7 @@ final class SampleModel: ObservableObject { actionDescription: "Local iOS sample", bridgeUrl: nil, allowLegacyProofs: false, + requireUserPresence: false, overrideConnectBaseUrl: nil, returnTo: returnToURL, environment: { diff --git a/swift/README.md b/swift/README.md index 70171361..b2d2dc9a 100644 --- a/swift/README.md +++ b/swift/README.md @@ -38,6 +38,7 @@ let config = IDKitRequestConfig( actionDescription: "Log in", bridgeUrl: nil, allowLegacyProofs: false, + requireUserPresence: false, overrideConnectBaseUrl: nil, returnTo: nil, environment: .staging diff --git a/swift/Sources/IDKit/IDKit.swift b/swift/Sources/IDKit/IDKit.swift index 79d09a19..7aae410c 100644 --- a/swift/Sources/IDKit/IDKit.swift +++ b/swift/Sources/IDKit/IDKit.swift @@ -1,27 +1,127 @@ import Foundation -public typealias IDKitRequestConfig = IdKitRequestConfig -public typealias IDKitSessionConfig = IdKitSessionConfig public typealias IDKitResult = IdKitResult +/// Configuration for uniqueness proof requests. +public struct IDKitRequestConfig { + public let appId: String + public let action: String + public let rpContext: RpContext + public let actionDescription: String? + public let bridgeUrl: String? + public let allowLegacyProofs: Bool + public let requireUserPresence: Bool + public let overrideConnectBaseUrl: String? + public let returnTo: String? + public let environment: Environment? + public let connectUrlMode: ConnectUrlMode? + + public init( + appId: String, + action: String, + rpContext: RpContext, + actionDescription: String? = nil, + bridgeUrl: String? = nil, + allowLegacyProofs: Bool = false, + requireUserPresence: Bool = false, + overrideConnectBaseUrl: String? = nil, + returnTo: String? = nil, + environment: Environment? = nil, + connectUrlMode: ConnectUrlMode? = nil + ) { + self.appId = appId + self.action = action + self.rpContext = rpContext + self.actionDescription = actionDescription + self.bridgeUrl = bridgeUrl + self.allowLegacyProofs = allowLegacyProofs + self.requireUserPresence = requireUserPresence + self.overrideConnectBaseUrl = overrideConnectBaseUrl + self.returnTo = returnTo + self.environment = environment + self.connectUrlMode = connectUrlMode + } + + fileprivate var native: IdKitRequestConfig { + IdKitRequestConfig( + appId: appId, + action: action, + rpContext: rpContext, + actionDescription: actionDescription, + bridgeUrl: bridgeUrl, + allowLegacyProofs: allowLegacyProofs, + requireUserPresence: requireUserPresence, + overrideConnectBaseUrl: overrideConnectBaseUrl, + returnTo: returnTo, + environment: environment, + connectUrlMode: connectUrlMode + ) + } +} + +/// Configuration for session requests. +public struct IDKitSessionConfig { + public let appId: String + public let rpContext: RpContext + public let actionDescription: String? + public let bridgeUrl: String? + public let requireUserPresence: Bool + public let overrideConnectBaseUrl: String? + public let returnTo: String? + public let environment: Environment? + + public init( + appId: String, + rpContext: RpContext, + actionDescription: String? = nil, + bridgeUrl: String? = nil, + requireUserPresence: Bool = false, + overrideConnectBaseUrl: String? = nil, + returnTo: String? = nil, + environment: Environment? = nil + ) { + self.appId = appId + self.rpContext = rpContext + self.actionDescription = actionDescription + self.bridgeUrl = bridgeUrl + self.requireUserPresence = requireUserPresence + self.overrideConnectBaseUrl = overrideConnectBaseUrl + self.returnTo = returnTo + self.environment = environment + } + + fileprivate var native: IdKitSessionConfig { + IdKitSessionConfig( + appId: appId, + rpContext: rpContext, + actionDescription: actionDescription, + bridgeUrl: bridgeUrl, + requireUserPresence: requireUserPresence, + overrideConnectBaseUrl: overrideConnectBaseUrl, + returnTo: returnTo, + environment: environment + ) + } +} + /// Main entry point for IDKit Swift SDK. public enum IDKit { public static let version = "4.0.8" /// Creates a builder for uniqueness proof requests. public static func request(config: IDKitRequestConfig) -> IDKitBuilder { - IDKitBuilder(inner: IdKitBuilder.fromRequest(config: config)) + IDKitBuilder(inner: IdKitBuilder.fromRequest(config: config.native)) } // TODO: Re-enable when World ID 4.0 is live // /// Creates a builder for creating a new session. // public static func createSession(config: IDKitSessionConfig) -> IDKitBuilder { - // IDKitBuilder(inner: IdKitBuilder.fromCreateSession(config: config)) + // IDKitBuilder(inner: IdKitBuilder.fromCreateSession(config: config.native)) // } // /// Creates a builder for proving an existing session. // public static func proveSession(sessionId: String, config: IDKitSessionConfig) -> IDKitBuilder { - // IDKitBuilder(inner: IdKitBuilder.fromProveSession(sessionId: sessionId, config: config)) + // IDKitBuilder(inner: IdKitBuilder.fromProveSession(sessionId: sessionId, config: config.native)) // } /// Hashes a string signal to the canonical 0x-prefixed field element string. @@ -99,6 +199,7 @@ public enum IDKitErrorCode: String, Equatable { case connectionFailed = "connection_failed" case maxVerificationsReached = "max_verifications_reached" case failedByHostApp = "failed_by_host_app" + case userPresenceFailed = "user_presence_failed" case invalidRpSignature = "invalid_rp_signature" case nullifierReplayed = "nullifier_replayed" case duplicateNonce = "duplicate_nonce" @@ -140,6 +241,8 @@ public enum IDKitErrorCode: String, Equatable { .maxVerificationsReached case .failedByHostApp: .failedByHostApp + case .userPresenceFailed: + .userPresenceFailed case .invalidRpSignature: .invalidRpSignature case .nullifierReplayed: diff --git a/swift/Tests/IDKitTests/IDKitTests.swift b/swift/Tests/IDKitTests/IDKitTests.swift index e1628e14..ba96a0ba 100644 --- a/swift/Tests/IDKitTests/IDKitTests.swift +++ b/swift/Tests/IDKitTests/IDKitTests.swift @@ -17,7 +17,7 @@ private actor StatusPoller { } } -private func sampleResult(sessionId: String? = nil) -> IDKitResult { +private func sampleResult(sessionId: String? = nil, userPresenceCompleted: Bool = false) -> IDKitResult { IDKitResult( protocolVersion: "4.0", nonce: "0x1234", @@ -25,6 +25,7 @@ private func sampleResult(sessionId: String? = nil) -> IDKitResult { actionDescription: "Sample action", sessionId: sessionId, responses: [], + userPresenceCompleted: userPresenceCompleted, environment: "production", identityAttested: nil ) @@ -50,6 +51,7 @@ func idkitEntrypoints() throws { actionDescription: nil, bridgeUrl: nil, allowLegacyProofs: false, + requireUserPresence: false, overrideConnectBaseUrl: nil, returnTo: nil, environment: nil, @@ -62,6 +64,7 @@ func idkitEntrypoints() throws { // rpContext: try sampleRpContext(), // actionDescription: nil, // bridgeUrl: nil, + // requireUserPresence: false, // overrideConnectBaseUrl: nil, // returnTo: nil, // environment: nil @@ -83,6 +86,7 @@ func statusMapping() { #expect(IDKitRequest.mapStatus(.awaitingConfirmation) == .awaitingConfirmation) #expect(IDKitRequest.mapStatus(.confirmed(result: result)) == .confirmed(result)) #expect(IDKitRequest.mapStatus(.failed(error: .invalidNetwork)) == .failed(.invalidNetwork)) + #expect(IDKitRequest.mapStatus(.failed(error: .userPresenceFailed)) == .failed(.userPresenceFailed)) #expect(IDKitRequest.mapStatus(.failed(error: .invalidRpSignature)) == .failed(.invalidRpSignature)) #expect(IDKitRequest.mapStatus(.failed(error: .nullifierReplayed)) == .failed(.nullifierReplayed)) #expect(IDKitRequest.mapStatus(.failed(error: .duplicateNonce)) == .failed(.duplicateNonce))