Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@utxos/sdk",
"version": "0.2.5",
"version": "0.2.6",
"description": "UTXOS SDK - Web3 infrastructure platform for UTXO blockchains",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
Expand Down
22 changes: 22 additions & 0 deletions src/functions/auth/clean-post-auth-redirect-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/** Strip OAuth hand-off params so wallet reload does not re-trigger sign-in. */
export function cleanPostAuthRedirectUrl(redirect: string): string {
try {
const url = new URL(redirect);
url.searchParams.delete("provider");
url.searchParams.delete("directTo");
url.searchParams.delete("from_provider");

const refreshToken = url.searchParams.get("refreshToken");
if (
!refreshToken ||
refreshToken === "undefined" ||
refreshToken === "null"
) {
url.searchParams.delete("refreshToken");
}

return url.toString();
} catch {
return redirect;
}
}
1 change: 1 addition & 0 deletions src/functions/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./clean-post-auth-redirect-url";
1 change: 1 addition & 0 deletions src/functions/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./auth";
export * from "./client";
export * from "./crypto";
export * from "./key-shard";
Expand Down
18 changes: 15 additions & 3 deletions src/functions/window/open-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,20 @@ export async function openWindow(
params: OpenWindowParams,
appUrl: string = "https://utxos.dev/",
): Promise<any> {
// Build the wallet URL with query parameters
const p = new URLSearchParams(params as Record<string, string>);
const p = new URLSearchParams();
for (const [key, value] of Object.entries(params as Record<string, string>)) {
if (
value != null &&
value !== "" &&
value !== "undefined" &&
value !== "null"
) {
p.set(key, value);
}
}
if (typeof window !== "undefined" && window.location?.origin) {
p.set("origin", window.location.origin);
}
const url = `${appUrl}/client/wallet?${p.toString()}`;

// Delegate to platform-specific linking adapter
Expand All @@ -33,7 +45,7 @@ export async function openWindow(
if (result.error) {
return {
success: false,
message: result.errorDescription || result.error
message: result.errorDescription || result.error,
};
}

Expand Down
68 changes: 57 additions & 11 deletions src/non-custodial/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import {
import axios from "axios";
import { trackPlatformMetric } from "../internal/metrics";


import { getStorage, getLinking, getEncoding } from "../internal/platform-context";
import {
getStorage,
getLinking,
getEncoding,
} from "../internal/platform-context";

export * from "./utils";

Expand Down Expand Up @@ -190,9 +193,7 @@ export class Web3NonCustodialProvider {

constructor(params: Web3NonCustodialProviderParams) {
if (params.appleOauth2ClientId) {
throw new Error(
"Apple Sign no longer supported in SDK.",
);
throw new Error("Apple Sign no longer supported in SDK.");
}
this.projectId = params.projectId;
this.appOrigin = params.appOrigin ? params.appOrigin : "https://utxos.dev";
Expand All @@ -211,7 +212,7 @@ export class Web3NonCustodialProvider {

private base64Decode(base64: string): string {
const encoding = getEncoding();
const normalized = base64.replace(/-/g, '+').replace(/_/g, '/');
const normalized = base64.replace(/-/g, "+").replace(/_/g, "/");
return encoding.bytesToUtf8(encoding.base64ToBytes(normalized));
}

Expand Down Expand Up @@ -316,7 +317,7 @@ export class Web3NonCustodialProvider {
}
| { error: null; data: { deviceId: string; walletId: string } }
> {
const userAgent = getLinking().getUserAgent() ?? 'unknown';
const userAgent = getLinking().getUserAgent() ?? "unknown";
const { data: user, error: userError } = await this.getUser();
if (userError) {
return { error: userError, data: null };
Expand Down Expand Up @@ -378,8 +379,6 @@ export class Web3NonCustodialProvider {
} catch (e) {}

await this.pushDevice({


deviceId: result.deviceId,
encryptedDeviceShard,
walletId: result.walletId,
Expand Down Expand Up @@ -459,7 +458,7 @@ export class Web3NonCustodialProvider {
newDeviceShardEncryptionKey,
);

const userAgent = getLinking().getUserAgent() ?? 'unknown';
const userAgent = getLinking().getUserAgent() ?? "unknown";

const createDeviceBody: CreateDeviceBody = {
walletId,
Expand Down Expand Up @@ -563,7 +562,13 @@ export class Web3NonCustodialProvider {
return;
} else if (provider === "email") {
// Email uses OTP flow, not OAuth - this method should not be called for email
throw new Error("Email provider uses OTP flow. Use the email OTP API endpoints instead.");
throw new Error(
"Email provider uses OTP flow. Use the email OTP API endpoints instead.",
);
} else if (provider === "fcb") {
throw new Error(
"FCB provider uses signInWithFcb(). Pass redirectUrl and optional newSession.",
);
}
}

Expand Down Expand Up @@ -644,6 +649,47 @@ export class Web3NonCustodialProvider {
return this.getUser();
}

async signInWithFcb(
params: {
redirectUrl: string;
newSession?: boolean;
},
callback: (authorizeUrl: string, logoutUrl?: string) => void,
): Promise<{ error: Error | null }> {
const { redirectUrl, newSession = false } = params;

const state = this.base64Encode(
JSON.stringify({
redirect: redirectUrl,
provider: "fcb",
projectId: this.projectId,
}),
);

const res = await fetch(this.appOrigin + "/api/auth/fcb", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state, newSession }),
});

if (!res.ok) {
const data = await res.json().catch(() => ({}));
return {
error: new Error(
(data as { error?: string }).error ??
`FCB authorize failed (HTTP ${res.status})`,
),
};
}

const json = (await res.json()) as {
authorizeUrl: string;
logoutUrl?: string;
};
callback(json.authorizeUrl, json.logoutUrl);
return { error: null };
}

private async putInStorage<ObjectType extends object>(
key: string,
data: ObjectType,
Expand Down
104 changes: 64 additions & 40 deletions src/platforms/browser/linking.browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,21 @@
* Handles URL opening, OAuth popups, and URL parsing for browsers
*/

import type { LinkingAdapter, AuthCallbackResult } from '../../adapters/types';
import type { LinkingAdapter, AuthCallbackResult } from "../../adapters/types";

/**
* Calculate centered popup position relative to current window
*/
function calculatePopupPosition(width: number, height: number): { left: number; top: number } {
const screenWidth = window.innerWidth || document.documentElement.clientWidth || screen.width;
const screenHeight = window.innerHeight || document.documentElement.clientHeight || screen.height;
function calculatePopupPosition(
width: number,
height: number,
): { left: number; top: number } {
const screenWidth =
window.innerWidth || document.documentElement.clientWidth || screen.width;
const screenHeight =
window.innerHeight ||
document.documentElement.clientHeight ||
screen.height;

// Account for window position on multi-monitor setups
const windowLeft = window.screenX || window.screenLeft || 0;
Expand All @@ -21,7 +28,7 @@ function calculatePopupPosition(width: number, height: number): { left: number;

return {
left: Math.max(0, left),
top: Math.max(0, top)
top: Math.max(0, top),
};
}

Expand Down Expand Up @@ -56,7 +63,7 @@ export const linkingAdapter: LinkingAdapter = {
* Open URL in new browser tab
*/
async openURL(url: string): Promise<void> {
window.open(url, '_blank', 'noopener,noreferrer');
window.open(url, "_blank", "noopener,noreferrer");
},

/**
Expand All @@ -71,16 +78,31 @@ export const linkingAdapter: LinkingAdapter = {
* Open OAuth popup window and wait for callback via postMessage
* Handles popup blocked, user close, and message events
*/
async openAuthWindow(url: string, callbackScheme: string): Promise<AuthCallbackResult> {
async openAuthWindow(
url: string,
callbackScheme: string,
): Promise<AuthCallbackResult> {
return new Promise((resolve, reject) => {
const width = 448;
const height = 668;
const features = buildWindowFeatures(width, height);

const popup = window.open(url, 'utxos', features);
const handleOriginRequest = (e: MessageEvent) => {
if (e.data?.type === "utxos_request_origin" && e.source) {
try {
(e.source as Window).postMessage(
{ type: "utxos_origin_response", target: "utxos" },
"*",
);
} catch {}
}
};
window.addEventListener("message", handleOriginRequest);

const popup = window.open(url, "utxos", features);

if (!popup) {
reject(new Error('Popup blocked. Please allow popups for this site.'));
reject(new Error("Popup blocked. Please allow popups for this site."));
return;
}

Expand All @@ -92,7 +114,8 @@ export const linkingAdapter: LinkingAdapter = {
clearInterval(pollTimer);
pollTimer = null;
}
window.removeEventListener('message', handleMessage);
window.removeEventListener("message", handleMessage);
window.removeEventListener("message", handleOriginRequest);
};

const resolveOnce = (result: AuthCallbackResult) => {
Expand All @@ -103,39 +126,40 @@ export const linkingAdapter: LinkingAdapter = {
};

const handleMessage = (event: MessageEvent) => {
// Verify message is from our auth flow
if (!event.data || typeof event.data !== 'object') {
if (!event.data || typeof event.data !== "object") {
return;
}

const { code, state, error, error_description, target } = event.data;
if (event.data.target !== "utxos") {
return;
}

// Check for utxos-specific target or OAuth params
if (target === 'utxos' || code || error) {
try {
if (!popup.closed) {
popup.close();
}
resolveOnce({
code,
state,
error,
errorDescription: error_description,
// Include full message data for wallet-specific payloads
data: event.data,
});
}
} catch {}

resolveOnce({
code: event.data.code,
state: event.data.state,
error: event.data.error,
errorDescription: event.data.error_description,
data: event.data,
});
};

window.addEventListener('message', handleMessage);
window.addEventListener("message", handleMessage);

// Poll for popup close (user manually closed)
pollTimer = setInterval(() => {
if (popup.closed) {
resolveOnce({
error: 'cancelled',
errorDescription: 'User closed the popup'
});
}
try {
if (popup.closed) {
resolveOnce({
error: "cancelled",
errorDescription: "User closed the popup",
});
}
} catch {}
}, 500);
});
},
Expand All @@ -144,15 +168,15 @@ export const linkingAdapter: LinkingAdapter = {
* Get current page URL
*/
getCurrentURL(): string | null {
if (typeof window === 'undefined') return null;
if (typeof window === "undefined") return null;
return window.location.href;
},

/**
* Parse URL query parameters from current page
*/
getURLParams(): Record<string, string> {
if (typeof window === 'undefined') return {};
if (typeof window === "undefined") return {};

const params = new URLSearchParams(window.location.search);
const result: Record<string, string> = {};
Expand All @@ -177,23 +201,23 @@ export const linkingAdapter: LinkingAdapter = {
* Returns cleanup function to remove listener
*/
addURLListener(callback: (url: string) => void): () => void {
if (typeof window === 'undefined') {
if (typeof window === "undefined") {
return () => {};
}

const handler = () => callback(window.location.href);

window.addEventListener('popstate', handler);
window.addEventListener('hashchange', handler);
window.addEventListener("popstate", handler);
window.addEventListener("hashchange", handler);

return () => {
window.removeEventListener('popstate', handler);
window.removeEventListener('hashchange', handler);
window.removeEventListener("popstate", handler);
window.removeEventListener("hashchange", handler);
};
},

getUserAgent(): string | null {
if (typeof navigator === 'undefined') return null;
if (typeof navigator === "undefined") return null;
return navigator.userAgent;
},
};
Loading
Loading