Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Relintio

@relintio/expo-agent

npm quickstart license

The Relintio agent for Expo and React Native.


createRelintio() returns a RelintioNativeClient: a small object that asks Relintio for a verdict, presents a challenge in a WebView when one is called for, and resolves the promise your code was waiting on when the user passes it. The protocol lives in @relintio/browser-core — the publishable-key refusal, the http/https check on a challenge URL, the ten-second timeout floor, the de-duplication of concurrent challenges, and failing open on every error path — shared byte for byte with the React, Vue, Svelte and Angular SDKs. This package is the React Native-shaped surface over it. What it changes is not the protocol but the environment: no location, no document, no iframe, and a device signal set that is smaller and honest about being smaller.

// relintio.ts
import { Platform } from 'react-native';
import { createRelintio } from '@relintio/expo-agent';

export const relintio = createRelintio({
  publishableKey: 'pk_live_...',
  // Required. There is no location.hostname on native, so the agent has to be
  // told which origin this app talks to.
  domain: 'api.example.com',
  environment: {
    platform: Platform.OS,
    platformVersion: Platform.Version,
  },
});

Installation

npx expo install @relintio/expo-agent react-native-webview

expo install rather than npm install, so the WebView is pinned to the version your Expo SDK ships with. react-native-webview contains native code: adding it to a project that has already been prebuilt means a new native build, and it does not work in Expo Go beyond the version bundled there.

Peer Range Declared
react >=17.0.0 yes
react-native >=0.70.0 yes
react-native-webview no

That last row is the one to read twice. react-native-webview is not declared as a peer dependency and is not a runtime dependency either, so npm will not warn you and the install will not fail — but it is not optional. webViewProps() returns props with no component to give them to, and there is no other way to present a challenge on native. Install it explicitly, alongside this package, in the same command.

@relintio/browser-core ^1.0.0 is the only declared runtime dependency.

Registration

Create the client once, at module scope near the app root, and import that instance everywhere. The agent is stateful: it holds the pending challenge, the subscriber set and the resolved count, and two clients means two independent views of whether this user has passed anything.

// App.tsx
import { relintio } from './relintio';
import { RelintioChallenge } from './RelintioChallenge';

export default function App() {
  useEffect(() => () => relintio.destroy(), []);

  return (
    <>
      <RootNavigator />
      <RelintioChallenge />
    </>
  );
}

destroy() disposes the underlying agent: it rejects any open challenge with Relintio agent unmounted, clears the listener set, and refuses to arm another. Calling it on the root unmount is tidy rather than essential in an app that lives as long as the process; it matters in tests and in anything that tears the tree down and rebuilds it.

If verifyOnMount is set, the verdict request fires inside createRelintio() — which, at module scope, is at import time, before the first screen has rendered.

Configuration

RelintioExpoConfig extends the shared RelintioConfig with two fields of its own.

Field Type Default Meaning
publishableKey string Required. Must begin pk_. Anything else is refused; see below.
domain string Required, and specific to this package. The origin this app talks to. Reported as domain and used to build return_url.
environment RelintioNativeEnvironment {} Device metadata you pass in. Nothing is collected without it.
apiUrl string https://api.relintio.com/v1 Control-plane base. Trailing slashes are trimmed once, at construction.
challengeTimeoutMs number 120000 How long a challenge may stay open before its promise rejects. Floored at 10000; a lower value is raised, not honoured.
verifyOnMount boolean false Ask for a verdict as soon as the client is created.
fallbackUrl string Declared on RelintioConfig and read by nothing in the core. Setting it has no effect.

domain has no default because there is nothing sensible to default it to. A browser agent reads location.hostname and knows which site it is protecting; an app binary knows only what you tell it. Without a domain the platform cannot match the request to a protected domain and every verdict comes back unmatched.

Which key goes here

This is a browser-family package and the credential rule is the same, for a sharper reason: an app binary is a file that anyone can download and unpack, and a string compiled into it is a published string. It takes a publishable key (pk_live_…) and nothing else. A publishable key is public by design and does one thing — ask for a verdict and read the answer.

Your licence key must never appear here. It is the HMAC key for challenge passports and for request signing, so anyone holding it can mint a pass through your WAF. Handed one, the agent matches the pk_ prefix, fails, writes a console.error saying so, and createRelintio() returns a client with usable false. That client transmits nothing: verify() resolves null without a request, so the key never leaves the device. It also does not throw — see Edge cases for what an unusable client does to the rest of your code.

What a native runtime can actually collect

The browser collector fingerprints a device through the DOM: a canvas rendered and hashed, a WebGL renderer string, twenty-eight font faces probed by text width, navigator.plugins, an offline audio render, and passive behavioural counters. React Native has none of that. There is no canvas, no WebGL context, no font enumeration, no navigator.plugins, and no document to attach a behaviour watcher to.

So the Expo agent overrides collectSignals() and sends a smaller set. It does not shim a browser: a fingerprint that fabricates values is worse than one that is short, because the platform would score it against browser baselines the device does not belong to.

You pass Sent as Typical source
platform env.platform Platform.OS
platformVersion env.platform_version Platform.Version
appVersion env.app_version expo-application
buildVersion env.build_version expo-application
deviceModel env.device_model expo-device
deviceBrand env.device_brand expo-device
isPhysicalDevice env.is_physical_device Device.isDevicefalse on a simulator
timezone env.timezone IANA zone
timezoneOffset env.timezone_offset minutes
locale env.locale BCP-47
extra spread into telemetry anything else you want scored

