unimontes-cead/sso-client

Socialite driver and client-app integration helpers for the UNIMONTES CEAD SSO.

Maintainers

Package info

github.com/suporte-sistemas-cead/sso-client

pkg:composer/unimontes-cead/sso-client

Transparency log

Statistics

Installs: 6

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.1.0 2026-07-17 15:16 UTC

This package is auto-updated.

Last update: 2026-07-17 15:18:12 UTC


README

Laravel Socialite driver + login glue for applications that authenticate through the UNIMONTES CEAD SSO (a Laravel Passport authorization server).

What this package does

  • Registers a sso Socialite driver against the SSO's own OAuth2 endpoints (/oauth/authorize, /oauth/token, /api/user) — no manual config/services.php entry needed.
  • Exposes the data returned by the SSO as a typed SsoUser DTO (id, name, email, cpf, phone, avatarUrl, roles) instead of a generic array. Every field but id is nullable: the SSO omits a field entirely when this client wasn't granted the matching scope, so null means "not authorized to see this", not "empty".
  • roles is an array of SsoUserRole (name, value, startDate, endDate) — every role the user currently holds on the SSO (a user can hold more than one at once, each with its own date range). value is typed as the SsoRole enum — the SSO's full, closed set of possible roles — so you know exactly what can come back and can map your own local roles against it (e.g. a match on $role->value) instead of guessing at an opaque ID. SsoUser::hasRole(SsoRole $role): bool is a shortcut for checking membership.
  • Defines Contracts\SsoAuthenticatable, an interface your own User model implements to say how SSO data maps to a local user.
  • Optionally registers ready-made GET {prefix}/redirect, GET {prefix}/callback, and POST {prefix}/logout routes that do the full login/logout round-trip for you.
  • SsoSyncClient pushes local edits back up to the SSO — the mirror image of the login flow above — so a "my profile" or "change password" screen in your app can keep the SSO's own copy of the user in sync instead of drifting out of date.

Scopes are the SSO's decision, not this app's

The driver always requests every scope the SSO knows about (name,email,cpf,phone,avatar,role) — there's no SSO_SCOPES env var to configure here. That's intentional: the SSO's ScopeRepository clips whatever's requested down to whatever an admin actually granted this client on the SSO's own "Aplicações" screen, regardless of what's asked for. So requesting everything unconditionally is safe — you only ever get back what the SSO already decided this client is allowed to see — and it means scope configuration lives in exactly one place (the SSO), not duplicated into every client's .env.

Installation

composer require unimontes-cead/sso-client
composer require laravel/socialite

Laravel's package auto-discovery registers SsoClientServiceProvider automatically — no manual entry in bootstrap/providers.php needed.

Configuration

Publish the config file:

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

Set these in .env (get SSO_CLIENT_ID/SSO_CLIENT_SECRET from an admin on the SSO's "Aplicações" screen — its "URL inicial" doesn't need to point here, but its registered redirect URIs must include your callback URL):

SSO_BASE_URL=https://sso.unimontes.example
SSO_CLIENT_ID=
SSO_CLIENT_SECRET=
# Only needed if you call SsoSyncClient (see "Syncing data back to the
# SSO" below) — a separate credential from SSO_CLIENT_SECRET, generated
# on the same "Aplicações" screen, under "Token de API".
# SSO_API_TOKEN=
# Only needed if you disable the built-in routes below and drive
# Socialite yourself with a different callback path.
# SSO_REDIRECT_URI=https://your-app.example/auth/sso/callback
SSO_ROUTES_ENABLED=true
SSO_USER_MODEL=App\Models\User

Implementing the contract

Your App\Models\User must implement SsoAuthenticatable::fromSsoData() — it receives the SsoUser DTO and returns the local user to log in as, typically an updateOrCreate() keyed on the SSO's id:

use UnimontesCead\SsoClient\Contracts\SsoAuthenticatable;
use UnimontesCead\SsoClient\SsoUser;

class User extends Authenticatable implements SsoAuthenticatable
{
    public static function fromSsoData(SsoUser $ssoUser): static
    {
        return static::updateOrCreate(
            ['sso_id' => $ssoUser->id],
            [
                'name' => $ssoUser->name,
                'email' => $ssoUser->email,
                'cpf' => $ssoUser->cpf,
                'phone' => $ssoUser->phone,
                'avatar_url' => $ssoUser->avatarUrl,
            ],
        );
    }

    public function ssoId(): string
    {
        return $this->sso_id;
    }
}

You'll need a sso_id column (string/uuid, unique) on your users table to key that lookup on. ssoId() is what SsoSyncClient uses to address this user on the SSO when pushing edits back (see "Syncing data back to the SSO" below).

Using roles

$ssoUser->roles is null when this client wasn't granted the role scope, otherwise an array of SsoUserRole, each with a value: SsoRole. SsoRole is a plain PHP enum listing every role the SSO can ever assign (Administrador, CoordenadorGeral, EquipeMultidisciplinar, EquipePedagogica, EquipeDeMonitoramento, CoordenadorDeCurso, CoordenadorDeTutoria, Professor, Tutor, Aluno) — a deliberate copy of the SSO's own Role enum, kept in sync by hand, so you get autocomplete and a compile-time-checked match instead of comparing against a raw int.

