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/true-plums-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@knocklabs/react-core": patch
"@knocklabs/react": patch
---

[Slack] Add support for nonce verification in slack auth
1 change: 1 addition & 0 deletions packages/react-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export {
type KnockSlackProviderProps,
type KnockSlackProviderState,
type SlackChannelQueryOptions,
getSlackNonceStorageKey,
useConnectedSlackChannels,
useKnockSlackClient,
useSlackAuth,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,44 @@ export interface UseAuthPostMessageListenerOptions {
popupWindowRef: React.MutableRefObject<Window | null>;
setConnectionStatus: (status: ConnectionStatus) => void;
onAuthenticationComplete?: (authenticationResp: string) => void;
/**
* The sessionStorage key where the CSRF nonce was stored when the auth URL
* was built. When provided, the listener will verify the nonce returned in
* the postMessage payload matches the stored value.
*/
nonceStorageKey?: string;
}

/**
* Extracts the message type from a postMessage event data payload.
* Supports both the legacy string format ("authComplete") and the new
* structured format ({ type: "authComplete", nonce: "..." }).
*/
function getMessageType(data: unknown): string {
if (typeof data === "object" && data !== null && "type" in data) {
return (data as { type: string }).type;
}
return data as string;
}

/**
* Extracts the nonce from a structured postMessage event data payload.
* Returns undefined for legacy string-format messages.
*/
function getMessageNonce(data: unknown): string | undefined {
if (typeof data === "object" && data !== null && "nonce" in data) {
const nonce = (data as { nonce: unknown }).nonce;
return typeof nonce === "string" ? nonce : undefined;
}
return undefined;
}

/**
* 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.
* validates the message origin, optionally verifies the CSRF nonce, updates
* connection status, and closes the popup.
*
* @param options - Configuration options for the postMessage listener
*
Expand All @@ -24,6 +55,7 @@ export interface UseAuthPostMessageListenerOptions {
* popupWindowRef,
* setConnectionStatus,
* onAuthenticationComplete,
* nonceStorageKey: "knock:slack-auth-nonce:channel_123:user_1",
* });
* ```
*/
Expand All @@ -35,26 +67,54 @@ export function useAuthPostMessageListener(
popupWindowRef,
setConnectionStatus,
onAuthenticationComplete,
nonceStorageKey,
} = options;

useEffect(() => {
const closePopup = () => {
if (popupWindowRef.current && !popupWindowRef.current.closed) {
popupWindowRef.current.close();
}
popupWindowRef.current = null;
};

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

if (event.data === "authComplete") {
const messageType = getMessageType(event.data);

if (messageType === "authComplete") {
// Verify CSRF nonce when a nonceStorageKey is configured.
if (nonceStorageKey) {
const returnedNonce = getMessageNonce(event.data);
const storedNonce = sessionStorage.getItem(nonceStorageKey);
sessionStorage.removeItem(nonceStorageKey);

// If nonce already consumed by a prior handler invocation, then bail
// out from checking again.
if (
!returnedNonce ||
(storedNonce && storedNonce !== returnedNonce)
) {
setConnectionStatus("error");
onAuthenticationComplete?.("authFailed");
closePopup();
return;
}
}

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();
onAuthenticationComplete?.(messageType);
closePopup();
} else if (messageType === "authFailed") {
if (nonceStorageKey) {
sessionStorage.removeItem(nonceStorageKey);
}
popupWindowRef.current = null;
} else if (event.data === "authFailed") {
setConnectionStatus("error");
onAuthenticationComplete?.(event.data);
onAuthenticationComplete?.(messageType);
popupWindowRef.current = null;
}
};
Expand All @@ -66,5 +126,6 @@ export function useAuthPostMessageListener(
onAuthenticationComplete,
setConnectionStatus,
popupWindowRef,
nonceStorageKey,
]);
}
5 changes: 4 additions & 1 deletion packages/react-core/src/modules/slack/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
export { default as useSlackConnectionStatus } from "./useSlackConnectionStatus";
export { default as useSlackChannels } from "./useSlackChannels";
export { default as useConnectedSlackChannels } from "./useConnectedSlackChannels";
export { default as useSlackAuth } from "./useSlackAuth";
export {
default as useSlackAuth,
getSlackNonceStorageKey,
} from "./useSlackAuth";
15 changes: 15 additions & 0 deletions packages/react-core/src/modules/slack/hooks/useSlackAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ const DEFAULT_SLACK_SCOPES = [
"groups:read",
];

export function getSlackNonceStorageKey(
channelId: string,
userId: string,
): string {
return `knock:slack-auth-nonce:${channelId}:${userId}`;
}

type UseSlackAuthOutput = {
buildSlackAuthUrl: () => string;
disconnectFromSlack: () => void;
Expand Down Expand Up @@ -83,8 +90,15 @@ function useSlackAuth(
]);

const buildSlackAuthUrl = useCallback(() => {
const nonce = crypto.randomUUID();
sessionStorage.setItem(
getSlackNonceStorageKey(knockSlackChannelId, knock.userId!),
nonce,
);

const rawParams = {
state: JSON.stringify({
nonce,
redirect_url: redirectUrl,
access_token_object: {
object_id: tenantId,
Expand All @@ -104,6 +118,7 @@ function useSlackAuth(
tenantId,
knockSlackChannelId,
knock.apiKey,
knock.userId,
knock.userToken,
knock.branch,
slackClientId,
Expand Down
1 change: 1 addition & 0 deletions packages/react-core/src/modules/slack/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
useSlackChannels,
useConnectedSlackChannels,
useSlackAuth,
getSlackNonceStorageKey,
} from "./hooks";
export {
type ContainerObject,
Expand Down
Loading
Loading