Search by

sdjgroup / sso-php

lushaoming

Framework-agnostic PHP SDK for the SSO unified login service: OAuth2 authorization-code flow in a few lines, with built-in state (CSRF) protection and typed errors.

v1.1.0 2026-10-12 03:22 UTC

This package is not auto-updated.

Last update: 2026-10-12 03:24:19 UTC


README

Latest Version on Packagist CI License

Framework-agnostic PHP SDK for the SSO unified login service. It wraps the OAuth2 authorization code flow so that signing a user in through SSO takes three lines of code — with CSRF protection and typed errors built in.

中文文档见 README.zh-CN.md。

Why this package

  • Three lines to integrate — start the flow, handle the callback, get the user.
  • Secure by default — a random state is generated and verified (fail-closed), and the client secret never leaves your server.
  • Typed errors — every failure is a SsoException; business errors come with the original error code, HTTP status and a SsoError enum case.
  • Least privilege — ask for the exact permissions you use (base_info, account, email); the server rejects anything beyond your application's registered set.
  • Zero dependencies — no Guzzle, no PSR packages. Only ext-curl and ext-json.
  • Framework agnostic — plain PHP, Laravel, ThinkPHP, Symfony, Hyperf… the transport, state store and token cache are all replaceable interfaces.

Requirements

RequirementVersion
PHP>= 8.2
ext-curlany
ext-jsonany

Installation

composer require sdjgroup/sso-php

You need to register your application with the SSO administrator first. You will receive a client_id, client_secret and you must provide the exact redirect_uri of your callback.

Quick start

use Sdjgroup\Sso\SsoClient;

$sso = SsoClient::create(
    host: 'https://sso.example.com',
    clientId: '5f8a1c...',
    clientSecret: 'b7e4d9...',   // server-side only, never expose it
    redirectUri: 'https://app.example.com/sso/callback',
);

// 1. The user clicks "Sign in with SSO" — send the browser to the authorization page.
session_start();
header('Location: ' . $sso->buildAuthorizeUrl());   // state is generated and stored for you
exit;
// Need more than the default profile? Pass the permissions you use:
// $sso->buildAuthorizeUrl(scopes: [SsoClient::SCOPE_BASE_INFO, SsoClient::SCOPE_EMAIL])

// 2. Your callback route — verify state, exchange the code, fetch the profile.
$user = $sso->handleCallback($_GET);

// 3. Bind the stable openid to your own account table.
$account = Account::firstOrCreate(
    ['sso_openid' => $user->openid],
    ['nickname' => $user->nickname],
);

That's it. handleCallback() performs the whole server-side chain:

code  ──POST /sso/openid──▶  openid  ──GET /sso/userinfo──▶  profile
            ▲
            └── Authorization: Bearer <access_token> (POST /sso/token, cached)

How the flow works

User          Your app (browser)      Your app (server)          SSO service
 │  click login      │                       │                        │
 │──────────────────▶│  ① 302 /authorize                             │
 │                   │──────────────────────────────────────────────▶│
 │  ② sign in on the SSO page & consent                                  │
 │◀─────────────────────────────────────────────────────────────────────┤
 │  ③ 302 to redirect_uri?code=...&state=...                            │
 │──────────────────▶│  ④                             │               │
 │                   │──────────────────────────────▶│               │
 │                   │                   ⑤ POST /sso/token           │
 │                   │                       (client_id + secret)     │
 │                   │──────────────────────────────────────────────▶│
 │                   │                   ⑥ POST /sso/openid (Bearer) │
 │                   │──────────────────────────────────────────────▶│
 │                   │                   ⑦ GET  /sso/userinfo        │
 │                   │──────────────────────────────────────────────▶│
 │                   │◀──────────────────────────────────────────────│
 │◀──────────────────┤  ⑧ your own local session                     │

Steps ⑤–⑦ always happen on your server.

API reference

SsoClient

Create it with the factory:

$sso = SsoClient::create(
    host: 'https://sso.example.com',
    clientId: '...',
    clientSecret: '...',
    redirectUri: 'https://app.example.com/sso/callback',
    timeout: 10,                       // optional, seconds
);

Or with full control over the dependencies:

