sdjgroup / sso-php
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.
Requires
- php: >=8.2
- ext-curl: *
- ext-json: *
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.64
- phpstan/phpstan: ^1.12 || ^2.0
- phpunit/phpunit: ^10.5 || ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-10-12 03:24:19 UTC
README
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
stateis 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 aSsoErrorenum 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-curlandext-json. - Framework agnostic — plain PHP, Laravel, ThinkPHP, Symfony, Hyperf… the transport, state store and token cache are all replaceable interfaces.
Requirements
| Requirement | Version |
|---|---|
| PHP | >= 8.2 |
| ext-curl | any |
| ext-json | any |
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
);
| Method | Description |
|---|---|
buildAuthorizeUrl(?string $state = null, ?array $scopes = null): string | Build 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): SsoUser | Verify state, exchange code, return the profile. The one-call entry point for your callback route. |
getUserFromCode(string $code): SsoUser | Full exchange without state handling (code → openid → profile). |
getOpenid(string $code): string | Exchange a one-time code for the user's openid. |
getUserInfo(string $openid): SsoUser | Fetch the profile for an openid. |
getAccessToken(bool $forceRefresh = false): AccessToken | Return a usable access token, from cache when possible. |
requestAccessToken(): AccessToken | Always fetch a new token and cache it. |
generateState(): string | Generate an unguessable state (32 hex chars). |
verifyState(?string $state): bool | Verify and consume a state (single use). |
config(): Config | Access 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.
openidis always returned.nickname/avatar/typerequirebase_info,usernamerequiresaccount,nulland are omitted fromtoArray().
Exceptions
All exceptions extend Sdjgroup\Sso\Exception\SsoException:
| Exception | Raised when |
|---|---|
SsoApiException | The SSO server answered with a business error. |
SsoHttpException | The HTTP request itself failed (DNS, timeout, TLS, malformed response). |
SsoStateMismatchException | The callback state is missing or does not match. |
SsoException | Generic failure (invalid payload, missing code, …). |
InvalidArgumentException | Programming 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
| code | HTTP | Meaning | What to do |
|---|---|---|---|
43001 | 400 | Application does not exist or is disabled | Check client_id, contact the SSO administrator |
43002 | 400 | redirect_uri mismatch | Compare it with the registered value (scheme/host/port/path) |
43003 | 400 | Unsupported response_type | Always use code |
43004 | 401 | Application authentication failed | Check client_id / client_secret, log and alert |
43005 | 401 | Access token invalid or expired | The SDK refreshes and retries once automatically |
43006 | 400 | Code invalid, expired or already used | Send the user through the flow again |
43007 | 404 | openid unknown or user disabled | Deny the login |
43008 | 401 | SSO session expired | Send the user through the flow again |
43009 | 400 | Requested scopes exceed what the application may request | Trim $scopes to the registered set, or ask the SSO administrator to grant them |
42200 | 422 | Parameter validation failed | Check the arguments you pass |
50000 | 500 | Server-side failure | Retry 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.
| Constant | Value | Grants |
|---|---|---|
SsoClient::SCOPE_BASE_INFO | base_info | Basic profile: nickname, avatar, type |
SsoClient::SCOPE_ACCOUNT | account | The account name: username |
SsoClient::SCOPE_EMAIL | email | The 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_infois the default. Omit$scopesand onlybase_infois 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 withhasUsername()/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
- Never ship
client_secretto a browser — keep it in an environment variable. - Always keep state verification on (the default). Only call
handleCallback($query, verifyState: false)when the flow was deliberately started without state. - Codes are single use and live 5 minutes — exchange them immediately.
- Do not expose raw 401/5xx messages to end users — log them, show a friendly error.
- Use HTTPS everywhere in production.
- 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.