diff --git a/js/examples/nextjs/app/ui.tsx b/js/examples/nextjs/app/ui.tsx
index 193da6fb..0d4b990d 100644
--- a/js/examples/nextjs/app/ui.tsx
+++ b/js/examples/nextjs/app/ui.tsx
@@ -21,9 +21,16 @@ const RP_ID = process.env.NEXT_PUBLIC_RP_ID;
const STAGING_CONNECT_BASE_URL = "https://staging.world.org/verify";
const CONNECT_URL_OVERRIDE_TOOLTIP =
"Enable this to change the deeplink base URL to the staging verify endpoint. Useful when testing with a Staging iOS World App build that supports this override.";
+const RETURN_TO_TOOLTIP =
+ "Enable this to append a return_to callback to the connector URL. The default value just reopens Chrome, and you can override it before starting a verification.";
type PresetKind = "orb" | "secure_document" | "document" | "device" | "selfie";
+function createChromeAppDeeplink(url: string): string {
+ const parsed = new URL(url);
+ return parsed.protocol === "https:" ? "googlechromes://" : "googlechrome://";
+}
+
function createPreset(kind: PresetKind, signal: string) {
switch (kind) {
case "orb":
@@ -113,6 +120,9 @@ export function DemoClient(): ReactElement {
const [useStagingConnectBaseUrl, setUseStagingConnectBaseUrl] =
useState(false);
const [isConnectUrlTooltipOpen, setIsConnectUrlTooltipOpen] = useState(false);
+ const [useReturnTo, setUseReturnTo] = useState(false);
+ const [returnTo, setReturnTo] = useState("");
+ const [isReturnToTooltipOpen, setIsReturnToTooltipOpen] = useState(false);
const widgetPreset = useMemo(
() => createPreset(widgetPresetKind, widgetSignal),
@@ -122,6 +132,9 @@ export function DemoClient(): ReactElement {
environment === "staging" && useStagingConnectBaseUrl
? STAGING_CONNECT_BASE_URL
: undefined;
+ const effectiveReturnTo = useReturnTo
+ ? returnTo.trim() || undefined
+ : undefined;
useEffect(() => {
document.documentElement.setAttribute(
@@ -137,6 +150,18 @@ export function DemoClient(): ReactElement {
}
}, [environment]);
+ useEffect(() => {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ setReturnTo((current) =>
+ current.length > 0
+ ? current
+ : createChromeAppDeeplink(window.location.href),
+ );
+ }, []);
+
const startWidgetFlow = async (presetKind: PresetKind) => {
setWidgetError(null);
setWidgetVerifyResult(null);
@@ -256,6 +281,52 @@ export function DemoClient(): ReactElement {
{STAGING_CONNECT_BASE_URL}
)}
+
+
+
setIsReturnToTooltipOpen(true)}
+ onMouseLeave={() => setIsReturnToTooltipOpen(false)}
+ >
+
+ {isReturnToTooltipOpen && (
+
+ {RETURN_TO_TOOLTIP}
+
+ )}
+
+
setUseReturnTo(e.target.checked)}
+ />
+
setReturnTo(e.target.value)}
+ disabled={!useReturnTo}
+ placeholder="googlechromes://"
+ />
+
@@ -294,6 +365,7 @@ export function DemoClient(): ReactElement {
}}
environment={environment}
override_connect_base_url={overrideConnectBaseUrl}
+ return_to={effectiveReturnTo}
/>
)}
diff --git a/js/packages/core/README.md b/js/packages/core/README.md
index 0ae33040..ea320846 100644
--- a/js/packages/core/README.md
+++ b/js/packages/core/README.md
@@ -50,6 +50,7 @@ const request = await IDKit.request({
signature: rpSig.sig,
},
allow_legacy_proofs: false,
+ return_to: "myapp://idkit/callback",
}).preset(orbLegacy({ signal: "user-123" }));
// Display QR code for World App
diff --git a/js/packages/core/src/request.ts b/js/packages/core/src/request.ts
index f132906d..928a42a7 100644
--- a/js/packages/core/src/request.ts
+++ b/js/packages/core/src/request.ts
@@ -371,6 +371,7 @@ function createWasmBuilderFromConfig(
config.bridge_url ?? null,
config.allow_legacy_proofs ?? false,
config.override_connect_base_url ?? null,
+ config.return_to ?? null,
config.environment ?? null,
);
}
@@ -383,6 +384,7 @@ function createWasmBuilderFromConfig(
config.action_description ?? null,
config.bridge_url ?? null,
config.override_connect_base_url ?? null,
+ config.return_to ?? null,
config.environment ?? null,
);
}
@@ -394,6 +396,7 @@ function createWasmBuilderFromConfig(
config.action_description ?? null,
config.bridge_url ?? null,
config.override_connect_base_url ?? null,
+ config.return_to ?? null,
config.environment ?? null,
);
}
@@ -602,6 +605,7 @@ function createRequest(config: IDKitRequestConfig): IDKitBuilder {
rp_context: config.rp_context,
action_description: config.action_description,
bridge_url: config.bridge_url,
+ return_to: config.return_to,
allow_legacy_proofs: config.allow_legacy_proofs,
override_connect_base_url: config.override_connect_base_url,
environment: config.environment,
@@ -651,6 +655,7 @@ function createSession(config: IDKitSessionConfig): IDKitBuilder {
rp_context: config.rp_context,
action_description: config.action_description,
bridge_url: config.bridge_url,
+ return_to: config.return_to,
override_connect_base_url: config.override_connect_base_url,
environment: config.environment,
});
@@ -705,6 +710,7 @@ function proveSession(
rp_context: config.rp_context,
action_description: config.action_description,
bridge_url: config.bridge_url,
+ return_to: config.return_to,
override_connect_base_url: config.override_connect_base_url,
environment: config.environment,
});
diff --git a/js/packages/core/src/transports/native.ts b/js/packages/core/src/transports/native.ts
index 7f730cc5..0877a57d 100644
--- a/js/packages/core/src/transports/native.ts
+++ b/js/packages/core/src/transports/native.ts
@@ -73,6 +73,7 @@ export interface BuilderConfig {
rp_context?: import("../types/config").RpContext;
action_description?: string;
bridge_url?: string;
+ return_to?: string;
allow_legacy_proofs?: boolean;
override_connect_base_url?: string;
environment?: string;
diff --git a/js/packages/core/src/types/config.ts b/js/packages/core/src/types/config.ts
index 7429a2bd..0fa77403 100644
--- a/js/packages/core/src/types/config.ts
+++ b/js/packages/core/src/types/config.ts
@@ -46,6 +46,8 @@ export type IDKitRequestConfig = {
action_description?: string;
/** URL to a third-party bridge to use when connecting to the World App. Optional. */
bridge_url?: string;
+ /** Optional deep-link callback URL appended as `return_to` on the connector URL. */
+ return_to?: string;
/**
* Whether to accept legacy (v3) World ID proofs as fallback.
@@ -80,6 +82,8 @@ export type IDKitSessionConfig = {
action_description?: string;
/** URL to a third-party bridge to use when connecting to the World App. Optional. */
bridge_url?: string;
+ /** Optional deep-link callback URL appended as `return_to` on the connector URL. */
+ return_to?: string;
/** Optional override for the connect base URL (e.g., for staging environments) */
override_connect_base_url?: string;
diff --git a/js/packages/react/README.md b/js/packages/react/README.md
index 75ab6198..77c0c87a 100644
--- a/js/packages/react/README.md
+++ b/js/packages/react/README.md
@@ -31,6 +31,7 @@ function Example() {
action: "my-action",
rp_context,
allow_legacy_proofs: false,
+ return_to: "myapp://idkit/callback",
preset: orbLegacy({ signal: "user-123" }),
});
const isBusy =
@@ -72,6 +73,7 @@ function WidgetExample() {
action="my-action"
rp_context={rpContext}
allow_legacy_proofs={false}
+ return_to="myapp://idkit/callback"
preset={orbLegacy({ signal: "user-123" })}
onSuccess={(result) => {
// required: runs after verification succeeds
diff --git a/js/packages/react/src/__tests__/hooks.test.tsx b/js/packages/react/src/__tests__/hooks.test.tsx
index 64f9aa55..faeaff69 100644
--- a/js/packages/react/src/__tests__/hooks.test.tsx
+++ b/js/packages/react/src/__tests__/hooks.test.tsx
@@ -119,6 +119,17 @@ describe("request/session hooks", () => {
});
expect(requestMock).toHaveBeenCalledTimes(1);
+ expect(requestMock).toHaveBeenCalledWith({
+ app_id: "app_test",
+ action: "test-action",
+ rp_context: baseRpContext,
+ action_description: undefined,
+ bridge_url: undefined,
+ return_to: undefined,
+ allow_legacy_proofs: false,
+ override_connect_base_url: undefined,
+ environment: undefined,
+ });
expect(result.current.connectorURI).toBe("wc://request");
expect(result.current.result).toEqual({ proof: "ok" });
});
@@ -151,6 +162,15 @@ describe("request/session hooks", () => {
});
expect(createSessionMock).toHaveBeenCalledTimes(1);
+ expect(createSessionMock).toHaveBeenCalledWith({
+ app_id: "app_test",
+ rp_context: baseRpContext,
+ action_description: undefined,
+ bridge_url: undefined,
+ override_connect_base_url: undefined,
+ return_to: undefined,
+ environment: undefined,
+ });
expect(proveSessionMock).not.toHaveBeenCalled();
expect(result.current.result?.session_id).toBe("session_1");
});
@@ -189,11 +209,133 @@ describe("request/session hooks", () => {
action_description: undefined,
bridge_url: undefined,
override_connect_base_url: undefined,
+ return_to: undefined,
environment: undefined,
});
expect(result.current.result?.session_id).toBe("session_2");
});
+ it("request hook forwards return_to 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,
+ return_to: "idkit://callback?step=proof",
+ preset: { type: "OrbLegacy" },
+ }),
+ );
+
+ act(() => {
+ result.current.open();
+ });
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true);
+ });
+
+ expect(requestMock).toHaveBeenCalledWith({
+ app_id: "app_test",
+ action: "test-action",
+ rp_context: baseRpContext,
+ action_description: undefined,
+ bridge_url: undefined,
+ return_to: "idkit://callback?step=proof",
+ allow_legacy_proofs: false,
+ override_connect_base_url: undefined,
+ environment: undefined,
+ });
+ });
+
+ it("session hook forwards return_to to createSession", async () => {
+ createSessionMock.mockReturnValue({
+ preset: vi.fn(async () => ({
+ connectorURI: "wc://session-create",
+ pollOnce: vi.fn(async () => ({
+ type: "confirmed",
+ result: { session_id: "session_1", responses: [] },
+ })),
+ })),
+ });
+
+ const { result } = renderHook(() =>
+ useIDKitSession({
+ app_id: "app_test",
+ rp_context: baseRpContext,
+ return_to: "idkit://callback?step=create",
+ preset: { type: "OrbLegacy" },
+ }),
+ );
+
+ act(() => {
+ result.current.open();
+ });
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true);
+ });
+
+ expect(createSessionMock).toHaveBeenCalledWith({
+ app_id: "app_test",
+ rp_context: baseRpContext,
+ action_description: undefined,
+ bridge_url: undefined,
+ override_connect_base_url: undefined,
+ return_to: "idkit://callback?step=create",
+ environment: undefined,
+ });
+ });
+
+ it("session hook forwards return_to to proveSession", async () => {
+ proveSessionMock.mockReturnValue({
+ preset: vi.fn(async () => ({
+ connectorURI: "wc://session-prove",
+ pollOnce: vi.fn(async () => ({
+ type: "confirmed",
+ result: { session_id: "session_2", responses: [] },
+ })),
+ })),
+ });
+
+ const { result } = renderHook(() =>
+ useIDKitSession({
+ app_id: "app_test",
+ rp_context: baseRpContext,
+ existing_session_id: "session_2",
+ return_to: "idkit://callback?step=prove",
+ preset: { type: "OrbLegacy" },
+ }),
+ );
+
+ act(() => {
+ result.current.open();
+ });
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true);
+ });
+
+ expect(proveSessionMock).toHaveBeenCalledWith("session_2", {
+ app_id: "app_test",
+ rp_context: baseRpContext,
+ action_description: undefined,
+ bridge_url: undefined,
+ override_connect_base_url: undefined,
+ return_to: "idkit://callback?step=prove",
+ environment: undefined,
+ });
+ });
+
it("session hook fails on empty existing_session_id", async () => {
const { result } = renderHook(() =>
useIDKitSession({
diff --git a/js/packages/react/src/hooks/useIDKitRequest.ts b/js/packages/react/src/hooks/useIDKitRequest.ts
index bfe7aaeb..3ec7c3eb 100644
--- a/js/packages/react/src/hooks/useIDKitRequest.ts
+++ b/js/packages/react/src/hooks/useIDKitRequest.ts
@@ -16,6 +16,7 @@ export function useIDKitRequest(
rp_context: config.rp_context,
action_description: config.action_description,
bridge_url: config.bridge_url,
+ return_to: config.return_to,
allow_legacy_proofs: config.allow_legacy_proofs,
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 0fb11e26..3475e816 100644
--- a/js/packages/react/src/hooks/useIDKitSession.ts
+++ b/js/packages/react/src/hooks/useIDKitSession.ts
@@ -33,6 +33,7 @@ export function useIDKitSession(
action_description: config.action_description,
bridge_url: config.bridge_url,
override_connect_base_url: config.override_connect_base_url,
+ return_to: config.return_to,
environment: config.environment,
})
: IDKit.createSession({
@@ -41,6 +42,7 @@ export function useIDKitSession(
action_description: config.action_description,
bridge_url: config.bridge_url,
override_connect_base_url: config.override_connect_base_url,
+ return_to: config.return_to,
environment: config.environment,
});
return builder.preset(config.preset);
diff --git a/rust/core/src/wasm_bindings.rs b/rust/core/src/wasm_bindings.rs
index 76d425ad..5f774d10 100644
--- a/rust/core/src/wasm_bindings.rs
+++ b/rust/core/src/wasm_bindings.rs
@@ -472,6 +472,7 @@ enum IDKitConfigWasm {
bridge_url: Option,
allow_legacy_proofs: bool,
override_connect_base_url: Option,
+ return_to: Option,
environment: Option,
},
CreateSession {
@@ -480,6 +481,7 @@ enum IDKitConfigWasm {
action_description: Option,
bridge_url: Option,
override_connect_base_url: Option,
+ return_to: Option,
environment: Option,
},
ProveSession {
@@ -489,6 +491,7 @@ enum IDKitConfigWasm {
action_description: Option,
bridge_url: Option,
override_connect_base_url: Option,
+ return_to: Option,
environment: Option,
},
}
@@ -508,6 +511,7 @@ impl IDKitConfigWasm {
bridge_url,
allow_legacy_proofs,
override_connect_base_url,
+ return_to,
environment,
} => {
let app_id = crate::AppId::new(app_id)
@@ -532,7 +536,7 @@ impl IDKitConfigWasm {
allow_legacy_proofs: *allow_legacy_proofs,
override_connect_base_url: override_connect_base_url.clone(),
- return_to: None,
+ return_to: return_to.clone(),
environment: environment.as_deref().map(|e| match e {
"staging" => crate::bridge::Environment::Staging,
_ => crate::bridge::Environment::Production,
@@ -545,6 +549,7 @@ impl IDKitConfigWasm {
action_description,
bridge_url,
override_connect_base_url,
+ return_to,
environment,
} => {
let app_id = crate::AppId::new(app_id)
@@ -567,7 +572,7 @@ impl IDKitConfigWasm {
allow_legacy_proofs: false,
override_connect_base_url: override_connect_base_url.clone(),
- return_to: None,
+ return_to: return_to.clone(),
environment: environment.as_deref().map(|e| match e {
"staging" => crate::bridge::Environment::Staging,
_ => crate::bridge::Environment::Production,
@@ -581,6 +586,7 @@ impl IDKitConfigWasm {
action_description,
bridge_url,
override_connect_base_url,
+ return_to,
environment,
} => {
let app_id = crate::AppId::new(app_id)
@@ -605,7 +611,7 @@ impl IDKitConfigWasm {
allow_legacy_proofs: false,
override_connect_base_url: override_connect_base_url.clone(),
- return_to: None,
+ return_to: return_to.clone(),
environment: environment.as_deref().map(|e| match e {
"staging" => crate::bridge::Environment::Staging,
_ => crate::bridge::Environment::Production,
@@ -647,6 +653,7 @@ impl IDKitBuilderWasm {
bridge_url: Option,
allow_legacy_proofs: bool,
override_connect_base_url: Option,
+ return_to: Option,
environment: Option,
) -> Self {
Self {
@@ -658,6 +665,7 @@ impl IDKitBuilderWasm {
bridge_url,
allow_legacy_proofs,
override_connect_base_url,
+ return_to,
environment,
},
}
@@ -672,6 +680,7 @@ impl IDKitBuilderWasm {
action_description: Option,
bridge_url: Option,
override_connect_base_url: Option,
+ return_to: Option,
environment: Option,
) -> Self {
Self {
@@ -681,6 +690,7 @@ impl IDKitBuilderWasm {
action_description,
bridge_url,
override_connect_base_url,
+ return_to,
environment,
},
}
@@ -696,6 +706,7 @@ impl IDKitBuilderWasm {
action_description: Option,
bridge_url: Option,
override_connect_base_url: Option,
+ return_to: Option,
environment: Option,
) -> Self {
Self {
@@ -706,6 +717,7 @@ impl IDKitBuilderWasm {
action_description,
bridge_url,
override_connect_base_url,
+ return_to,
environment,
},
}
@@ -917,6 +929,7 @@ pub fn request(
bridge_url: Option,
allow_legacy_proofs: bool,
override_connect_base_url: Option,
+ return_to: Option,
environment: Option,
) -> IDKitBuilderWasm {
IDKitBuilderWasm::new(
@@ -927,6 +940,7 @@ pub fn request(
bridge_url,
allow_legacy_proofs,
override_connect_base_url,
+ return_to,
environment,
)
}
@@ -940,6 +954,7 @@ pub fn create_session(
action_description: Option,
bridge_url: Option,
override_connect_base_url: Option,
+ return_to: Option,
environment: Option,
) -> IDKitBuilderWasm {
IDKitBuilderWasm::for_create_session(
@@ -948,6 +963,7 @@ pub fn create_session(
action_description,
bridge_url,
override_connect_base_url,
+ return_to,
environment,
)
}
@@ -955,6 +971,7 @@ pub fn create_session(
/// Entry point for proving an existing session (WASM)
#[must_use]
#[wasm_bindgen(js_name = proveSession)]
+#[allow(clippy::too_many_arguments)]
pub fn prove_session(
session_id: String,
app_id: String,
@@ -962,6 +979,7 @@ pub fn prove_session(
action_description: Option,
bridge_url: Option,
override_connect_base_url: Option,
+ return_to: Option,
environment: Option,
) -> IDKitBuilderWasm {
IDKitBuilderWasm::for_prove_session(
@@ -971,6 +989,7 @@ pub fn prove_session(
action_description,
bridge_url,
override_connect_base_url,
+ return_to,
environment,
)
}
@@ -1216,6 +1235,8 @@ export interface IDKitSessionConfig {
action_description?: string;
/** Optional bridge URL (defaults to production) */
bridge_url?: string;
+ /** Optional deep-link callback URL appended as `return_to` on the connector URL */
+ return_to?: string;
}
/** RpContext for proof requests */
@@ -1347,6 +1368,7 @@ export function createSession(
action_description?: string,
bridge_url?: string,
override_connect_base_url?: string,
+ return_to?: string,
environment?: string
): IDKitBuilder;
@@ -1362,6 +1384,86 @@ export function proveSession(
action_description?: string,
bridge_url?: string,
override_connect_base_url?: string,
+ return_to?: string,
environment?: string
): IDKitBuilder;
"#;
+
+#[cfg(test)]
+mod tests {
+ use super::IDKitConfigWasm;
+ use crate::{ConstraintNode, RpContext};
+
+ fn sample_rp_context() -> RpContext {
+ RpContext::new("rp_123456789abcdef0", "0x01", 1, 2, "0x1234").expect("valid rp_context")
+ }
+
+ #[test]
+ fn request_params_preserve_return_to() {
+ 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,
+ override_connect_base_url: None,
+ return_to: Some("idkit://callback?step=request".to_string()),
+ environment: None,
+ };
+
+ let params = config
+ .to_params(ConstraintNode::Any { any: Vec::new() })
+ .expect("request params");
+
+ assert_eq!(
+ params.return_to.as_deref(),
+ Some("idkit://callback?step=request")
+ );
+ }
+
+ #[test]
+ fn create_session_params_preserve_return_to() {
+ let config = IDKitConfigWasm::CreateSession {
+ app_id: "app_staging_test".to_string(),
+ rp_context: sample_rp_context(),
+ action_description: None,
+ bridge_url: None,
+ override_connect_base_url: None,
+ return_to: Some("idkit://callback?step=create".to_string()),
+ environment: None,
+ };
+
+ let params = config
+ .to_params(ConstraintNode::Any { any: Vec::new() })
+ .expect("create session params");
+
+ assert_eq!(
+ params.return_to.as_deref(),
+ Some("idkit://callback?step=create")
+ );
+ }
+
+ #[test]
+ fn prove_session_params_preserve_return_to() {
+ let config = IDKitConfigWasm::ProveSession {
+ session_id: "session_123".to_string(),
+ app_id: "app_staging_test".to_string(),
+ rp_context: sample_rp_context(),
+ action_description: None,
+ bridge_url: None,
+ override_connect_base_url: None,
+ return_to: Some("idkit://callback?step=prove".to_string()),
+ environment: None,
+ };
+
+ let params = config
+ .to_params(ConstraintNode::Any { any: Vec::new() })
+ .expect("prove session params");
+
+ assert_eq!(
+ params.return_to.as_deref(),
+ Some("idkit://callback?step=prove")
+ );
+ }
+}