Official PHP SDK for Latch Vector SSO — token verification and authentication.

Maintainers

Package info

github.com/latchvector/php-sdk

pkg:composer/latchvector/sso

Transparency log

Statistics

Installs: 7

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.3 2026-08-11 07:34 UTC

This package is auto-updated.

Last update: 2026-08-11 07:35:10 UTC


README

PHP SDK for Latch Vector SSO. PHP 8.1+, with Laravel and Symfony integrations.

composer require latchvector/sso

Note on firebase/php-jwt. This SDK requires ^7.1. Everything below 7.0.0 is affected by CVE-2025-45769, and Composer will refuse to install those versions. Do not work around that with policy.advisories.ignore.

Contents

Protecting an API — the common case

Most integrations only need this. Your API verifies tokens locally; it does not call the SSO service on every request.

use LatchVector\Sso\TokenVerifier;

$verifier = new TokenVerifier(
    issuer: 'https://sso.yourdomain.com',
    audience: 'https://api.yourcompany.com',   // your registered identifier
    cache: $psr16Cache,                        // see below — do not skip this
);

$principal = $verifier->verifyAuthorizationHeader(
    $request->getHeaderLine('Authorization'),
);

Pass a cache

PHP is not Node or Python here. The process dies at the end of every request, so without a PSR-16 cache the SDK refetches the JWKS on every single request — turning the SSO service into a hard dependency of your hot path, which is the exact thing local verification exists to avoid.

The Laravel provider wires this up for you. On Symfony, pass @cache.app.

Laravel

The service provider is auto-discovered. Add to .env:

SSO_ISSUER=https://sso.yourdomain.com
SSO_AUDIENCE=https://api.yourcompany.com
Route::get('/invoices', [InvoiceController::class, 'index'])
    ->middleware('sso.auth');

Route::post('/invoices/{id}/approve', [InvoiceController::class, 'approve'])
    ->middleware(['sso.auth', 'sso.can:invoice.approve']);

// Several codes are "all of them"; append ",any" for "at least one"
->middleware('sso.can:invoice.approve,invoice.admin,any');

In a controller:

$principal = $request->attributes->get('sso_principal');

Publish the config if you want to change the defaults:

php artisan vendor:publish --tag=latchvector-sso-config

Machine-to-machine (API clients)

For endpoints called by a backend service, not a user — the OAuth2 client_credentials grant. sso.client is the machine counterpart of sso.auth, and sso.scope of sso.can. It verifies with verifyClient, so a user access token is rejected here just as a machine token is rejected by sso.auth — the two never cross.

// A route a backend service calls (no user, no session).
Route::post('/reports/sync', [ReportController::class, 'sync'])
    ->middleware(['sso.client', 'sso.scope:reports.write']);

// "all of them" by default; append ",any" for "at least one".
->middleware('sso.scope:reports.read,reports.write,any');

In the controller, the verified client is a ClientPrincipal:

use LatchVector\Sso\ClientPrincipal;

public function sync(Request $request)
{
    /** @var ClientPrincipal $client */
    $client = $request->attributes->get('sso_client');

    // Which customer the machine acts for, and what it may do.
    $orgId  = $client->orgId;
    $scopes = $client->scopes;                 // e.g. ['reports.write']
    // $client->clientId, $client->applicationId, $client->hasScope('reports.write')
}

Calling another service (your app is the backend job) — obtain a token with the injected SsoClient. Cache it; it lasts ~15 minutes and there is no refresh:

use LatchVector\Sso\SsoClient;

public function pushNightly(SsoClient $sso)
{
    $token = cache()->remember('sso_machine_token', now()->addMinutes(14), function () use ($sso) {
        return $sso->clientCredentials(
            config('services.reporting.client_id'),
            config('services.reporting.client_secret'),
            ['reports.write'],
        )->accessToken;
    });

    Http::withToken($token)->post('https://api.internal/reports', $payload);
}

Symfony

