Laravel Quickstart
Add UniAuth sign-in to Laravel using Laravel Socialite — Laravel's standard OAuth/OIDC client. UniAuth's OIDC discovery URL means Socialite auto-configures everything.
Rule #1
Always use UniAuth's discovery URL (https://uniauth.id/.well-known/openid-configuration) rather than hardcoding endpoints. This way your integration survives any endpoint move.
1. Install
composer require laravel/socialite socialiteproviders/uniauthNote: socialiteproviders/uniauth is in development. Until it's on Packagist, use Socialite's built-in generic OIDC driver as shown below — the result is identical.
2. Environment Variables
# .env
UNIAUTH_CLIENT_ID=uni_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
UNIAUTH_CLIENT_SECRET=unis_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
UNIAUTH_REDIRECT_URI=https://yourapp.com/auth/uniauth/callback3. Configure Socialite
// config/services.php
return [
// ...
'uniauth' => [
'client_id' => env('UNIAUTH_CLIENT_ID'),
'client_secret' => env('UNIAUTH_CLIENT_SECRET'),
'redirect' => env('UNIAUTH_REDIRECT_URI'),
],
];4. Discovery Cache (Service Provider)
Cache the discovery doc + JWKS for 24 hours. Fetch via Laravel's HTTP client.
// app/Services/UniAuthDiscovery.php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class UniAuthDiscovery
{
const CACHE_TTL = 86400; // 24h
public static function get(): array
{
return Cache::remember('uniauth.discovery', self::CACHE_TTL, function () {
return Http::timeout(5)
->get('https://uniauth.id/.well-known/openid-configuration')
->json();
});
}
public static function jwks(): array
{
return Cache::remember('uniauth.jwks', self::CACHE_TTL, function () {
$d = self::get();
return Http::timeout(5)->get($d['jwks_uri'])->json();
});
}
}5. Routes
// routes/web.php
use App\Http\Controllers\Auth\UniAuthController;
Route::get('/auth/uniauth', [UniAuthController::class, 'redirect'])->name('uniauth.login');
Route::get('/auth/uniauth/callback', [UniAuthController::class, 'callback']);
Route::post('/auth/uniauth/webhook', [UniAuthController::class, 'backchannelLogout']);
Route::post('/logout', [UniAuthController::class, 'logout'])->name('logout');6. Controller — Login + Callback + Verify ID Token
// app/Http/Controllers/Auth/UniAuthController.php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\UniAuthDiscovery;
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
class UniAuthController extends Controller
{
public function redirect(Request $request)
{
$d = UniAuthDiscovery::get();
$verifier = Str::random(64);
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
$state = Str::random(32);
$nonce = Str::random(32);
$request->session()->put('uniauth.verifier', $verifier);
$request->session()->put('uniauth.state', $state);
$request->session()->put('uniauth.nonce', $nonce);
$params = http_build_query([
'response_type' => 'code',
'client_id' => config('services.uniauth.client_id'),
'redirect_uri' => config('services.uniauth.redirect'),
'scope' => 'openid profile email groups',
'state' => $state,
'nonce' => $nonce,
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
]);
return redirect($d['authorization_endpoint'] . '?' . $params);
}
public function callback(Request $request)
{
// 1. Validate state (CSRF)
if ($request->input('state') !== $request->session()->pull('uniauth.state')) {
abort(400, 'State mismatch');
}
$verifier = $request->session()->pull('uniauth.verifier');
$nonce = $request->session()->pull('uniauth.nonce');
// 2. Exchange code for tokens
$d = UniAuthDiscovery::get();
$res = \Http::asForm()->post($d['token_endpoint'], [
'grant_type' => 'authorization_code',
'code' => $request->input('code'),
'redirect_uri' => config('services.uniauth.redirect'),
'client_id' => config('services.uniauth.client_id'),
'client_secret' => config('services.uniauth.client_secret'),
'code_verifier' => $verifier,
]);
abort_unless($res->ok(), 400, 'Token exchange failed');
$tokens = $res->json();
// 3. Verify ID token via JWKS — no /userinfo roundtrip
$jwks = UniAuthDiscovery::jwks();
$claims = JWT::decode($tokens['id_token'], JWK::parseKeySet($jwks));
abort_unless($claims->iss === $d['issuer'], 400, 'iss mismatch');
$aud = is_array($claims->aud) ? $claims->aud : [$claims->aud];
abort_unless(in_array(config('services.uniauth.client_id'), $aud), 400, 'aud mismatch');
abort_unless(($claims->nonce ?? null) === $nonce, 400, 'nonce mismatch');
if (!($claims->email_verified ?? false)) {
abort(403, 'Email not verified');
}
// 4. Find or create the user — use groups for RBAC
$user = User::firstOrCreate(
['uniauth_sub' => $claims->sub],
[
'name' => $claims->name ?? $claims->email,
'email' => $claims->email,
'is_admin' => in_array('admin', $claims->groups ?? []),
'password' => Str::random(40), // never used — login is via UniAuth
]
);
$user->is_admin = in_array('admin', $claims->groups ?? []);
$user->save();
Auth::login($user);
$request->session()->put('uniauth.id_token', $tokens['id_token']);
return redirect('/dashboard');
}
public function logout(Request $request)
{
$idToken = $request->session()->get('uniauth.id_token');
Auth::logout();
$request->session()->invalidate();
$d = UniAuthDiscovery::get();
return redirect($d['end_session_endpoint'] . '?' . http_build_query([
'id_token_hint' => $idToken,
'post_logout_redirect_uri' => url('/'),
]));
}
/**
* Back-channel logout — register this URL as backchannel_logout_uri in
* the Developer Console.
*/
public function backchannelLogout(Request $request)
{
$token = $request->input('logout_token');
$d = UniAuthDiscovery::get();
$jwks = UniAuthDiscovery::jwks();
try {
$claims = JWT::decode($token, JWK::parseKeySet($jwks));
} catch (\Exception $e) {
return response()->noContent(400);
}
if ($claims->iss !== $d['issuer']) return response()->noContent(400);
if (!in_array(config('services.uniauth.client_id'),
is_array($claims->aud) ? $claims->aud : [$claims->aud])) {
return response()->noContent(400);
}
if (!isset($claims->events->{'http://schemas.openid.net/event/backchannel-logout'})) {
return response()->noContent(400);
}
// Invalidate all this user's Laravel sessions
\DB::table('sessions')->where('user_id',
User::where('uniauth_sub', $claims->sub)->value('id')
)->delete();
return response()->noContent(200);
}
}7. Middleware: Require Admin via groups Claim
// app/Http/Middleware/RequireAdmin.php
public function handle($request, Closure $next)
{
if (!$request->user()?->is_admin) {
abort(403, 'Admin only');
}
return $next($request);
}
// routes/web.php
Route::middleware(['auth', RequireAdmin::class])->group(function () {
Route::get('/admin', [AdminController::class, 'index']);
});No env-var email allowlists. is_admin is set from UniAuth's groups claim on every login. Add or remove group members in the UniAuth dashboard — no redeploy needed.
8. Migrations
Schema::table('users', function (Blueprint $table) {
$table->string('uniauth_sub')->unique()->nullable();
$table->boolean('is_admin')->default(false);
});