From 0344f8ddcc9d1cb5c6c309f866bb6e28ce77343f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 14:42:21 +0800 Subject: [PATCH] feat(socket-auth): authenticate realtime socket with customer and checkout socket tokens - In-memory authEngine delivers a customer socket token (POST storefront/v1/customers/socket-token, store key + Customer-Token) in the handshake; never persisted, never in the URL query. - Refresh at expires_in - 60s via socket.authenticate; recover from deauthenticate/kickOut/subscribeFail by re-minting, re-authenticating and resubscribing tracked channels (bounded retries). - Re-authenticate on login, reconnect anonymously on logout. - Guest QPay checkout authenticates with the checkout-scoped socket_token from checkout initialize before subscribing to checkout.. - 404 from the mint route (older server) or no customer => anonymous, as before. - Handshake query carries client=storefront-app/. --- src/contexts/SocketClusterContext.native.tsx | 65 ++- src/contexts/SocketClusterContext.web.tsx | 64 ++- src/hooks/use-qpay-checkout.ts | 15 +- src/hooks/use-socket-cluster-client.ts | 3 +- src/utils/socket-auth.ts | 420 +++++++++++++++++++ 5 files changed, 559 insertions(+), 8 deletions(-) create mode 100644 src/utils/socket-auth.ts diff --git a/src/contexts/SocketClusterContext.native.tsx b/src/contexts/SocketClusterContext.native.tsx index b3601702..d2a32ae6 100644 --- a/src/contexts/SocketClusterContext.native.tsx +++ b/src/contexts/SocketClusterContext.native.tsx @@ -1,6 +1,17 @@ -import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'; +import React, { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react'; import socketClusterClient from 'socketcluster-client'; +import DeviceInfo from 'react-native-device-info'; import { config, toBoolean, consumeAsyncIterator } from '../utils'; +import { SocketAuthManager, getCustomerToken } from '../utils/socket-auth'; +import { useAuth } from './AuthContext'; + +const getClientTag = () => { + try { + return `storefront-app/${DeviceInfo.getVersion()}`; + } catch (_) { + return 'storefront-app'; + } +}; const SocketClusterContext = createContext(null); @@ -12,6 +23,12 @@ export const SocketClusterProvider = ({ children }) => { const [socket, setSocket] = useState(null); const [isConnected, setIsConnected] = useState(false); const [error, setError] = useState(null); + const { customer } = useAuth(); + const customerRef = useRef(customer); + customerRef.current = customer; + const authRef = useRef(null); + const customerId = customer?.id ?? null; + const previousCustomerId = useRef(customerId); useEffect(() => { // Initialize the socket connection @@ -22,7 +39,17 @@ export const SocketClusterProvider = ({ children }) => { secure: toBoolean(config('SOCKETCLUSTER_SECURE', true)), }; - const scSocket = socketClusterClient.create(options); + // Socket auth: the token lives in memory only and is delivered in the + // handshake by this authEngine (never storage, never the URL query). + const auth = new SocketAuthManager({ getCustomerToken: () => getCustomerToken(customerRef.current) }); + authRef.current = auth; + + const scSocket = socketClusterClient.create({ + ...options, + authEngine: auth.authEngine, + query: { client: getClientTag() }, + }); + auth.attach(scSocket); // Define handlers for socket events const handleConnect = () => { @@ -59,11 +86,39 @@ export const SocketClusterProvider = ({ children }) => { stopDisconnect(); stopError(); + auth.destroy(); + authRef.current = null; scSocket.disconnect(); console.log('Socket connection closed.'); }; }, []); + // Re-authenticate the socket when the customer logs in, out or switches account. + useEffect(() => { + if (previousCustomerId.current === customerId) return; + previousCustomerId.current = customerId; + const auth = authRef.current; + if (!auth) return; + if (customerId) { + auth.onLogin().catch((err) => console.warn('Socket re-authentication failed:', err)); + } else { + auth.onLogout(); + } + }, [customerId]); + + /** + * Authenticate the socket with a checkout-scoped token (guest checkout). + * No-op when a customer is logged in: their own token covers their checkouts. + */ + const authenticateWithCheckoutToken = useCallback(async (socketToken) => { + if (!authRef.current) return; + try { + await authRef.current.applyCheckoutToken(socketToken); + } catch (err) { + console.warn('Unable to authenticate socket with checkout token:', err); + } + }, []); + /** * Subscribes to a specific channel. * @param {string} channelName - The name of the channel to subscribe to. @@ -78,6 +133,7 @@ export const SocketClusterProvider = ({ children }) => { try { const channel = socket.subscribe(channelName); + authRef.current?.track(channelName); if (channel.isSubscribed()) { console.log(`Already subscribed to channel "${channelName}".`); return channel; @@ -106,6 +162,7 @@ export const SocketClusterProvider = ({ children }) => { } try { + authRef.current?.untrack(channelName); await socket.closeChannel(channelName); console.log(`Gracefully closed channel "${channelName}".`); } catch (err) { @@ -127,6 +184,7 @@ export const SocketClusterProvider = ({ children }) => { } try { + authRef.current?.untrack(channelName); await socket.killChannel(channelName); console.log(`Forcefully killed channel "${channelName}".`); } catch (err) { @@ -146,6 +204,7 @@ export const SocketClusterProvider = ({ children }) => { } try { + authRef.current?.untrackAll(); await socket.closeAllChannels(); console.log('Gracefully closed all channels.'); } catch (err) { @@ -163,6 +222,7 @@ export const SocketClusterProvider = ({ children }) => { } try { + authRef.current?.untrackAll(); await socket.killAllChannels(); console.log('Forcefully killed all channels.'); } catch (err) { @@ -181,6 +241,7 @@ export const SocketClusterProvider = ({ children }) => { killChannel, closeAllChannels, killAllChannels, + authenticateWithCheckoutToken, }} > {children} diff --git a/src/contexts/SocketClusterContext.web.tsx b/src/contexts/SocketClusterContext.web.tsx index f4813bc2..ba455aaf 100644 --- a/src/contexts/SocketClusterContext.web.tsx +++ b/src/contexts/SocketClusterContext.web.tsx @@ -1,6 +1,16 @@ -import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'; +import React, { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react'; import socketClusterClient from 'socketcluster-client'; import { config, toBoolean } from '../utils'; +import { SocketAuthManager, getCustomerToken } from '../utils/socket-auth'; +import { useAuth } from './AuthContext'; + +const getClientTag = () => { + try { + return `storefront-app/${require('../../package.json').version}`; + } catch (_) { + return 'storefront-app'; + } +}; const SocketClusterContext = createContext(null); @@ -8,6 +18,12 @@ export const SocketClusterProvider = ({ children }) => { const [socket, setSocket] = useState(null); const [isConnected, setIsConnected] = useState(false); const [error, setError] = useState(null); + const { customer } = useAuth(); + const customerRef = useRef(customer); + customerRef.current = customer; + const authRef = useRef(null); + const customerId = customer?.id ?? null; + const previousCustomerId = useRef(customerId); useEffect(() => { const options = { @@ -17,7 +33,17 @@ export const SocketClusterProvider = ({ children }) => { secure: toBoolean(config('SOCKETCLUSTER_SECURE', true)), }; - const scSocket = socketClusterClient.create(options); + // Socket auth: the token lives in memory only and is delivered in the + // handshake by this authEngine (never storage, never the URL query). + const auth = new SocketAuthManager({ getCustomerToken: () => getCustomerToken(customerRef.current) }); + authRef.current = auth; + + const scSocket = socketClusterClient.create({ + ...options, + authEngine: auth.authEngine, + query: { client: getClientTag() }, + }); + auth.attach(scSocket); // Listen for socket events using async iterators (async () => { @@ -56,11 +82,39 @@ export const SocketClusterProvider = ({ children }) => { setSocket(scSocket); return () => { + auth.destroy(); + authRef.current = null; scSocket.disconnect(); console.log('Socket connection closed.'); }; }, []); + // Re-authenticate the socket when the customer logs in, out or switches account. + useEffect(() => { + if (previousCustomerId.current === customerId) return; + previousCustomerId.current = customerId; + const auth = authRef.current; + if (!auth) return; + if (customerId) { + auth.onLogin().catch((err) => console.warn('Socket re-authentication failed:', err)); + } else { + auth.onLogout(); + } + }, [customerId]); + + /** + * Authenticate the socket with a checkout-scoped token (guest checkout). + * No-op when a customer is logged in: their own token covers their checkouts. + */ + const authenticateWithCheckoutToken = useCallback(async (socketToken) => { + if (!authRef.current) return; + try { + await authRef.current.applyCheckoutToken(socketToken); + } catch (err) { + console.warn('Unable to authenticate socket with checkout token:', err); + } + }, []); + /** * Subscribes to a channel and listens for its events using async iterators. * Returns the channel if successful. @@ -73,6 +127,7 @@ export const SocketClusterProvider = ({ children }) => { } try { const channel = socket.subscribe(channelName); + authRef.current?.track(channelName); // Listen for the subscription confirmation. (async () => { @@ -115,6 +170,7 @@ export const SocketClusterProvider = ({ children }) => { return; } try { + authRef.current?.untrack(channelName); await socket.closeChannel(channelName); console.log(`Gracefully closed channel "${channelName}".`); } catch (err) { @@ -131,6 +187,7 @@ export const SocketClusterProvider = ({ children }) => { return; } try { + authRef.current?.untrack(channelName); await socket.killChannel(channelName); console.log(`Forcefully killed channel "${channelName}".`); } catch (err) { @@ -146,6 +203,7 @@ export const SocketClusterProvider = ({ children }) => { return; } try { + authRef.current?.untrackAll(); await socket.closeAllChannels(); console.log('Gracefully closed all channels.'); } catch (err) { @@ -159,6 +217,7 @@ export const SocketClusterProvider = ({ children }) => { return; } try { + authRef.current?.untrackAll(); await socket.killAllChannels(); console.log('Forcefully killed all channels.'); } catch (err) { @@ -177,6 +236,7 @@ export const SocketClusterProvider = ({ children }) => { killChannel, closeAllChannels, killAllChannels, + authenticateWithCheckoutToken, }} > {children} diff --git a/src/hooks/use-qpay-checkout.ts b/src/hooks/use-qpay-checkout.ts index 2affa20e..6be1e62d 100644 --- a/src/hooks/use-qpay-checkout.ts +++ b/src/hooks/use-qpay-checkout.ts @@ -25,7 +25,7 @@ export default function useQPayCheckout({ onOrderComplete }) { const { t } = useLanguage(); const { customer, updateCustomerMeta } = useAuth(); const { currentLocation: deliveryLocation, updateDefaultLocation } = useCurrentLocation(); - const { listen } = useSocketClusterClient(); + const { listen, authenticateWithCheckoutToken } = useSocketClusterClient(); const [cart, updateCart] = useCart(); const isCheckingStatus = useRef(false); const [checkoutOptions, setCheckoutOptions] = useState({ @@ -38,6 +38,9 @@ export default function useQPayCheckout({ onOrderComplete }) { const [invoice, setInvoice] = useState(); const [checkoutId, setCheckoutId] = useState(); const [checkoutToken, setCheckoutToken] = useState(); + // Checkout-scoped socket token (guests); kept in memory only. + const checkoutSocketTokenRef = useRef(null); + const isGuest = !customer; const [serviceQuote, setServiceQuote] = useState(null); const [isServiceQuoteUnavailable, setIsServiceQuoteUnavailable] = useState(false); const [isLoading, setIsLoading] = useState(true); @@ -178,7 +181,8 @@ export default function useQPayCheckout({ onOrderComplete }) { setIsLoading(true); try { - const { token, checkout, invoice } = await storefront.checkout.initialize(customer, cart, serviceQuote, 'qpay', checkoutOptions); + const { token, checkout, invoice, socket_token } = await storefront.checkout.initialize(customer, cart, serviceQuote, 'qpay', checkoutOptions); + checkoutSocketTokenRef.current = socket_token ?? null; setInvoice(invoice); setCheckoutId(checkout); setCheckoutToken(token); @@ -311,6 +315,11 @@ export default function useQPayCheckout({ onOrderComplete }) { if (!checkoutId || !checkoutToken || listenerRef.current) return; const listenForOrderStatus = async () => { + // Guests: authenticate with the checkout-scoped socket token before subscribing. + // Logged-in customers' own socket tokens already cover their checkouts. + if (isGuest && checkoutSocketTokenRef.current && typeof authenticateWithCheckoutToken === 'function') { + await authenticateWithCheckoutToken(checkoutSocketTokenRef.current); + } console.log(`[Listener created for socket channel: checkout.${checkoutId}]`); const listener = await listen(`checkout.${checkoutId}`, (event) => { console.log(`[checkout channel ${checkoutId} event]`, event); @@ -336,7 +345,7 @@ export default function useQPayCheckout({ onOrderComplete }) { listenerRef.current = null; } }; - }, [listen, checkoutId, checkoutToken, handleOrderCompletion, handlePaymentError]); + }, [listen, authenticateWithCheckoutToken, isGuest, checkoutId, checkoutToken, handleOrderCompletion, handlePaymentError]); // Run order status check when the screen gains focus useFocusEffect( diff --git a/src/hooks/use-socket-cluster-client.ts b/src/hooks/use-socket-cluster-client.ts index d18d70bd..42d4511f 100644 --- a/src/hooks/use-socket-cluster-client.ts +++ b/src/hooks/use-socket-cluster-client.ts @@ -7,7 +7,7 @@ import { consumeAsyncIterator, isAsyncIterable } from '../utils'; * Provides functionalities to subscribe, listen, and manage channels. */ const useSocketClusterClient = () => { - const { socket, isConnected, error, subscribeChannel, closeChannel, killChannel, closeAllChannels, killAllChannels } = useSocketCluster(); + const { socket, isConnected, error, subscribeChannel, closeChannel, killChannel, closeAllChannels, killAllChannels, authenticateWithCheckoutToken } = useSocketCluster(); /** * Listens to a channel for all incoming events/data. @@ -70,6 +70,7 @@ const useSocketClusterClient = () => { listen, closeAllChannels, killAllChannels, + authenticateWithCheckoutToken, }; }; diff --git a/src/utils/socket-auth.ts b/src/utils/socket-auth.ts new file mode 100644 index 00000000..d11fabfb --- /dev/null +++ b/src/utils/socket-auth.ts @@ -0,0 +1,420 @@ +/** + * Socket authentication for the realtime (SocketCluster) connection. + * + * The socket server only lets a client subscribe to the channels its token + * covers. Logged-in customers mint a short-lived token from + * `POST storefront/v1/customers/socket-token` (store key as Bearer plus the + * `Customer-Token` header, the same way every other customer request is + * authenticated). Guests paying through QPay get a checkout-scoped token in the + * `socket_token` field of the checkout initialize response. + * + * Tokens are kept in memory only: never persisted to device/browser storage and + * never put in the socket URL. They reach the server through a custom + * `authEngine` (handshake) and `socket.authenticate()` (refresh / login). + * + * Servers without socket auth answer the mint route with 404; the app then + * connects anonymously, exactly as it did before. + */ +import { adapter as storefrontAdapter } from '../hooks/use-storefront'; +import { getString } from '../hooks/use-storage'; + +export interface SocketTokenResponse { + token: string; + expires_in?: number; + expires_at?: string; +} + +type TokenSource = 'customer' | 'checkout'; + +/** Refresh / reload this many ms before the token expires. */ +const EXPIRY_MARGIN_MS = 60 * 1000; +/** Never schedule a refresh sooner than this. */ +const MIN_REFRESH_DELAY_MS = 5 * 1000; +/** Re-subscribe attempts per channel before giving up (reset on success, login, logout). */ +const MAX_RESUBSCRIBE_ATTEMPTS = 3; +/** Consecutive failed `socket.authenticate()` calls before giving up until the next login/refresh. */ +const MAX_AUTH_FAILURES = 3; +/** Collapse bursts of kickOut/subscribeFail events (e.g. after a principal change) into one recovery. */ +const RECOVER_DEBOUNCE_MS = 250; + +export function getCustomerToken(customer: any): string | null { + let token = null; + if (customer && typeof customer.getAttribute === 'function') { + token = customer.getAttribute('token'); + } else if (customer && typeof customer === 'object') { + token = customer.token; + } + if (!token) { + try { + token = getString('_customer_token'); + } catch (_) { + token = null; + } + } + return typeof token === 'string' && token.length > 0 ? token : null; +} + +export function normalizeSocketToken(value: any): SocketTokenResponse | null { + if (!value) return null; + if (typeof value === 'string') return { token: value }; + if (typeof value === 'object' && typeof value.token === 'string' && value.token.length > 0) { + return { token: value.token, expires_in: value.expires_in, expires_at: value.expires_at }; + } + return null; +} + +function computeExpiresAt(response: SocketTokenResponse): number { + const expiresIn = Number(response.expires_in); + if (Number.isFinite(expiresIn) && expiresIn > 0) { + return Date.now() + expiresIn * 1000; + } + if (response.expires_at) { + const parsed = Date.parse(response.expires_at); + if (Number.isFinite(parsed)) return parsed; + } + // Fall back to the JWT's own exp claim. + try { + const payload = response.token.split('.')[1]; + const json = JSON.parse(base64UrlDecode(payload)); + if (json && Number.isFinite(json.exp)) return json.exp * 1000; + } catch (_) { + // ignore + } + return 0; +} + +function base64UrlDecode(input: string): string { + const base64 = input.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); + if (typeof atob === 'function') return atob(padded); + // eslint-disable-next-line no-undef + return Buffer.from(padded, 'base64').toString('binary'); +} + +/** + * Mint a socket token for the logged-in customer. + * Returns null when there is no customer, the server does not support socket + * auth (404 => `unsupported: true`) or the request fails. + */ +export async function requestCustomerSocketToken(customerToken: string): Promise<{ response: SocketTokenResponse | null; unsupported: boolean }> { + const adapter: any = storefrontAdapter; + const host = String(adapter?.host ?? '').replace(/\/+$/, ''); + const namespace = String(adapter?.namespace ?? '').replace(/^\/+|\/+$/g, ''); + const url = [host, namespace, 'customers/socket-token'].filter(Boolean).join('/'); + + let res; + try { + res = await fetch(url, { + method: 'POST', + headers: { + ...(adapter?.headers ?? {}), + 'Content-Type': 'application/json', + Accept: 'application/json', + 'Customer-Token': customerToken, + }, + body: '{}', + }); + } catch (err) { + console.warn('[SocketAuth] Unable to reach the socket token endpoint:', err?.message ?? err); + return { response: null, unsupported: false }; + } + + if (res.status === 404) { + return { response: null, unsupported: true }; + } + if (!res.ok) { + console.warn(`[SocketAuth] Socket token request failed with HTTP ${res.status}; connecting anonymously.`); + return { response: null, unsupported: false }; + } + + try { + return { response: normalizeSocketToken(await res.json()), unsupported: false }; + } catch (_) { + return { response: null, unsupported: false }; + } +} + +interface SocketAuthManagerOptions { + /** Returns the logged-in customer's token, or null for guests. */ + getCustomerToken: () => string | null; + /** Injectable for tests. */ + requestToken?: (customerToken: string) => Promise<{ response: SocketTokenResponse | null; unsupported: boolean }>; +} + +/** + * Holds the socket token in memory and keeps the socket authenticated. + * One instance per SocketCluster client. + */ +export class SocketAuthManager { + socket: any = null; + + private token: string | null = null; + private expiresAt = 0; + private source: TokenSource | null = null; + private scoped: SocketTokenResponse | null = null; + private unsupported = false; + private inflight: Promise | null = null; + private refreshTimer: any = null; + private recoverTimer: any = null; + private recovering: Promise = Promise.resolve(); + private authFailures = 0; + private tracked = new Set(); + private attempts = new Map(); + private stopListeners: Array<() => void> = []; + private destroyed = false; + private getCustomerToken: () => string | null; + private requestToken: (customerToken: string) => Promise<{ response: SocketTokenResponse | null; unsupported: boolean }>; + + constructor(options: SocketAuthManagerOptions) { + this.getCustomerToken = options.getCustomerToken; + this.requestToken = options.requestToken ?? requestCustomerSocketToken; + } + + /** In-memory authEngine for socketClusterClient.create({ authEngine }). */ + authEngine = { + saveToken: (_name: string, token: string) => { + // Called with the token we just authenticated with, or one the server set. + if (token && token !== this.token) { + this.token = token; + this.expiresAt = computeExpiresAt({ token }); + } + return Promise.resolve(token); + }, + removeToken: (_name: string) => { + const old = this.token; + this.clearToken(); + return Promise.resolve(old); + }, + loadToken: async (_name: string) => { + try { + if (this.isFresh()) return this.token; + return await this.fetchToken(); + } catch (_) { + return null; + } + }, + }; + + /** Attach to a created socket and start handling auth events. */ + attach(socket: any) { + this.socket = socket; + this.consume(socket.listener('deauthenticate'), () => { + // Only react while connected; on (re)connect the handshake loads a token itself. + if (this.isOpen()) this.scheduleRecover(true); + }); + this.consume(socket.listener('kickOut'), ({ channel }) => { + if (this.tracked.has(channel)) this.scheduleRecover(true); + }); + this.consume(socket.listener('subscribeFail'), ({ channel }) => { + if (this.tracked.has(channel)) this.scheduleRecover(true); + }); + this.consume(socket.listener('subscribe'), ({ channel }) => { + this.attempts.delete(channel); + }); + this.consume(socket.listener('authenticate'), () => { + this.authFailures = 0; + }); + } + + track(channelName: string) { + this.tracked.add(channelName); + } + + untrack(channelName: string) { + this.tracked.delete(channelName); + this.attempts.delete(channelName); + } + + untrackAll() { + this.tracked.clear(); + this.attempts.clear(); + } + + /** Customer logged in (or switched): mint a customer token, authenticate, resubscribe. */ + async onLogin() { + this.unsupported = false; + this.scoped = null; + this.authFailures = 0; + this.attempts.clear(); + this.clearToken(); + await this.reauthenticate(); + } + + /** Customer logged out: drop the token and reconnect anonymously. */ + onLogout() { + this.unsupported = false; + this.scoped = null; + this.authFailures = 0; + this.attempts.clear(); + this.clearToken(); + const socket = this.socket; + if (!socket) return; + try { + socket.disconnect(); + // The handshake calls loadToken, which now resolves to null => anonymous. + socket.connect(); + } catch (err) { + console.warn('[SocketAuth] Error while resetting the socket after logout:', err); + } + } + + /** + * Use a checkout-scoped token (guest QPay). Ignored when a customer is logged + * in, since the customer's own token already covers their checkouts. + */ + async applyCheckoutToken(value: any) { + const response = normalizeSocketToken(value); + if (!response || this.getCustomerToken()) return; + this.scoped = response; + this.setToken(response, 'checkout'); + await this.authenticateCurrent(); + } + + destroy() { + this.destroyed = true; + clearTimeout(this.refreshTimer); + clearTimeout(this.recoverTimer); + this.stopListeners.forEach((stop) => stop()); + this.stopListeners = []; + this.clearToken(); + this.scoped = null; + this.untrackAll(); + this.socket = null; + } + + // -- internals -------------------------------------------------------- + + private isOpen() { + return !!this.socket && this.socket.state === this.socket.OPEN; + } + + private isFresh() { + return !!this.token && this.expiresAt - Date.now() > EXPIRY_MARGIN_MS; + } + + private clearToken() { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + this.token = null; + this.expiresAt = 0; + this.source = null; + } + + private setToken(response: SocketTokenResponse, source: TokenSource) { + this.token = response.token; + this.expiresAt = computeExpiresAt(response); + this.source = source; + this.scheduleRefresh(); + } + + /** Fetch a fresh token (deduplicated). Resolves to null for anonymous. */ + private fetchToken(): Promise { + if (this.inflight) return this.inflight; + this.inflight = (async () => { + try { + const customerToken = this.getCustomerToken(); + if (customerToken && !this.unsupported) { + const { response, unsupported } = await this.requestToken(customerToken); + if (unsupported) this.unsupported = true; + if (response) { + this.setToken(response, 'customer'); + return this.token; + } + } + // Guest checkout token: cannot be re-minted here, use it until it expires. + if (!customerToken && this.scoped && computeExpiresAt(this.scoped) > Date.now()) { + this.setToken(this.scoped, 'checkout'); + return this.token; + } + this.clearToken(); + return null; + } finally { + this.inflight = null; + } + })(); + return this.inflight; + } + + private scheduleRefresh() { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + // Checkout tokens cannot be refreshed client-side. + if (this.destroyed || this.source !== 'customer' || !this.expiresAt) return; + const delay = Math.max(this.expiresAt - Date.now() - EXPIRY_MARGIN_MS, MIN_REFRESH_DELAY_MS); + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null; + this.reauthenticate().catch(() => {}); + }, delay); + } + + private async authenticateCurrent() { + if (!this.token || !this.isOpen()) return; + if (this.socket.signedAuthToken === this.token) return; + if (this.authFailures >= MAX_AUTH_FAILURES) return; + try { + const status = await this.socket.authenticate(this.token); + if (status && status.isAuthenticated === false) this.authFailures++; + } catch (err) { + this.authFailures++; + console.warn('[SocketAuth] Socket authentication failed:', err?.message ?? err); + } + } + + /** Fetch a fresh token, authenticate the live socket, resubscribe tracked channels. */ + private async reauthenticate() { + if (this.destroyed) return; + await this.fetchToken(); + await this.authenticateCurrent(); + this.resubscribeTracked(); + } + + private resubscribeTracked() { + if (!this.socket || this.destroyed) return; + this.tracked.forEach((channelName) => { + if (this.socket.isSubscribed(channelName, true)) return; + const attempts = this.attempts.get(channelName) ?? 0; + if (attempts >= MAX_RESUBSCRIBE_ATTEMPTS) return; + this.attempts.set(channelName, attempts + 1); + // Re-creating the subscription keeps existing channel consumers working: + // channel data/events are routed by channel name. + this.socket.subscribe(channelName); + }); + } + + private scheduleRecover(forceFetch: boolean) { + if (this.destroyed) return; + clearTimeout(this.recoverTimer); + this.recoverTimer = setTimeout(() => { + this.recoverTimer = null; + this.recovering = this.recovering + .then(async () => { + if (forceFetch || !this.isFresh()) { + await this.fetchToken(); + } + await this.authenticateCurrent(); + this.resubscribeTracked(); + }) + .catch((err) => console.warn('[SocketAuth] Recovery failed:', err?.message ?? err)); + }, RECOVER_DEBOUNCE_MS); + } + + private consume(iterable: any, handler: (data: any) => void) { + let stopped = false; + const iterator = iterable[Symbol.asyncIterator](); + (async () => { + while (!stopped) { + const { value, done } = await iterator.next(); + if (done || stopped) break; + try { + handler(value ?? {}); + } catch (err) { + console.warn('[SocketAuth] Event handler error:', err); + } + } + })().catch(() => {}); + this.stopListeners.push(() => { + stopped = true; + if (typeof iterator.return === 'function') iterator.return(); + }); + } +}