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
6 changes: 6 additions & 0 deletions .changeset/tiny-donuts-travel.md
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🍩

Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@knocklabs/react-core": patch
"@knocklabs/react": patch
---

fix: poll to check if OAuth connection succeeded in case popup communication fails during Slack and Microsoft Teams auth flows
4 changes: 4 additions & 0 deletions packages/react-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ export {
renderNodeOrFallback,
slackProviderKey,
toSentenceCase,
type AuthCheckResult,
type ColorMode,
type ConnectionStatus,
type KnockProviderProps,
type KnockProviderState,
useAuthenticatedKnockClient,
useAuthPolling,
useAuthPostMessageListener,
useKnockClient,
useStableOptions,
} from "./modules/core";
Expand Down
2 changes: 2 additions & 0 deletions packages/react-core/src/modules/core/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export { default as useAuthenticatedKnockClient } from "./useAuthenticatedKnockClient";
export { default as useStableOptions } from "./useStableOptions";
export { useAuthPostMessageListener } from "./useAuthPostMessageListener";
export { useAuthPolling } from "./useAuthPolling";
115 changes: 115 additions & 0 deletions packages/react-core/src/modules/core/hooks/useAuthPolling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { useEffect } from "react";

import { AuthCheckResult, ConnectionStatus } from "../types";

export interface UseAuthPollingOptions {
popupWindowRef: React.MutableRefObject<Window | null>;
setConnectionStatus: (status: ConnectionStatus) => void;
authCheckFn: () => Promise<AuthCheckResult>;
onAuthenticationComplete?: (authenticationResp: string) => void;
}

