Skip to content

Commit

Permalink
fix(clerk-js): Decouple captcha heartbeat from token refresh (#4630)
Browse files Browse the repository at this point in the history
  • Loading branch information
nikosdouvlis authored Nov 22, 2024
1 parent 3f890cf commit 7623a99
Show file tree
Hide file tree
Showing 12 changed files with 144 additions and 88 deletions.
6 changes: 6 additions & 0 deletions .changeset/fast-wolves-love.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/types': patch
---

Decouple captcha heartbeat from token refresh mechanism
2 changes: 1 addition & 1 deletion packages/clerk-js/src/core/__tests__/tokenCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ describe('MemoryTokenCache', () => {
expect(cache.get(key)).toMatchObject(key);

// 44s since token created
jest.advanceTimersByTime(44 * 1000);
jest.advanceTimersByTime(45 * 1000);
expect(cache.get(key)).toMatchObject(key);

// 46s since token created
Expand Down
44 changes: 44 additions & 0 deletions packages/clerk-js/src/core/auth/CaptchaHeartbeat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { createWorkerTimers } from '@clerk/shared/workerTimers';

import { CaptchaChallenge } from '../../utils/captcha/CaptchaChallenge';
import type { Clerk } from '../resources/internal';

export class CaptchaHeartbeat {
constructor(
private clerk: Clerk,
private captchaChallenge = new CaptchaChallenge(clerk),
private timers = createWorkerTimers(),
) {}

public async start() {
if (!this.isEnabled()) {
return;
}

await this.challengeAndSend();
this.timers.setInterval(() => {
void this.challengeAndSend();
}, this.intervalInMs());
}

private async challengeAndSend() {
if (!this.clerk.client) {
return;
}

try {
const params = await this.captchaChallenge.invisible();
await this.clerk.client.sendCaptchaToken(params);
} catch (e) {
// Ignore unhandled errors
}
}

private isEnabled() {
return !!this.clerk.__unstable__environment?.displayConfig.captchaHeartbeat;
}

private intervalInMs() {
return this.clerk.__unstable__environment?.displayConfig.captchaHeartbeatIntervalMs ?? 10 * 60 * 1000;
}
}
5 changes: 4 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ import { assertNoLegacyProp } from '../utils/assertNoLegacyProp';
import { memoizeListenerCallback } from '../utils/memoizeStateListenerCallback';
import { RedirectUrls } from '../utils/redirectUrls';
import { AuthCookieService } from './auth/AuthCookieService';
import { CaptchaHeartbeat } from './auth/CaptchaHeartbeat';
import { CLERK_SATELLITE_URL, CLERK_SUFFIXED_COOKIES, CLERK_SYNCED, ERROR_CODES } from './constants';
import {
clerkErrorInitFailed,
Expand Down Expand Up @@ -178,6 +179,7 @@ export class Clerk implements ClerkInterface {
#domain: DomainOrProxyUrl['domain'];
#proxyUrl: DomainOrProxyUrl['proxyUrl'];
#authService?: AuthCookieService;
#captchaHeartbeat?: CaptchaHeartbeat;
#broadcastChannel: LocalStorageBroadcastChannel<ClerkCoreBroadcastChannelEvent> | null = null;
#componentControls?: ReturnType<MountComponentRenderer> | null;
//@ts-expect-error with being undefined even though it's not possible - related to issue with ts and error thrower
Expand Down Expand Up @@ -1828,8 +1830,9 @@ export class Clerk implements ClerkInterface {
}
}

this.#captchaHeartbeat = new CaptchaHeartbeat(this);
void this.#captchaHeartbeat.start();
this.#clearClerkQueryParams();

this.#handleImpersonationFab();
this.#handleAccountlessPrompt();
return true;
Expand Down
74 changes: 3 additions & 71 deletions packages/clerk-js/src/core/fraudProtection.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import { getCaptchaToken, retrieveCaptchaInfo } from '../utils/captcha';
import { CaptchaChallenge } from '../utils/captcha/CaptchaChallenge';
import type { Clerk } from './resources/internal';

/**
* TODO: @nikos Move captcha and fraud detection logic to this class
*/
class FraudProtectionService {
private inflightRequest: Promise<unknown> | null = null;
private ticks = 0;
private readonly interval = 6;

public async execute<T extends () => Promise<any>>(cb: T): Promise<Awaited<ReturnType<T>>> {
if (this.inflightRequest) {
Expand All @@ -26,71 +21,8 @@ class FraudProtectionService {
return this.inflightRequest ? this.inflightRequest.then(() => null) : Promise.resolve();
}

public async challengeHeartbeat(clerk: Clerk) {
if (!clerk.__unstable__environment?.displayConfig.captchaHeartbeat || this.ticks++ % (this.interval - 1)) {
return undefined;
}
return this.invisibleChallenge(clerk);
}

/**
* Triggers an invisible challenge.
* This will always use the non-interactive variant of the CAPTCHA challenge and will
* always use the fallback key.
*/
public async invisibleChallenge(clerk: Clerk) {
const { captchaSiteKey, canUseCaptcha, captchaURL, captchaPublicKeyInvisible } = retrieveCaptchaInfo(clerk);

if (canUseCaptcha && captchaSiteKey && captchaURL && captchaPublicKeyInvisible) {
return getCaptchaToken({
siteKey: captchaPublicKeyInvisible,
invisibleSiteKey: captchaPublicKeyInvisible,
widgetType: 'invisible',
scriptUrl: captchaURL,
captchaProvider: 'turnstile',
}).catch(e => {
if (e.captchaError) {
return { captchaError: e.captchaError };
}
return undefined;
});
}

return undefined;
}

/**
* Triggers a smart challenge if the user is required to solve a CAPTCHA.
* Depending on the environment settings, this will either trigger an
* invisible or smart (managed) CAPTCHA challenge.
* Managed challenged start as non-interactive and escalate to interactive if necessary.
* Important: For this to work at the moment, the instance needs to be using SMART protection
* as we need both keys (visible and invisible) to be present.
*/
public async managedChallenge(clerk: Clerk) {
const { captchaSiteKey, canUseCaptcha, captchaURL, captchaWidgetType, captchaProvider, captchaPublicKeyInvisible } =
retrieveCaptchaInfo(clerk);

if (canUseCaptcha && captchaSiteKey && captchaURL && captchaPublicKeyInvisible) {
return getCaptchaToken({
siteKey: captchaSiteKey,
widgetType: captchaWidgetType,
invisibleSiteKey: captchaPublicKeyInvisible,
scriptUrl: captchaURL,
captchaProvider,
modalWrapperQuerySelector: '#cl-modal-captcha-wrapper',
modalContainerQuerySelector: '#cl-modal-captcha-container',
openModal: () => clerk.__internal_openBlankCaptchaModal(),
closeModal: () => clerk.__internal_closeBlankCaptchaModal(),
}).catch(e => {
if (e.captchaError) {
return { captchaError: e.captchaError };
}
return undefined;
});
}

return {};
public managedChallenge(clerk: Clerk) {
return new CaptchaChallenge(clerk).managed();
}
}

Expand Down
4 changes: 4 additions & 0 deletions packages/clerk-js/src/core/resources/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ export class Client extends BaseResource implements ClientResource {
.toString();
}

public sendCaptchaToken(params: unknown): Promise<ClientResource> {
return this._basePatch({ body: params });
}

fromJSON(data: ClientJSON | null): this {
if (data) {
this.id = data.id;
Expand Down
2 changes: 2 additions & 0 deletions packages/clerk-js/src/core/resources/DisplayConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource
captchaPublicKeyInvisible: string | null = null;
captchaOauthBypass: OAuthStrategy[] = [];
captchaHeartbeat: boolean = false;
captchaHeartbeatIntervalMs?: number = undefined;
homeUrl!: string;
instanceEnvironmentType!: string;
faviconImageUrl!: string;
Expand Down Expand Up @@ -85,6 +86,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource
// before the introduction of the captcha_oauth_bypass field
this.captchaOauthBypass = data.captcha_oauth_bypass || ['oauth_google', 'oauth_microsoft', 'oauth_apple'];
this.captchaHeartbeat = data.captcha_heartbeat || false;
this.captchaHeartbeatIntervalMs = data.captcha_heartbeat_interval_ms;
this.supportEmail = data.support_email || '';
this.clerkJSVersion = data.clerk_js_version;
this.organizationProfileUrl = data.organization_profile_url;
Expand Down
23 changes: 9 additions & 14 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,20 +275,15 @@ export class Session extends BaseResource implements SessionResource {
// this handles all getToken invocations with skipCache: true
await fraudProtection.blockUntilReady();

const createTokenWithCaptchaProtection = async () => {
const heartbeatParams = skipCache ? undefined : await fraudProtection.challengeHeartbeat(Session.clerk);
return Token.create(path, { ...params, ...heartbeatParams }).catch(e => {
if (isClerkAPIResponseError(e) && e.errors[0].code === 'requires_captcha') {
return fraudProtection.execute(async () => {
const captchaParams = await fraudProtection.managedChallenge(Session.clerk);
return Token.create(path, { ...params, ...captchaParams });
});
}
throw e;
});
};

const tokenResolver = createTokenWithCaptchaProtection();
const tokenResolver = Token.create(path, params).catch(e => {
if (isClerkAPIResponseError(e) && e.errors[0].code === 'requires_captcha') {
return fraudProtection.execute(async () => {
const captchaParams = await fraudProtection.managedChallenge(Session.clerk);
return Token.create(path, { ...params, ...captchaParams });
});
}
throw e;
});

SessionTokenCache.set({ tokenId, tokenResolver });
return tokenResolver.then(token => {
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/src/core/tokenCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ interface TokenCache {

const KEY_PREFIX = 'clerk';
const DELIMITER = '::';
const LEEWAY = 11;
const LEEWAY = 10;
// This value should have the same value as the INTERVAL_IN_MS in SessionCookiePoller
const SYNC_LEEWAY = 5;

Expand Down
67 changes: 67 additions & 0 deletions packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Clerk } from '../../core/resources/internal';
import { getCaptchaToken } from './getCaptchaToken';
import { retrieveCaptchaInfo } from './retrieveCaptchaInfo';

export class CaptchaChallenge {
public constructor(private clerk: Clerk) {}

/**
* Triggers an invisible challenge.
* This will always use the non-interactive variant of the CAPTCHA challenge and will
* always use the fallback key.
*/
public async invisible() {
const { captchaSiteKey, canUseCaptcha, captchaURL, captchaPublicKeyInvisible } = retrieveCaptchaInfo(this.clerk);

if (canUseCaptcha && captchaSiteKey && captchaURL && captchaPublicKeyInvisible) {
return getCaptchaToken({
siteKey: captchaPublicKeyInvisible,
invisibleSiteKey: captchaPublicKeyInvisible,
widgetType: 'invisible',
scriptUrl: captchaURL,
captchaProvider: 'turnstile',
}).catch(e => {
if (e.captchaError) {
return { captchaError: e.captchaError };
}
return undefined;
});
}

return undefined;
}

/**
* Triggers a smart challenge if the user is required to solve a CAPTCHA.
* Depending on the environment settings, this will either trigger an
* invisible or smart (managed) CAPTCHA challenge.
* Managed challenged start as non-interactive and escalate to interactive if necessary.
* Important: For this to work at the moment, the instance needs to be using SMART protection
* as we need both keys (visible and invisible) to be present.
*/
public async managed() {
const { captchaSiteKey, canUseCaptcha, captchaURL, captchaWidgetType, captchaProvider, captchaPublicKeyInvisible } =
retrieveCaptchaInfo(this.clerk);

if (canUseCaptcha && captchaSiteKey && captchaURL && captchaPublicKeyInvisible) {
return getCaptchaToken({
siteKey: captchaSiteKey,
widgetType: captchaWidgetType,
invisibleSiteKey: captchaPublicKeyInvisible,
scriptUrl: captchaURL,
captchaProvider,
modalWrapperQuerySelector: '#cl-modal-captcha-wrapper',
modalContainerQuerySelector: '#cl-modal-captcha-container',
openModal: () => this.clerk.__internal_openBlankCaptchaModal(),
closeModal: () => this.clerk.__internal_closeBlankCaptchaModal(),
}).catch(e => {
if (e.captchaError) {
return { captchaError: e.captchaError };
}
return undefined;
});
}

return {};
}
}
1 change: 1 addition & 0 deletions packages/types/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface ClientResource extends ClerkResource {
signIn: SignInResource;
isNew: () => boolean;
create: () => Promise<ClientResource>;
sendCaptchaToken: (params: unknown) => Promise<ClientResource>;
destroy: () => Promise<void>;
removeSessions: () => Promise<ClientResource>;
clearCache: () => void;
Expand Down
2 changes: 2 additions & 0 deletions packages/types/src/displayConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface DisplayConfigJSON {
captcha_provider: CaptchaProvider;
captcha_oauth_bypass: OAuthStrategy[] | null;
captcha_heartbeat?: boolean;
captcha_heartbeat_interval_ms?: number;
home_url: string;
instance_environment_type: string;
logo_image_url: string;
Expand Down Expand Up @@ -66,6 +67,7 @@ export interface DisplayConfigResource extends ClerkResource {
*/
captchaOauthBypass: OAuthStrategy[];
captchaHeartbeat: boolean;
captchaHeartbeatIntervalMs?: number;
homeUrl: string;
instanceEnvironmentType: string;
logoImageUrl: string;
Expand Down

0 comments on commit 7623a99

Please sign in to comment.