spinen/laravel-discourse-sso
Provides single sign-on between a Laravel application and a Discourse forum using Discourse's SSO protocol. It builds signed payloads, validates nonces, and lets users log in to Discourse with their existing Laravel credentials, keeping account data in sync and avoiding separate forum passwords.
composer require spinen/laravel-discourse-sso
php artisan vendor:publish --tag=config # inspect src/ if the tag differs
config/discourse-sso.php first. It is the contract between your app and Discourse. You are looking for the shared secret (or the env key it reads) and any route/URL settings. Put the same value in .env as DISCOURSE_SSO_SECRET and confirm config:cache isn't serving a stale copy.enable_sso = onsso_url = the URL of the package's login route (e.g. https://app.example.com/discourse/sso)sso_secret = the exact same secret string as your .envphp artisan route:list | grep -i sso. Many Spinen packages auto-register routes in the service provider; if not, define them in routes/web.php pointing at the package controller.auth (or your guard middleware), then click Log In on the Discourse forum. Discourse will redirect to your app; after Laravel auth succeeds, your app redirects back and Discourse creates/links the account. That round trip is the whole feature — get it working end-to-end before customizing payload fields.Where to look next: src/ for the payload signing/validation class and the controller handling the two legs of the flow. That class is the extension point you will actually touch.
The two-leg flow. Discourse SSO always has two hops:
?sso=<payload>&sig=<hmac> to your app. Validate the sig against the secret, base64-decode the payload, and read the nonce.nonce, signs it, and redirects to {sso_url}?sso=<payload>&sig=<hmac>.Most day-to-day integration work happens on the outbound leg, where the user record is mapped.
Mapping your user model to the payload. The fields Discourse cares about:
$payload = [
'nonce' => $nonce, // echo it back — required
'external_id' => $user->getKey(), // must be stable forever
'email' => $user->email,
'username' => $user->username, // or derive from email/name
'name' => $user->name,
'avatar_url' => $user->avatar_url ?: null,
'admin' => $user->hasRole('admin'),
'moderator' => $user->hasRole('moderator'),
// custom fields are prefixed and merged in
'custom' => ['plan' => $user->plan],
];
Prefer your own controller over the package's. If the bundled controller hardcodes Auth::user(), subclass it or write a thin controller that calls the package's signing helper. That keeps guard/tenant logic in your hands while still using the package's HMAC implementation (so you don't re-implement base64url + signature quirks).
class SsoController extends Controller
{
public function __construct(private DiscourseSso $sso) {}
public function login(Request $request)
{
$data = $this->sso->validate($request->query('sso'), $request->query('sig'));
return redirect()->route('discourse.sso.callback')
->with('sso_nonce', $data['nonce']);
}
public function callback(Request $request)
{
$user = $request->user();
return redirect()->away($this->sso->buildRedirect($user, session('sso_nonce')));
}
}
Authorization patterns that pay off:
['auth', 'verified'] on the login leg so unverified accounts never reach Discourse.admin/moderator flags computed from your policy/roles, not hardcoded.Syncing back. The login leg is the natural place to record discourse_last_login_at, provision a Discourse-specific profile row, or reconcile a username collision before the payload is sent.
Testing. You rarely want a live Discourse in CI. Generate a payload with the known secret and assert your controller redirects with a correctly signed sso param. A unit test on the encode/decode helpers plus one HTTP feature test on the redirect covers most regressions.
Signature and encoding
base64url, -/_, no =). Plain PHP base64_encode will produce a signature mismatch. Use rtrim(strtr(base64_encode($s), '+/', '-_'), '=').urldecode/$request->query() before HMACing..env will cause an opaque 422 from Discourse. config:cache after setting it.Payload rules
nonce must be echoed back or Discourse rejects the login.external_id must be immutable. Switching from id to a UUID later orphans every Discourse account. Pick it deliberately on day one.email must be the one Discourse should trust; don't send a stale or placeholder address or you'll get accounts Discourse can't verify.http_build_query, not as a POST body — Discourse expects the redirect.custom.* and must be whitelisted in the Discourse SSO settings on the forum side, or they're silently dropped.Package / framework compatibility
ServiceProvider::$namespace removal, package auto-discovery (extra.laravel.providers in composer.json), typed method signatures, and Route::middleware API changes. Expect to fork or add a dont-discover entry and register manually.web middleware group or CSRF expectations. The SSO endpoint is a GET so CSRF isn't an issue, but session middleware is — the inbound leg must see the authenticated session.auth must run before the SSO controller resolves the user, or you'll redirect to Discourse with a null user.Debugging
sso/sig, the decoded payload, and the outgoing payload before the redirect. Most failures look identical from the browser (Discourse shows "Login Error").APP_URL correctly and configure TrustProxies, or the sso_url you redirect to may be http:// and Discourse rejects it.Extension points & limits
How can I help you explore Laravel packages today?