$sso = new SsoClient(
    new Config('https://sso.example.com', $clientId, $clientSecret, $redirectUri),
    new CurlHttpClient(timeout: 10),   // ?HttpClientInterface
    new ArrayStateStore(),             // ?StateStoreInterface
    new MemoryTokenCache(),            // ?TokenCacheInterface
);
MethodDescription
buildAuthorizeUrl(?string $state = null, ?array $scopes = null): stringBuild the authorization URL. null (default) generates a random state, stores it and appends it — recommended. '' disables state. $scopes lists the permissions you need (see Permissions); omit it to request the default, base_info.
handleCallback(array $query, bool $verifyState = true): SsoUserVerify state, exchange code, return the profile. The one-call entry point for your callback route.
getUserFromCode(string $code): SsoUserFull exchange without state handling (code → openid → profile).
getOpenid(string $code): stringExchange a one-time code for the user's openid.
getUserInfo(string $openid): SsoUserFetch the profile for an openid.
getAccessToken(bool $forceRefresh = false): AccessTokenReturn a usable access token, from cache when possible.
requestAccessToken(): AccessTokenAlways fetch a new token and cache it.
generateState(): stringGenerate an unguessable state (32 hex chars).
verifyState(?string $state): boolVerify and consume a state (single use).
config(): ConfigAccess the configuration.

Value objects

AccessToken — token, tokenType, expiresIn, issuedAt, plus expiresAt(), isExpired(?int $now, int $leeway = 60), remainingSeconds(), isBearer(), authorizationHeader().

SsoUser — openid, nickname, avatar, type, plus username and email when the corresponding permission was granted. Helpers: isAdmin(), isSuperAdmin(), hasUsername(), hasEmail(), displayName() (never empty) and toArray(). toArray() only contains the fields the user actually authorised. type is 0 regular user, 1 administrator, 2 super administrator.

openid is always returned. nickname / avatar / type require base_info, username requires account, email requires email. Fields outside the granted set come back as null and are omitted from toArray().

Exceptions

All exceptions extend Sdjgroup\Sso\Exception\SsoException:

ExceptionRaised when
SsoApiExceptionThe SSO server answered with a business error.
SsoHttpExceptionThe HTTP request itself failed (DNS, timeout, TLS, malformed response).
SsoStateMismatchExceptionThe callback state is missing or does not match.
SsoExceptionGeneric failure (invalid payload, missing code, …).
InvalidArgumentExceptionProgramming error: empty argument, invalid config, oversized state.

SsoApiException exposes:

try {
    $user = $sso->handleCallback($_GET);
} catch (SsoApiException $e) {
    $e->getErrorCode();             // 43006
    $e->getHttpStatus();            // 400
    $e->error();                    // SsoError::CodeInvalid (or null)
    $e->getMessage();               // message returned by the SSO server
    $e->getResponse();              // raw decoded response body
    $e->isRetryable();              // true for 50000
    $e->requiresReauthorization();  // true for 43006 / 43008
}

Error codes

codeHTTPMeaningWhat to do
43001400Application does not exist or is disabledCheck client_id, contact the SSO administrator
43002400redirect_uri mismatchCompare it with the registered value (scheme/host/port/path)
43003400Unsupported response_typeAlways use code
43004401Application authentication failedCheck client_id / client_secret, log and alert
43005401Access token invalid or expiredThe SDK refreshes and retries once automatically
43006400Code invalid, expired or already usedSend the user through the flow again
43007404openid unknown or user disabledDeny the login
43008401SSO session expiredSend the user through the flow again
43009400Requested scopes exceed what the application may requestTrim $scopes to the registered set, or ask the SSO administrator to grant them
42200422Parameter validation failedCheck the arguments you pass
50000500Server-side failureRetry later

Permissions

Each application is registered with the set of permissions it is allowed to request. buildAuthorizeUrl() sends the requested set as the scopes query parameter; the user consents on the SSO page, and userinfo then returns exactly the fields those permissions cover.

ConstantValueGrants
SsoClient::SCOPE_BASE_INFObase_infoBasic profile: nickname, avatar, type
SsoClient::SCOPE_ACCOUNTaccountThe account name: username
SsoClient::SCOPE_EMAILemailThe email address: email
use Sdjgroup\Sso\SsoClient;

$url = $sso->buildAuthorizeUrl(
    scopes: [SsoClient::SCOPE_BASE_INFO, SsoClient::SCOPE_EMAIL],
);
// …/authorize?client_id=…&redirect_uri=…&response_type=code&scopes=base_info%2Cemail

