Search by

iocloud / laravel-sdk

IbaaIbrahim

Laravel SDK for managing IOCloud partner spaces

v0.7.2 2026-10-25 10:26 UTC

This package is auto-updated.

Last update: 2026-10-25 10:34:07 UTC


README

comlposer require iocloud/laravel-sdk

Publish the configuration if it needs customization:

php artisan vendor:publish --tag=iocloud-config

Configure the client:

IOCLOUD_BASE_URL=https://api.example.com
IOCLOUD_CLIENT_ID=partner-client-id
IOCLOUD_CLIENT_SECRET=partner-client-secret

The package is auto-discovered by Laravel. Resolve the client with dependency injection or use the facade:

use IOCloud\Laravel\Facades\IOCloud;

$tenant = IOCloud::createTenant(
    applicationUuid: '11111111-1111-1111-1111-111111111111',
    name: 'Acme workspace',
    contactEmail: 'ops@acme.example', // optional
    externalId: 'acme-tenant-1', // optional: the tenant claim your tokens carry
);

The platform generates the tenant's slug from its name and returns it as $tenant->slug (acme-workspace-5d3c1a7e, say), so createTenant() takes none. Its third parameter, $slug, remains only to refuse a positional call written for 0.5.0, which would otherwise shift its contact email and external id: any value throws InvalidArgumentException, and the slot goes in 1.0. Pass the other arguments by name. $tenant->contactEmail is null for a tenant created without one.

Partner and tenant tokens are cached until shortly before expiration. A 401 causes one token refresh and retry. API failures throw IOCloudAPIException; authentication failures throw IOCloudAuthenticationException.

Standing context

Standing context is the short instructions and memories the assistant is told in every conversation: a workspace's, which every user of the tenant gets, and one user's personal entries, on top of them. Leave userUuid out to manage the workspace's; pass it to manage that user's. All five calls use the partner token; pass their arguments by name:

use IOCloud\Laravel\Exceptions\IOCloudAPIException;
use IOCloud\Laravel\Facades\IOCloud;

$entry = IOCloud::createStandingEntry(
    tenantUuid: $tenant->uuid,
    kind: 'instruction',                // or 'memory'
    body: 'Answer in formal German.',
    expiresOn: '2026-12-31',            // optional: the last day it applies, UTC
);
$note = IOCloud::createStandingEntry(
    tenantUuid: $tenant->uuid,
    kind: 'memory',
    body: 'Manages the Hamburg site.',
    userUuid: $user->uuid,
);

$entries = IOCloud::listStandingEntries(tenantUuid: $tenant->uuid);
$mine = IOCloud::listStandingEntries(
    tenantUuid: $tenant->uuid,
    userUuid: $user->uuid,
    includeRetired: true,
);
$entry = IOCloud::getStandingEntry(tenantUuid: $tenant->uuid, entryUuid: $entry->uuid);

IOCloud::updateStandingEntry(tenantUuid: $tenant->uuid, entryUuid: $entry->uuid, body: 'Answer in formal English.');
IOCloud::updateStandingEntry(tenantUuid: $tenant->uuid, entryUuid: $entry->uuid, clearExpiresOn: true);
IOCloud::updateStandingEntry(tenantUuid: $tenant->uuid, entryUuid: $entry->uuid, status: 'retired');
IOCloud::deleteStandingEntry(tenantUuid: $tenant->uuid, entryUuid: $note->uuid, userUuid: $user->uuid);

updateStandingEntry() changes only what you pass, and a null argument is left unchanged, as in the package's other update calls. So a null $expiresOn keeps the expiry, and clearing it — the entry then never expires — takes clearExpiresOn: true; passing a date with it throws InvalidArgumentException before any request. $expiresOn takes a 'Y-m-d' string or a DateTimeInterface, sent as its Y-m-d as it stands.

Each result is a StandingEntry data object: uuid, scope ('tenant' or 'user'), kind, body, status ('active' or 'retired', with isActive()), expiresOn ('Y-m-d' — a calendar day, so a string — or null), expired, source ('agent' when the assistant saved it, 'api' otherwise), sourceJobUuid, createdAt and updatedAt. A listing includes expired entries, flagged expired, and retired ones only with includeRetired: true; it reads 100 entries a request and stops after 50 pages.

The platform enforces its limits for each owner — its active, unexpired instructions together at most 4,000 characters, one memory at most 300, at most 50 active, unexpired memories — and refuses with an IOCloudAPIException whose $reason says why:

try {
    IOCloud::createStandingEntry(tenantUuid: $tenant->uuid, kind: 'memory', body: $text);
} catch (IOCloudAPIException $exception) {
    $exception->errorCode;  // 'STANDING_CONTEXT_FULL' (409) or 'STANDING_ENTRY_INVALID' (422), …
    $exception->reason;     // e.g. 'STANDING_ENTRY_TOO_LONG'; null when the response has none
    $exception->fields;     // a VALIDATION_ERROR's invalid fields; [] otherwise
}

STANDING_ENTRY_INVALID's reason is one of STANDING_ENTRY_EMPTY, STANDING_ENTRY_TOO_LONG, STANDING_ENTRY_INVALID_EXPIRY and STANDING_ENTRY_NO_CHANGES. A 404 is STANDING_ENTRY_NOT_FOUND, TENANT_NOT_FOUND (not one of your tenants) or USER_NOT_FOUND (not a user of that tenant). See Standing context.

Federation: signing your users into IOCloud

Federating means your application acts as a small OIDC issuer — it signs a short-lived JWT for each user who logs in, and publishes the matching public key so IOCloud can verify it. The SDK owns all the cryptography; what is left is one artisan command, a little configuration, and two one-line calls:

// A route of your choosing publishes the public keys.
Route::get('/.well-known/jwks.json', fn () => IOCloud::jwks());

// The endpoint your frontend's chat client calls signs a subject token; the
// chat client exchanges it with IOCloud itself.
$subjectToken = IOCloud::federatedLogin(subject: $user->id, externalTenantId: $user->tenant_id);

1. Generate the signing keypair

Same shape as passport:keys:

php artisan iocloud:keys

Writes the pair into storage/:

storage/iocloud-federation-private.key   chmod 0600 — the secret
storage/iocloud-federation-public.key    chmod 0644 — for reference

and prints the key id, the JWKS URL to serve, the jwks_path to register, and the public key set. It refuses to overwrite existing keys without --force. Use --show to print the private key for a secret manager instead of writing it, or --path= to put it elsewhere.

The private key is the source of truth — the JWKS is derived from it, so the public file only exists to inspect or hand to another tool.

The key id is the RFC 7638 thumbprint of the key itself, so it stays stable across restarts and deployments — a published JWKS and a signed token always agree on it.

2. Configure the issuer

# The URL IOCloud can reach your app on. Must match the registered issuer byte
# for byte: scheme, host, port, no trailing slash.
IOCLOUD_FEDERATION_ISSUER=https://portal.acme.example
IOCLOUD_FEDERATION_AUDIENCE=ai-ecosystem
IOCLOUD_FEDERATION_TOKEN_TTL=300

Keys can also come from a PEM string via IOCLOUD_FEDERATION_PRIVATE_KEY, which takes precedence over the path.

3. Publish the JWKS from your own route

IOCloud::jwks() returns the document. One line in routes/web.php:

use IOCloud\Laravel\Facades\IOCloud;

Route::get('/.well-known/jwks.json', fn () => IOCloud::jwks());

Laravel serializes the returned array as JSON. The path is yours, on the issuer's origin and under /.well-known/ — it only has to match the jwks_path you register below — so wrap it in whatever middleware, caching, or rate limiting you use for public endpoints.

// GET /.well-known/jwks.json
{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "kid": "Z8uGuexwoImnwxMB4E86A4vRhCSmQJR21rLH9jhG9Gw",
      "n": "vFj4wRAEcZaUUyFro_pZSzLU…",
      "e": "AQAB"
    }
  ]
}

Public key material only — safe to serve publicly and to cache.

Two alternatives if you would rather not write that line:

// Set iocloud.federation.jwks_route (or IOCLOUD_FEDERATION_JWKS_ROUTE) and the
// package registers the route itself, named `iocloud.federation.jwks`.

// Or route the ready-made controller, which adds a Cache-Control header:
Route::get('/.well-known/jwks.json', IOCloud\Laravel\Http\Controllers\JwksController::class);

IOCloud::federationDetails() returns the issuer, audience, jwks_url (where to serve it), jwks_path (what to register), and kid this application signs under — handy for a diagnostics page.

4. Register your issuer with IOCloud, once

use IOCloud\Laravel\Facades\IOCloud;
use IOCloud\Laravel\Federation\FederationConfig;

$federation = app(FederationConfig::class);

