onehux / sso
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.
Requires
- php: ^8.2
- ext-json: *
- firebase/php-jwt: ^7.1
- guzzlehttp/guzzle: ^7.8
Requires (Dev)
- illuminate/support: ^12.0 || ^13.0
- orchestra/testbench: ^10.0 || ^11.2
- phpunit/phpunit: ^11.0
Suggests
- illuminate/support: Required to use the Laravel service provider (Onehux\Sso\OneHuxSSOServiceProvider) and its routes/session wiring.
Provides
None
Conflicts
None
Replaces
None
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)
-
Register a real confidential-client
Applicationin your OneHux Accounts Organization (Dashboard → Applications), with aredirect_uripointing at wherever this package's{prefix}/callbackroute resolves (default prefixauth, sohttps://yourapp.example.com/auth/callback), and yourpost_logout_redirect_uriregistered in that same list — OneHux Accounts validates both against the oneredirect_urislist, not two separate ones. -
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
-
(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_tokento your/auth/backchannel-logoutroute the instant the session is revoked. This package verifies it and destroys the matching local Laravel session server-side (viaapp('session')->driver()->getHandler()->destroy(), generic across whateverSESSION_DRIVERyou 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 real401/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:
- Register the exact URL with OneHux:
The response includesPATCH /api/v1/applications/{id}/backchannel-logout/ { "backchannel_logout_uri": "https://yourapp.example.com/auth/backchannel-logout" }backchannel_logout_secretexactly once — this is a dedicated signing secret, deliberately not yourONEHUX_CLIENT_SECRET(the backend stores that only as a one-way hash and can never read it back to sign anything with it). - 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.