UniAuth.ID

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/uniauth

Note: 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/callback

3. 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);
});

Next