@hfunlabs/hypurr-connect 0.1.22 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hfunlabs/hypurr-connect",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@10.10.0",
6
6
  "main": "./dist/index.js",
@@ -60,6 +60,9 @@ interface InternalConnectState extends HypurrConnectState {
60
60
  const TELEGRAM_STORAGE_KEY = "hypurr-connect-tg-jwt";
61
61
  const LEGACY_TELEGRAM_STORAGE_KEY = "hypurr-connect-tg-user";
62
62
  const TELEGRAM_AUTH_STATE_KEY = "hypurr-connect-auth-state";
63
+ const TELEGRAM_AUTH_CODE_VERIFIER_PREFIX =
64
+ "hypurr-connect-auth-code-verifier:";
65
+ const TELEGRAM_AUTH_RETURN_TO_PREFIX = "hypurr-connect-auth-return-to:";
63
66
  const TELEGRAM_AUTH_MESSAGE = "hypurr-connect:telegram-auth";
64
67
  const DEFAULT_AUTH_HUB_URL = "https://auth.hypurr.fun/login";
65
68
  const DEFAULT_MEDIA_URL = "https://media.hypurr.fun";
@@ -239,6 +242,9 @@ function withExpectedFrom(
239
242
  function currentReturnTo(): string {
240
243
  const url = new URL(window.location.href);
241
244
  for (const param of [
245
+ "code",
246
+ "error",
247
+ "error_description",
242
248
  "token",
243
249
  "token_type",
244
250
  "token_source",
@@ -250,6 +256,31 @@ function currentReturnTo(): string {
250
256
  return url.toString();
251
257
  }
252
258
 
259
+ function cleanAuthCallbackUrl(): void {
260
+ const cleanUrl = new URL(window.location.href);
261
+ for (const param of [
262
+ "code",
263
+ "error",
264
+ "error_description",
265
+ "token",
266
+ "token_type",
267
+ "token_source",
268
+ "state",
269
+ "scope",
270
+ ]) {
271
+ cleanUrl.searchParams.delete(param);
272
+ }
273
+ window.history.replaceState({}, document.title, cleanUrl.toString());
274
+ }
275
+
276
+ function base64UrlEncode(bytes: Uint8Array): string {
277
+ let value = "";
278
+ for (const byte of bytes) {
279
+ value += String.fromCharCode(byte);
280
+ }
281
+ return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
282
+ }
283
+
253
284
  function randomState(): string {
254
285
  const bytes = new Uint8Array(16);
255
286
  crypto.getRandomValues(bytes);
@@ -258,6 +289,25 @@ function randomState(): string {
258
289
  );
259
290
  }
260
291
 
292
+ function randomCodeVerifier(): string {
293
+ const bytes = new Uint8Array(64);
294
+ crypto.getRandomValues(bytes);
295
+ return base64UrlEncode(bytes);
296
+ }
297
+
298
+ async function createCodeChallenge(codeVerifier: string): Promise<string> {
299
+ if (!crypto.subtle) {
300
+ throw new Error(
301
+ "[HypurrConnect] Web Crypto API is required for Telegram auth.",
302
+ );
303
+ }
304
+ const digest = await crypto.subtle.digest(
305
+ "SHA-256",
306
+ new TextEncoder().encode(codeVerifier),
307
+ );
308
+ return base64UrlEncode(new Uint8Array(digest));
309
+ }
310
+
261
311
  function normalizeScopes(scope?: string | string[]): string {
262
312
  if (Array.isArray(scope)) return scope.join(" ");
263
313
  return scope?.trim() || DEFAULT_TELEGRAM_SCOPES.join(" ");
@@ -266,25 +316,185 @@ function normalizeScopes(scope?: string | string[]): string {
266
316
  function normalizeClientId(clientId: unknown): string {
267
317
  const normalized = typeof clientId === "string" ? clientId.trim() : "";
268
318
  if (!normalized) {
269
- throw new Error("[HypurrConnect] config.client_id is required.");
319
+ throw new Error("[HypurrConnect] config.clientId is required.");
270
320
  }
271
321
  return normalized;
272
322
  }
273
323
 
324
+ function codeVerifierStorageKey(state: string): string {
325
+ return `${TELEGRAM_AUTH_CODE_VERIFIER_PREFIX}${state}`;
326
+ }
327
+
328
+ function returnToStorageKey(state: string): string {
329
+ return `${TELEGRAM_AUTH_RETURN_TO_PREFIX}${state}`;
330
+ }
331
+
332
+ function storeTelegramAuthSession(
333
+ state: string,
334
+ codeVerifier: string,
335
+ returnTo: string,
336
+ ): void {
337
+ const previousState = sessionStorage.getItem(TELEGRAM_AUTH_STATE_KEY);
338
+ if (previousState) {
339
+ sessionStorage.removeItem(codeVerifierStorageKey(previousState));
340
+ sessionStorage.removeItem(returnToStorageKey(previousState));
341
+ }
342
+ sessionStorage.setItem(TELEGRAM_AUTH_STATE_KEY, state);
343
+ sessionStorage.setItem(codeVerifierStorageKey(state), codeVerifier);
344
+ sessionStorage.setItem(returnToStorageKey(state), returnTo);
345
+ }
346
+
347
+ function clearTelegramAuthSession(state: string): void {
348
+ sessionStorage.removeItem(TELEGRAM_AUTH_STATE_KEY);
349
+ sessionStorage.removeItem(codeVerifierStorageKey(state));
350
+ sessionStorage.removeItem(returnToStorageKey(state));
351
+ }
352
+
353
+ function takeTelegramAuthSession(state: string):
354
+ | {
355
+ codeVerifier: string | null;
356
+ returnTo: string | null;
357
+ }
358
+ | null {
359
+ const expectedState = sessionStorage.getItem(TELEGRAM_AUTH_STATE_KEY);
360
+ sessionStorage.removeItem(TELEGRAM_AUTH_STATE_KEY);
361
+ if (!expectedState || state !== expectedState) {
362
+ if (expectedState) {
363
+ sessionStorage.removeItem(codeVerifierStorageKey(expectedState));
364
+ sessionStorage.removeItem(returnToStorageKey(expectedState));
365
+ }
366
+ return null;
367
+ }
368
+
369
+ const codeVerifierKey = codeVerifierStorageKey(state);
370
+ const returnToKey = returnToStorageKey(state);
371
+ const codeVerifier = sessionStorage.getItem(codeVerifierKey);
372
+ const returnTo = sessionStorage.getItem(returnToKey);
373
+ sessionStorage.removeItem(codeVerifierKey);
374
+ sessionStorage.removeItem(returnToKey);
375
+ return { codeVerifier, returnTo };
376
+ }
377
+
378
+ function resolveAuthTokenUrl(authHubUrl?: string, tokenUrl?: string): string {
379
+ const configuredTokenUrl = tokenUrl?.trim();
380
+ if (configuredTokenUrl) return configuredTokenUrl;
381
+
382
+ const url = new URL(authHubUrl || DEFAULT_AUTH_HUB_URL);
383
+ const pathWithoutTrailingSlash = url.pathname.replace(/\/+$/, "");
384
+ const basePath = pathWithoutTrailingSlash.replace(/\/[^/]*$/, "");
385
+ url.pathname = `${basePath}/token`;
386
+ url.search = "";
387
+ url.hash = "";
388
+ return url.toString();
389
+ }
390
+
391
+ function getTokenFromExchangeResponse(data: unknown): string | null {
392
+ if (typeof data === "string") {
393
+ const token = data.trim();
394
+ return token || null;
395
+ }
396
+ if (typeof data !== "object" || data === null) return null;
397
+
398
+ const response = data as {
399
+ access_token?: unknown;
400
+ jwt?: unknown;
401
+ token?: unknown;
402
+ };
403
+ for (const token of [response.token, response.access_token, response.jwt]) {
404
+ if (typeof token === "string" && token.trim()) return token.trim();
405
+ }
406
+ return null;
407
+ }
408
+
409
+ async function exchangeTelegramAuthCode({
410
+ authHubUrl,
411
+ clientId,
412
+ code,
413
+ codeVerifier,
414
+ returnTo,
415
+ tokenUrl,
416
+ }: {
417
+ authHubUrl?: string;
418
+ clientId: string;
419
+ code: string;
420
+ codeVerifier: string;
421
+ returnTo: string;
422
+ tokenUrl?: string;
423
+ }): Promise<string> {
424
+ const body = new URLSearchParams({
425
+ client_id: clientId,
426
+ code,
427
+ code_verifier: codeVerifier,
428
+ grant_type: "authorization_code",
429
+ return_to: returnTo,
430
+ });
431
+
432
+ const response = await fetch(resolveAuthTokenUrl(authHubUrl, tokenUrl), {
433
+ method: "POST",
434
+ headers: {
435
+ accept: "application/json",
436
+ "content-type": "application/x-www-form-urlencoded",
437
+ },
438
+ body,
439
+ });
440
+ const responseText = await response.text();
441
+
442
+ if (!response.ok) {
443
+ const detail = responseText.trim();
444
+ throw new Error(
445
+ detail
446
+ ? `[HypurrConnect] Auth code exchange failed: ${detail}`
447
+ : `[HypurrConnect] Auth code exchange failed with HTTP ${response.status}.`,
448
+ );
449
+ }
450
+
451
+ let responseData: unknown = responseText;
452
+ if (responseText) {
453
+ try {
454
+ responseData = JSON.parse(responseText) as unknown;
455
+ } catch {
456
+ responseData = responseText;
457
+ }
458
+ }
459
+
460
+ const token = getTokenFromExchangeResponse(responseData);
461
+ if (!token) {
462
+ throw new Error("[HypurrConnect] Auth code exchange did not return a JWT.");
463
+ }
464
+ return token;
465
+ }
466
+
467
+ type TelegramAuthCallback = {
468
+ type?: typeof TELEGRAM_AUTH_MESSAGE;
469
+ state: string;
470
+ token?: string;
471
+ code?: string;
472
+ error?: string;
473
+ };
474
+
274
475
  function isTelegramAuthMessage(data: unknown): data is {
275
476
  type: typeof TELEGRAM_AUTH_MESSAGE;
276
- token: string;
277
477
  state: string;
478
+ token?: string;
479
+ code?: string;
480
+ error?: string;
278
481
  } {
482
+ if (typeof data !== "object" || data === null) return false;
483
+ if (!("type" in data) || !("state" in data)) return false;
484
+ const message = data as {
485
+ type: unknown;
486
+ state: unknown;
487
+ token?: unknown;
488
+ code?: unknown;
489
+ error?: unknown;
490
+ };
491
+ const hasToken = typeof message.token === "string";
492
+ const hasCode = typeof message.code === "string";
493
+ const hasError = typeof message.error === "string";
279
494
  return (
280
- typeof data === "object" &&
281
- data !== null &&
282
- "type" in data &&
283
- "token" in data &&
284
- "state" in data &&
285
- (data as { type: unknown }).type === TELEGRAM_AUTH_MESSAGE &&
286
- typeof (data as { token: unknown }).token === "string" &&
287
- typeof (data as { state: unknown }).state === "string"
495
+ message.type === TELEGRAM_AUTH_MESSAGE &&
496
+ typeof message.state === "string" &&
497
+ (hasToken || hasCode || hasError)
288
498
  );
289
499
  }
290
500
 
@@ -362,6 +572,58 @@ export function HypurrConnectProvider({
362
572
  localStorage.removeItem(LEGACY_TELEGRAM_STORAGE_KEY);
363
573
  }, []);
364
574
 
575
+ const handleTelegramAuthCallback = useCallback(
576
+ (callback: TelegramAuthCallback) => {
577
+ const authSession = takeTelegramAuthSession(callback.state);
578
+ if (!authSession) {
579
+ setTgError("Invalid auth callback state.");
580
+ return;
581
+ }
582
+
583
+ if (callback.error) {
584
+ setTgError(callback.error);
585
+ return;
586
+ }
587
+
588
+ if (callback.code) {
589
+ if (!authSession.codeVerifier) {
590
+ setTgError("Missing auth code verifier.");
591
+ return;
592
+ }
593
+
594
+ setTgLoading(true);
595
+ setTgError(null);
596
+ void exchangeTelegramAuthCode({
597
+ authHubUrl: config.telegram?.authHubUrl,
598
+ clientId: normalizeClientId(config.clientId),
599
+ code: callback.code,
600
+ codeVerifier: authSession.codeVerifier,
601
+ returnTo: authSession.returnTo || currentReturnTo(),
602
+ tokenUrl: config.telegram?.tokenUrl,
603
+ })
604
+ .then(acceptTelegramToken)
605
+ .catch((err) =>
606
+ setTgError(err instanceof Error ? err.message : String(err)),
607
+ )
608
+ .finally(() => setTgLoading(false));
609
+ return;
610
+ }
611
+
612
+ if (callback.token) {
613
+ acceptTelegramToken(callback.token);
614
+ return;
615
+ }
616
+
617
+ setTgError("Invalid auth callback.");
618
+ },
619
+ [
620
+ acceptTelegramToken,
621
+ config.clientId,
622
+ config.telegram?.authHubUrl,
623
+ config.telegram?.tokenUrl,
624
+ ],
625
+ );
626
+
365
627
  // Eagerly fetch the modal fonts on mount. They're declared via @import in the
366
628
  // injected stylesheet but browsers only fetch a webfont when visible text
367
629
  // first uses it — that lazy fetch makes scores/labels flash the system
@@ -382,66 +644,44 @@ export function HypurrConnectProvider({
382
644
  useEffect(() => {
383
645
  const params = new URLSearchParams(window.location.search);
384
646
  const token = params.get("token");
385
- if (!token) {
647
+ const code = params.get("code");
648
+ const error =
649
+ params.get("error_description") || params.get("error") || undefined;
650
+ if (!token && !code && !error) {
386
651
  localStorage.removeItem(LEGACY_TELEGRAM_STORAGE_KEY);
387
652
  return;
388
653
  }
389
654
 
390
655
  const callbackState = params.get("state") ?? "";
656
+ const callback = {
657
+ code: code || undefined,
658
+ error,
659
+ state: callbackState,
660
+ token: token || undefined,
661
+ type: TELEGRAM_AUTH_MESSAGE,
662
+ } satisfies TelegramAuthCallback;
391
663
 
392
664
  if (window.opener && window.opener !== window) {
393
- window.opener.postMessage(
394
- {
395
- type: TELEGRAM_AUTH_MESSAGE,
396
- token,
397
- state: callbackState,
398
- },
399
- window.location.origin,
400
- );
665
+ window.opener.postMessage(callback, window.location.origin);
401
666
  window.close();
402
667
  return;
403
668
  }
404
669
 
405
- const expectedState = sessionStorage.getItem(TELEGRAM_AUTH_STATE_KEY);
406
- sessionStorage.removeItem(TELEGRAM_AUTH_STATE_KEY);
407
- if (!expectedState || callbackState !== expectedState) {
408
- setTgError("Invalid auth callback state.");
409
- return;
410
- }
411
-
412
- acceptTelegramToken(token);
413
-
414
- const cleanUrl = new URL(window.location.href);
415
- for (const param of [
416
- "token",
417
- "token_type",
418
- "token_source",
419
- "state",
420
- "scope",
421
- ]) {
422
- cleanUrl.searchParams.delete(param);
423
- }
424
- window.history.replaceState({}, document.title, cleanUrl.toString());
425
- }, [acceptTelegramToken]);
670
+ cleanAuthCallbackUrl();
671
+ handleTelegramAuthCallback(callback);
672
+ }, [handleTelegramAuthCallback]);
426
673
 
427
674
  useEffect(() => {
428
675
  function onMessage(event: MessageEvent) {
429
676
  if (event.origin !== window.location.origin) return;
430
677
  if (!isTelegramAuthMessage(event.data)) return;
431
678
 
432
- const expectedState = sessionStorage.getItem(TELEGRAM_AUTH_STATE_KEY);
433
- sessionStorage.removeItem(TELEGRAM_AUTH_STATE_KEY);
434
- if (!expectedState || event.data.state !== expectedState) {
435
- setTgError("Invalid auth callback state.");
436
- return;
437
- }
438
-
439
- acceptTelegramToken(event.data.token);
679
+ handleTelegramAuthCallback(event.data);
440
680
  }
441
681
 
442
682
  window.addEventListener("message", onMessage);
443
683
  return () => window.removeEventListener("message", onMessage);
444
- }, [acceptTelegramToken]);
684
+ }, [handleTelegramAuthCallback]);
445
685
 
446
686
  useEffect(() => {
447
687
  if (!tgAuthToken || !telegramRpcOptions) return;
@@ -1361,28 +1601,21 @@ export function HypurrConnectProvider({
1361
1601
  // ── Auth actions ─────────────────────────────────────────────
1362
1602
  const loginTelegram = useCallback(() => {
1363
1603
  const state = randomState();
1364
- sessionStorage.setItem(TELEGRAM_AUTH_STATE_KEY, state);
1604
+ const codeVerifier = randomCodeVerifier();
1365
1605
 
1366
1606
  const configuredReturnTo = config.telegram?.returnTo;
1367
1607
  const returnTo =
1368
1608
  typeof configuredReturnTo === "function"
1369
1609
  ? configuredReturnTo()
1370
1610
  : configuredReturnTo || currentReturnTo();
1371
-
1372
- const authUrl = new URL(
1373
- config.telegram?.authHubUrl || DEFAULT_AUTH_HUB_URL,
1374
- );
1375
- authUrl.searchParams.set("client_id", normalizeClientId(config.client_id));
1376
- authUrl.searchParams.set("return_to", returnTo);
1377
- authUrl.searchParams.set("state", state);
1378
- authUrl.searchParams.set("scope", normalizeScopes(config.telegram?.scope));
1611
+ storeTelegramAuthSession(state, codeVerifier, returnTo);
1379
1612
 
1380
1613
  const width = 520;
1381
1614
  const height = 720;
1382
1615
  const left = window.screenX + Math.max(0, (window.outerWidth - width) / 2);
1383
1616
  const top = window.screenY + Math.max(0, (window.outerHeight - height) / 2);
1384
1617
  const popup = window.open(
1385
- authUrl.toString(),
1618
+ "about:blank",
1386
1619
  "hypurr_telegram_auth",
1387
1620
  [
1388
1621
  `width=${width}`,
@@ -1394,14 +1627,42 @@ export function HypurrConnectProvider({
1394
1627
  ].join(","),
1395
1628
  );
1396
1629
 
1397
- if (popup) {
1398
- popup.focus();
1399
- return;
1400
- }
1630
+ void (async () => {
1631
+ try {
1632
+ const authUrl = new URL(
1633
+ config.telegram?.authHubUrl || DEFAULT_AUTH_HUB_URL,
1634
+ );
1635
+ authUrl.searchParams.set(
1636
+ "client_id",
1637
+ normalizeClientId(config.clientId),
1638
+ );
1639
+ authUrl.searchParams.set("return_to", returnTo);
1640
+ authUrl.searchParams.set("state", state);
1641
+ authUrl.searchParams.set(
1642
+ "scope",
1643
+ normalizeScopes(config.telegram?.scope),
1644
+ );
1645
+ authUrl.searchParams.set(
1646
+ "code_challenge",
1647
+ await createCodeChallenge(codeVerifier),
1648
+ );
1649
+ authUrl.searchParams.set("code_challenge_method", "S256");
1401
1650
 
1402
- window.location.assign(authUrl.toString());
1651
+ if (popup) {
1652
+ popup.location.assign(authUrl.toString());
1653
+ popup.focus();
1654
+ return;
1655
+ }
1656
+
1657
+ window.location.assign(authUrl.toString());
1658
+ } catch (err) {
1659
+ clearTelegramAuthSession(state);
1660
+ if (popup && !popup.closed) popup.close();
1661
+ setTgError(err instanceof Error ? err.message : String(err));
1662
+ }
1663
+ })();
1403
1664
  }, [
1404
- config.client_id,
1665
+ config.clientId,
1405
1666
  config.telegram?.authHubUrl,
1406
1667
  config.telegram?.returnTo,
1407
1668
  config.telegram?.scope,
package/src/types.ts CHANGED
@@ -21,7 +21,7 @@ import type { HyperliquidWallet } from "hypurr-grpc/ts/hypurr/wallet";
21
21
 
22
22
  export interface HypurrConnectConfig {
23
23
  /** Auth hub client identifier. Sent as `client_id` during Telegram login. */
24
- client_id: string;
24
+ clientId: string;
25
25
  /** gRPC-web base URL. Defaults to https://grpc.hypurr.fun. */
26
26
  grpcUrl?: string;
27
27
  /** Media base URL for user-uploaded assets. Defaults to https://media.hypurr.fun. */
@@ -33,6 +33,8 @@ export interface HypurrConnectConfig {
33
33
  telegram?: {
34
34
  /** Auth hub login URL. Defaults to https://auth.hypurr.fun/login. */
35
35
  authHubUrl?: string;
36
+ /** Auth hub token exchange URL. Defaults to the auth hub login URL with `/login` replaced by `/token`. */
37
+ tokenUrl?: string;
36
38
  /** Optional callback URL. Defaults to the current page without auth query params. */
37
39
  returnTo?: string | (() => string);
38
40
  /** Requested hub scopes. Defaults to the scopes required by this SDK. */