Search by

onehux / sso

onehux

PHP/Laravel SDK for OneHux Accounts SSO. Two patterns: the OAuth client/BFF (Authorization Code + PKCE, real hosted login page, RP-initiated logout) and a resource server verifying Bearer tokens against the platform's published JWKS for a PHP API sitting behind a separate frontend BFF.

v0.3.0 2026-09-10 08:12 UTC

This package is auto-updated.

Last update: 2026-09-10 08:13:51 UTC


README

A real, installable PHP/Laravel SDK for OneHux Accounts SSO — covering both sides of a real integration, not just one.

Which pattern applies to you — read this before installing anything

There are two genuinely different integrations, and picking the wrong one produces a real, confusing failure (an app wired as the OAuth client gets an immediate, unexplained logout when it was actually meant to be a pure resource server behind someone else's BFF — this exact bug was reported and traced against this SDK family's Django/Node/Go packages before this package's own resource-server piece existed).

Is this PHP app the thing that talks to OneHux Accounts directly — redirects the browser to the hosted login page, holds a session, exchanges an authorization code for tokens? Then it's the OAuth client/BFF. Use Onehux\Sso\OneHuxClient directly, or (Laravel) the auto- discovered OneHuxSSOServiceProvider — see "Setup (Laravel)" below.

Is a separate frontend already the BFF (a SvelteKit/Next.js app using @onehux/sso, a Go API using onehux-sso-go, a mobile app) and this PHP app just needs to verify the Bearer token it was handed? Then this app is a resource server, and none of the above applies — use Onehux\Sso\ResourceServerVerifier only (Laravel: the onehux.resource-server middleware). No session, no redirect, no clientSecret. See "Resource server" below.

Framework-agnostic at its core (Onehux\Sso\OneHuxClient, Onehux\Sso\ResourceServerVerifier, both using Guzzle for HTTP) — usable in any PHP project — plus a Laravel service provider (Onehux\Sso\OneHuxSSOServiceProvider, auto-discovered) that wires the OAuth-client pattern to a real Laravel Session and registers real routes, and wires the resource-server pattern to two real middleware aliases (onehux.resource-server, onehux.scope).

Install

composer require onehux/sso

packagist.org/packages/onehux/sso

Two hosts — don't mix them up

accounts.onehux.com serves the hosted login/logout pages a browser is redirected to. api-accounts.onehux.com serves the actual OAuth API your backend calls server-to-server. This package keeps them as two separate config values (login_base_url / api_base_url) precisely because collapsing them into one host was a real, confirmed bug in the original integration guides (see the backend repo's README.md, ADR-070) — the wrong host doesn't error loudly, it silently 404s.

If your Organization has a live custom domain (Dashboard → Settings → Branding, see the backend repo's README.md ADR-027), set ONEHUX_LOGIN_BASE_URL to that domain instead — it's what your end users' browsers actually land on, so it should match whatever you've branded. Never override ONEHUX_API_BASE_URL: it has no per-Organization customization and never needs any — every call there is server-to-server via your clientId/clientSecret, never seen by an end user.

Setup (Laravel)

  1. Register a real confidential-client Application in your OneHux Accounts Organization (Dashboard → Applications), with a redirect_uri pointing at wherever this package's {prefix}/callback route resolves (default prefix auth, so https://yourapp.example.com/auth/callback), and your post_logout_redirect_uri registered in that same list — OneHux Accounts validates both against the one redirect_uris list, not two separate ones.

  2. Add to your .env:

    ONEHUX_CLIENT_ID=onehux_client_...
    ONEHUX_CLIENT_SECRET=onehux_secret_...
    ONEHUX_REDIRECT_URI=https://yourapp.example.com/auth/callback
    ONEHUX_POST_LOGOUT_REDIRECT_URI=https://yourapp.example.com/auth/logged-out
  3. (Optional) publish the config to customize the route prefix, success redirect, or the two hosts:

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

That's it — the service provider is auto-discovered. This gives you four real, working routes: /auth/login, /auth/callback, /auth/logout, and /auth/userinfo (a ready-to-use JSON endpoint your own frontend can call with credentials included, matching the BFF pattern — your frontend never talks to OneHux directly) — plus a fifth, /auth/backchannel-logout, which only does anything once you configure it (see "Logging out" below).

Resource server — verifying tokens independently

Use this when a separate frontend BFF already holds the tokens (a SvelteKit/Next.js app using @onehux/sso, a Go API using onehux-sso-go, a mobile app, another service) and this PHP app is a pure API sitting behind it — not the OAuth client, no session, no redirect.

Laravel:

// routes/api.php
use Illuminate\Support\Facades\Route;

Route::middleware(['onehux.resource-server', 'onehux.scope:invoices:read'])
    ->get('/invoices', function (\Illuminate\Http\Request $request) {
        $claims = $request->attributes->get('onehux_claims'); // Onehux\Sso\TokenClaims
        return response()->json(['sub' => $claims->subject]);
    });

Configure which Application(s) this API trusts via .env:

ONEHUX_RESOURCE_SERVER_TRUSTED_CLIENT_IDS=<your frontend BFF's client_id>

onehux.resource-server requires a valid Authorization: Bearer <token> header and attaches the verified TokenClaims to $request->attributes->get('onehux_claims'); onehux.scope composes on top (does not verify the token itself) to additionally require a specific scope on those already-verified claims.

Any other PHP framework, or a plain script:

use Onehux\Sso\ResourceServerVerifier;
use Onehux\Sso\Exceptions\TokenVerificationException;

$verifier = new ResourceServerVerifier(
    trustedClientIds: ['<your frontend BFFs client_id>'],
    // issuer defaults to https://api-accounts.onehux.com
);

try {
    $claims = $verifier->verifyAccessToken($bearerToken);
} catch (TokenVerificationException $exception) {
    // 401 -- invalid, expired, or untrusted token.
}

if (!$claims->hasScope('invoices:read')) {
    // 403
}

verifyAccessToken() fetches and caches the platform's real, live JWKS (/.well-known/jwks.json), matched by kid, and automatically re-fetches on a kid it hasn't seen — key rotation on the platform side needs zero action here. trustedClientIds is optional but recommended: it restricts acceptance to token(s) issued to specific Application(s), which is real tenant isolation on a shared identity platform, not just "is this token valid at all."

Using the client directly (any PHP framework, or a custom flow)

use Onehux\Sso\Exceptions\TokenExpiredException;
use Onehux\Sso\OneHuxClient;

$client = new OneHuxClient(
    clientId: 'onehux_client_...',
    clientSecret: 'onehux_secret_...',
    redirectUri: 'https://yourapp.example.com/auth/callback',
    postLogoutRedirectUri: 'https://yourapp.example.com/auth/logged-out',
);

$pending = $client->startAuthorization();
// stash $pending->state / $pending->codeVerifier in your own session, then redirect the
// browser to $pending->authorizationUrl

$tokens = $client->exchangeCode(
    code: $_GET['code'],
    state: $_GET['state'],
    expectedState: $session['onehux_sso_state'],
    codeVerifier: $session['onehux_sso_pkce_verifier'],
);
// $tokens->refreshToken: persist it server-side alongside $tokens->accessToken if you're not
// using OneHuxSSOController (which already does this for you) -- see "Refresh tokens" below.

try {
    $claims = $client->getUserinfo($tokens->accessToken);
} catch (TokenExpiredException $exception) {
    // getUserinfo() never retries itself (it's a pure API call, no session concept) -- a
    // caller using OneHuxClient directly owns this retry, same as OneHuxSSOController::userinfo()
    // does internally. See "Refresh tokens" below.
    $refreshed = $client->refreshAccessToken($session['onehux_sso_refresh_token']);
    $session['onehux_sso_refresh_token'] = $refreshed->refreshToken; // rotated -- persist the new one
    $claims = $client->getUserinfo($refreshed->accessToken);
}

$logoutUrl = $client->buildLogoutUrl();

Public application launcher

GET /api/v1/organizations/{orgSlug}/public-applications/ is a real, public, unauthenticated platform endpoint — no clientId/clientSecret involved, usable for any Organization by its own slug, not just your own configured one. It returns only name/logoUrl/homeUrl for Applications that Organization has opted into public listing — a pure "what can I launch" list, never a way to start a sign-in flow.

$apps = $client->getPublicApplications('onehux');
// [PublicApplication { name: 'ODS', logoUrl: 'https://...', homeUrl: 'https://...' }]

Rendering is entirely up to you — this package ships the data method only, no Blade component. A plain, unstyled illustration (adapt this to your own design, don't copy it as-is):

@foreach ($apps as $app)
    <a href="{{ $app->homeUrl }}">
        <img src="{{ $app->logoUrl }}" alt="{{ $app->name }}">
        {{ $app->name }}
    </a>
@endforeach

Logging out — what the user actually sees

There are two different triggers, and — once you wire up back-channel logout (below) — they produce the same fast, correct result. Understanding both is still worth it, since the second one only becomes immediate if you actually complete the setup:

1. The user clicks "Log out" inside your app (SP-initiated). This package's own {prefix}/logout route clears its local session and redirects through /end-session in the same action, which ends the real, shared platform session immediately. From the user's point of view: they click Log out, land on your app's own logged-out page, and if they then open the dashboard or any other app, they're asked to log in again — everywhere, right away. This works cleanly because your own app is the one driving both halves of the logout at once, with no dependency on back-channel logout at all.

2. The user logs out somewhere else — a different app, or directly at accounts.onehux.com/the dashboard (IdP-initiated). The shared platform session is revoked immediately and correctly on the backend — same underlying revocation call as case 1. Whether your app finds out immediately depends entirely on whether you've completed the back-channel logout setup below:

  • With it wired up: OneHux POSTs a signed logout_token to your /auth/backchannel-logout route the instant the session is revoked. This package verifies it and destroys the matching local Laravel session server-side (via app('session')->driver()->getHandler()->destroy(), generic across whatever SESSION_DRIVER you use). From the user's point of view: functionally identical to case 1 — if they reload or navigate, they're asked to log in again right away, even though they never touched this app's own logout button.
  • Without it: your app has no way to find out proactively. It'll keep showing the user as signed in — its own local session cookie hasn't changed — right up until the moment it makes its next real call to /userinfo, which returns a real 401/TokenExpiredException. In the worst realistic case, that's up to 15 minutes of stale "signed in" UI, bounded by the access token's own lifetime. This is not a security hole — no protected data actually leaks, since the real API call starts failing the moment it's tried — but the displayed state can look stale for that window.

To wire up back-channel logout:

  1. Register the exact URL with OneHux:
    PATCH /api/v1/applications/{id}/backchannel-logout/
    { "backchannel_logout_uri": "https://yourapp.example.com/auth/backchannel-logout" }
    
    The response includes backchannel_logout_secret exactly once — this is a dedicated signing secret, deliberately not your ONEHUX_CLIENT_SECRET (the backend stores that only as a one-way hash and can never read it back to sign anything with it).
  2. Add it to your .env:
    ONEHUX_BACKCHANNEL_LOGOUT_SIGNING_SECRET=bcls_...

That's the whole setup — {prefix}/backchannel-logout is already mounted (see Setup above), and starts verifying/acting on real logout_token deliveries as soon as the secret is configured.

Spec: openid-connect-backchannel-1_0.

Refresh tokens

OneHux Accounts access tokens are a 15-minute, single-issue lifetime — that hasn't changed. What has: every real login now also issues a refresh token (backend repo README.md ADR-081, RFC 6749 section 6 / RFC 9700 section 4.14.2 rotation with reuse detection), which this package uses to renew an expired access token without a full re-login.

{prefix}/userinfo (OneHuxSSOController::userinfo()) does this automatically: an expired access token triggers exactly one silent $client->refreshAccessToken() call using the session's stored refresh token, and the caller only ever sees TokenExpiredException if that refresh also fails. The new access/refresh token pair is persisted back into the session, replacing the old one — a refresh token is single-use and rotates on every real use, the old value stops working the moment a new one is issued.

TokenExpiredException is still the exception you catch, but its meaning is now "not signed in, full stop" rather than "the 15-minute access token died" — it's thrown only once a refresh has already been attempted and failed too (or no refresh token was ever stored, e.g. a session from before this package version). In every one of those cases, catch it and send the user back through $client->startAuthorization() for a fresh login. The backend deliberately does not tell this package why a refresh failed — ordinary expiry, an already-rotated token being replayed (a real reuse/compromise signal), or the underlying session being revoked all produce the same generic rejection (RFC 9700 section 4.14.2's own reasoning: the server can't tell which party presented the stale token) — so this package has nothing more specific to offer a caller than "not valid anymore."

If you call $client->getUserinfo() yourself outside of {prefix}/userinfo (see "Using the client directly" above), it never retries on your behalf — it's a pure API call with no session concept. Catch TokenExpiredException, call $client->refreshAccessToken() yourself if you have a stored refresh token, persist the newly-rotated one, and retry once.

This package has no middleware of its own and doesn't protect any of your app's own routes; {prefix}/userinfo is a ready-to-call JSON endpoint, not a gate Laravel enforces for you — that's unchanged by this feature.

Public clients (a future mobile/desktop SDK, no clientSecret) get tighter refresh-token settings than this package's confidential-client model (7-day idle timeout / 14-day absolute lifetime vs. 30/30 here) — not relevant to this package today, but worth knowing the number "30 days" isn't a platform-wide constant.

A real bug fixed along the way

Tracing the transient-failure/real-expiry distinction this feature needed to preserve surfaced a real, pre-existing bug: getUserinfo() used to catch ANY GuzzleException — including a genuine network/transport failure (DNS, connection refused, timeout) — and wrap it as TokenExpiredException. A caller catching TokenExpiredException specifically could not tell "OneHux is unreachable right now" from "this token is genuinely dead." Fixed: a transport failure now propagates as the real GuzzleException, never TokenExpiredException. The same distinction is applied throughout the new refreshAccessToken() and OneHuxSSOController::userinfo()'s refresh-and-retry logic — a transport failure during a refresh attempt is never downgraded to a fabricated "session expired," and never clears the stored tokens (they may still be perfectly valid). See CHANGELOG.md for the full detail.

Laravel's SESSION_LIFETIME is a separate thing from "how long is the user signed in"

Laravel's own session cookie (SESSION_LIFETIME, default 120 minutes) governs how long Session::get('onehux_access_token') keeps returning a value — it's still independent of whether that value (or the refresh token behind it) is actually valid. What's changed: "how long is the user actually signed in" is no longer bounded by a flat 15 minutes either. {prefix}/userinfo now silently refreshes an expired access token using the stored refresh token (see above), so a signed-in user's real session length is bounded by the refresh token's own lifetime (30 days for a confidential client like this one; see the backend repo's README.md ADR-081) — not by the access token's 15 minutes, and also not by SESSION_LIFETIME. If your SESSION_LIFETIME outlives the refresh token itself, the session key will still exist but getUserinfo()/{prefix}/userinfo will eventually throw/return 401 anyway once the refresh token itself expires or is rejected — that's still real and still possible, just on a longer, rotation-extended clock instead of a flat 15 minutes. Never treat Session::has('onehux_access_token') alone as proof of a signed-in user — the only real check is calling getUserinfo() (or hitting {prefix}/userinfo) and handling the 401/TokenExpiredException it throws when the session truly can't be renewed.

Example project

See example/ for a complete, runnable Laravel application using this package end-to-end — registered against a real disposable test Application and actually run through the full browser flow against production, not just unit-tested in isolation.

License

Apache License 2.0 — see LICENSE.