A common pattern is mapping each SSO role to your own local role:

use UnimontesCead\SsoClient\SsoRole;
use UnimontesCead\SsoClient\SsoUserRole;

$localRoles = $ssoUser->roles !== null
    ? array_map(fn (SsoUserRole $role) => match ($role->value) {
        SsoRole::Administrador, SsoRole::CoordenadorGeral => 'admin',
        SsoRole::Professor, SsoRole::Tutor => 'staff',
        default => 'member',
    }, $ssoUser->roles)
    : [];

$user->roles()->sync($localRoles);

// or a quick membership check, e.g. gating admin-only UI:
if ($ssoUser->hasRole(SsoRole::Administrador)) { /* ... */ }

If you'd rather use the SSO's roles as your local roles directly (no mapping), just persist $role->value->value (the enum's backing int) or $role->value->name instead.

Logging in

With the built-in routes (default), just link to the redirect route:

<a href="{{ route('sso.redirect') }}">Entrar com o SSO</a>

That's the whole flow — it exchanges the code, fetches /api/user, calls your User::fromSsoData(), logs the user in, and redirects to sso.routes.redirect_after_login.

Logging out

<form method="POST" action="{{ route('sso.logout') }}">
    @csrf
    <button type="submit">Sair</button>
</form>

This ends the local session and redirects the browser to the SSO's own single-logout endpoint (GET /oauth/logout/{client_id} on the SSO), which ends the SSO's session and revokes every OAuth token this user has been issued, then bounces back to this client's registered "URL inicial".

What this does and doesn't do: the SSO won't silently re-authenticate the user anymore, so the next "entrar com o SSO" — in this app or any other — requires fresh credentials. It does not reach into other client apps' already-open local sessions and close them; that would require a back-channel logout protocol (each client exposing its own logout webhook that the SSO calls server-to-server) which this package doesn't implement. For most internal tooling, killing the SSO session is the meaningful "logout everywhere" boundary — treat true simultaneous multi-tab/multi-app session termination as a separate, bigger feature if you need it.

Syncing data back to the SSO

Logging in pulls the user's data down from the SSO. If your app has its own "meu perfil" or "alterar senha" screen, SsoSyncClient pushes those edits back up, so the SSO's own copy doesn't drift out of date.

It's resolved from the container, so just type-hint it:

use UnimontesCead\SsoClient\Exceptions\SsoSyncException;
use UnimontesCead\SsoClient\SsoSyncClient;

class ProfileController extends Controller
{
    public function update(Request $request, SsoSyncClient $sync)
    {
        $user = $request->user();

        $user->update($request->validated());

        try {
            $sync->updateProfile($user, $request->only(['name', 'email', 'cpf', 'phone', 'avatar_url']));
        } catch (SsoSyncException $e) {
            report($e); // local save already succeeded; sync is best-effort
        }

        return back();
    }

    public function updatePassword(Request $request, SsoSyncClient $sync)
    {
        $data = $request->validate(['password' => ['required', 'confirmed', Password::defaults()]]);

        $request->user()->update(['password' => $data['password']]);

        $sync->updatePassword($request->user(), $data['password']);

        return back();
    }
}

A few things worth knowing:

  • Which fields actually land is the SSO's decision, not yours. updateProfile() accepts any subset of name, email, cpf, phone, avatar_url and returns the field names the SSO actually applied — a field you sent can be silently missing from that list if this client wasn't granted the matching scope on the SSO's "Aplicações" screen. Same rule as reading data at login: this app never needs its own copy of which fields it's allowed to touch.
  • avatar_url is a URL, not a file upload. The SSO fetches the image from that URL itself and stores it as the user's new avatar — nothing to stream or multipart-encode on your end. The URL must be publicly reachable by the SSO server, point directly at the image (jpeg, png, webp or gif, 2MB max), and stay valid at least until the SSO has fetched it. Pass avatar_url: null to clear the avatar.
  • Password sync is opt-in per client. An admin has to explicitly enable "Permitir sincronização de senha" for this client on the SSO; otherwise updatePassword() throws SsoSyncException.
  • It's authenticated by SSO_API_TOKEN, not the user's OAuth token — there's no token to refresh or that can expire mid-request, so it's safe to call from a background job as well as a request.
  • Both methods throw SsoSyncException on any failure (network, bad credentials, validation rejected by the SSO). Treat it as best-effort: the local write already happened, so a sync failure shouldn't undo it or block the response — log it and move on, as in the example above.

Rolling your own flow

Set SSO_ROUTES_ENABLED=false and drive it yourself:

use Laravel\Socialite\Facades\Socialite;

Route::get('/login/sso', fn () => Socialite::driver('sso')->redirect());

Route::get('/login/sso/callback', function () {
    $ssoUser = Socialite::driver('sso')->user(); // SsoUser instance
    $user = \App\Models\User::fromSsoData($ssoUser);
    auth()->login($user, remember: true);
    return redirect('/');
});

Route::post('/logout/sso', function (\Illuminate\Http\Request $request) {
    auth()->guard('web')->logout();
    $request->session()->invalidate();
    $request->session()->regenerateToken();
    return redirect()->away(
        rtrim(config('sso.base_url'), '/').'/oauth/logout/'.config('sso.client_id'),
    );
});