@last1id/sdk-react-native
v0.1.0
Published
React Native / Expo companion for @last1id/sdk: PKCE flow via expo-web-browser, refresh tokens persisted to expo-secure-store (or any injected secure store)
Maintainers
Readme
@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-browserexpo-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://last1.id";
const CLIENT_ID = "radiocheck";
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_ONLYKeychain on iOS — survives reboots but only readable when the device is unlocked. - Backup behavior:
WHEN_UNLOCKED_THIS_DEVICE_ONLYis 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
onAuthErrorhook 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
If your app currently uses last1.id's /api/identity/authorize/complete bridge endpoint (returns just identity_id, no OAuth tokens), the migration to this package looks like:
- Register your
redirectUrion theapp_clientsrow at last1.id. - Swap
connectToLast1Id()forbeginPkceFlow()+WebBrowser.openAuthSessionAsync()+completePkceFlow(). - Replace
/api/identity/*reads withclient.fetchUserinfo()/client.listCredentials()/ etc.
The on-screen UX doesn't have to change.
Versioning
@last1id/sdk-react-native tracks the base @last1id/sdk major version. ^0.1.0 requires @last1id/sdk@^0.2.0.
License
MIT
