← Integration guides

Laravel integration

A confidential client — your Laravel app holds a real client_secret. Uses Laravel's own Session facade for server-side state and Http for HTTP calls. Redirects to this platform's real hosted login page — see the integration guides overview — which shows every currently-real first-factor option; you never build a login form. Two different hosts are used below: accounts.onehux.com for the hosted login/logout pages a browser is redirected to, and api-accounts.onehux.com for the actual API calls your backend makes server-to-server — mixing these up is the single most common integration mistake, since the wrong host doesn't error loudly, it just 404s.

Prefer the SDK — it already implements everything below (PKCE, the redirect, callback handling, token exchange, automatic refresh-token rotation, `/userinfo`, RP-initiated logout, OIDC Back-Channel Logout, and the public application launcher), real and maintained, rather than hand-rolling the raw API calls this page walks through manually:

composer require onehux/sso

packagist.org/packages/onehux/sso — the manual walkthrough below is still worth reading to understand what the SDK is doing on your behalf, and remains the reference if you're integrating in a language without an official SDK.

1. Start the flow — PKCE + redirect

// routes/web.php
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;

const ONEHUX_LOGIN_BASE_URL = 'https://your-org.onehux.com';     // see "Two different hosts" above
const ONEHUX_API_BASE_URL = 'https://api.your-domain.com';       // see "Two different hosts" above
const REDIRECT_URI = 'https://yourapp.example.com/auth/callback';

function base64url(string $data): string {
    return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

Route::get('/auth/login', function () {
    $codeVerifier = base64url(random_bytes(48));
    $codeChallenge = base64url(hash('sha256', $codeVerifier, true));
    $state = base64url(random_bytes(16));

    // PKCE verifier + state live server-side in the session — never in a cookie the
    // browser can read, never round-tripped through the client.
    Session::put('onehux_pkce_verifier', $codeVerifier);
    Session::put('onehux_oauth_state', $state);

    $params = http_build_query([
        'client_id' => config('services.onehux.client_id'),
        'redirect_uri' => REDIRECT_URI,
        'code_challenge' => $codeChallenge,
        'code_challenge_method' => 'S256',
        'scope' => 'openid profile email',
        'state' => $state,
    ]);
    return redirect(ONEHUX_LOGIN_BASE_URL . '/login?' . $params);
});

2. Callback — verify state, exchange the code

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;

Route::get('/auth/callback', function (Request $request) {
    $code = $request->query('code');
    $state = $request->query('state');
    $expectedState = Session::pull('onehux_oauth_state');
    $codeVerifier = Session::pull('onehux_pkce_verifier');

    if (!$code || !$state || $state !== $expectedState) {
        abort(400, 'Invalid or missing OAuth state.');
    }

    $tokenRes = Http::post(ONEHUX_API_BASE_URL . '/api/v1/oauth/token/', [
        'grant_type' => 'authorization_code',
        'code' => $code,
        'redirect_uri' => REDIRECT_URI,
        'client_id' => config('services.onehux.client_id'),
        'client_secret' => config('services.onehux.client_secret'),
        'code_verifier' => $codeVerifier,
    ]);
    if ($tokenRes->failed()) {
        abort(400, 'Sign-in failed.');
    }

    $tokens = $tokenRes->json(); // access_token, id_token, refresh_token, token_type, expires_in, scope

    // Session establishment — the access token lives server-side only (Laravel's own
    // session store), the same BFF discipline this platform's own dashboard follows on
    // itself. The browser only ever gets Laravel's session cookie, never the raw token.
    Session::put('onehux_access_token', $tokens['access_token']);
    Session::put('onehux_refresh_token', $tokens['refresh_token']);
    return redirect('/dashboard');
});

3. Using the token — and refreshing it

A confidential client's refresh token rotates on every use and is itself long-lived (30-day idle timeout, 30-day absolute cap — see Sessions), so a 401 from /userinfo almost always means just the 15-minute access token expired, not that the user needs to sign in again:

function refreshTokens(): ?string {
    $refreshToken = Session::get('onehux_refresh_token');
    if (!$refreshToken) {
        return null;
    }

    $res = Http::post(ONEHUX_API_BASE_URL . '/api/v1/oauth/token/', [
        'grant_type' => 'refresh_token',
        'refresh_token' => $refreshToken,
        'client_id' => config('services.onehux.client_id'),
        'client_secret' => config('services.onehux.client_secret'),
    ]);
    if ($res->failed()) {
        return null;
    }

    $tokens = $res->json(); // access_token, id_token, refresh_token, token_type, expires_in, scope
    Session::put('onehux_access_token', $tokens['access_token']);
    Session::put('onehux_refresh_token', $tokens['refresh_token']);
    return $tokens['access_token'];
}

Route::get('/auth/me', function () {
    $accessToken = Session::get('onehux_access_token');
    if (!$accessToken) {
        return redirect('/auth/login');
    }

    $res = Http::withToken($accessToken)->get(ONEHUX_API_BASE_URL . '/api/v1/oauth/userinfo/');
    if ($res->status() === 401) {
        $accessToken = refreshTokens();
        if (!$accessToken) {
            Session::forget('onehux_access_token');
            Session::forget('onehux_refresh_token');
            return redirect('/auth/login');
        }
        $res = Http::withToken($accessToken)->get(ONEHUX_API_BASE_URL . '/api/v1/oauth/userinfo/');
    }
    if ($res->failed()) {
        return redirect('/auth/login');
    }

    $claims = $res->json(); // sub, name, email, picture, roles, permissions, ...
    return response()->json($claims);
});

Logging out — RP-initiated, real SLO

Redirect to end the platform-wide session, not just clear your own local one — every other Application relying on that same session is signed out too:

Route::get('/auth/logout', function () {
    Session::forget('onehux_access_token');
    Session::forget('onehux_refresh_token');
    $params = http_build_query([
        'client_id' => config('services.onehux.client_id'),
        'post_logout_redirect_uri' => 'https://yourapp.example.com/logged-out',
    ]);
    return redirect(ONEHUX_LOGIN_BASE_URL . '/end-session?' . $params);
});

Register this URL too: post_logout_redirect_uri is validated against the exact same registered redirect_uris list as your login callback — if https://yourapp.example.com/logged-out isn't in that list too, /end-session rejects the request with a real 400, even though your login callback works fine.

The redirect above only notifies the app the user actually clicked "log out" in. If they instead log out of a different app, or directly at accounts.onehux.com, the shared session is still revoked immediately and correctly — and if this Application has registered a Back-Channel Logout endpoint, this platform pushes it a real, spec-compliant logout_token (server-to-server, HS256-signed with a dedicated backchannel signing secret — deliberately not your OAuth client_secret) as soon as the revocation happens, rather than waiting for your app to notice on its own:

PATCH /api/v1/applications/{id}/backchannel-logout/
{ "backchannel_logout_uri": "https://yourapp.example.com/auth/backchannel-logout" }

-> { "backchannel_logout_uri": "...", "backchannel_logout_secret": "..." }  (shown once)

Receiving and verifying that push is its own integration surface — not covered by this walkthrough. Without registering it, your app's own local session keeps showing "signed in" until its next real /userinfo call fails, bounded by the access token's 15-minute lifetime. Don't treat a locally-held session as a live signal of the IdP's true logout state unless you've wired up the push.