$provider = IOCloud::createIdentityProvider(
    applicationUuid: '11111111-1111-1111-1111-111111111111',
    name: 'Acme Portal',
    issuer: $federation->requireIssuer(),
    allowedAudiences: [$federation->audience],
    requireEmailVerified: true,
    allowJitUsers: true,
    claimNames: $federation->claimNames,
    jwksPath: $federation->jwksPath(),
);

// Give a tenant of that application the organisation id your tokens carry, or
// pass `externalId` to createTenant(). Without it, logins fail with
// `invalid_target`; `externalId: null` clears it again.
IOCloud::setTenantExternalId(
    tenantUuid: '22222222-2222-2222-2222-222222222222',
    externalId: 'acme-tenant-1',
);

The provider belongs to that application: a token it signs logs users into the application's tenants only. An application may have several providers, and any of them logs in any of its users, so they must all sign the same tenant and user ids. A tenant's externalId is unique within its application; one another tenant holds is refused with TENANT_EXTERNAL_ID_TAKEN.

Passing the config's claimNames and jwksPath() is what keeps the registration and the tokens you sign from drifting apart. IOCloud fetches the keys from the issuer's origin plus jwksPath — a path under /.well-known/, /.well-known/jwks.json when omitted — and from nowhere else.

$jwksUrl is deprecated (an E_USER_DEPRECATED notice, which Laravel sends to its deprecations log channel when one is configured), and $jwksPath is the last parameter, so pass it by name. The SDK sends only the URL's path, so it must be on the issuer's own origin and carry no query or fragment, or createIdentityProvider() throws an InvalidArgumentException before any request.

listIdentityProviders() reads back what IOCloud has stored: each provider's issuerOrigin and jwksPath, and its jwksUrl, derived from the two. Pass allowJitTenants: true as well to create tenants at their first login (see below).

5. Give your frontend a subject token

Your frontend's chat client asks your backend for a subject token, through the callback you pass it, and exchanges the token with IOCloud itself. The endpoint it calls sits behind your own login:

use Illuminate\Http\Request;
use IOCloud\Laravel\Facades\IOCloud;

Route::post('/iocloud/subject-token', function (Request $request) {
    $user = $request->user();

    return ['subject_token' => IOCloud::federatedLogin(
        subject: $user->id,                  // stable and never reused
        externalTenantId: $user->tenant_id,
        email: $user->email,
        name: $user->name,
        emailVerified: $user->hasVerifiedEmail(),
    )];
})->middleware('auth');

federatedLogin() signs the subject token with your private key and returns it; it sends nothing. The chat client exchanges it (RFC 8693):

const response = await fetch(`${iocloudBaseUrl}/v1/federation/token`, {
  method: "POST",
  body: new URLSearchParams({
    grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
    subject_token: subjectToken,
    subject_token_type: "urn:ietf:params:oauth:token-type:jwt",
  }),
});
const session = await response.json();
session.access_token;   // opaque platform token — Authorization: Bearer …
session.user_uuid;      // the IOCloud user this session belongs to
session.tenant_uuid;    // and its tenant
session.expires_in;     // no refresh tokens: fetch a new subject token and exchange it

The platform session never passes through your backend. A subject token is short-lived and exchanged once, so the chat client asks your endpoint for a new one each time it needs a session rather than keeping one. A backend that wants the session itself passes the token to exchangeSubjectToken(), which returns it as a FederatedSession; that is also how to exchange a token signed elsewhere.

subject is the identity key IOCloud stores — the user's externalId within its tenant. It must be stable across logins and never reused for a different person — an email change at your end must not change it.

A rejected exchange answers 400 with an RFC 6749 body, error and error_description; exchangeSubjectToken() throws it as IOCloudTokenExchangeException, carrying error and errorDescription:

error Cause
invalid_grant Unknown or disabled issuer, signature does not verify against the published JWKS, wrong audience, token expired or replayed, missing claims, unverified email where required, a subject no user of the tenant has as itsexternalId with JIT provisioning off, a user that is not active — a pre-created user is pending until activated — or a malformed tenant_profile when the tenant must be created.
invalid_target No tenant of the provider's application has the tenant claim as its externalId and this login may not create one, that tenant is not active, the profile's planCode names none of your tenant plans, or, rarely, the tenant could not be created.

IOCloudFederationException is thrown for local misconfiguration — no signing key, no issuer, an unreadable PEM — before any request is made.

