unimontes-cead / sso-client
Socialite driver and client-app integration helpers for the UNIMONTES CEAD SSO.
Requires
- php: ^8.2
- guzzlehttp/guzzle: ^7.0
- illuminate/contracts: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
- laravel/socialite: ^5.14
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
ssoSocialite driver against the SSO's own OAuth2 endpoints (/oauth/authorize,/oauth/token,/api/user) — no manualconfig/services.phpentry needed. - Exposes the data returned by the SSO as a typed
SsoUserDTO (id,name,email,cpf,phone,avatarUrl,roles) instead of a generic array. Every field butidis nullable: the SSO omits a field entirely when this client wasn't granted the matching scope, sonullmeans "not authorized to see this", not "empty". rolesis an array ofSsoUserRole(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).valueis typed as theSsoRoleenum — 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. amatchon$role->value) instead of guessing at an opaque ID.SsoUser::hasRole(SsoRole $role): boolis a shortcut for checking membership.- Defines
Contracts\SsoAuthenticatable, an interface your ownUsermodel implements to say how SSO data maps to a local user. - Optionally registers ready-made
GET {prefix}/redirect,GET {prefix}/callback, andPOST {prefix}/logoutroutes that do the full login/logout round-trip for you. SsoSyncClientpushes 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 ofname,email,cpf,phone,avatar_urland 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_urlis 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. Passavatar_url: nullto 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()throwsSsoSyncException. - 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
SsoSyncExceptionon 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'), ); });