From f339208d58bbcbbcde1cc3404a657b58ddbfc0ab Mon Sep 17 00:00:00 2001 From: aslnbxrz Date: Tue, 30 Sep 2025 15:45:32 +0500 Subject: [PATCH] Add OneID provider (Uzbekistan SSO) --- src/OneID/OneIDExtendSocialite.php | 15 +++ src/OneID/OneIDLogout.php | 57 +++++++++ src/OneID/OneIDUser.php | 43 +++++++ src/OneID/Provider.php | 91 ++++++++++++++ src/OneID/README.md | 193 +++++++++++++++++++++++++++++ src/OneID/composer.json | 38 ++++++ 6 files changed, 437 insertions(+) create mode 100644 src/OneID/OneIDExtendSocialite.php create mode 100644 src/OneID/OneIDLogout.php create mode 100644 src/OneID/OneIDUser.php create mode 100644 src/OneID/Provider.php create mode 100644 src/OneID/README.md create mode 100644 src/OneID/composer.json diff --git a/src/OneID/OneIDExtendSocialite.php b/src/OneID/OneIDExtendSocialite.php new file mode 100644 index 000000000..e8dfa86b5 --- /dev/null +++ b/src/OneID/OneIDExtendSocialite.php @@ -0,0 +1,15 @@ +extendSocialite('oneid', Provider::class); + } +} + + diff --git a/src/OneID/OneIDLogout.php b/src/OneID/OneIDLogout.php new file mode 100644 index 000000000..19c440489 --- /dev/null +++ b/src/OneID/OneIDLogout.php @@ -0,0 +1,57 @@ +post(rtrim($this->getConfig('base_url', 'https://sso.egov.uz'), '/') . '/sso/oauth/Authorization.do', [ + RequestOptions::FORM_PARAMS => [ + 'grant_type' => 'one_log_out', + 'client_id' => $this->getConfig('client_id'), + 'client_secret' => $this->getConfig('client_secret'), + 'access_token' => $accessTokenOrSessionId, + 'scope' => $this->getConfig('scope', 'one_code'), + ], + 'headers' => ['Accept' => 'application/json'], + ]); + Log::info('OneIDSocialiteLogout', [ + 'status_code' => $res->getStatusCode(), + 'res' => $res->getBody()->getContents(), + 'accessTokenOrSessionId' => $accessTokenOrSessionId, + ]); + } catch (Throwable $e) { + Log::error('OneIDSocialiteThrow', [ + 'throw' => $e->getMessage(), + 'config' => $this->getConfig(), + ]); + } + } + + /** + * @param string|null $key + * @param mixed|null $default + * @return mixed|array + */ + protected function getConfig(?string $key = null, mixed $default = null): mixed + { + $config = Config::get('services.oneid'); + // check manually if a key is given and if it exists in the config + // this has to be done to check for spoofed additional config keys so that null isn't returned + if (!empty($key) && empty($config[$key])) { + return $default; + } + + return $key ? Arr::get($config, $key, $default) : $config; + } +} \ No newline at end of file diff --git a/src/OneID/OneIDUser.php b/src/OneID/OneIDUser.php new file mode 100644 index 000000000..0374f0910 --- /dev/null +++ b/src/OneID/OneIDUser.php @@ -0,0 +1,43 @@ +attributes['pinfl'] ?? null; + } + + /** Return OneID session id */ + public function getSessionId(): ?string + { + return $this->attributes['sess_id'] ?? null; + } + + /** Return passport number */ + public function getPassport(): ?string + { + return $this->attributes['passport'] ?? null; + } + + /** Return normalized phone */ + public function getPhone(): ?string + { + return $this->attributes['phone'] ?? null; + } + + /** Lightweight gender guess based on PINFL first digit (if numeric) */ + public function getGender(): ?string + { + $pin = $this->getPinfl(); + if (empty($pin) || !ctype_digit($pin)) { + return null; // or 'unknown' + } + // Odd => male, Even => female + return ((int)$pin[0]) % 2 ? 'male' : 'female'; + } +} \ No newline at end of file diff --git a/src/OneID/Provider.php b/src/OneID/Provider.php new file mode 100644 index 000000000..3a7a9dcf8 --- /dev/null +++ b/src/OneID/Provider.php @@ -0,0 +1,91 @@ +buildAuthUrlFromBase(rtrim($this->getBaseUrl(), '/') . '/sso/oauth/Authorization.do', $state); + } + + protected function getTokenUrl(): string + { + return rtrim($this->getBaseUrl(), '/') . '/sso/oauth/Authorization.do'; + } + + protected function getUserByToken($token) + { + $response = $this->getHttpClient()->post($this->getTokenUrl(), [ + RequestOptions::FORM_PARAMS => [ + 'grant_type' => 'one_access_token_identify', + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'access_token' => $token, + 'scope' => $this->getScope(), + ], + 'headers' => ['Accept' => 'application/json'], + ]); + + return json_decode($response->getBody()->getContents(), true); + } + + protected function getCodeFields($state = null): array + { + $fields = parent::getCodeFields($state); + $fields['response_type'] = 'one_code'; + $fields['scope'] = $this->getScope(); + $fields['state'] = $state; + return $fields; + } + + protected function getTokenFields($code): array + { + $fields = parent::getTokenFields($code); + $fields['grant_type'] = 'one_authorization_code'; + return $fields; + } + + protected function mapUserToObject(array $user): OneIDUser + { + // Build fallback name if full_name is missing + $name = $user['full_name'] ?? trim(implode(' ', array_filter([ + $user['first_name'] ?? null, + $user['sur_name'] ?? null, + $user['mid_name'] ?? null, + ]))); + + return (new OneIDUser())->setRaw($user)->map([ + // Standard Socialite fields + 'id' => $user['user_id'] ?? $user['pin'] ?? $user['sess_id'] ?? null, + 'name' => $name ?: null, + 'email' => $user['email'] ?? null, + 'avatar' => $user['avatar'] ?? null, + + // Custom fields (use consistent keys!) + 'pinfl' => $user['pin'] ?? null, // citizen PIN/INN + 'sess_id' => $user['sess_id'] ?? null, // OneID session id + 'passport' => $user['pport_no'] ?? null, // passport number + 'phone' => $user['mob_phone_no'] ?? $user['phone'] ?? null, // prefer mob_phone_no + ]); + } + + protected function getBaseUrl(): string + { + return $this->getConfig('base_url', 'https://sso.egov.uz'); + } + + protected function getScope(): string + { + return (string)($this->getConfig('scope', $this->scope)); + } +} + + diff --git a/src/OneID/README.md b/src/OneID/README.md new file mode 100644 index 000000000..d8cd2a04f --- /dev/null +++ b/src/OneID/README.md @@ -0,0 +1,193 @@ +# OneID for Laravel Socialite + +OneID (Uzbekistan SSO) provider for [SocialiteProviders](https://github.com/SocialiteProviders/Providers). + +## Requirements + +- PHP 8.1+ +- Laravel 10/11+ +- `laravel/socialite` +- `socialiteproviders/manager` or `aslnbxrz/oneid-socialite` + +## Installation + +```bash +composer require socialiteproviders/oneid +``` + +or + +```bash +composer require aslnbxrz/oneid-socialite +``` + +## Configuration + +Add to `config/services.php`: + +```php +'oneid' => [ + 'client_id' => env('ONEID_CLIENT_ID'), + 'client_secret' => env('ONEID_CLIENT_SECRET'), + 'redirect' => env('ONEID_REDIRECT_URI'), + // Optional (defaults shown): + 'base_url' => env('ONEID_BASE_URL', 'https://sso.egov.uz'), + 'scope' => env('ONEID_SCOPE', 'one_code'), +], +``` + +Add to your `.env` + +```dotenv +ONEID_CLIENT_ID=your-client-id +ONEID_CLIENT_SECRET=your-client-secret +ONEID_REDIRECT_URI=https://your-app.com/auth/oneid/callback +# Optional: +# ONEID_BASE_URL=https://sso.egov.uz +# ONEID_SCOPE=one_code +``` + +## Laravel 11+ Event Listener + +Place this in a service provider boot() method (e.g. App\Providers\AppServiceProvider): + +```php +use Illuminate\Support\Facades\Event; +use SocialiteProviders\Manager\SocialiteWasCalled; +use SocialiteProviders\OneID\Provider; // or Aslnbxrz\OneID\Provider + +public function boot(): void +{ + Event::listen(function (SocialiteWasCalled $event) { + $event->extendSocialite('oneid', Provider::class); + }); +} +``` + +## Usage + +#### Web (redirect flow) + +```php +use Laravel\Socialite\Facades\Socialite; + +// Redirect to OneID +Route::get('/auth/oneid/redirect', function () { + return Socialite::driver('oneid')->redirect(); +}); + +// Callback +Route::get('/auth/oneid/callback', function () { + /** @var \Aslnbxrz\OneID\OneIDUser $user */ + $user = Socialite::driver('oneid')->user(); + + // Standard fields + $id = $user->getId(); + $name = $user->getName(); + $email = $user->getEmail(); + + // Custom fields + $pinfl = $user->getPinfl(); + $sessId = $user->getSessionId(); + $passport = $user->getPassport(); + $phone = $user->getPhone(); + $gender = $user->getGender(); + + // TODO: login/register user logic +}); +``` + +#### API (stateless mode) + +OneID can be integrated into API flows (e.g. mobile apps). +You may authenticate users by either **access_token** (already issued by OneID) or **authorization code**. + +```php +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Route; +use Laravel\Socialite\Facades\Socialite; + +// --- Variant A: using access_token directly --- +Route::post('/api/auth/oneid/token', function (Request $request) { + $validated = $request->validate([ + 'access_token' => 'required|string', + ]); + + /** @var \Aslnbxrz\OneID\OneIDUser $user */ + $user = Socialite::driver('oneid')->userFromToken($validated['access_token']); + + return response()->json([ + 'id' => $user->getId(), + 'name' => $user->getName(), + 'email' => $user->getEmail(), + 'pinfl' => $user->getPinfl(), + 'sess_id' => $user->getSessionId(), + 'passport' => $user->getPassport(), + 'phone' => $user->getPhone(), + 'gender' => $user->getGender(), + ]); +}); + +// --- Variant B: exchanging authorization code --- +Route::post('/api/auth/oneid/code', function (Request $request) { + $validated = $request->validate([ + 'code' => 'required|string', + ]); + + /** @var \Aslnbxrz\OneID\OneIDUser $user */ + $user = Socialite::driver('oneid')->stateless()->user(); + + return response()->json([ + 'id' => $user->getId(), + 'name' => $user->getName(), + 'email' => $user->getEmail(), + 'pinfl' => $user->getPinfl(), + 'sess_id' => $user->getSessionId(), + 'passport' => $user->getPassport(), + 'phone' => $user->getPhone(), + 'gender' => $user->getGender(), + ]); +}); +``` + +## OneID Logout + +In addition to logging out locally (revoking your Laravel session or API token), you may also notify OneID to invalidate +the session on their side. This is **REQUIRED** and should be done **after** your database transaction commits. + +### Usage + +```php +use Aslnbxrz\OneID\OneIDLogout; + +// $accessTokenOrSessionId - access_token or sess_id +$success = app(OneIDLogout::class)->handle($accessTokenOrSessionId); +``` + +## Endpoints + +- Authorize / Token / Userinfo: `https://sso.egov.uz/sso/oauth/Authorization.do` + +## Returned User fields + +**Standard fields** +- `id` — from `user_id` or `pin` or `sess_id` +- `name` — from `full_name` or concatenation of (`first_name` + `sur_name` + `mid_name`) +- `email` — if provided by OneID +- `avatar` — if provided (usually `null`) + +**Custom OneID fields** +- `pinfl` — citizen ID (PINFL) +- `sess_id` — OneID session identifier +- `passport` — passport number (`pport_no`) +- `phone` — mobile phone number (`mob_phone_no` or `phone`) +- `gender` — derived from the first digit of PINFL (`male` if odd, `female` if even) + +**Raw payload** +- `raw` — full OneID response as returned by the API (`$user->getRaw()`) + +--- + +## License + +MIT diff --git a/src/OneID/composer.json b/src/OneID/composer.json new file mode 100644 index 000000000..574169a78 --- /dev/null +++ b/src/OneID/composer.json @@ -0,0 +1,38 @@ +{ + "name": "socialiteproviders/oneid", + "description": "OneID (Uzbekistan SSO) provider for Laravel Socialite", + "license": "MIT", + "keywords": [ + "provider", + "laravel", + "socialite", + "socialiteproviders", + "oneid", + "oauth", + "oauth2", + "uzbekistan", + "sso" + ], + "authors": [ + { + "name": "aslnbxrz", + "email": "bexruz.aslonov1@gmail.com", + "homepage": "https://aslonov.uz", + "role": "Developer" + } + ], + "support": { + "issues": "https://github.com/socialiteproviders/providers/issues", + "source": "https://github.com/socialiteproviders/providers", + "docs": "https://socialiteproviders.com/oneid" + }, + "require": { + "php": "^8.0", + "socialiteproviders/manager": "^4.4" + }, + "autoload": { + "psr-4": { + "Aslnbxrz\\OneID\\": "" + } + } +}