A thin, fail-closed Rust client for the Laravel IAM authorization server. It asks the control plane for policy decisions and verifies OIDC tokens — and it is built so that a gate cannot accidentally open.
📖 Documentation: doc.laravel-iam-rust.padosoft.com · Crate: crates.io/crates/laravel-iam · API docs: docs.rs/laravel-iam
Same wire contract as the production PHP client (
Padosoft\Iam\Client), different language. No policy logic lives on the client: every decision belongs to the server.
Authorization clients fail in the worst possible way when they fail open — a timeout or a 500 quietly becomes "allow". This SDK makes that impossible by construction:
- A network error, timeout, 5xx, 4xx, malformed body or unverifiable token always maps to deny.
- There is no fail-open switch. (If you need to tolerate an outage, do it deliberately at the application layer — never silently in the transport.)
- An
alloweddecision that stillrequires_step_upis treated as not yet allowed.
[dependencies]
laravel-iam = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }Enable the synchronous client with the blocking feature:
laravel-iam = { version = "1", features = ["blocking"] }use std::time::Duration;
use laravel_iam::{IamClient, DecisionQuery, Subject, ResultExt};
use serde_json::json;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let iam = IamClient::builder()
.base_url("https://iam.example.com/api/iam/v1")
.token(std::env::var("IAM_SERVICE_TOKEN")?) // Client Credentials service token
.timeout(Duration::from_secs(2))
.build()?;
let decision = iam.check(DecisionQuery {
subject: Subject::user("usr_123"),
application: Some("warehouse".into()),
permission: "stock.adjust".into(),
resource: Some("wh_milan".into()),
context: json!({ "amount": 300 }),
..Default::default()
}).await;
// `decision` is `Result<Decision, IamError>`.
// On ANY error — network, timeout, 5xx, 4xx, malformed — this is `false`.
if !decision.is_allowed() {
return Err("forbidden".into()); // fail-closed
}
Ok(())
}check() returns Result<Decision, IamError>. The [ResultExt::is_allowed] helper is the safe
gate: it is true only when the call succeeded and the decision is truly granted (allowed
with no pending step-up). Every other outcome — including every error variant — is false.
# use laravel_iam::ResultExt;
# async fn demo(iam: laravel_iam::IamClient, q: laravel_iam::DecisionQuery) {
if iam.check(q).await.is_allowed() {
// allow — the ONLY path that reaches here is an explicit server permit
} else {
// deny — network error, timeout, 4xx/5xx, malformed body, or an explicit server deny
}
# }If you need the details (auditing, step-up flows), inspect the Ok(Decision):
# use laravel_iam::{Decision, IamError};
# fn demo(decision: Result<Decision, IamError>) {
match decision {
Ok(d) if d.granted() => { /* allow */ }
Ok(d) if d.requires_step_up => { /* prompt step-up to d.required_aal */ }
Ok(_) => { /* explicit deny */ }
Err(_) => { /* transport/parse failure → deny */ }
}
# }verify_token validates an ES256 signature against the server's JWKS
({base_url}/.well-known/jwks.json, cached) plus the iss / aud / exp / nbf claims. Configure
the expected issuer and audience on the builder:
# use std::error::Error;
# fn demo() -> Result<(), Box<dyn Error>> {
use laravel_iam::IamClient;
let iam = IamClient::builder()
.base_url("https://iam.example.com/api/iam/v1")
.issuer("https://iam.example.com")
.audience("warehouse-api")
.build()?;
# let _ = iam;
# Ok(())
# }# async fn demo(iam: laravel_iam::IamClient, jwt: &str) {
match iam.verify_token(jwt).await {
Ok(claims) => { /* trusted: claims.sub, claims.iss, ... */ }
Err(_) => { /* reject — bad signature, expired, wrong aud/iss, unknown key, ... */ }
}
# }| Method | Description |
|---|---|
IamClient::builder() |
base_url, token or client_id+client_secret (+oauth_url), timeout (default 2s), issuer, audience → build() / build_blocking() |
Authenticate to the PDP in one of three ways (highest precedence first):
-
private_key_jwt— asymmetric, no shared secret (strongest): pass.client_id(...)and.private_key(pem)(an ES256 PKCS#8 PEM,.private_key_kid(...)optional). The client signs a short-lived assertion per token request instead of sending a secret. Register the matching public key (JWKS) in IAM. Both the async andblockingclients support it.let iam = IamClient::builder() .base_url("https://iam.example.com/api/iam/v1") .client_id("cli_warehouse") .private_key(std::fs::read_to_string("iam-client.pem")?) // ES256 PKCS#8 PEM .private_key_kid("k1") .build()?;
Full guide: private_key_jwt.
-
Static token (
.token(...)): a service bearer obtained out of band. -
Self-managed
client_credentials(recommended for long-lived services): pass.client_id(...)and.client_secret(...). The client mints and refreshes its own access token, and when IAM auto-rotates the secret it self-fetches the new one (during the grace) and hot-swaps it — the service never breaks on a rotation and no secret is handled by hand. Takes precedence overtoken.
let iam = IamClient::builder()
.base_url("https://iam.example.com/api/iam/v1")
.client_id("cli_warehouse")
.client_secret(std::env::var("IAM_CLIENT_SECRET")?) // rotatable; followed automatically
// .oauth_url("https://iam.example.com/oauth") // optional; derived from base_url if omitted
.build()?;Enable the self-fetch endpoint server-side with IAM_OAUTH_CLIENT_SELFFETCH=true. Both the async and
blocking clients support it. See
Application credentials & lifecycle.
| check(DecisionQuery) -> Result<Decision, IamError> | POST {base_url}/decisions/check |
| list_resources(Subject, relation) -> Result<Vec<Resource>, IamError> | POST {base_url}/decisions/list-resources |
| submit_manifest(Option<&str>, &Value) -> Result<Value, IamError> | POST {base_url}/applications/{app}/manifests |
| verify_token(jwt) -> Result<Claims, IamError> | ES256 + iss/aud/exp against the cached JWKS |
| validate_manifest(&Value) -> ManifestValidation | local check vs laravel-iam.manifest.v2 (no network) |
| Decision::granted() / is_allowed() | allowed and no pending step-up |
| Result::is_allowed() (via ResultExt) | the fail-closed gate: any error ⇒ false |
A service that owns a permission catalog declares it in a manifest (a versioned file — the source of
truth) and pushes it to IAM. validate_manifest checks it locally (mirrors the server + the published schema
at /.well-known/iam-manifest-schema.json); submit_manifest pushes it (bearer needs iam:manifests.submit).
let manifest: serde_json::Value = serde_json::from_str(&std::fs::read_to_string("iam.manifest.json")?)?;
let v = laravel_iam::validate_manifest(&manifest);
if !v.valid { return Err(format!("invalid manifest: {}", v.errors.join("; ")).into()); }
// IAM diffs it: additive changes apply, a removal is gated for approval and DEPRECATED (kept, disabled).
let result = iam.submit_manifest(None, &manifest).await?; // app.key comes from the manifestSee Keeping IAM in sync.
All variants of IamError (Network, Timeout, Unauthorized, Http, Malformed,
TokenInvalid, Config) mean "could not obtain a permit" and must be treated as deny — which
ResultExt::is_allowed does for you.
This client mirrors the PHP HttpDecider exactly:
- Endpoint:
POST {base_url}/decisions/check - Headers:
Accept: application/json,Authorization: Bearer <service token> - Request body:
{ subject: {type, id}, permission, organization, application, resource, context, current_aal, explain } - Response:
{ allowed, decision_id, policy_version, requires_step_up, required_aal, explanation }, parsed defensively (any wrong-typed field falls back to its safe default; a non-object body is a deny).
This SDK is one client in the Laravel IAM suite. The server is the policy authority; the SDKs and clients only transport the question and the answer.
| Package | Role |
|---|---|
laravel-iam-server |
The IAM server: identity, org, Application Registry, PDP (RBAC+ABAC+ReBAC), OAuth/OIDC, tamper-evident audit, IGA, Admin API. |
laravel-iam-contracts |
Shared contracts/interfaces + DTOs. |
laravel-iam-client |
Laravel client for consumer apps (OIDC login, JWT/JWKS verify, middleware, Gate adapter). |
laravel-iam-node |
Node/TS SDK (@padosoft/laravel-iam-node), thin + fail-closed. |
laravel-iam-react-native |
React Native SDK (@padosoft/laravel-iam-react-native). |
laravel-iam-rust |
This package — Rust SDK (crate laravel-iam), async + blocking, fail-closed. |
All SDKs and the PHP client speak the same wire contract. Full docs: doc.laravel-iam-rust.padosoft.com.
MIT © Padosoft. See LICENSE.