Every field is optional and every field is something the app already has. telemetry.surface is always 'native'. Nothing else is gathered — there is no ambient collection on this platform, and a field you do not pass is a field that is not sent. expo-device and expo-application are the usual sources and neither is a dependency of this package; the fields that come from them are ?? undefined away from being usable.

The other overrides are environmental. path is always /, referrer is always empty, return_url is always https://<domain>/, and up_token is always null — a native launch has no query string to carry one.

Presenting the challenge

A challenge is a WebView in a modal, not an iframe. webViewProps() returns the props to spread, or null when there is no challenge on screen.

import { useEffect, useState } from 'react';
import { Modal } from 'react-native';
import { WebView } from 'react-native-webview';

import { relintio } from './relintio';

export function RelintioChallenge() {
  const [, setState] = useState(relintio.getState());
  useEffect(() => relintio.subscribe(setState), []);

  const props = relintio.webViewProps();
  if (!props) return null;

  return (
    <Modal visible animationType="slide">
      <WebView
        {...props}
        onMessage={(event) => relintio.handleWebViewMessage(event.nativeEvent.data)}
      />
    </Modal>
  );
}

incognito: true is deliberate: a challenge is an identity check, and one that reuses cookies from the last check is checking the cookie. injectedJavaScript installs a four-line bridge that listens for message events on the challenge page and forwards string payloads to window.ReactNativeWebView.postMessage, which is what onMessage receives. It ends in true; because an injected script whose last expression is an object makes react-native-webview complain on iOS.

The three-part postMessage check does not apply here, and nothing replaces it in code. In a browser the agent verifies the event origin, the source frame, and the exact message body, because any page can post to any window. In a WebView there is no cross-origin window to impersonate and no event.source to compare, so handleWebViewMessage does one thing: compare the string to relintio_challenge_success exactly. What stands in for the browser's checks is structural — the WebView is created by your app, it loads exactly the URL the platform issued, and it is the only thing wired to this handler. That holds as long as those three things stay true; see the edge case on navigation below.

Asking for a verdict

There is no interceptor on native. React Native does provide a global fetch, but it is a polyfill over the platform networking stack rather than the browser's fetch sitting behind an origin and a CORS model, and this package never patches it — patching a global inside somebody else's app is not a thing a dependency should do. RelintioNativeClient exposes subscribe, getState, verify, challenge, handleWebViewMessage, webViewProps and destroy, and the core's interceptFetch is not among them.

So the decision points are yours to place:

const verdict = await relintio.verify();

if (verdict?.action === 'challenge' && verdict.challenge_url) {
  await relintio.challenge(verdict.challenge_url); // rejects if unsolved
}

verify() already presents a challenge itself when the verdict carries one, so the explicit call above is for the other path: your own API answering 403 with an X-Relintio-Challenge header, which on native you read and hand over yourself.

Edge cases

Nothing stops the challenge WebView navigating away. webViewProps() sets source, javaScriptEnabled, incognito and injectedJavaScript, and nothing else. There is no originWhitelist and no onShouldStartLoadWithRequest. If the challenge page redirects, the destination is loaded in the same WebView with the same bridge attached, and a postMessage of the exact success string from that page resolves the challenge. Add a navigation guard of your own if your threat model includes it.

A refused key resolves challenges instead of presenting them. With an unusable client, challenge() returns Promise.resolve(), so code that awaits it before releasing a request proceeds as though the user had passed. subscribe() calls your listener once with the current state and returns a no-op unsubscribe, so the UI never updates and webViewProps() stays null forever. The console.error at construction is the only signal that the app is unprotected.

The passport stays in the WebView. Passing the challenge resolves the promise in your app, and that is all this package moves across the boundary. Any cookie the challenge page set lives in the WebView's incognito store, which is discarded, and nothing here copies it into your app's HTTP client. If your origin expects a passport cookie on subsequent calls, that plumbing is yours.

domain is used verbatim and never validated. It is reported as-is and interpolated into `https://${domain}/` for return_url. Passing https://api.example.com rather than api.example.com produces https://https://api.example.com/, which is accepted locally and useless on the platform.

The environment object is read at request time, not captured. collectSignals() reads it off the config object you handed to createRelintio() on every verdict call, so mutating that object later is picked up — and replacing it is not. Values that arrive asynchronously can be filled in on the same object; a new object will be ignored. Any field left undefined is dropped by JSON.stringify and simply does not appear on the wire.

Concurrent failures produce one challenge. Several requests failing at once join the same pending promise, so there is one modal rather than several racing to resolve the same user. The first challenge URL is the one shown; later ones are discarded.

An unsolved challenge rejects. Timing out past challengeTimeoutMs, or destroy() while one is open, rejects the promise, and a rejection means do not release whatever was being held. A disposed client refuses to arm a new challenge at all, so no two-minute timer outlives the tree that created it.

The success string is duplicated here as a literal. The core exports it as CHALLENGE_SUCCESS_MESSAGE; handleWebViewMessage compares against a copy of the string rather than importing the constant. It is correct today and it is the one place in this binding where a change to the shared protocol would not propagate on its own.

Every failure path fails open. An unreachable control plane, a non-2xx answer, an abort at five seconds, a body that will not parse: all of them return no verdict and the app carries on. A security agent that blanks a screen because it could not reach its own control plane has turned our outage into the customer's.

Links

Security reports go to support@relintio.com, not to a public issue.

License

MIT. See LICENSE.

About

Official Expo and React Native agent for Relintio. Native client and WebView challenge — npx expo install @relintio/expo-agent. Takes a publishable key, never a licence key.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages