PHPackages                             jmluang/sso-consumer - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Authentication &amp; Authorization](/categories/authentication)
4. /
5. jmluang/sso-consumer

ActiveLibrary[Authentication &amp; Authorization](/categories/authentication)

jmluang/sso-consumer
====================

Laravel SSO consumer package that verifies portal-signed JWT tickets and bridges upstream identity to local admin auth.

v0.0.8(1mo ago)017[1 PRs](https://github.com/jmluang/sso-consumer/pulls)MITPHPPHP ^8.3CI passing

Since Apr 24Pushed 1mo agoCompare

[ Source](https://github.com/jmluang/sso-consumer)[ Packagist](https://packagist.org/packages/jmluang/sso-consumer)[ Docs](https://github.com/jmluang/sso-consumer)[ RSS](/packages/jmluang-sso-consumer/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (5)Dependencies (12)Versions (10)Used By (0)

jmluang/sso-consumer
====================

[](#jmluangsso-consumer)

[![Latest Version on Packagist](https://camo.githubusercontent.com/0ff4c7ae5081d1a587787358e722ae0ddacc9bd3cde43aea9c8a2c3496963c23/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6a6d6c75616e672f73736f2d636f6e73756d65722e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/jmluang/sso-consumer)[![Tests](https://camo.githubusercontent.com/c3ae67a44711c45ac79d16145c7dbf0ecad45512bc37a6c94081519e848880ce/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f6a6d6c75616e672f73736f2d636f6e73756d65722f72756e2d74657374732e796d6c3f6272616e63683d6d61696e266c6162656c3d7465737473267374796c653d666c61742d737175617265)](https://github.com/jmluang/sso-consumer/actions?query=workflow%3Arun-tests+branch%3Amain)[![License: MIT](https://camo.githubusercontent.com/942e017bf0672002dd32a857c95d66f28c5900ab541838c6c664442516309c8a/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d626c75652e7376673f7374796c653d666c61742d737175617265)](LICENSE.md)

A Laravel consumer package for an upstream SSO portal. Verifies portal-signed JWT tickets and bridges upstream identity to the consuming app's local admin auth.

The companion portal application signs the tickets; integrators receive the architecture and contract specs separately. The key pieces (JWT claims v1/v2, error codes) are mirrored below for consumers.

Requirements
------------

[](#requirements)

- PHP `^8.3`
- Laravel `^11.0 || ^12.0 || ^13.0`

Installation
------------

[](#installation)

```
composer require jmluang/sso-consumer:^1.0
```

Publish the config:

```
php artisan vendor:publish --tag=sso-consumer-config
```

Optionally publish views &amp; translations:

```
php artisan vendor:publish --tag=sso-consumer-views
php artisan vendor:publish --tag=sso-consumer-lang
```

Configuration
-------------

[](#configuration)

Fill in `.env`:

```
SSO_PORTAL_URL=https://sso.example.com
SSO_SYSTEM_CODE=your-system-code     # must match tenant_registry.system_code on the portal
SSO_EXPECTED_HOST=admin.example.com  # single callback host, required in production unless SSO_EXPECTED_HOSTS is set
# SSO_EXPECTED_HOSTS=admin-a.example.com,admin-b.example.com  # multi-tenant callback hosts
SSO_PORTAL_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"

```

> The `SSO_PORTAL_PUBLIC_KEY` value **must be wrapped in double quotes** so phpdotenv interprets the `\n` escapes as real newlines. Single-quoted or unquoted values will be passed to OpenSSL with literal `\n`, and signature verification will silently fail.

Then point the resolver to your implementation in `config/sso-consumer.php`:

```
'resolver' => \App\Sso\AppSsoUserResolver::class,
```

A full integration guide is distributed with the portal application; ask your portal admin for it if you need the end-to-end setup.

How it works
------------

[](#how-it-works)

```
Portal (signs RS256 ticket)
   │  302 → https://{tenant_domain}/admin-app/sso/consume?ticket=
   ▼
This package's ConsumeController:
   1. Verify JWT (signature, alg, exp, iss, v, aud, tenant_domain)
   2. Claim jti in cache (one-time-use guard)
   3. Resolve a local user via SsoUserResolver::findByPhone()/findByEmail()
   4. Call SsoUserResolver::login($user, $claims, $request)
   5. Dispatch SsoLoginSucceeded → 302 to success_redirect
   On any failure → dispatch SsoLoginFailed → render error page with
                    "Return to portal" action.

```

JWT Claims
----------

[](#jwt-claims)

Ticket is RS256-signed by the portal; consumer must verify using the portal's public key.

ClaimTypeRequiredNotes`iss`string✓Must be `sso-portal``aud`string✓Must equal `config('sso-consumer.system_code')``sub`string✓v2: phone. v1 legacy: email`phone`stringv2 onlyPrimary lookup key for v2 tickets`email`stringv1 required, v2 optionalSecondary legacy lookup key`name`stringoptionalDisplay name from portal/upstream identity`tenant_domain`string✓Must match `SSO_EXPECTED_HOST` or one of `SSO_EXPECTED_HOSTS`; outside production, falls back to the request host with port when neither is configured`tenant_id`int✓`tenant_system`string✓Same as `aud``jti`string✓32 hex chars, one-time-use`v`int✓`2` for phone-primary tickets, `1` for legacy email tickets`iat` / `exp`int✓120s TTL recommended`nbf`intoptionalError Codes
-----------

[](#error-codes)

Rendered on the error page and emitted via `SsoLoginFailed` events.

`ticket_missing`, `ticket_invalid`, `ticket_expired`, `ticket_replayed`, `ticket_version_unsupported`, `audience_mismatch`, `tenant_mismatch`, `user_not_found`, `identity_conflict`, `resolver_failed`

The `SsoUserResolver` contract
------------------------------

[](#the-ssouserresolver-contract)

The package does **not** touch `Auth` or `session` directly — that's your job inside `login()`. The package **does** orchestrate the phone/email lookups and detects conflicts, so a careless implementation can no longer silently log an attacker into the wrong account.

You implement three primitives:

```
namespace App\Sso;

use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Jmluang\SsoConsumer\Contracts\SsoUserResolver;
use Jmluang\SsoConsumer\Support\PhoneNormalizer;

class AppSsoUserResolver implements SsoUserResolver
{
    public function findByPhone(string $phone, array $claims, Request $request): ?Authenticatable
    {
        // Normalize both sides so domestic ("15912340001") and international
        // ("+852 91234567") tickets match local rows regardless of how the
        // number was originally typed. See "Phone format" below.
        $normalized = PhoneNormalizer::normalize($phone);

        return AdminUser::query()->where('phone', $normalized)->first();
    }

    public function findByEmail(string $email, array $claims, Request $request): ?Authenticatable
    {
        return AdminUser::query()->where('email', $email)->first();
    }

    public function login(Authenticatable $user, array $claims, Request $request): void
    {
        Auth::guard('admin')->login($user);
        $request->session()->regenerate();
        // Update last_login_at, fire app-specific events, etc.
    }
}
```

The library will:

1. Call `findByPhone()` if (and only if) the verified ticket carries a non-empty phone claim.
2. Call `findByEmail()` if (and only if) the verified ticket carries a non-empty email claim.
3. Throw `IdentityConflictException` (error code `identity_conflict`) if both lookups succeed but return users with different identifiers — `login()` is **not** called.
4. Throw `UserNotFoundException` if both lookups return `null`.
5. Otherwise call `login($user, $claims, $request)` exactly once.

Phone format
------------

[](#phone-format)

The portal issues `phone` claims in one of two canonical shapes:

- **Domestic** — digits only, e.g. `15912340001`.
- **International** — `+ `, separated by a single space, e.g. `+852 91234567`. The country code is 1–4 digits; the local number is 3–20 digits with no inner separators.

Use `Jmluang\SsoConsumer\Support\PhoneNormalizer::normalize($phone)` on both the inbound claim and the locally stored column before comparing — operators typing `159-1234-0001`, `(415) 555-0123`, or `+852-9123-4567` all collapse to the canonical form, so lookups don't miss because of formatting drift. The helper returns `null` for empty input and throws `InvalidArgumentException`for inputs that can't be parsed (letters, too few digits, missing separator between country code and local number, etc.).

If your local column already stores normalized values, you only need to normalize the inbound claim. If you're migrating an existing column, run the helper during the backfill described below.

Upgrading To Phone-Primary Tickets
----------------------------------

[](#upgrading-to-phone-primary-tickets)

Before enabling portal-issued v2 tickets:

1. Add a normalized phone column to the consuming app's admin user table.
2. Backfill existing admin users from the trusted upstream SSO phone value.
3. Implement `findByPhone()` using the normalized column, and `findByEmail()` for legacy rows.
4. Deploy the resolver before switching the portal kill-switch from v1 to v2.
5. Monitor `user_not_found`, `identity_conflict`, and resolver failures during the rollout window.

Production Hardening
--------------------

[](#production-hardening)

The defaults are safe to use in development, but a production deployment **must** review the following:

1. **At least one expected host is required.** In production, set `SSO_EXPECTED_HOST` for a single callback domain or `SSO_EXPECTED_HOSTS` for comma-separated multi-tenant callback domains. Consume requests fail when the expected host list is empty; `php artisan sso:check` also reports the misconfiguration. Outside production, the consumer can fall back to the request host with port for local testing.
2. **`replay_cache_store` must be a shared, atomic cache** (Redis, Memcached, or Database). The `array` driver gives each PHP worker its own memory, which silently disables replay protection. The `file` driver is not atomic. `php artisan sso:check` enforces this in production.
3. **The consume route is rate-limited by default** (`throttle:sso-consume`). Each request triggers an RSA signature verification, which is CPU-expensive — without throttling the endpoint is a DoS amplifier. The package registers a default `sso-consume` limiter at 60 requests/minute per IP; override it in `App\Providers\AppServiceProvider::boot()` when your app needs tenant-aware or user-aware limits: ```
    RateLimiter::for('sso-consume', fn (Request $request) =>
        Limit::perMinute(60)->by($request->ip()));
    ```

    Override `consume_middleware` if your app already has a tenant-aware throttle.
4. **HTTPS enforcement depends on trusted proxy configuration.** Production consume requests must be HTTPS. If TLS terminates at a load balancer or reverse proxy, configure Laravel trusted proxies so `$request->isSecure()` honors `X-Forwarded-Proto: https`; otherwise the package will correctly reject the internal plaintext hop as `ticket_invalid`.
5. **`SsoLoginFailed` events carry the full claim array**, including `phone`, `email`, and `name`. Listeners that ship to log aggregators or alerting systems should redact or hash PII before forwarding.
6. **Octane / Swoole / RoadRunner caveat.** The verifier mutates the static `Firebase\JWT\JWT::$leeway` while decoding. Concurrent requests sharing a worker process can race on this state. Pin a single value via config and avoid hot-reloading it, or run under traditional php-fpm if this is a concern.

Events
------

[](#events)

- `Jmluang\SsoConsumer\Events\SsoLoginSucceeded` — `$user`, `$claims`, `$requestId`
- `Jmluang\SsoConsumer\Events\SsoLoginFailed` — `$errorCode`, `$claims?`, `$rawTicketHead?`, `$requestId`, `$exception?`

Write your own listeners for audit logging / alerting.

Commands
--------

[](#commands)

```
php artisan sso:check    # verify config is production-ready
```

Testing
-------

[](#testing)

The RSA key pair under `tests/Fixtures/keys/` is only for automated tests. Never use it to sign or verify production SSO tickets.

```
composer test
composer analyse
composer format
```

Versioning
----------

[](#versioning)

- `0.x.y` — prerelease, API may change
- `1.x.y` — semver stable
- Adding optional JWT claims → minor; removing/renaming claims → major

License
-------

[](#license)

MIT — see [LICENSE.md](LICENSE.md).

###  Health Score

40

—

FairBetter than 86% of packages

Maintenance93

Actively maintained with recent releases

Popularity8

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity44

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~1 days

Total

8

Last Release

33d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/e1d388e924996c6946e6d7a855955a291bc933b78105395fcc2282a28cb48472?d=identicon)[jmkylvy](/maintainers/jmkylvy)

---

Top Contributors

[![jmluang](https://avatars.githubusercontent.com/u/11557732?v=4)](https://github.com/jmluang "jmluang (17 commits)")

---

Tags

jwtlaravelSSOjmluangsso-consumer

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/jmluang-sso-consumer/health.svg)

```
[![Health](https://phpackages.com/badges/jmluang-sso-consumer/health.svg)](https://phpackages.com/packages/jmluang-sso-consumer)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3325.1M337](/packages/psalm-plugin-laravel)[laravel/socialite

Laravel wrapper around OAuth 1 &amp; OAuth 2 libraries.

5.7k104.3M822](/packages/laravel-socialite)[larastan/larastan

Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel

6.4k51.0M7.4k](/packages/larastan-larastan)[laravel/passport

Laravel Passport provides OAuth2 server support to Laravel.

3.4k89.4M569](/packages/laravel-passport)[laravel/mcp

Rapidly build MCP servers for your Laravel applications.

76318.2M110](/packages/laravel-mcp)[illuminate/auth

The Illuminate Auth package.

9327.9M1.2k](/packages/illuminate-auth)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