# config/services.yaml
services:
    LatchVector\Sso\TokenVerifier:
        arguments:
            $issuer: '%env(SSO_ISSUER)%'
            $audience: '%env(SSO_AUDIENCE)%'
            $cache: '@cache.app'

    LatchVector\Sso\Symfony\SsoAuthenticator: ~
# config/packages/security.yaml
security:
    firewalls:
        api:
            pattern: ^/api
            stateless: true
            custom_authenticators:
                - LatchVector\Sso\Symfony\SsoAuthenticator

Permission codes become roles verbatim, so this works with the codes your application already defined:

#[IsGranted('invoice.approve')]
public function approve(int $id): Response { … }

The authenticated user is an SsoUser wrapping the Principal. There is no database row behind it — everything it reports was proven by the signature, so establishing identity costs no query.

Machine-to-machine (API clients)

For machine callers, add SsoClientAuthenticator on a firewall of its own, so machine and user callers stay separated. It verifies with verifyClient, so a user token is rejected there just as a machine token is rejected by SsoAuthenticator.

# config/services.yaml
    LatchVector\Sso\Symfony\SsoClientAuthenticator: ~
# config/packages/security.yaml
security:
    firewalls:
        api_machine:
            pattern: ^/api/machine
            stateless: true
            custom_authenticators:
                - LatchVector\Sso\Symfony\SsoClientAuthenticator

The authenticated user is an SsoClientUser wrapping the ClientPrincipal, and scopes become roles verbatim (the mirror of permissions on the user side):

#[IsGranted('reports.write')]
public function sync(): Response { … }   // requires the reports.write scope

To call another service, obtain a token with the SsoClient service:

$machine = $sso->clientCredentials($clientId, $clientSecret, ['reports.write']);
// cache $machine->accessToken (~15 min, no refresh), send as Bearer.

Multitenancy (Laravel & Symfony)

Verifying a token tells you who is calling. Multitenancy is about what data they may touch. The SDK ties your models to the tenant and org reach in the verified token, so a query cannot read or write outside them even if you forget the where clause. It works the same on Laravel (Eloquent) and Symfony (Doctrine).

Org-tree isolated by default. A tenant_id is the hard wall between customers; but within one customer, sibling orgs must not see each other's rows either. So a tenant-owned model is confined to the caller's org subtree out of the box — you opt a table into whole-tenant visibility deliberately, never by forgetting.

The columns a tenant-owned table carries:

Column Type
tenant_id bigint the hard customer wall — every tenant-aware table
org_id bigint the owning node — subtree tables (the default)
org_path varchar materialized path /1/57/903/, trailing slash — subtree tables

Laravel

Add the trait to a model that has the three columns:

use Illuminate\Database\Eloquent\Model;
use LatchVector\Sso\Laravel\BelongsToTenant;

class Chart extends Model
{
    use BelongsToTenant;   // org-subtree isolated (the default)
}

For genuinely tenant-wide data (visible to every org under the tenant), opt out — only tenant_id needed:

class TenantSetting extends Model
{
    use BelongsToTenant;
    protected string $tenantScope = 'tenant';
}

Behind the sso.auth (or sso.client) middleware, that is all:

Chart::all();          // only the caller's reach within their tenant
Chart::create([...]);  // tenant_id + the caller's own org_id/org_path stamped
$chart->update([...]); // still scoped — you cannot touch a row outside your reach

Configure in config/latchvector-sso.php:

'tenant' => [
    'enabled' => env('SSO_TENANT_SCOPING', true), // OFF in sandbox/local dev
    'column'  => 'tenant_id',
    'bypass_permissions' => ['PLATFORM_ADMIN'],    // see across tenants — operators only
],

Symfony (Doctrine)

Enable the bundle (config/bundles.php) and each entity declares its isolation mode — the default (subtree) is one trait, and an entity that declares neither mode is rejected loudly rather than exposed to the whole tenant:

use Doctrine\ORM\Mapping as ORM;
use LatchVector\Sso\Doctrine\{BelongsToTenantTree, OrgSubtreeAware};

