Skip to content

Commit 5f2bc26

Browse files
authored
Merge pull request #890 from tkhq/ethan/arnaud-comments
Addressed comments and updated session expiration logic
2 parents 9c0fb2d + d8cab2e commit 5f2bc26

File tree

8 files changed

+280
-102
lines changed

8 files changed

+280
-102
lines changed

packages/core/src/__clients__/core.ts

Lines changed: 47 additions & 54 deletions
Large diffs are not rendered by default.

packages/core/src/utils.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,3 +1083,14 @@ export async function assertValidP256ECDSAKeyPair(
10831083
);
10841084
}
10851085
}
1086+
1087+
export function isValidPasskeyName(name: string): string {
1088+
const nameRegex = /^[a-zA-Z0-9 _\-:\/]{1,64}$/;
1089+
if (!nameRegex.test(name)) {
1090+
throw new TurnkeyError(
1091+
"Passkey name must be 1-64 characters and only contain letters, numbers, spaces, dashes, underscores, colons, or slashes.",
1092+
TurnkeyErrorCodes.INVALID_REQUEST,
1093+
);
1094+
}
1095+
return name;
1096+
}

packages/react-wallet-kit/src/components/auth/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { DeveloperError } from "../design/Failure";
2424
import { useModal } from "../../providers/modal/Hook";
2525
import { useTurnkey } from "../../providers/client/Hook";
2626
import { ClientState } from "../../types/base";
27-
import { isWalletConnect } from "@utils";
27+
import { isWalletConnect } from "../../utils/utils";
2828