Federation with just-in-time provisioning off

With allowJitUsers: false, a login reaches only a user that already exists. Create each one with the sub your tokens will carry as its externalId, then activate it — a user created this way starts pending. The user endpoints are tenant-scoped, so these calls take a tenant credential instead of the partner token, and refresh the tenant token once on a 401:

// The secret is returned once, at creation: persist it for later user calls.
$credential = IOCloud::createTenantCredentials($tenant->uuid);

$user = IOCloud::createUser(
    name: $portalUser->name,
    email: $portalUser->email,
    tenantClientId: $credential->clientId,
    tenantClientSecret: $credential->clientSecret,
    externalId: (string) $portalUser->id,   // the `sub` your tokens carry
);
$user->status;   // 'pending'

IOCloud::updateUserStatus(
    userUuid: $user->uuid,
    status: 'active',
    tenantClientId: $credential->clientId,
    tenantClientSecret: $credential->clientSecret,
);

An externalId another user of the tenant already holds is refused with USER_EXTERNAL_ID_TAKEN. status: 'deactivated' stops a user's logins without deleting it.

Creating the tenant at its first login

With allowJitTenants: true — which requires allowJitUsers: true, or the platform answers 422 — a login whose tenant claim names no tenant of the provider's application creates that tenant from the TenantProfile you pass as tenant: the external id, name and optional contact email createTenant() takes, plus an optional plan (below). The external id is the login's tenant claim, and becomes the tenant's externalId; the login's user is created just in time in it, so the login needs email:

use IOCloud\Laravel\Data\TenantProfile;
use IOCloud\Laravel\Facades\IOCloud;

$subjectToken = IOCloud::federatedLogin(
    subject: $user->id,
    email: $user->email,
    tenant: new TenantProfile(
        name: $organisation->name,
        contactEmail: $organisation->contact_email,
        externalTenantId: $organisation->id,
    ),
);

A tenant created without a plan has none, so it draws on your credits uncapped until you subscribe it with subscribeTenant(). The exchange that creates it answers tenant_created: true, but your chat client receives that answer, not your backend. So name one of your tenant plans by its code instead, and the login creates the tenant on that plan, with nothing to subscribe afterwards:

$subjectToken = IOCloud::federatedLogin(
    subject: $user->id,
    email: $user->email,
    tenant: new TenantProfile(
        name: $organisation->name,
        planCode: 'growth',
        externalTenantId: $organisation->id,
    ),
);

The tenant is then subscribed to that plan, on a monthly billing cycle and active at once, as subscribeTenant() leaves it by default, with its cap and the cap of the login's user provisioned from the plan, all in the same transaction as the tenant: it exists on its plan or not at all. The code is matched exactly, case included, against the planCode you set on the plan in the Admin Dashboard, which listTenantPlans() returns as $plan->planCode (null for a plan without one). A code none of your plans has refuses the login with invalid_target and creates nothing. $planCode is the constructor's third parameter, after $contactEmail, and $externalTenantId the fourth.

The profile's externalTenantId is sent once, as the tenant claim, so federatedLogin() needs no $externalTenantId of its own; pass both and they must be the same id, or the SDK refuses the login before signing, since the platform finds and creates the tenant by the claim alone. The rest is signed as the subject token's tenant_profile claim, under that name whatever claimNames say, and extraClaims can neither set nor override it. Leave out contactEmail and planCode and the claim carries the name alone. The profile creates a tenant and never updates one: once the tenant exists it is ignored, so passing it on every login is harmless, and it never changes an existing tenant's plan. The SDK throws IOCloudFederationException for an empty name, or an empty contact email, plan code or external tenant id when one is given, before signing; the platform judges the rest and answers a malformed profile with invalid_grant.

The exchange's tenant_created is true only for the login that created the tenant; exchangeSubjectToken() reads it as tenantCreated. A platform that predates just-in-time tenants sends neither tenant_uuid nor tenant_created, so they read as null and false — and a provider's allowJitTenants as false.

Rotating keys

Generate a replacement with --force, and publish both keys while tokens signed by the old one are still in flight:

use IOCloud\Laravel\Federation\FederationSigningKey;

return response()->json(FederationSigningKey::buildJwks($current, $retiring));

Verifiers select by kid, so a retiring key keeps working until it is dropped from the document.

Working example

examples/laravel-demo is a runnable portal with tests that verify the SDK's tokens against its own JWKS endpoint using an independent JWT library.