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.

Maintainers

Package info

github.com/padosoft/laravel-iam-client

pkg:composer/padosoft/laravel-iam-client

Transparency log

Statistics

Installs: 196

Dependents: 1

Suggesters: 0

Stars: 1

Open Issues: 0

v1.6.0 2026-07-09 18:33 UTC

This package is auto-updated.

Last update: 2026-07-09 20:20:58 UTC


README

Laravel IAM

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.

Tests Latest Version on Packagist Total Downloads PHP Version License

📖 Read the documentation →

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's permission: 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.auth middleware — fail-closed guard that 401s any request without a resolvable subject.
  • Gate adapter — registers Gate::before so $user->can('billing:invoices.update'), @can Blade directives and authorize() 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; explain queries 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.
  • Iam facadeIam::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 and authorize() keep working; the answer just comes from the central PDP.
  • Per-resource (ReBAC) checks. iam.can:projects:edit,project binds 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 to mode=http when 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 https in production (fail-closed). Credentials and the Bearer-carrying decision call never travel over plain http://: a non-https IAM_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. Set IAM_CLIENT_ALLOW_INSECURE=true to 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 your client_id and 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 OAuth client_id + client_secret and 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.

License

MIT © Padosoft. See LICENSE.