#[ORM\Entity]
class Chart implements OrgSubtreeAware
{
    use BelongsToTenantTree;   // tenant_id + org_id + org_path, org-subtree isolated
}
use LatchVector\Sso\Doctrine\{BelongsToTenant, TenantWide};

#[ORM\Entity]
class TenantSetting implements TenantWide   // deliberately whole-tenant
{
    use BelongsToTenant;
}

The bundle registers the Doctrine SQL filter (enabled for the whole EntityManager, so it is fail-closed before auth), the prePersist stamp listener, and the SsoAuthenticator that fills the context from the token. See docs/symfony-tenancy.md for the escape hatches (native SQL, bulk DQL, workers).

How the reach is decided

New rows are stamped with the writer's own node; reads are confined to:

  • SUBTREE grants — the caller's node and everything below it (a left-anchored prefix match on org_path);
  • SELF grants — that node only (an exact match).

Which applies is decided by the caller's roles at token-issue time and carried in the scope_subtree / scope_self claims — you write nothing. A machine (client-credentials) token has no org reach, so a subtree model falls back to tenant-wide for it — still leak-safe across customers.

The trailing slash matters. Paths are stored /1/57/ (not /1/57), so the prefix /1/57/ can never leak into a sibling like /1/570/.

Filter within your subtree by a chosen org