Rules to keep in mind:

  • Ask for the minimum. Only request what your application really uses.
  • base_info is the default. Omit $scopes and only base_info is requested.
  • A subset of your registered permissions only. Requesting anything the application was not registered for fails with 43009 / HTTP 400 before the user is even asked.
  • The last authorization wins. Re-authorising with a narrower set replaces the stored set for that user instead of adding to it — data you no longer ask for stops being returned.
  • A denied field is null. Check with hasUsername() / hasEmail() rather than truthiness if an empty-but-granted value matters.

Advanced usage

Only the pieces you need

$code = $_GET['code'];

$openid = $sso->getOpenid($code);
$user   = $sso->getUserInfo($openid);

Custom HTTP transport (Guzzle, PSR-18, a mock…)

use Sdjgroup\Sso\Http\HttpClientInterface;
use Sdjgroup\Sso\Http\HttpResponse;

final class GuzzleHttpClient implements HttpClientInterface
{
    public function __construct(private \GuzzleHttp\ClientInterface $client) {}

    public function request(string $method, string $url, array $headers = [], ?array $json = null): HttpResponse
    {
        $response = $this->client->request($method, $url, [
            'headers' => $headers,
            'json' => $json,
            'http_errors' => false,   // let the SDK interpret 4xx/5xx
        ]);

        return new HttpResponse($response->getStatusCode(), (string) $response->getBody());
    }
}

Custom state store

The default NativeSessionStateStore requires session_start(). Under Swoole/Hyperf, or when you prefer Redis, implement the interface:

final class RedisStateStore implements StateStoreInterface
{
    public function __construct(private \Redis $redis) {}

    public function put(string $state, int $ttlSeconds): void
    {
        $this->redis->setex('sso:state:' . $state, $ttlSeconds, '1');
    }

    public function pull(string $state): bool
    {
        return (bool) $this->redis->del('sso:state:' . $state);
    }
}

Sharing the access token between requests

final class CacheTokenCache implements TokenCacheInterface
{
    public function __construct(private \Psr\SimpleCache\CacheInterface $cache) {}

    public function get(string $key): ?AccessToken
    {
        $data = $this->cache->get($key);

        return is_array($data) ? AccessToken::fromArray($data) : null;
    }

    public function set(string $key, AccessToken $token): void
    {
        $this->cache->set($key, [
            'access_token' => $token->token,
            'token_type' => $token->tokenType,
            'expires_in' => $token->expiresIn,
            'issued_at' => $token->issuedAt,
        ], $token->remainingSeconds());
    }

    public function delete(string $key): void
    {
        $this->cache->delete($key);
    }
}

Laravel

// config/services.php
'sso' => [
    'host' => env('SSO_HOST'),
    'client_id' => env('SSO_CLIENT_ID'),
    'client_secret' => env('SSO_CLIENT_SECRET'),
    'redirect_uri' => env('SSO_REDIRECT_URI'),
],

// AppServiceProvider::register()
$this->app->singleton(SsoClient::class, fn () => new SsoClient(
    Config::fromArray(config('services.sso')),
    null,
    new LaravelSessionStateStore(),
    new LaravelTokenCache(Cache::store()),
));
// routes/web.php
Route::get('/sso/login', function (SsoClient $sso) {
    return redirect()->away($sso->buildAuthorizeUrl());
});

Route::get('/sso/callback', function (SsoClient $sso) {
    $user = $sso->handleCallback(request()->query());

    auth()->login(Account::firstOrCreate(
        ['sso_openid' => $user->openid],
        ['nickname' => $user->nickname],
    ));

    return redirect('/');
});

LaravelSessionStateStore implemented with the session facade:

final class LaravelSessionStateStore implements StateStoreInterface
{
    public function put(string $state, int $ttlSeconds): void
    {
        session()->put('sso.states.' . $state, time() + $ttlSeconds);
    }

    public function pull(string $state): bool
    {
        $expiresAt = session()->pull('sso.states.' . $state);

        return is_int($expiresAt) && $expiresAt > time();
    }
}

Security notes

  1. Never ship client_secret to a browser — keep it in an environment variable.
  2. Always keep state verification on (the default). Only call handleCallback($query, verifyState: false) when the flow was deliberately started without state.
  3. Codes are single use and live 5 minutes — exchange them immediately.
  4. Do not expose raw 401/5xx messages to end users — log them, show a friendly error.
  5. Use HTTPS everywhere in production.
  6. Register the narrowest possible redirect_uri — a concrete path, not a wildcard.

Testing

composer test        # PHPUnit
composer analyse     # PHPStan, level 8
composer cs-check    # coding style (PSR-12)
composer check       # all of the above

License

MIT — see LICENSE.