2929
export function AuthComponent() {
3030
const {

packages/react-wallet-kit/src/components/user/LinkWallet.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { useTurnkey } from "../../providers/client/Hook";
88
import { ActionPage } from "../auth/Action";
99
import { SuccessPage } from "../design/Success";
1010
import type { WalletProvider } from "@turnkey/core";
11-
import { isWalletConnect } from "@utils";
11+
import { isWalletConnect } from "../../utils/utils";
1212

1313
interface LinkWalletModalProps {
1414
providers: WalletProvider[];

packages/react-wallet-kit/src/providers/client/Provider.tsx

Lines changed: 63 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@ import {
1717
useDebouncedCallback,
1818
useWalletProviderState,
1919
withTurnkeyErrorHandling,
20-
} from "../../utils";
20+
} from "../../utils/utils";
21+
import {
22+
type TimerMap,
23+
clearKey,
24+
clearAll,
25+
setCappedTimeoutInMap,
26+
setTimeoutInMap,
27+
} from "../../utils/timers";
2128
import {
2229
Chain,
2330
CreateSubOrgParams,
@@ -146,7 +153,7 @@ export const ClientProvider: React.FC<ClientProviderProps> = ({
146153
// this is so our useEffect that calls `initializeWalletProviderListeners()` only runs when it needs to
147154
const [walletProviders, setWalletProviders] = useWalletProviderState();
148155

149-
const expiryTimeoutsRef = useRef<Record<string, NodeJS.Timeout>>({});
156+
const expiryTimeoutsRef = useRef<TimerMap>({});
150157
const proxyAuthConfigRef = useRef<ProxyTGetWalletKitConfigResponse | null>(
151158
null,
152159
);
@@ -648,43 +655,42 @@ export const ClientProvider: React.FC<ClientProviderProps> = ({
648655

649656
/**
650657
* @internal
651-
* Schedules a session expiration and warning timeout for the given session key.
658+
* Schedules a session expiration and warning timer for the given session key.
652659
*
653-
* - This function sets up two timeouts: one for warning before the session expires and another to expire the session.
654-
* - The warning timeout is set to trigger before the session expires, allowing for actions like refreshing the session.
655-
* - The expiration timeout clears the session and triggers any necessary callbacks.
660+
* - Sets up two timers: one for warning before expiry and one for actual expiry.
661+
* - Uses capped timeouts under the hood so delays > 24.8 days are safe (see utils/timers.ts).
656662
*
657663
* @param params.sessionKey - The key of the session to schedule expiration for.
658-
* @param params.expiry - The expiration time in seconds for the session.
664+
* @param params.expiry - The expiration time in seconds since epoch.
659665
* @throws {TurnkeyError} If an error occurs while scheduling the session expiration.
660666
*/
661667
async function scheduleSessionExpiration(params: {
662668
sessionKey: string;
663-
expiry: number;
669+
expiry: number; // seconds since epoch
664670
}) {
665671
const { sessionKey, expiry } = params;
666672

667673
try {
668-
// Clear any existing timeout for this session key
669-
if (expiryTimeoutsRef.current[sessionKey]) {
670-
clearTimeout(expiryTimeoutsRef.current[sessionKey]);
671-
delete expiryTimeoutsRef.current[sessionKey];
672-
}
674+
const warnKey = `${sessionKey}-warning`;
673675

674-
if (expiryTimeoutsRef.current[`${sessionKey}-warning`]) {
675-
clearTimeout(expiryTimeoutsRef.current[`${sessionKey}-warning`]);
676-
delete expiryTimeoutsRef.current[`${sessionKey}-warning`];
677-
}
676+
// Replace any prior timers for this session
677+
clearKey(expiryTimeoutsRef.current, sessionKey);
678+
clearKey(expiryTimeoutsRef.current, warnKey);
678679

679-
const timeUntilExpiry = expiry * 1000 - Date.now();
680+
const now = Date.now();
681+
const expiryMs = expiry * 1000;
682+
const timeUntilExpiry = expiryMs - now;
680683

681684
const beforeExpiry = async () => {
682685
const activeSession = await getSession();
683-
if (!activeSession && expiryTimeoutsRef.current[sessionKey]) {
684-
clearTimeout(expiryTimeoutsRef.current[`${sessionKey}-warning`]);
685-
expiryTimeoutsRef.current[`${sessionKey}-warning`] = setTimeout(
686+
687+
if (!activeSession && expiryTimeoutsRef.current[warnKey]) {
688+
// Keep nudging until session materializes (short 10s timer is fine)
689+
setTimeoutInMap(
690+
expiryTimeoutsRef.current,
691+
warnKey,
686692
beforeExpiry,
687-
10000,
693+
10_000,
688694
);
689695
return;
690696
}
@@ -693,6 +699,7 @@ export const ClientProvider: React.FC<ClientProviderProps> = ({
693699
if (!session) return;
694700

695701
callbacks?.beforeSessionExpiry?.({ sessionKey });
702+
696703
if (masterConfig?.auth?.autoRefreshSession) {
697704
await refreshSession({
698705
expirationSeconds: session.expirationSeconds!,
@@ -706,34 +713,50 @@ export const ClientProvider: React.FC<ClientProviderProps> = ({
706713
if (!expiredSession) return;
707714

708715
callbacks?.onSessionExpired?.({ sessionKey });
716+
709717
if ((await getActiveSessionKey()) === sessionKey) {
710718
setSession(undefined);
711719
}
712-
setAllSessions((prevSessions) => {
713-
if (!prevSessions) return prevSessions;
714-
const newSessions = { ...prevSessions };
715-
delete newSessions[sessionKey];
716-
return newSessions;
720+
721+
setAllSessions((prev) => {
722+
if (!prev) return prev;
723+
const next = { ...prev };
724+
delete next[sessionKey];
725+
return next;
717726
});
718727

719728
await clearSession({ sessionKey });
720729

721-
delete expiryTimeoutsRef.current[sessionKey];
722-
delete expiryTimeoutsRef.current[`${sessionKey}-warning`];
730+
// Remove timers for this session
731+
clearKey(expiryTimeoutsRef.current, sessionKey);
732+
clearKey(expiryTimeoutsRef.current, warnKey);
723733

724734
await logout();
725735
};
726736

727-
if (timeUntilExpiry <= SESSION_WARNING_THRESHOLD_MS) {
728-
beforeExpiry();
737+
// Already expired → expire immediately
738+
if (timeUntilExpiry <= 0) {
739+
await expireSession();
740+
return;
741+
}
742+
743+
// Warning timer (if threshold is in the future)
744+
const warnAt = expiryMs - SESSION_WARNING_THRESHOLD_MS;
745+
if (warnAt <= now) {
746+
void beforeExpiry(); // fire-and-forget is fine
729747
} else {
730-
expiryTimeoutsRef.current[`${sessionKey}-warning`] = setTimeout(
748+
setCappedTimeoutInMap(
749+
expiryTimeoutsRef.current,
750+
warnKey,
731751
beforeExpiry,
732-
timeUntilExpiry - SESSION_WARNING_THRESHOLD_MS,
752+
warnAt - now,
733753
);
734754
}
735755

736-
expiryTimeoutsRef.current[sessionKey] = setTimeout(
756+
// Actual expiry timer (safe for long delays)
757+
setCappedTimeoutInMap(
758+
expiryTimeoutsRef.current,
759+
sessionKey,
737760
expireSession,
738761
timeUntilExpiry,
739762
);
@@ -756,20 +779,16 @@ export const ClientProvider: React.FC<ClientProviderProps> = ({
756779
}
757780

758781
/**
759-
* Clears all scheduled session expiration and warning timeouts for the client.
782+
* Clears all scheduled session timers (warning + expiry).
760783
*
761-
* - This function removes all active session expiration and warning timeouts managed by the provider.
762-
* - It is called automatically when sessions are re-initialized or on logout to prevent memory leaks and ensure no stale timeouts remain.
763-
* - All timeouts stored in `expiryTimeoutsRef` are cleared and the reference is reset.
784+
* - Removes all active timers managed by this client.
785+
* - Useful on re-init or logout to avoid stale timers.
764786
*
765-
* @throws {TurnkeyError} If an error occurs while clearing the timeouts.
787+
* @throws {TurnkeyError} If an error occurs while clearing the timers.
766788
*/
767789
function clearSessionTimeouts() {
768790
try {
769-
Object.values(expiryTimeoutsRef.current).forEach((timeout) => {
770-
clearTimeout(timeout);
771-
});
772-
expiryTimeoutsRef.current = {};
791+
clearAll(expiryTimeoutsRef.current); // clears & deletes everything
773792
} catch (error) {
774793
if (
775794
error instanceof TurnkeyError ||
@@ -779,7 +798,7 @@ export const ClientProvider: React.FC<ClientProviderProps> = ({
779798
} else {
780799
callbacks?.onError?.(
781800
new TurnkeyError(
782-
`Failed to clear session timeouts`,
801+
"Failed to clear session timeouts",
783802
TurnkeyErrorCodes.CLEAR_SESSION_TIMEOUTS_ERROR,
784803
error,
785804
),

packages/react-wallet-kit/src/providers/modal/Provider.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useScreenSize } from "@utils";
3+
import { useScreenSize } from "../../utils/utils";
44
import { createContext, useState, ReactNode } from "react";
55

66
export type ModalPage = {
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// This file contains utility functions for managing timers and timeouts for session management.
2+
// Unfortunately, the delay param in Node Timeouts is a 32-bit signed integer, meaning it can only represent delays up to ~24.8 days.
3+
// This file provides functions to handle long delays by breaking them into smaller chunks.
4+
// These functions are separate from the normal utils function to reduce clutter and improve visibility.
5+
// Use `setTimeoutInMap` for short, frequent nudges (10s), and `setCappedTimeoutInMap` for anything that could exceed ~24.8 days.
6+
7+
// Always store controllers (uniform shape)
8+
export type TimerController = { clear: () => void };
9+
export type TimerMap = Record<string, TimerController>;
10+
11+
export const MAX_DELAY_MS = 2_147_483_647; // ~24.8 days
12+
13+
/** @internal */
14+
function toIntMs(x: number) {
15+
return Math.max(0, Math.floor(Number.isFinite(x) ? x : 0));
16+
}
17+
18+
/**
19+
* @internal A drop-in replacement for `setTimeout` that supports arbitrarily long delays.
20+
*
21+
* ### Why?
22+
* Browsers clamp `setTimeout` delays to a 32-bit signed integer,
23+
* i.e. a maximum of ~24.8 days (`2_147_483_647ms`). Any larger value
24+
* will fire almost immediately. This helper safely schedules timeouts
25+
* that can be months or years into the future.
26+
*
27+
* ### How it works
28+
* - On call, we record a fixed **target timestamp** (`now + delayMs`).
29+
* - We schedule a single "leg" of at most ~24.8 days into the future,
30+
* with an internal `tick` function as the callback.
31+
* - When a leg expires, `tick` checks how much time is left:
32+
* - If `remaining > 0`, it schedules the next leg (`min(MAX_DELAY_MS, remaining)`).
33+
* - If `remaining <= 0`, the full delay has passed and we finally call your `cb()`.
34+
* - At any time, only **one** leg is active. Calling `.clear()` cancels
35+
* the current leg and prevents further hops.
36+
*
37+
* ### Benefits
38+
* - Works seamlessly for both short and very long delays.
39+
* - Survives tab sleep / system suspend: when the tab wakes,
40+
* `tick` re-checks the target timestamp and fires immediately if overdue.
41+
* - No drift accumulation: each hop recalculates based on the fixed target,
42+
* so you always land as close as possible to the intended deadline.
43+
*
44+
* ### Example
45+
* ```ts
46+
* const controller = setCappedTimeout(() => {
47+
* console.log("A whole year later!");
48+
* }, 1000 * 60 * 60 * 24 * 365);
49+
*
50+
* // Cancel if needed
51+
* controller.clear();
52+
* ```
53+
*
54+
* @param cb - Function to run once the full delay has elapsed.
55+
* @param delayMs - Delay in milliseconds (can exceed 2_147_483_647).
56+
* @returns A controller with a `.clear()` method to cancel the timeout.
57+
*/
58+
export function setCappedTimeout(
59+
cb: () => void,
60+
delayMs: number,
61+
): TimerController {
62+
const target = Date.now() + toIntMs(delayMs);
63+
let handle: number | undefined;
64+
65+
const tick = () => {
66+
const remaining = target - Date.now();
67+
if (remaining <= 0) {
68+
cb();
69+
return;
70+
}
71+
handle = window.setTimeout(tick, Math.min(MAX_DELAY_MS, remaining));
72+
};
73+
74+
tick();
75+
76+
return {
77+
clear() {
78+
if (handle !== undefined) {
79+
window.clearTimeout(handle);
80+
handle = undefined;
81+
}
82+
},
83+
};
84+
}
85+
86+
/** @internal Simple one-shot timeout that still returns a controller for uniformity. */
87+
export function setTimeoutController(
88+
cb: () => void,
89+
delayMs: number,
90+
): TimerController {
91+
const ms = toIntMs(delayMs);
92+
let id = window.setTimeout(cb, ms);
93+
return { clear: () => window.clearTimeout(id) };
94+
}
95+
96+
/** @internal Replace any existing timer for `key` with `controller`. */
97+
export function putTimer(
98+
map: TimerMap,
99+
key: string,
100+
controller: TimerController,
101+
) {
102+
map[key]?.clear?.();
103+
map[key] = controller;
104+
}
105+
106+
/** @internal Clear a specific key (noop if missing). */
107+
export function clearKey(map: TimerMap, key: string) {
108+
map[key]?.clear?.();
109+
delete map[key];
110+
}
111+
112+
/** @internal Clear several keys at once (noop on missing). */
113+
export function clearKeys(map: TimerMap, keys: string[]) {
114+
for (const k of keys) clearKey(map, k);
115+
}
116+
117+
/** @internal Clear all timers in the map. */
118+
export function clearAll(map: TimerMap) {
119+
for (const k of Object.keys(map)) {
120+
map[k]?.clear?.();
121+
delete map[k];
122+
}
123+
}
124+
125+
/**
126+
* @internal Convenience: set a capped timeout directly into the map for `key`.
127+
* @param map The map to store the timer in (pass in the ref to the timer map)
128+
* @param key The key to associate with the timer
129+
* @param cb The callback to invoke when the timer expires
130+
* @param delayMs The delay in milliseconds before the timer expires
131+
* */
132+
export function setCappedTimeoutInMap(
133+
map: TimerMap,
134+
key: string,
135+
cb: () => void,
136+
delayMs: number,
137+
) {
138+
putTimer(map, key, setCappedTimeout(cb, delayMs));
139+
}
140+
141+
/**
142+
* @internal Convenience: set a regular (short) timeout into the map for `key`.
143+
* @param map The map to store the timer in (pass in the ref to the timer map)
144+
* @param key The key to associate with the timer
145+
* @param cb The callback to invoke when the timer expires
146+
* @param delayMs The delay in milliseconds before the timer expires
147+
*/
148+
export function setTimeoutInMap(
149+
map: TimerMap,
150+
key: string,
151+
cb: () => void,
152+
delayMs: number,
153+
) {
154+
putTimer(map, key, setTimeoutController(cb, delayMs));
155+
}

packages/react-wallet-kit/src/utils.ts renamed to packages/react-wallet-kit/src/utils/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Session, TurnkeyError, TurnkeyErrorCodes } from "@turnkey/sdk-types";
2-
import type { TurnkeyCallbacks } from "./types/base";
2+
import type { TurnkeyCallbacks } from "../types/base";
33
import { useCallback, useRef, useState, useEffect } from "react";
44
import { WalletInterfaceType, WalletProvider } from "@turnkey/core";
55

0 commit comments

Comments
 (0)