Let a caller narrow to one org inside their reach (I'm /2/, show me only /2/5/). This ANDs onto the always-on scope, so it only ever shrinks the result, and a branch outside the token's reach throws OrgReachException (map to 403):

// Laravel
Chart::forOrg('/2/5/')->get();        // exactly that node (SELF)
Chart::forOrg('/2/5/', true)->get();  // node and everything below (needs a SUBTREE grant)
Chart::forOrgId(5)->get();            // single node by org id (safe by intersection)

// Symfony
use LatchVector\Sso\Doctrine\OrgScope;
OrgScope::apply($qb, 'c', '/2/5/', includeSubtree: false, context: $context);
OrgScope::byId($qb, 'c', 5);

Acting on behalf of a user (delegation)

A backend that authenticates with its own machine token but serves an end user should scope by the user, not the (tenant-wide) machine. Verify the forwarded user token and adopt its reach:

$user = $verifier->verify($forwardedUserToken);
app(TenantContext::class)->fromPrincipal($user);   // models now scope to the user's subtree

Operational notes

  • Bypass. A caller whose token carries a bypass_permissions code (a platform operator) is left unconstrained. An org admin is still bound to their own tenant.
  • Sandbox. Set SSO_TENANT_SCOPING=false in dev so seed data and tests aren't confined to one tenant.
  • Cross-tenant on purpose. Laravel Chart::allTenants()->...; Doctrine $tenantContext->configure(false) for that operation — explicit, greppable.
  • Console & queues. With no request there is no tenant, so the scope is fail-closed. In a job that must be tenant-bound, set it yourself: app(\LatchVector\Sso\Tenancy\TenantContext::class)->set($tenantId); (and forget() between messages in a long-running worker).

Multitenancy at scale

For tables that will hold billions of rows, three columns and the right indexes keep every scoped query a range scan, never a table scan:

Column Type Why
tenant_id bigint the hard customer wall; on every tenant-aware table
org_id bigint the owning node — subtree tables only
org_path text materialized path /1/57/903/, trailing slash — subtree only

Index tenant-leading, so the tenant predicate drives the scan:

-- every tenant-aware table
CREATE INDEX ON invoices (tenant_id, created_at DESC);

-- subtree tables: prefix scans on org_path within the tenant
CREATE INDEX ON charts (tenant_id, org_path text_pattern_ops);

text_pattern_ops is what makes org_path LIKE '/1/57/%' an index range scan under any collation. For the largest tenants, partition or shard by tenant_id (Postgres declarative partitioning, or Citus/Nile-style distribution): the tenant-leading key means a query already touches only its own partition.

What audience is for

It is your application's registered identifier, and it is required — there is no option to turn the check off.

A token issued for a different application is still validly signed by a trusted issuer. If you check the signature but not the audience, you accept it, which means you accept one from every user of every application on the platform. This is the single most common way an SSO integration is compromised, and it is why the parameter has no default.

firebase/php-jwt checks only the signature, exp, nbf and iat — it does not check iss or aud, unlike the equivalent libraries in Node and Python. This SDK enforces both explicitly. If you ever verify tokens without it, that is the gap to close first.

The principal

$principal->uid          // 4711 — key your records on this
$principal->email        // display only, see below
$principal->orgId        // 57
$principal->tenantId     // 1
$principal->orgPath      // "/1/57/"
$principal->permissions  // ['invoice.approve']
$principal->scopeSelf     // nodes the caller may act on (that node only)
$principal->scopeSubtree  // nodes the caller may act on together with everything below
$principal->expiresAt    // DateTimeImmutable

$principal->has('invoice.approve');
$principal->hasAny('invoice.approve', 'invoice.admin');
$principal->hasAll('invoice.read', 'invoice.approve');
$principal->canReach('/1/57/903/');   // does their granted scope cover this node?

Key your own tables on uid, never on the email. Addresses change, and a GDPR erasure request scrubs the address while uid survives. Rows keyed on email lose the link to their own user the first time either happens.

Do not cache permissions past expiresAt. They are a snapshot from issue time; a revoked role takes effect on the next token, which is why access tokens last only 15 minutes.

Logging users in

Only whatever actually handles the password needs this — a login controller, a BFF, a mobile gateway. Your resource APIs do not.

use LatchVector\Sso\{SsoClient, MfaRequired, TokenPair};

$sso = new SsoClient(
    issuer: 'https://sso.yourdomain.com',
    audience: 'https://api.yourcompany.com',
);

$result = $sso->login($email, $password);

if ($result instanceof MfaRequired) {
    $code = $this->promptForCode();                    // TOTP or recovery code
    $tokens = $sso->verifyMfa($result->pendingToken, $code);
} else {
    $tokens = $result;   // TokenPair
}

login() returns LoginResult, which is either a TokenPair or an MfaRequired — never a token object with an empty accessToken. You have to branch before you can reach a token, so the MFA path cannot be quietly skipped. A customer will enable MFA eventually, and the failure mode of the nullable version is an empty string reaching production.

Social login

$result = $sso->socialLogin('google', $googleIdToken);

The user must already exist. Accounts are provisioned by an administrator; a first-time social login for an unknown email is refused rather than silently creating an account.

Refresh

$fresh = $sso->refresh($storedRefreshToken);
$this->saveRefreshToken($fresh->refreshToken);   // before you use it

Refresh tokens rotate: the old one is dead the moment refresh() returns. Persist the new one first.

use LatchVector\Sso\Exception\{RefreshTokenException, RefreshTokenReusedException};

try {
    return $sso->refresh($stored);
} catch (RefreshTokenReusedException $e) {
    $this->destroySession();
    $this->alertSecurityTeam($userId);      // this is a security event
    throw $e;
} catch (RefreshTokenException) {
    return $this->redirectToLogin();        // expired or unknown — ordinary
}

RefreshTokenReusedException is deliberately not a subclass of RefreshTokenException, so a catch block that only meant "refresh or re-login" cannot swallow a compromise signal.

Logout

$sso->logout($refreshToken);

This revokes the refresh token. The current access token stays valid for the rest of its 15 minutes — it is a signed bearer token, not a session. For immediate cut-off, have an administrator disable the account.

Errors

Every exception extends SsoException and carries ->errorCode and ->status.

->errorCode, not ->code: PHP's own \Exception already declares a non-readonly int $code, and redeclaring it as a readonly string is a fatal error.

Class Codes
AuthenticationException invalid_credentials, invalid_code, invalid_id_token, invalid_token, invalid_token_use, invalid_or_expired_pending_token
RefreshTokenException invalid_refresh_token, refresh_token_expired
RefreshTokenReusedException refresh_token_reused
AccountNotActiveException account_not_active
AccountLockedException account_locked
AccessDeniedException access_denied
ValidationException validation_failed (with ->fields)
RateLimitException too_many_requests (with ->retryAfterSeconds)
ConfigurationException unknown_audience, discovery failures

429 is retried automatically with exponential backoff and jitter (twice by default). Nothing else is retried, and ->isRetryable() is false for everything but RateLimitException. A 403 is a decision the service already made; retrying it produces a stream of ACCESS_DENIED audit entries that a compliance officer will eventually ask you about.

access_denied does not distinguish "forbidden" from "does not exist" — telling them apart would let anyone enumerate records across tenants.

Configuration

You configure one URL. The JWKS endpoint is resolved from {issuer}/.well-known/openid-configuration and cached, so the SDK keeps working if it ever moves.

Argument Default
issuer required
audience required, cannot be disabled
leewaySeconds 30 skew allowance; keep NTP running regardless
jwksCacheSeconds 600
cache null PSR-16. Effectively required in PHP — see above
maxRateLimitRetries 2 client only

Smoke test

SSO_ISSUER=http://localhost:9000 SSO_AUDIENCE=http://localhost:9000 \
SSO_EMAIL=… SSO_PASSWORD=… php examples/smoke.php

Beyond the happy path it asserts that a token minted for a different audience is rejected and that a tampered signature is rejected — the two checks whose absence turns a working integration into an open door.

Before you go live

  • audience is set to your identifier, not ours
  • A PSR-16 cache is wired in, so the JWKS is not refetched per request
  • Your tables key on uid, not email
  • RefreshTokenReusedException is handled as a compromise, not retried
  • The MfaRequired branch is implemented and tested
  • Tokens are never written to logs, URLs, or error reports

Password reset

The invite / forgot-password flow (the token comes from the emailed link or an admin-issued setup link):

$sso->forgotPassword($email);              // emails a one-time link (no account oracle)
$sso->resetPassword($token, $newPassword); // redeem the link's token

Device sessions (mobile)

A mobile app gets a longer-lived, device-bound session by passing a device at login (also on verifyMfa/socialLogin). The service returns a stable deviceId — store it in secure storage and resend it so the same device is reused. The refresh token lives far longer than the web one and slides on every use; the user can list and revoke devices via GET/DELETE /api/users/me/devices (also on the ManagementClient).

use LatchVector\Sso\DeviceInfo;
// Presence of a device ⇒ a longer-lived, device-bound session.
$r = $sso->login($email, $password, new DeviceInfo(name: "Ana's iPhone", platform: "ios"));
saveToSecureStore($r->deviceId);   // store it; resend as new DeviceInfo(deviceId: …) next launch

Management API

Everything the console does, in code — users, organizations, roles, applications, API clients, webhooks, audit, bulk import, GDPR. Authenticated with a management token (log in with audience equal to the issuer):

use LatchVector\Sso\SsoClient;
use LatchVector\Sso\ManagementClient;

$sso = new SsoClient($issuer, $issuer);              // management token
$tokens = $sso->login($email, $password);
$mgmt = new ManagementClient($issuer, $tokens->accessToken);
$mgmt->createUser(['organizationId' => $orgId, 'email' => $email, 'fullName' => $name]);
$mgmt->request('POST', '/api/anything', body: ['x' => 1]); // every endpoint, incl. new ones

Management API guide — every resource, the token model, and the generic request() escape hatch.

Webhooks

Get notified the moment a user's access changes — a role assigned or revoked, a role's permissions changed, an account disabled or erased — so you can clear caches or force a refresh instead of waiting for the next failed call. Every delivery is HMAC-signed and timestamped, and this SDK ships a one-call verifier.

Webhooks guide — events, payload, the signature scheme, and a verified handler example.

Migrating from your current system

Bring an existing estate — organizations, users, roles, permissions — across in one validated pass. Records reference each other by your own ids, bcrypt passwords carry over (everyone else is invited), and re-runs are safe.

Migration guide — the two-step validate/commit flow, the full payload schema, and a worked example.