@padosoft/laravel-iam-react-native
v1.2.0
Published
Thin, fail-closed React Native client and hooks for the Laravel IAM control plane.
Readme
@padosoft/laravel-iam-react-native
Thin, fail-closed React Native client and hooks for the Laravel IAM control plane.
Ask the IAM server "can this user do this?" from your React Native (or React) app — with the exact same wire contract and guarantees as the PHP and Node clients, plus React hooks that stay fail-closed while loading.
📚 Full documentation: doc.laravel-iam-react-native.padosoft.com — quickstart, hook lifecycle, fail-closed theory, the Hermes/Web Crypto caveat, ADRs, and the full API reference.
Why
- Fail-closed by construction. Network error, timeout, 5xx, 4xx, malformed body, or unverifiable token resolves to deny. Always. Loading resolves to deny. No fail-open switch.
- No PDP logic client-side. Every verdict comes from the server's
decisions/check. The client never interprets grants. - React Native safe. No
node:crypto, no Node built-ins. Uses the RN fetch polyfill andjose(Web Crypto /globalThis.crypto.subtle). - Zero runtime coupling to the Node SDK. Wire types are imported from
@padosoft/laravel-iam-nodewithimport type— completely erased at build time. No Node SDK runtime code runs in your app. - First-class React hooks.
usePermissionanduseCanintegrate with React context — check a permission in one line.
Install
npm install @padosoft/laravel-iam-react-nativeRequires React Native >= 0.71 (Hermes with
globalThis.crypto.subtle) forverifyToken. Forcheck()/ hooks only, any RN version withfetchworks.
Quick start
1. Configure the client and provider
import { IamClient, IamProvider } from '@padosoft/laravel-iam-react-native';
const iam = new IamClient({
baseUrl: 'https://iam.example.com/api/iam/v1',
token: process.env.IAM_SERVICE_TOKEN,
timeoutMs: 2000,
cache: { ttlMs: 5000 },
});
export default function App() {
const userId = useAuth().userId;
return (
<IamProvider client={iam} subject={{ type: 'user', id: userId }}>
<Navigation />
</IamProvider>
);
}2. Check permissions with hooks
import { usePermission } from '@padosoft/laravel-iam-react-native';
function StockAdjustButton({ warehouseId }: { warehouseId: string }) {
const { allowed, loading } = usePermission(
'stock.adjust',
{ type: 'warehouse', id: warehouseId },
);
if (loading) return <ActivityIndicator />;
if (!allowed) return null;
return <Button title="Adjust stock" onPress={handleAdjust} />;
}3. Or use the client imperatively
import { IamClient } from '@padosoft/laravel-iam-react-native';
const decision = await iam.check({
subject: { type: 'user', id: 'usr_123' },
application: 'warehouse',
permission: 'stock.adjust',
resource: { type: 'warehouse', id: 'wh_milan' },
context: { amount: 300 },
});
if (!decision.allowed) throw new Error('Forbidden');
if (decision.requiresStepUp) promptStepUp(decision.requiredAal);Fail-closed: read this
allowed === true alone is not permission. When requiresStepUp is true, the action is only permitted at a higher AAL. The hooks apply isGranted() automatically — they only set allowed: true when the PDP allowed and no step-up is pending.
Provider & Hooks API
<IamProvider client={...} subject={...}>
| Prop | Type | Description |
|------|------|-------------|
| client | IamClient | Pre-configured client instance. |
| subject | Subject | The authenticated user. Hooks use it automatically. |
| children | ReactNode | Your app tree. |
useIam()
Returns { client, subject } from the nearest IamProvider. Throws if no provider is found.
useCan(query: DecisionQuery): PermissionState
Full-control hook — accepts a complete DecisionQuery.
const { allowed, loading, requiresStepUp } = useCan({
subject: { type: 'user', id: userId },
permission: 'doc.publish',
resource: { type: 'document', id: docId },
});useDelegatedPermission(actors, permission, resource?, extra?): PermissionState
Like usePermission, but asks whether an agent may do it on behalf of the provider's subject. See Delegated access.
usePermission(permission, resource?, extra?): PermissionState
Convenience hook — reads subject from context, you supply permission and optionally resource.
const { allowed } = usePermission('orders.approve', { type: 'order', id: orderId });PermissionState
| Field | Type | Description |
|-------|------|-------------|
| allowed | boolean | true only when PDP granted AND no step-up pending. false while loading. |
| loading | boolean | true while the check is in flight. |
| requiresStepUp | boolean | true if a higher AAL is required. |
Client API
new IamClient(config)
| Option | Default | Description |
|--------|---------|-------------|
| baseUrl | required | Full API base, e.g. https://iam.example.com/api/iam/v1. |
| token | — | The user's access token, sent as Authorization: Bearer. |
| timeoutMs | 2000 | Per-request timeout in ms. |
| retries | 0 | Retries for idempotent network errors (never on 4xx/5xx). |
| cache | off | { ttlMs, maxEntries? } in-memory decision cache. Delegated decisions bypass it by design. |
| checkDelegatedPath | decisions/check-delegated | Path for the delegated PDP check. |
| verify | — | { issuer?, audience?, jwksUri? } defaults for verifyToken. |
| fetch | globalThis.fetch | Inject a custom fetch (tests, proxies). |
Credential model — this is a PUBLIC client (no shared secret)
Unlike the server SDKs (laravel-iam-client, laravel-iam-node, laravel-iam-rust), a mobile app is a
public OAuth client: it must never embed a client_secret (anything shipped in an app binary is
extractable). So the client-secret rotation / self-fetch feature does not apply here — there is no
secret to rotate.
Instead, obtain the token through the Authorization Code + PKCE flow (the user logs in against IAM),
keep it short-lived, and refresh it with the refresh token — never with a static secret. Pass the
current user access token as token; when it expires, run the refresh/re-auth flow and update the client.
See Application credentials & lifecycle
(§ public clients).
check(query): Promise<Decision>
POST {baseUrl}/decisions/check. Returns a normalised Decision. Never throws.
can(query): Promise<boolean>
check() reduced to the fail-safe boolean.
listResources(subject, relation): Promise<Resource[]>
ReBAC list-resources. Returns [] on any error.
verifyToken(jwt, options?): Promise<Claims>
Verifies an ES256 token against the server JWKS. Rejects with TokenVerificationError on any failure — including a valid delegated token, see below.
Delegated access (agents acting for the user)
When an AI agent acts on behalf of a user, the token carries two identities: sub is the user, act is the agent (nested outermost-first when the chain is longer than one hop — RFC 8693 §4.1). The verdict is the strict intersection of what the user may do and what every actor may do — never the union. Adding a hop can only narrow authority.
verifyToken now REFUSES a delegated token
This is the part that matters most on a mobile client, and it is worth being blunt about. A delegated token has a real signature, the right issuer and audience, and a sub naming the user — so it verifies perfectly. Returning its claims would hand your app the user's full authority while silently discarding the bound scope of the agent that actually holds the token. That is the confused deputy, on-device.
So verifyToken rejects it, and says why. A malformed act is rejected just as firmly as a well-formed one: "unreadable" must never quietly become "not delegated".
There is deliberately no verifyDelegatedToken here
Delegated tokens are introspection-mandatory: only the server can confirm the delegation is still live (the grant not revoked, the user's session not ended), and RFC 7662 introspection requires an authenticated caller. A mobile app is a public client — it holds no secret to authenticate with, and shipping one would publish it (see the credential-model note above). So this SDK does not pretend to verify delegated tokens.
If your app receives one, hand it to your backend, which holds the credentials, does the introspection, and returns a plain answer. That is the same split the mobile security rules require of every AI feature: the device never holds the key, and the server re-validates.
checkDelegated / canDelegated / useDelegatedPermission
What the app can do is ask the PDP — which is exactly what you need to drive UI about agents: a consent screen previewing what an agent would be able to do, a "your agents" list, a button disabled because an agent cannot take that action for you.
import { useDelegatedPermission } from '@padosoft/laravel-iam-react-native';
function AgentDraftButton({ actors }: { actors: string[] }) {
// actors = ['agent:assistant'] — CURRENT actor first
const { allowed, loading } = useDelegatedPermission(actors, 'orders.draft', {
type: 'order',
id: orderId,
});
return <Button disabled={!allowed || loading} title="Let the assistant draft it" />;
}Fail-closed exactly like usePermission: denied while loading, denied on any error, and denied without a network call when there is no subject or the actor chain is empty. An empty chain is never a fall-back to the plain user check — that would answer a question about the user when you asked about an agent.
The imperative forms are client.checkDelegated(subject, actors, permission, options?) and canDelegated(...).
Delegated decisions are never cached
Even with the cache enabled. A grant can be revoked at any moment, and a cached delegated allow would outlive the revocation meant to stop it. Plain checks cache exactly as before.
Reading the chain for display
inspectDelegatedBearer(jwt) parses a token locally — no Buffer, no node:crypto, UTF-8 safe — and returns { sub, actors, grantId, scopes }, or null when the token is not delegated. It is display and routing information, never authorization: use it to render "Agent X, acting for you", then let the PDP decide. A malformed act throws MalformedDelegationError rather than degrading.
Requires laravel-iam-agents on the server.
Managing permissions/roles? Not here — this SDK is a consumer
This package consumes decisions (it asks "can this subject do X?"); it does not own a permission catalog and does not declare or push manifests. A mobile/public client has no server-side catalog to sync. Permissions and roles are declared by the service that owns them (a Laravel app with the bridge, or a Node/Rust service, or the console) and synced there. See Keeping IAM in sync.
Ecosystem
| Package | Runtime | Description | Docs |
|---------|---------|-------------|------|
| laravel-iam-server | PHP/Laravel | The IAM server — the PDP itself (RBAC + ABAC + ReBAC, OAuth/OIDC, audit) | docs |
| @padosoft/laravel-iam-node | Node 18+ | Core TypeScript/Node SDK — this package builds on its wire types | docs |
| @padosoft/laravel-iam-react-native | React Native / React | This package | docs |
| padosoft/laravel-iam-client | PHP 8.1+ | PHP client — the wire-contract reference | docs |
| laravel-iam-rust | Rust | Rust client SDK (crate laravel-iam), async + blocking | docs |
License
MIT (c) Padosoft
