societyai.org
Preparing the collaboration space...
Society AI society-ai.org
Developers

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

Terminal
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.

.env
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

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.

database/migrations/…_add_sai_sso_to_users.php
<?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

routes/web.php
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).

bootstrap/app.php
->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

app/Http/Controllers/SaiSsoController.php
<?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 state and 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.

app/Http/Middleware/TrySilentLogin.php
<?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 (a POST /auth/logout form).
  • Text at least 15px and tap targets at least 44px. Some members are over 60.
Next section Other stacks

Start here support@society-ai.org