/**
* Hook that polls an authentication check endpoint until success or timeout.
*
* Polls every 2 seconds for up to 3 minutes (90 iterations). Has three stop conditions:
* 1. Max timeout reached → sets error status
* 2. Popup closed + 10s grace period → stops silently
* 3. Success detected via authCheckFn → updates status and closes popup
*
* @param options - Configuration options for the polling mechanism
*
* @example
* ```tsx
* useAuthPolling({
* popupWindowRef,
* setConnectionStatus,
* onAuthenticationComplete,
* authCheckFn: useCallback(async () => {
* return knock.slack.authCheck({
* tenant: tenantId,
* knockChannelId: knockSlackChannelId,
* });
* }, [knock.slack, tenantId, knockSlackChannelId]),
* });
* ```
*/
export function useAuthPolling(options: UseAuthPollingOptions): void {
const {
popupWindowRef,
setConnectionStatus,
onAuthenticationComplete,
authCheckFn,
} = options;

useEffect(
() => {
let pollCount = 0;
const maxPolls = 90;
let popupClosedAt: number | null = null;
let isActive = true;

const pollInterval = setInterval(async () => {
if (!isActive) {
clearInterval(pollInterval);
return;
}

const popupWindow = popupWindowRef.current;
if (!popupWindow) {
return;
}

pollCount++;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timeout counter never increments when popup ref is null

Medium Severity

The pollCount++ increment occurs after the early return for !popupWindow. When popupWindowRef.current is null (e.g., if the popup was blocked by the browser, or for other reasons), every interval iteration early-returns at line 60, never reaching line 63 to increment the counter. The max timeout check at line 71 (pollCount >= maxPolls) is never satisfied, so users remain stuck in "connecting" state indefinitely instead of receiving an error after the expected 3-minute timeout.

Fix in Cursor Fix in Web


const isPopupClosed = popupWindow.closed;
if (isPopupClosed && !popupClosedAt) {
popupClosedAt = Date.now();
}

// Stop condition 1: Max timeout reached
if (pollCount >= maxPolls) {
clearInterval(pollInterval);
setConnectionStatus("error");
return;
}

// Stop condition 2: Popup closed + grace period expired
if (popupClosedAt && Date.now() - popupClosedAt > 10000) {
clearInterval(pollInterval);
popupWindowRef.current = null;
return;
}

try {
const authRes = await authCheckFn();

// Stop condition 3: Success detected
if (authRes.connection?.ok) {
clearInterval(pollInterval);
setConnectionStatus("connected");
if (onAuthenticationComplete) {
onAuthenticationComplete("authComplete");
}
if (popupWindow && !popupWindow.closed) {
popupWindow.close();
}
popupWindowRef.current = null;
}
} catch (_error) {
// Continue polling on error
}
}, 2000);

return () => {
isActive = false;
clearInterval(pollInterval);
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
// Empty deps - run once on mount and keep polling
// This is intentionally simple/brute force
],
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useEffect } from "react";

import { ConnectionStatus } from "../types";

export interface UseAuthPostMessageListenerOptions {
knockHost: string;
popupWindowRef: React.MutableRefObject<Window | null>;
setConnectionStatus: (status: ConnectionStatus) => void;
onAuthenticationComplete?: (authenticationResp: string) => void;
}

/**
* Hook that listens for postMessage events from OAuth popup windows.
*
* Handles "authComplete" and "authFailed" messages sent from the OAuth flow popup,
* validates the message origin, updates connection status, and closes the popup.
*
* @param options - Configuration options for the postMessage listener
*
* @example
* ```tsx
* useAuthPostMessageListener({
* knockHost: knock.host,
* popupWindowRef,
* setConnectionStatus,
* onAuthenticationComplete,
* });
* ```
*/
export function useAuthPostMessageListener(
options: UseAuthPostMessageListenerOptions,
): void {
const {
knockHost,
popupWindowRef,
setConnectionStatus,
onAuthenticationComplete,
} = options;

useEffect(() => {
const receiveMessage = (event: MessageEvent) => {
// Validate message origin for security
if (event.origin !== knockHost) {
return;
}

if (event.data === "authComplete") {
setConnectionStatus("connected");
onAuthenticationComplete?.(event.data);
// Clear popup ref so polling stops and doesn't trigger callback again
if (popupWindowRef.current && !popupWindowRef.current.closed) {
popupWindowRef.current.close();
}
popupWindowRef.current = null;
} else if (event.data === "authFailed") {
setConnectionStatus("error");
onAuthenticationComplete?.(event.data);
popupWindowRef.current = null;
}
};

window.addEventListener("message", receiveMessage, false);
return () => window.removeEventListener("message", receiveMessage);
}, [
knockHost,
onAuthenticationComplete,
setConnectionStatus,
popupWindowRef,
]);
}
8 changes: 7 additions & 1 deletion packages/react-core/src/modules/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ export {
type KnockProviderProps,
type KnockProviderState,
} from "./context";
export { useAuthenticatedKnockClient, useStableOptions } from "./hooks";
export {
useAuthenticatedKnockClient,
useStableOptions,
useAuthPostMessageListener,
useAuthPolling,
} from "./hooks";
export { FilterStatus, type ColorMode } from "./constants";
export {
formatBadgeCount,
Expand All @@ -15,3 +20,4 @@ export {
slackProviderKey,
msTeamsProviderKey,
} from "./utils";
export { type ConnectionStatus, type AuthCheckResult } from "./types";
25 changes: 25 additions & 0 deletions packages/react-core/src/modules/core/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Represents the connection status for OAuth-based integrations (Slack, MS Teams, etc.)
*/
export type ConnectionStatus =
| "connecting"
| "connected"
| "disconnected"
| "error"
| "disconnecting";

/**
* Result returned by authentication check API calls
*/
export interface AuthCheckResult {
connection?: {
ok?: boolean;
error?: string;
};
code?: string;
response?: {
data?: {
message?: string;
};
};
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import * as React from "react";
import { PropsWithChildren } from "react";
import { PropsWithChildren, useRef } from "react";

import { useKnockClient } from "../../core";
import { type ConnectionStatus, useKnockClient } from "../../core";
import { msTeamsProviderKey } from "../../core/utils";
import { useMsTeamsConnectionStatus } from "../hooks";
import { ConnectionStatus } from "../hooks/useMsTeamsConnectionStatus";

export interface KnockMsTeamsProviderState {
knockMsTeamsChannelId: string;
Expand All @@ -15,6 +14,7 @@ export interface KnockMsTeamsProviderState {
setErrorLabel: (label: string) => void;
actionLabel: string | null;
setActionLabel: (label: string | null) => void;
popupWindowRef: React.MutableRefObject<Window | null>;
}

const MsTeamsProviderStateContext =
Expand All @@ -29,6 +29,7 @@ export const KnockMsTeamsProvider: React.FC<
PropsWithChildren<KnockMsTeamsProviderProps>
> = ({ knockMsTeamsChannelId, tenantId, children }) => {
const knock = useKnockClient();
const popupWindowRef = useRef<Window | null>(null);

const {
connectionStatus,
Expand Down Expand Up @@ -56,6 +57,7 @@ export const KnockMsTeamsProvider: React.FC<
setActionLabel,
knockMsTeamsChannelId,
tenantId,
popupWindowRef,
}}
>
{children}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
import Knock from "@knocklabs/client";
import { useEffect, useState } from "react";

import { type ConnectionStatus } from "../../core/types";
import { useTranslations } from "../../i18n";

export type ConnectionStatus =
| "connecting"
| "connected"
| "disconnected"
| "error"
| "disconnecting";

type UseMsTeamsConnectionStatusOutput = {
connectionStatus: ConnectionStatus;
setConnectionStatus: (status: ConnectionStatus) => void;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { useSlackConnectionStatus } from "..";
import * as React from "react";
import { PropsWithChildren } from "react";
import { PropsWithChildren, useRef } from "react";

import { slackProviderKey } from "../../core";
import { type ConnectionStatus, slackProviderKey } from "../../core";
import { useKnockClient } from "../../core";
import { ConnectionStatus } from "../hooks/useSlackConnectionStatus";

export interface KnockSlackProviderState {
knockSlackChannelId: string;
Expand All @@ -19,6 +18,7 @@ export interface KnockSlackProviderState {
setErrorLabel: (label: string) => void;
actionLabel: string | null;
setActionLabel: (label: string | null) => void;
popupWindowRef: React.MutableRefObject<Window | null>;
}

const SlackProviderStateContext =
Expand All @@ -44,6 +44,7 @@ export const KnockSlackProvider: React.FC<
const tenantId = "tenantId" in props ? props.tenantId : props.tenant;

const knock = useKnockClient();
const popupWindowRef = useRef<Window | null>(null);

const {
connectionStatus,
Expand Down Expand Up @@ -73,6 +74,7 @@ export const KnockSlackProvider: React.FC<
// Assign the same value to both tenant and tenantId for backwards compatibility
tenant: tenantId,
tenantId,
popupWindowRef,
}}
>
{children}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
import Knock from "@knocklabs/client";
import { useEffect, useState } from "react";

import { type ConnectionStatus } from "../../core/types";
import { useTranslations } from "../../i18n";

export type ConnectionStatus =
| "connecting"
| "connected"
| "disconnected"
| "error"
| "disconnecting";

type UseSlackConnectionStatusOutput = {
connectionStatus: ConnectionStatus;
setConnectionStatus: (status: ConnectionStatus) => void;
Expand Down
Loading
Loading