Skip to content

@last1id/sdk-react-native

React Native / Expo companion to @last1id/sdk. Wraps the boilerplate around:

  • PKCE flow via expo-web-browser
  • Refresh token persistence via expo-secure-store (iOS Keychain / Android Keystore)
  • Auto-refresh with rotated tokens written back to secure storage transparently

The base SDK handles the OAuth/OIDC mechanics. This package handles the mobile-specific glue.

Install

npm install @last1id/sdk @last1id/sdk-react-native expo-secure-store expo-web-browser

expo-secure-store and expo-web-browser are peer dependencies — pass them in, the companion doesn't bundle them. Non-Expo React Native projects can wire react-native-keychain and react-native-inappbrowser-reborn by implementing the SecureTokenStore interface (see below).

Quick start

// app/_layout.tsx or wherever you initialize Last1
import * as SecureStore from "expo-secure-store";
import * as WebBrowser from "expo-web-browser";
import {
  createExpoSecureStoreAdapter,
  loadSecureLast1Client,
  beginPkceFlow,
  completePkceFlow,
  clearSecureLast1Tokens,
} from "@last1id/sdk-react-native";

const ISSUER = "https://auth.last1.id";
const CLIENT_ID = "radiocheck"; // App ID slug; Copy Client ID (UUID) also works
const REDIRECT_URI = "radiocheck://last1-id/callback";

const storage = createExpoSecureStoreAdapter(SecureStore);

// On app boot — restore any prior session.
const session = await loadSecureLast1Client({
  issuerUrl: ISSUER,
  clientId: CLIENT_ID,
  storage,
});

if (session) {
  // Already linked. Use session.client for partner-read calls:
  const creds = await session.client.listCredentials();
  // ... refresh happens automatically + persists transparently
}

Linking a new user

async function linkLast1() {
  // 1. Build authorize URL + PKCE pair.
  const flow = await beginPkceFlow({
    issuerUrl: ISSUER,
    clientId: CLIENT_ID,
    redirectUri: REDIRECT_URI,
    scopes: ["openid", "profile", "credentials:read"],
    // offline_access is added automatically — it's required for a refresh token.
  });

  // 2. Open the auth session (ASWebAuthenticationSession on iOS).
  const result = await WebBrowser.openAuthSessionAsync(flow.authorizeUrl, REDIRECT_URI);
  if (result.type !== "success") return;

  const callbackUrl = new URL(result.url);
  const code = callbackUrl.searchParams.get("code");
  const returnedState = callbackUrl.searchParams.get("state");

  // 3. Verify state to prevent CSRF.
  if (returnedState !== flow.state) throw new Error("OAuth state mismatch");
  if (!code) throw new Error("OAuth callback missing code");

  // 4. Exchange the code for tokens, persist to Keychain, return primed client.
  const { client, identityId } = await completePkceFlow({
    issuerUrl: ISSUER,
    clientId: CLIENT_ID,
    redirectUri: REDIRECT_URI,
    code,
    codeVerifier: flow.codeVerifier,
    storage,
  });

  // 5. Profit. The user is linked. From here on, just call client methods.
  const me = await client.fetchUserinfo();
  console.log("Linked", { identityId, me });
}

Unlinking / logout

async function unlinkLast1() {
  // Optionally revoke server-side first (RFC 7009):
  // (the base SDK ships revokeToken() — call it before clearing storage)

  await clearSecureLast1Tokens({ storage });
  // The next loadSecureLast1Client() call will return null.
}

Error handling

import { Last1AuthError, Last1HttpError } from "@last1id/sdk-react-native";

try {
  const creds = await session.client.listCredentials();
} catch (err) {
  if (err instanceof Last1AuthError) {
    // Refresh chain dead — user revoked consent or token was rotated
    // out from under us. Clear local tokens and route to the
    // re-link CTA.
    await clearSecureLast1Tokens({ storage });
    return router.replace("/link-last1");
  }
  if (err instanceof Last1HttpError && err.status === 403 && err.code === "insufficient_scope") {
    // User granted credentials:read but not, say, trust_events:read.
    // Surface a "re-link with extra permissions" flow.
  }
  // Any other error: transient. Show a retry.
  throw err;
}
Error Meaning Recovery
Last1AuthError Refresh chain dead Clear tokens + re-link
Last1HttpError 403 insufficient_scope User didn't grant a required scope Re-link with extra scopes
Last1HttpError 404 (on getCredential) Credential not found null is returned, not thrown
Network / 5xx Transient upstream issue Retry

Custom storage adapter

Don't use Expo? Implement SecureTokenStore:

import * as Keychain from "react-native-keychain";
import type { SecureTokenStore } from "@last1id/sdk-react-native";

const keychainAdapter: SecureTokenStore = {
  async getItem(key) {
    const result = await Keychain.getInternetCredentials(key);
    return result ? result.password : null;
  },
  async setItem(key, value) {
    await Keychain.setInternetCredentials(key, key, value, {
      accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
    });
  },
  async deleteItem(key) {
    await Keychain.resetInternetCredentials(key);
  },
};

In-memory storage (tests / "don't remember me")

import { createMemoryTokenStore } from "@last1id/sdk-react-native";

const storage = createMemoryTokenStore();
// or seed:
const storage = createMemoryTokenStore({ "last1.id.refreshToken": "RT-1" });

Multi-tenant isolation

If your app talks to two Last1 deployments (e.g. staging + prod side-by-side), use distinct prefixes:

const stagingSession = await loadSecureLast1Client({
  issuerUrl: STAGING_URL,
  clientId: STAGING_CLIENT,
  storage,
  secureStorageKeyPrefix: "last1.staging",
});

const prodSession = await loadSecureLast1Client({
  issuerUrl: PROD_URL,
  clientId: PROD_CLIENT,
  storage,
  secureStorageKeyPrefix: "last1.prod",
});

Security notes

  • Refresh tokens never live in AsyncStorage. AsyncStorage is plaintext on disk. The default adapter writes to WHEN_UNLOCKED_THIS_DEVICE_ONLY Keychain on iOS — survives reboots but only readable when the device is unlocked.
  • Backup behavior: WHEN_UNLOCKED_THIS_DEVICE_ONLY is NOT included in iCloud Keychain backup, so a stolen iCloud password doesn't give an attacker your users' Last1 refresh tokens.
  • No biometric gate by default. Backgrounded silent refreshes would otherwise demand FaceID, which is the wrong UX. Layer biometric gating at the call site if you need it for high-value operations.
  • The onAuthError hook never throws. Persist failures are logged via the hook (if you wire one) but never break the outer request — the in-memory token pair is still valid for the current session.

Migration: RadioCheck-style bridge → full PKCE

G3 closed the C2 hole: last1.id no longer puts GoTrue access_token / refresh_token on the partner redirect hash. The bridge still returns identity_id until RadioCheck and MATR cut over. New builds must use this package:

  1. Register your redirectUri on the app_clients row in Last1 ID Integrations (exact match).
  2. Swap connectToLast1Id() for beginPkceFlow() + WebBrowser.openAuthSessionAsync() + completePkceFlow().
  3. Replace /api/identity/* reads with client.fetchUserinfo() / client.listCredentials() / etc.

The on-screen UX doesn't have to change. After both partners ship PKCE, /authorize?app= and authorize/complete retire.

Versioning

@last1id/sdk-react-native tracks the base @last1id/sdk major version. ^0.1.0 requires @last1id/sdk@^0.3.0.

License

MIT