Repository navigation
feat: handle the MFA inbound-SMS link in the host app #119
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ import ReactNativeVersion from 'react-native/Libraries/Core/ReactNativeVersion'; | |
|
|
||
| import md5 from './md5'; | ||
| import hcaptchaPackage from './package.json'; | ||
| import { parseSmsLink } from './hcaptchaSmsLink'; | ||
| import { | ||
| clearJourneyEvents, | ||
| disableJourneyConsumer, | ||
|
|
@@ -429,29 +430,68 @@ const Hcaptcha = ({ | |
| webViewRef.current.injectJavaScript('loadApiScript(); true;'); | ||
| }; | ||
|
|
||
| const openSmsLink = (smsUrl) => { | ||
| const link = parseSmsLink(smsUrl); | ||
|
|
||
| // Rebuilt from the parsed parts rather than passed through: the recipient can arrive with | ||
| // formatting a messaging app rejects, and the challenge emits an empty leading query | ||
| // parameter (`?&body=`). The body itself is re-encoded unchanged, so the one-time code | ||
| // reaches the composer byte-exact. | ||
| const target = | ||
| link && link.recipient | ||
| ? `sms:${link.recipient}${link.body ? `?body=${encodeURIComponent(link.body)}` : ''}` | ||
| : smsUrl; | ||
|
|
||
| Linking.openURL(target).catch(() => { | ||
| // The rejection message embeds the URL, and the body carries a one-time code, so it is | ||
| // deliberately not forwarded: this event reaches host apps that log it. | ||
| onMessage({ | ||
| nativeEvent: { | ||
| data: 'sms-open-failed', | ||
| description: 'Could not open the messaging app', | ||
| }, | ||
| success: false, | ||
| }); | ||
| }); | ||
| }; | ||
|
|
||
| // Shared by both navigation callbacks. The challenge opens its `sms:` link with | ||
| // `target="_blank"`, which never reaches `onShouldStartLoadWithRequest` - on Android | ||
| // `setSupportMultipleWindows` is always on, so it surfaces as `onOpenWindow` instead. | ||
| const handleExternalUrl = (candidate) => { | ||
| if (typeof candidate !== 'string') { | ||
| return false; | ||
| } | ||
|
|
||
| if (candidate.slice(0, 24) === 'https://www.hcaptcha.com') { | ||
| Linking.openURL(candidate); | ||
| return true; | ||
| } | ||
|
|
||
| if (candidate.toLowerCase().startsWith('sms:')) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| openSmsLink(candidate); | ||
| return true; | ||
| } | ||
|
|
||
| return false; | ||
| }; | ||
|
|
||
| return ( | ||
| <View style={styles.container}> | ||
| <WebView | ||
| ref={webViewRef} | ||
| originWhitelist={['*']} | ||
| onShouldStartLoadWithRequest={(event) => { | ||
| if (event.url.slice(0, 24) === 'https://www.hcaptcha.com') { | ||
| Linking.openURL(event.url); | ||
| return false; | ||
| } else if (event.url.toLowerCase().startsWith('sms:')) { | ||
| Linking.openURL(event.url).catch((err) => { | ||
| onMessage({ | ||
| nativeEvent: { | ||
| data: 'sms-open-failed', | ||
| description: err.message, | ||
| }, | ||
| success: false, | ||
| }); | ||
| }); | ||
| return false; | ||
| onShouldStartLoadWithRequest={(event) => !handleExternalUrl(event.url)} | ||
| onOpenWindow={(event) => { | ||
| const { targetUrl } = event.nativeEvent; | ||
| if (handleExternalUrl(targetUrl)) { | ||
| return; | ||
| } | ||
|
|
||
| return true; | ||
| // A `target="_blank"` link the challenge does not own. Android already sent these out | ||
| // to the system before this callback existed, so keep that behaviour rather than | ||
| // swallowing the navigation. | ||
| Linking.openURL(targetUrl).catch(() => {}); | ||
| }} | ||
| mixedContentMode={'always'} | ||
| onMessage={(e) => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -740,7 +740,7 @@ describe('Hcaptcha', () => { | |
| expect(openURL).toHaveBeenCalledWith('https://www.hcaptcha.com/privacy'); | ||
| }); | ||
|
|
||
| it('opens sms links externally and reports failures back through onMessage', async () => { | ||
| it('opens sms links externally, normalising the challenge link shape', async () => { | ||
| const openURL = jest.spyOn(Linking, 'openURL'); | ||
| const onMessage = jest.fn(); | ||
| const component = render( | ||
|
|
@@ -751,17 +751,20 @@ describe('Hcaptcha', () => { | |
| /> | ||
| ); | ||
|
|
||
| // The shape the challenge emits, with the empty leading query parameter. | ||
| openURL.mockResolvedValueOnce(true); | ||
| const successfulSms = getWebView(component).props.onShouldStartLoadWithRequest({ | ||
| url: 'sms:+15551234567', | ||
| url: 'sms:+15550001111?&body=Do%20not%20share%20the%20code%3A%20aaaa-bbbb-cccc', | ||
| }); | ||
|
|
||
| expect(successfulSms).toBe(false); | ||
| expect(openURL).toHaveBeenCalledWith('sms:+15551234567'); | ||
| expect(openURL).toHaveBeenCalledWith( | ||
| 'sms:+15550001111?body=Do%20not%20share%20the%20code%3A%20aaaa-bbbb-cccc' | ||
| ); | ||
|
|
||
| openURL.mockRejectedValueOnce(new Error('sms unavailable')); | ||
| const failedSms = getWebView(component).props.onShouldStartLoadWithRequest({ | ||
| url: 'sms:+15557654321', | ||
| url: 'sms:+15550002222', | ||
| }); | ||
|
|
||
| expect(failedSms).toBe(false); | ||
|
|
@@ -770,13 +773,43 @@ describe('Hcaptcha', () => { | |
| expect(onMessage).toHaveBeenCalledWith({ | ||
| nativeEvent: { | ||
| data: 'sms-open-failed', | ||
| description: 'sms unavailable', | ||
| // The rejection message embeds the URL, which carries the one-time code, so it is | ||
| // deliberately not forwarded. | ||
| description: 'Could not open the messaging app', | ||
| }, | ||
| success: false, | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| const component = render( | ||
| <Hcaptcha | ||
| siteKey="00000000-0000-0000-0000-000000000000" | ||
| url="https://hcaptcha.com" | ||
| onMessage={jest.fn()} | ||
| /> | ||
| ); | ||
|
|
||
| getWebView(component).props.onOpenWindow({ | ||
| nativeEvent: { targetUrl: 'sms:+15550001111?&body=code%3A%20aaaa-bbbb-cccc' }, | ||
| }); | ||
| expect(openURL).toHaveBeenCalledWith('sms:+15550001111?body=code%3A%20aaaa-bbbb-cccc'); | ||
|
|
||
| getWebView(component).props.onOpenWindow({ | ||
| nativeEvent: { targetUrl: 'https://www.hcaptcha.com/privacy' }, | ||
| }); | ||
| expect(openURL).toHaveBeenCalledWith('https://www.hcaptcha.com/privacy'); | ||
|
|
||
| getWebView(component).props.onOpenWindow({ | ||
| nativeEvent: { targetUrl: 'https://example.com/other' }, | ||
| }); | ||
| expect(openURL).toHaveBeenCalledWith('https://example.com/other'); | ||
| }); | ||
|
|
||
| it('allows non-hcaptcha, non-sms navigations to continue inside the WebView', () => { | ||
| const openURL = jest.spyOn(Linking, 'openURL').mockResolvedValue(true); | ||
| const component = render( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| import { parseSmsLink } from '../hcaptchaSmsLink'; | ||
|
|
||
| // The shape the MFA challenge emits: an hCaptcha number and a full sentence carrying a | ||
| // hyphenated one-time code. The numbers and codes here are dummies. | ||
| const POOL_NUMBER = '+15550001111'; | ||
| const POOL_BODY = | ||
| 'Return to the app and press Confirm after sending this message. ' + | ||
| 'Do not edit or share the code: aaaa-bbbb-cccc'; | ||
| const POOL_LINK = | ||
| 'sms:+15550001111?body=Return%20to%20the%20app%20and%20press%20Confirm%20after%20sending%20' + | ||
| 'this%20message.%20Do%20not%20edit%20or%20share%20the%20code%3A%20aaaa-bbbb-cccc'; | ||
|
|
||
| // The same shape with the empty first query parameter the challenge is reported to emit: | ||
| // `?&body=`, not `?body=`. | ||
| const EMPTY_LEADING_PAIR_LINK = | ||
| 'sms:+15550002222?&body=Return%20to%20the%20app%20and%20press%20Confirm%20after%20sending%20' + | ||
| 'this%20message.%20Do%20not%20edit%20or%20share%20the%20code%3A%20dddd-eeee-ffff'; | ||
|
|
||
| const NUMBER = '+15551234567'; | ||
| const CODE = 'code'; | ||
| const CODE_QUERY = '?body=code'; | ||
|
|
||
| /** @param suffix everything after the hCaptcha number in the link */ | ||
| const parseSuffix = (suffix) => parseSmsLink(`sms:${NUMBER}${suffix}`); | ||
|
|
||
| describe('parseSmsLink', () => { | ||
| it('parses a full challenge-shaped link', () => { | ||
| expect(parseSmsLink(POOL_LINK)).toEqual({ recipient: POOL_NUMBER, body: POOL_BODY }); | ||
| }); | ||
|
|
||
| it('parses a link with an empty leading query parameter', () => { | ||
| expect(parseSmsLink(EMPTY_LEADING_PAIR_LINK)).toEqual({ | ||
| recipient: '+15550002222', | ||
| body: | ||
| 'Return to the app and press Confirm after sending this message. ' + | ||
| 'Do not edit or share the code: dddd-eeee-ffff', | ||
| }); | ||
| }); | ||
|
|
||
| it('skips empty query parameters', () => { | ||
| expect(parseSuffix('?&body=code').body).toBe(CODE); | ||
| expect(parseSuffix('?&&body=code').body).toBe(CODE); | ||
| expect(parseSuffix('?x=1&body=code&').body).toBe(CODE); | ||
| }); | ||
|
|
||
| it('keeps the body byte-exact', () => { | ||
| // The code is hyphenated in the SMS body and unhyphenated in the challenge UI; the backend | ||
| // normalises, so nothing here may trim, reformat or re-case the body. | ||
| expect(parseSuffix('?body=%20%20spaced%20%20').body).toBe(' spaced '); | ||
| expect(parseSuffix('?body=aaaa-bbbb-cccc').body).toBe('aaaa-bbbb-cccc'); | ||
| }); | ||
|
|
||
| it('splits the query before the subscriber params', () => { | ||
| // RFC 5724 allows `;` subscriber params before the query. Folding the two grammars together | ||
| // loses the body. | ||
| expect(parseSuffix(`;phone-context=+1${CODE_QUERY}`)).toEqual({ | ||
| recipient: NUMBER, | ||
| body: CODE, | ||
| }); | ||
| }); | ||
|
|
||
| it('treats a semicolon inside the query as body text', () => { | ||
| expect(parseSuffix('?body=code;more').body).toBe('code;more'); | ||
| }); | ||
|
|
||
| it('reads the legacy body parameter from the head', () => { | ||
| expect(parseSuffix(';body=hello').body).toBe('hello'); | ||
| }); | ||
|
|
||
| it('keeps a plus in the body', () => { | ||
| // `+` is a literal plus in a URI query (RFC 3986); only form encoding reads it as a space. | ||
| expect(parseSuffix('?body=a+b').body).toBe('a+b'); | ||
| }); | ||
|
|
||
| it('decodes utf-8 escapes', () => { | ||
| expect(parseSuffix('?body=caf%C3%A9%20%E2%80%93%20ok').body).toBe('café – ok'); | ||
| }); | ||
|
|
||
| it('keeps malformed escapes as literal text', () => { | ||
| expect(parseSuffix('?body=100%25').body).toBe('100%'); | ||
| expect(parseSuffix('?body=50%').body).toBe('50%'); | ||
| expect(parseSuffix('?body=%zz').body).toBe('%zz'); | ||
| }); | ||
|
|
||
| it('normalises recipient formatting', () => { | ||
| expect(parseSmsLink('sms:+123-456-789?body=Hello%20World').recipient).toBe('+123456789'); | ||
| expect(parseSmsLink('sms:%2B1%20(555)%20123.4567').recipient).toBe(NUMBER); | ||
| }); | ||
|
|
||
| it('keeps only the first recipient', () => { | ||
| expect(parseSuffix(`,+15559999999${CODE_QUERY}`).recipient).toBe(NUMBER); | ||
| }); | ||
|
|
||
| it('accepts the smsto and authority forms', () => { | ||
| expect(parseSmsLink(`smsto:${NUMBER}${CODE_QUERY}`).recipient).toBe(NUMBER); | ||
| expect(parseSmsLink(`sms://${NUMBER}${CODE_QUERY}`).body).toBe(CODE); | ||
| expect(parseSmsLink(`SMS:${NUMBER}?BODY=code`).body).toBe(CODE); | ||
| }); | ||
|
|
||
| it('reports missing parts as null', () => { | ||
| expect(parseSuffix('').body).toBeNull(); | ||
| expect(parseSuffix('?body=').body).toBeNull(); | ||
| expect(parseSuffix('?subject=hi').body).toBeNull(); | ||
| expect(parseSmsLink(`sms:${CODE_QUERY}`).recipient).toBeNull(); | ||
| expect(parseSmsLink('sms:not-a-number?body=nope').recipient).toBeNull(); | ||
| }); | ||
|
|
||
| it('rejects non-sms urls', () => { | ||
| expect(parseSmsLink(null)).toBeNull(); | ||
| expect(parseSmsLink(undefined)).toBeNull(); | ||
| expect(parseSmsLink(`https://example.com${CODE_QUERY}`)).toBeNull(); | ||
| expect(parseSmsLink(`tel:${NUMBER}`)).toBeNull(); | ||
| expect(parseSmsLink(`smsx:${NUMBER}`)).toBeNull(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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.