Skip to content

feat: handle the MFA inbound-SMS link in the host app - #119

Draft
CAMOBAP wants to merge 2 commits into
masterfrom
feat/sms-link-handling
Draft

CAMOBAP wants to merge 2 commits into
masterfrom
feat/sms-link-handling

Conversation

@CAMOBAP

@CAMOBAP CAMOBAP commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

Handles the sms: link from the MFA inbound-SMS challenge: parses it and opens the messaging app
with the recipient and body prefilled.

Changes

  • hcaptchaSmsLink.js — parser for sms:/smsto: links. Behaviour ported from
    HCaptchaSmsLink.java in hcaptcha-android-sdk#279 (behaviour and test table, not the Java).
    Splits on ? before ; so subscriber params don't eat the body, skips empty query pairs
    (?&body=), decodes without form semantics so a literal + survives.
  • Hcaptcha.js — adds onOpenWindow alongside onShouldStartLoadWithRequest, both going
    through one handler. The challenge opens the link with target="_blank", which on Android goes
    to onCreateWindow and only surfaces as onOpenWindow. Verified on a Pixel 7: without it no JS
    callback fires and the URL leaves the app. Same applies to the hCaptcha policy links.
  • The body is re-encoded unchanged, so the one-time code stays byte-exact.
  • Linking.openURL's rejection message embeds the URL, so it's no longer forwarded through
    onMessage — the body carries the one-time code and host apps log that event.

Note: smsto: is not a useful alternative — WhatsApp claims sms: and smsto: identically, so
both show the same chooser.

Not verified

  • Live MFA challenge never run — testing used a page reproducing the link shapes, not an
    enterprise sitekey.
  • Body prefill unverified: Messages rejected the dummy recipient for both link shapes.
  • iOS untested on hardware. onOpenWindow makes iOS route _blank there instead of loading
    in the WebView; unhandled URLs now open externally.

Test plan

npm test (76 passing) and npm run lint are green. Both need __e2e__/host absent — when that
generated dir is present, jest picks up a second React and 32 tests fail on master too.

🤖 Generated with Claude Code

The challenge opens its `sms:` link with `target="_blank"`. On Android
`setSupportMultipleWindows` is always on in react-native-webview, so that
navigation goes to `WebChromeClient.onCreateWindow` and never reaches
`onShouldStartLoadWithRequest`. Measured on a Pixel 7: with no `onOpenWindow`
prop set, no JS callback fires at all and the URL is handed to the system,
which shows an app chooser listing WhatsApp - the app the challenge tells
users not to use. The same applies to the `https://www.hcaptcha.com` policy
links, which left the app the same way.

Route both callbacks through one handler and add `onOpenWindow`, which
recovers every `target="_blank"` case in JS.

Parse the link rather than passing it through. The recipient can carry
formatting a messaging app rejects, and the challenge emits an empty leading
query parameter (`?&body=`). Splitting on `?` before `;` keeps the body of an
RFC 5724 link with subscriber params, and percent-decoding is done without
form semantics so a literal `+` survives. The body is re-encoded unchanged so
the one-time code reaches the composer byte-exact.

The rejection message from `Linking.openURL` embeds the URL, and the body
carries the one-time code, so it is no longer forwarded to `onMessage`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ghost

ghost commented Oct 4, 2026 via email

Copy link
Copy Markdown

@CAMOBAP CAMOBAP changed the title fix: handle the MFA sms link on the callback the challenge actually uses feat: handle the MFA inbound-SMS link in the host app Oct 4, 2026
Comment thread hcaptchaSmsLink.js Outdated
while (start < url.length && url[start] === '/') {
start++;
}
const payload = url.slice(start);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Similar as the one in IOS and Android.

Copied and paste from the other PR comment
"I left a similar message to the IOS PR, we strip literal fragments before parsing and preserve %23. So sms:+15551234567#123?body=code changes the outtput recipient to +15551234567123."

Comment thread Hcaptcha.js Outdated
}

if (candidate.slice(0, 24) === 'https://www.hcaptcha.com') {
Linking.openURL(candidate);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

_blank privacy/terms links return through this branch before the fallback catch, leaving Linking.openURL() failures unhandled.

Comment thread Hcaptcha.js Outdated
return true;
}

if (candidate.toLowerCase().startsWith('sms:')) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry I am a bit confused with this, if the smsto is to be supported can it use openSmslink() too or no? I was reading it yesterday and the parser accepts it but this branch bypasses it normalization and sms failure reporting.

it('handles target="_blank" links through onOpenWindow', () => {
// The challenge opens its sms link with target="_blank", which never reaches
// onShouldStartLoadWithRequest on Android.
const openURL = jest.spyOn(Linking, 'openURL').mockResolvedValue(true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

How can we verify the body prefill and the returned challenege on mobile for both the IOS and android? I see it checking the outgoing URL but I don't see the messaging app test.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good question, and it's the one place the JS SDK can't fully close. Neither native SDK tests the real messaging app either, so I've matched what they settled on:

  • Android (feat(sdk): keep the MFA inbound-SMS hand-off inside the host app hcaptcha-android-sdk#279) stubs the launch with Espresso — intending(hasAction(ACTION_SENDTO)).respondWith(...) — so the messaging app never starts, then asserts the hand-off itself: action, smsto: data URI, and the sms_body extra byte-exact. The live path is confirmed by hand and recorded in a comment on testSmsHandledFromNewWindow: "Verified against a live sitekey."
  • iOS (feat: sms modal HCaptcha-ios-sdk#201) couldn't run MFMessageComposeViewController in CI at all (canSendText() == false on the simulator), so it injected a presenter stand-in specifically to keep the didFinishWith dismissal and return-to-challenge path testable.

So the accepted bar is "assert the outgoing hand-off, verify the live flow manually." These tests assert the same category of thing — the exact URL handed to Linking.openURL, including the body — which is the RN equivalent of Android's intent assertion.

Addresses the review on #119, following the implementations merged in
HCaptcha-ios-sdk#201 and hcaptcha-android-sdk#279.

A literal `#` starts a fragment (RFC 3986), so it and everything after it -
including a `?query` that follows it - is not part of the link. It was not
stripped, and the recipient allowlist then silently swallowed the `#` and
concatenated the fragment digits onto the number: `sms:+15551234567#123?body=code`
dialled +15551234567123. Stripped before parsing, as both native SDKs do; an
encoded `%23` is payload and still survives. The parser cases are ported from
HCaptchaSMSLink__Tests.

The `sms:` scheme check in the dispatch was a second, narrower matcher than the
parser it guards, so `smsto:` - which `parseSmsLink` accepts - never reached the
hand-off and went out unnormalised, with no failure reporting. The parser is now
the only matcher, mirroring Android's `openSmsComposer`.

The hcaptcha.com branch returns before the `onOpenWindow` fallback that would
have caught for it, leaving `Linking.openURL()` rejections unhandled in the host
app. Android guards every external launch; this one now catches too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@CAMOBAP

CAMOBAP commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator Author

One thing that isn't in the review but I think we should settle before this merges: the hand-off model here doesn't match either native SDK.

This PR uses Linking.openURL('sms:…') on both platforms. But:

Both are built around the same goal, stated in the Android source: "keep the challenge reachable." A plain Linking.openURL gives us neither — on iOS it bypasses the modal the SDK just shipped, and on Android it has no control over task placement, so back may not return to the challenge.

Is matching native in scope for this PR, or should it land as-is and follow up?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants