Laravel guide
Complete, tested code: sign-in, automatic sign-in, sign-out, and back-channel logout.
All code here was tried on a fresh Laravel 13 app. You don't need an OIDC package. Laravel's own Http client and firebase/php-jwt for checking tokens are enough.
1. Install the package
composer require firebase/php-jwt
2. Fill in .env
You get the Client ID and secret from an admin (see Client ID and secret). The callback URL must match the registered one exactly.
SAI_SSO_ISSUER=https://accounts.society-ai.org
SAI_SSO_CLIENT_ID=paste-the-client-id
SAI_SSO_CLIENT_SECRET=paste-the-client-secret
SAI_SSO_REDIRECT_URI=https://kelas.society-ai.org/auth/callback
SAI_SSO_SCOPES="openid profile email membership"
3. Add it to config/services.php
'sai_sso' => [
'issuer' => env('SAI_SSO_ISSUER', 'https://accounts.society-ai.org'),
'client_id' => env('SAI_SSO_CLIENT_ID'),
'client_secret' => env('SAI_SSO_CLIENT_SECRET'),
'redirect' => env('SAI_SSO_REDIRECT_URI'),
'scopes' => env('SAI_SSO_SCOPES', 'openid profile email membership'),
],
4. Prepare the users table
Members are recognised by sub, not by email, because emails can change. The password column becomes nullable, since members never set a password in your app.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->string('sai_sub')->nullable()->unique()->after('id');
$table->string('membership_status')->default('none');
// Members never set a password in your app.
$table->string('password')->nullable()->change();
});
}
};
If your service needs no user table at all, you can keep the claims in the session and skip this step.
5. Routes
use App\Http\Controllers\SaiSsoController;
Route::get('/auth/redirect', [SaiSsoController::class, 'redirect'])->name('login');
Route::get('/auth/callback', [SaiSsoController::class, 'callback']);
Route::post('/auth/logout', [SaiSsoController::class, 'logout'])->middleware('auth')->name('logout');
Route::post('/auth/backchannel-logout', [SaiSsoController::class, 'backchannelLogout']);
The sign-in route is named login, so the auth middleware sends guests to Society AI sign-in on its own.
6. Middleware
Back-channel logout is called from the Society AI server without a CSRF token, so that route is excluded. The second line is only needed if you want members who are signed in elsewhere to be recognised right away (see step 8).
->withMiddleware(function (Middleware $middleware): void {
// Society AI calls this from its server, without a CSRF token.
$middleware->validateCsrfTokens(except: ['auth/backchannel-logout']);
// Optional: recognise members who are already signed in elsewhere.
$middleware->appendToGroup('web', \App\Http\Middleware\TrySilentLogin::class);
})
Don't use withoutMiddleware(VerifyCsrfToken::class). Since Laravel 13, CSRF in the web group is handled by PreventRequestForgery, so that line does nothing and every back-channel call gets a 419.
7. Controller
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class SaiSsoController extends Controller
{
/** Errors that only mean "this member is not signed in yet". */
private const SILENT_MISSES = ['login_required', 'consent_required', 'interaction_required'];
public function redirect(Request $request): RedirectResponse
{
$verifier = Str::random(64);
$state = Str::random(40);
$silent = $request->boolean('silent');
$request->session()->put('sai_sso', compact('state', 'verifier', 'silent'));
$query = [
'response_type' => 'code',
'client_id' => config('services.sai_sso.client_id'),
'redirect_uri' => config('services.sai_sso.redirect'),
'scope' => config('services.sai_sso.scopes'),
'state' => $state,
'code_challenge' => rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='),
'code_challenge_method' => 'S256',
];
if ($silent) {
$query['prompt'] = 'none';
}
return redirect()->away($this->discovery()['authorization_endpoint'].'?'.http_build_query($query));
}
public function callback(Request $request): RedirectResponse
{
$pending = $request->session()->pull('sai_sso');
$silent = is_array($pending) && ($pending['silent'] ?? false);
if (! is_array($pending) || ! hash_equals($pending['state'], (string) $request->query('state'))) {
return $silent ? redirect()->intended('/') : redirect('/')->with('status', 'Sesi masuk sudah lewat. Coba masuk lagi.');
}
if ($request->filled('error')) {
if ($silent && in_array($request->query('error'), self::SILENT_MISSES, true)) {
return redirect()->intended('/'); // not signed in anywhere: carry on as a guest
}
return redirect('/')->with('status', 'Masuk dibatalkan. Coba lagi sebentar.');
}
$tokens = Http::asForm()
->withBasicAuth(config('services.sai_sso.client_id'), config('services.sai_sso.client_secret'))
->post($this->discovery()['token_endpoint'], [
'grant_type' => 'authorization_code',
'code' => (string) $request->query('code'),
'redirect_uri' => config('services.sai_sso.redirect'),
'code_verifier' => $pending['verifier'],
])->throw()->json();
$claims = $this->verifyJwt($tokens['id_token']);
$user = $this->findOrCreateUser($claims);
Auth::login($user);
$request->session()->regenerate();
$request->session()->put('sai_id_token', $tokens['id_token']);
// Back-channel logout only sends sid, so remember which session it belongs to.
if (isset($claims['sid'])) {
Cache::put('sai-sid:'.$claims['sid'], $request->session()->getId(), now()->addDays(30));
}
return redirect()->intended('/');
}
public function logout(Request $request): RedirectResponse
{
$idToken = $request->session()->get('sai_id_token');
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
$query = $idToken ? '?'.http_build_query(['id_token_hint' => $idToken]) : '';
// Society AI signs the member out everywhere and shows "Kamu sudah keluar".
return redirect()->away($this->discovery()['end_session_endpoint'].$query);
}
public function backchannelLogout(Request $request): Response
{
try {
$claims = $this->verifyJwt((string) $request->input('logout_token'));
} catch (\Throwable) {
return response('', 400);
}
$events = (array) ($claims['events'] ?? []);
if (! array_key_exists('http://schemas.openid.net/event/backchannel-logout', $events) || isset($claims['nonce'])) {
return response('', 400);
}
if (isset($claims['sid']) && ($sessionId = Cache::pull('sai-sid:'.$claims['sid']))) {
app('session')->getHandler()->destroy($sessionId);
}
return response('', 200);
}
/** @param array<string, mixed> $claims */
private function findOrCreateUser(array $claims): User
{
$user = User::query()->where('sai_sub', $claims['sub'])->first();
// An account made in your app before Society AI login: link it once, by verified email.
if (! $user && ($claims['email_verified'] ?? false) && isset($claims['email'])) {
$user = User::query()->whereNull('sai_sub')->where('email', $claims['email'])->first();
}
$user ??= new User;
$user->forceFill([
'sai_sub' => $claims['sub'],
'name' => $claims['name'] ?? '',
'email' => $claims['email'] ?? null,
'membership_status' => $claims['membership']['status'] ?? 'none',
])->save();
return $user;
}
/** @return array<string, mixed> */
private function verifyJwt(string $jwt): array
{
JWT::$leeway = 30;
try {
$decoded = JWT::decode($jwt, JWK::parseKeySet($this->keys()));
} catch (\UnexpectedValueException) {
// The signing key may have been rotated: fetch the keys once more.
Cache::forget('sai-sso-jwks');
$decoded = JWT::decode($jwt, JWK::parseKeySet($this->keys()));
}
$claims = json_decode(json_encode($decoded), true);
$audience = (array) ($claims['aud'] ?? []);
if (($claims['iss'] ?? null) !== config('services.sai_sso.issuer')
|| ! in_array(config('services.sai_sso.client_id'), $audience, true)) {
throw new \UnexpectedValueException('Token is not for this app.');
}
return $claims;
}
/** @return array<string, mixed> */
private function keys(): array
{
return Cache::remember('sai-sso-jwks', 3600, fn () => Http::get($this->discovery()['jwks_uri'])->throw()->json());
}
/** @return array<string, string> */
private function discovery(): array
{
return Cache::remember('sai-sso-discovery', 3600, fn () => Http::get(
rtrim(config('services.sai_sso.issuer'), '/').'/.well-known/openid-configuration'
)->throw()->json());
}
}
What it does:
- redirect makes a fresh
stateand PKCE pair every time, then sends the browser to Society AI. - callback checks
state, swaps the code for tokens from the server, checks the ID token signature, then creates or updates the user. - findOrCreateUser looks the user up by
sub. An account that existed in your app before Society AI sign-in is linked once, by verified email. - logout clears your session, then sends the browser to Society AI so the member is signed out of every service.
- backchannelLogout hears from Society AI when the member signs out elsewhere, and deletes the matching session.
Back-channel logout needs a session driver that can delete a session by id: database, redis, or file. The cookie driver can't.
8. Automatic sign-in (optional)
This middleware quietly asks Society AI once per browser session. Members already signed in to another service come back signed in. Everyone else comes back as a guest, without seeing a sign-in screen. Bots and JSON requests are skipped.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Once per browser session, quietly ask Society AI whether this visitor is
* already signed in. Members come back signed in; everyone else comes back
* as a guest, without seeing a login screen.
*/
class TrySilentLogin
{
public function handle(Request $request, Closure $next): Response
{
if ($request->isMethod('GET')
&& ! $request->user()
&& ! $request->expectsJson()
&& ! $request->is('auth/*')
&& ! $request->session()->has('sai_sso_checked')
&& ! preg_match('/bot|crawl|spider|slurp|preview|monitor/i', (string) $request->userAgent())) {
$request->session()->put('sai_sso_checked', true);
$request->session()->put('url.intended', $request->fullUrl());
return redirect()->route('login', ['silent' => 1]);
}
return $next($request);
}
}
If you don't want it, remove the appendToGroup line from step 6. How it behaves is covered in Automatic sign-in and sign-out.
9. Button and account menu
So every service feels the same to members:
- The sign-in button just says "Masuk" ("Sign in" in English). Not "Sign in with SSO" or another logo, since a Society AI account is the only way in.
- The account menu has the member's name, Akun saya (to
https://society-ai.org/member), and Keluar (aPOST /auth/logoutform). - Text at least 15px and tap targets at least 44px. Some members are over 60.