padosoft / laravel-iam-client
Client Laravel per app che consumano Laravel IAM: login OIDC, verifica JWT/JWKS, introspection, middleware iam.auth/iam.can, Gate adapter, policy cache, webhook receiver.
Requires
- php: ^8.3
- guzzlehttp/guzzle: ^7.0
- lcobucci/jwt: ^5.0
- padosoft/laravel-iam-contracts: ^1.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- padosoft/laravel-iam-server: ^1.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
README
Laravel IAM — Client
The drop-in Laravel client for apps that delegate authorization to a Laravel IAM server.
iam.can / iam.auth middleware, a Gate adapter so $user->can() just works, decision caching — fail-closed by design.
Why this package
Laravel IAM centralizes who can do what into one Policy Decision Point
(PDP): RBAC + ABAC + ReBAC, step-up assurance, tenant isolation, tamper-evident audit. But your consuming
apps shouldn't have to learn any of that — they already speak Laravel: middleware, Gate, policies,
$user->can().
laravel-iam-client is the bridge. You point it at your IAM server, and authorization decisions flow
through the tools your app already uses:
Route::put('/invoices/{invoice}', [InvoiceController::class, 'update']) ->middleware('iam.can:billing:invoices.update,invoice'); // ← decided by the central PDP
It works in two modes with identical app code: local (the IAM server lives in the same app — the
client calls the PDP in-process, zero network) or http (remote server — the client calls the Admin API).
Swap a single env var to move from a modular monolith to a distributed deployment.
Fail-closed, always. Every transport (
LocalDecider,HttpDecider) denies on any error — unreachable PDP, non-2xx, unparseable body, engine exception. There is no fail-open opt-out: an outage never opens the doors.
Features
iam.can:<permission>middleware — a drop-in replacement for Spatie'spermission:middleware, decided by the central PDP. Bind a route parameter (iam.can:billing:invoices.update,invoice) and the decision is scoped to that resource — including through route-model binding.iam.authmiddleware — fail-closed guard that 401s any request without a resolvable subject.- Gate adapter — registers
Gate::beforeso$user->can('billing:invoices.update'),@canBlade directives andauthorize()in controllers all consult IAM. By default it only intercepts namespaced abilities (those containing:), leaving your local Gates/policies untouched. - Pluggable transports (
Decider) —LocalDecider(in-process PDP),HttpDecider(remote Admin API), both fully fail-closed. Your app code never knows which is in use. CachingDecider— decisions are deterministic per input, so they're cached for a short TTL;explainqueries are never cached.- Step-up aware — a permit that requires a higher assurance level (
requiresStepUp) is treated as not yet granted by middleware and the Gate adapter (IamDecision::granted()), so you can't accidentally let a low-AAL session through. Iamfacade —Iam::can($user, 'warehouse:stock.adjust', ['amount' => 300])for ABAC checks with context.
Use cases
- Protect routes against a central policy. Replace scattered role checks with
->middleware('iam.can:hr:salaries.view')— the rule lives in IAM, not in your app. - Keep using Laravel's authorization API.
@can,$user->can(), policies andauthorize()keep working; the answer just comes from the central PDP. - Per-resource (ReBAC) checks.
iam.can:projects:edit,projectbinds the decision to the bound{project}— "can this user edit this project", not the permission in the abstract. - Go from monolith to services without rewriting. Start with
mode=local(same app), flip tomode=httpwhen you extract the IAM server — the controllers don't change.
Installation
composer require padosoft/laravel-iam-client
Publish the config:
php artisan vendor:publish --tag=laravel-iam-client-config
Requirements: PHP 8.3+, Laravel 11/12+. Depends on
padosoft/laravel-iam-contracts.
Quick start
1. Configure the transport
config/iam-client.php (publish it, then set env):
# Remote IAM server (use mode=local if the server lives in this same app) IAM_CLIENT_MODE=http IAM_CLIENT_BASE_URL=https://iam.example.com/api/iam/v1 IAM_CLIENT_TOKEN=your-service-bearer-token # Defaults applied to every decision query IAM_CLIENT_APP=billing IAM_CLIENT_ORG=org_acme
That's it — the service provider wires the right decider (with caching) and registers the middleware aliases and the Gate adapter automatically.
Use
httpsin production (fail-closed). Credentials and the Bearer-carrying decision call never travel over plainhttp://: a non-httpsIAM_CLIENT_BASE_URL/IAM_CLIENT_OAUTH_URL(except loopback —localhost/127.0.0.1/::1) is denied rather than sending a secret or token in clear. SetIAM_CLIENT_ALLOW_INSECURE=trueto lift this for local development only.
Authentication modes (choose one)
The SDK authenticates to the PDP in one of three ways (checked in this order of precedence):
-
private_key_jwt— asymmetric, no shared secret (strongest): give the SDK yourclient_idand an ES256 private key; it signs a short-lived assertion per token request and exchanges it for an access token — nothing secret ever leaves your app, nothing to rotate. Register the matching public key (JWKS) in IAM. Takes precedence over everything else.IAM_CLIENT_ID=cli_billing IAM_CLIENT_PRIVATE_KEY=/secrets/iam-client.pem # ES256 PEM (file path or inline contents) IAM_CLIENT_PRIVATE_KEY_KID=k1 # kid of the registered public key IAM_CLIENT_OAUTH_URL=https://iam.example.com/oauth # optional; derived from base_url if omitted
Full guide: private_key_jwt.
-
Self-managed
client_credentials: give the SDK your OAuthclient_id+client_secretand it mints and refreshes the token itself, and — when IAM auto-rotates the secret — it self-fetches the new one (during the grace) and hot-swaps it, so the service never breaks on a rotation and you never touch a secret by hand.IAM_CLIENT_ID=cli_billing IAM_CLIENT_SECRET=the-secret-iam-issued # rotatable; the SDK follows rotations automatically IAM_CLIENT_OAUTH_URL=https://iam.example.com/oauth # optional; derived from base_url if omitted
The rotated secret is cached (in your cache store) on pickup; enable IAM's self-fetch endpoint server-side with
IAM_OAUTH_CLIENT_SELFFETCH=true. See Application credentials & lifecycle. -
Static token (default): you supply a service bearer token via
IAM_CLIENT_TOKEN, obtained out of band.
2. Protect routes with iam.can
use Illuminate\Support\Facades\Route; // Permission only Route::get('/reports', [ReportController::class, 'index']) ->middleware(['auth', 'iam.can:reports:view']); // Permission bound to a route resource (ReBAC): "can edit THIS invoice" Route::put('/invoices/{invoice}', [InvoiceController::class, 'update']) ->middleware(['auth', 'iam.can:billing:invoices.update,invoice']);
iam.auth ensures there's a resolvable subject (401 otherwise); iam.can denies with 403 when IAM says
no — or when a step-up is required but not yet satisfied.
3. Use the Gate adapter (your existing code keeps working)
// In a controller public function update(Request $request, Invoice $invoice) { $this->authorize('billing:invoices.update', $invoice); // → consults the central PDP // ... }
@can('reports:view') <a href="/reports">Reports</a> @endcan
4. Ask IAM directly with the facade
use Padosoft\Iam\Client\Facades\Iam; // ABAC: pass context facts; IAM evaluates the policy if (Iam::can($user, 'warehouse:stock.adjust', ['amount' => 300, 'resource' => 'wh_milan'])) { // approved } // Need the full decision (step-up, explanation)? $decision = Iam::check($user, 'billing:invoices.delete', ['explain' => true]); $decision->granted(); // permit AND no pending step-up $decision->requiresStepUp; // true → ask the user to re-authenticate at a higher AAL $decision->explanation; // why (when explain=true)
5. Declare your permissions/roles: push a manifest
Apps that don't use spatie/laravel-permission declare their permission catalog + roles in a manifest file (versioned in your repo — it is your source of truth). Push it to IAM whenever it changes:
# validate locally against the published schema (any JSON-schema tool works): # curl https://your-iam.example.com/.well-known/iam-manifest-schema.json php artisan iam:manifest:push resources/iam/manifest.json # app.key comes from the manifest php artisan iam:manifest:push resources/iam/manifest.json --app=warehouse
It submits to IAM's Admin API (authenticated with this client's own bearer — the token needs
iam:manifests.submit). IAM diffs it: additive changes apply, a removal is gated for approval in the
console and the removed role/permission is deprecated (kept for history, disabled), never deleted. Run it
in CI on deploy for hands-off sync. See
Keeping IAM in sync.
How it fits the ecosystem
| Package | Role |
|---|---|
| laravel-iam-contracts | Shared interfaces & DTOs — the dependency root |
| laravel-iam-server | The IAM server: identity, PDP, OAuth/OIDC, audit, governance, Admin API & panel |
| laravel-iam-client (this repo) | Consumer SDK: iam.can/iam.auth middleware, Gate adapter, decision caching |
| laravel-iam-ai | Optional AI module: advisory-only governance (redaction + hallucination guard + audit) |
| laravel-iam-directory | Optional directory module: LDAP / Active Directory (LdapRecord); SCIM in v2 |
| laravel-iam-bridge-spatie-permission | Migration bridge from spatie/laravel-permission: scan, shadow mode, decision diffing, cutover |
Documentation
Full documentation is published at doc.laravel-iam-client.padosoft.com —
quickstart, guides (iam.can, the Gate adapter, the facade, transports, ReBAC, step-up, caching), the
fail-closed theory, architecture + ADRs, and a complete PHP/config reference. The source for that site lives
in docs-site/; a lightweight in-repo copy is under docs/.
Security
This client is fail-closed by design: any transport error, unreachable PDP, non-2xx response or engine exception resolves to deny — never an allow, never an opaque 500. Step-up-required permits are treated as not-yet-granted. There is no fail-open switch. If you discover a security issue, please email security@padosoft.com rather than opening a public issue.
