Developer Resources

Get started quickly with our automated scripts and collections.

1. Architecture Overview

The Main Site acts as the authoritative Identity Provider (IdP). It is responsible for authenticating users and issuing tokens that allow access to the Central Blogs system.

Bridge Sso Pattern: We use a "Bridge and Introspection" pattern. Instead of sending sensitive user data in the URL, we send a short-lived, opaque bridge_token. The Blogs system then calls back to your Main Site (Introspection) to validate this token securely.
Critical Rules:
  • Guest mode is honored ONLY when there is NO local authenticated session in Blogs.
  • If a local session exists and the URL contains ?guest=1, Blogs will ignore the flag and strip it from the URL.
  • bridge_token is the PRIMARY authentication signal. Blogs validates it via introspection—this is the source of truth.
  • JWT is optional/secondary (used for replay protection and extra claims). Introspection remains authoritative.

2. Callback Host Allowlist (403 Fix)

To prevent Open Redirect vulnerabilities, the Main Site MUST validate the callback host against an allowlist.

Configuration Rules
  • Hostnames Only: Do NOT include ports or protocols. Example: blogs.captchaai.test (Correct), blogs.captchaai.test:8001 (Incorrect).
  • Protocol: Production requires HTTPS. Local dev environments may allow HTTP if configured.
  • Error: If you see "CALLBACK HOST NOT ALLOWED", it means the incoming callback host is missing from your configuration.

3. Installation & Setup

Environment Configuration

Add the following to your Main Site's .env file. Note that exact key names depend on how you map them in your config/services.php.

# .env (Example)
BLOGS_SSO_CALLBACK=https://blogs.flare99.com/auth/bridge
BLOGS_SSO_ALLOWED_HOSTS=blogs.flare99.com,blogs.local.test
BLOGS_SSO_BRIDGE_TTL_SECONDS=3600
BLOGS_SSO_USER_VERSION_TTL_SECONDS=604800
BLOGS_API_KEY=your_secure_random_64_char_key_here

Exact ENV keys depend on config/services.php mapping. Ensure services.blogs_sso.* maps to these env values.

4. Modifying Standard Authentication

You need to modify your AuthenticatedSessionController to handle the SSO flow correctly during login and logout.

A. Login Logic (store)

After a successful login, check if the request contains a callback parameter. If so, redirect to the Bridge Start route instead of the dashboard.

public function store(LoginRequest $request): RedirectResponse
{
    $request->authenticate();
    $request->session()->regenerate();

    // SSO Interception Logic
    if ($request->filled('callback')) {
        $callback = $request->input('callback');
        $returnTo = $this->sanitizeReturnTo($request->input('return_to'));
        
        // Ensure we only redirect to allowed callbacks (from config)
        $allowedCallback = config('services.blogs_sso.callback');
        
        // Always enforce the configured callback to prevent spoofing
        $qs = http_build_query([
            'callback' => ($callback === $allowedCallback) ? $callback : $allowedCallback,
            'return_to' => $returnTo
        ]);

        // Redirect to Bridge Start
        return redirect()->to(url('/sso/bridge/start') . '?' . $qs);
    }

    return redirect()->intended(route('dashboard'));
}

B. Logout Logic (destroy)

When logging out, you MUST update the session version in the cache. This forces the Central Blogs system to invalidate its local session upon the next introspection check.

Cross-Domain Limitation: Main Site cannot delete cookies set on the Blogs domain. We rely on "Session Versioning" (Cache) to invalidate the session remotely.
public function destroy(Request $request): RedirectResponse
{
    $user = Auth::user();

    Auth::guard('web')->logout();

    // 1. Bump Session Version (Critical for SSO security)
    // This invalidates the bridge token on the Blogs side during introspection
    if ($user) {
        try {
            Cache::put('sso_user_version:' . $user->id, Str::uuid()->toString());
        } catch (\Exception $e) {
            Log::warning('Failed to bump sso_user_version', ['error' => $e->getMessage()]);
        }
    }

    // 2. Optional: Clear local cookie if on same domain
    // Cookie::queue(Cookie::forget('blogs_bridge_token'));

    $request->session()->invalidate();
    $request->session()->regenerateToken();

    return redirect('/');
}

5. Bridge Controller Logic

The bridgeStart method initiates the flow. It generates the tokens and redirects back to the blog.

public function bridgeStart(Request $request)
{
    $callback = $request->query('callback');
    $returnTo = $request->query('return_to', '/');

    // 1. Parse and validate callback URL
    $parsed = parse_url($callback);
    $scheme = $parsed['scheme'] ?? '';
    $host = $parsed['host'] ?? '';
    $path = $parsed['path'] ?? '/';
    
    // 2. Protocol enforcement: HTTPS in production, HTTP allowed for dev hosts only
    $isLocalEnv = app()->environment(['local', 'development']);
    $isDevHost = in_array($host, ['localhost', '127.0.0.1']) || 
                 str_ends_with($host, '.test') || str_ends_with($host, '.local');
    
    if ($scheme === 'http' && (!$isLocalEnv || !$isDevHost)) {
        abort(403, 'HTTPS required for production');
    }

    // 3. Normalize and validate callback path
    $normalizedPath = '/' . trim($path, '/');
    $normalizedPath = preg_replace('#/+#', '/', $normalizedPath); // collapse duplicate slashes
    if ($normalizedPath !== '/auth/bridge') {
        abort(403, 'Invalid callback path. Must be /auth/bridge');
    }

    // 4. Host allowlist validation
    $allowedHosts = config('services.blogs_sso.allowed_hosts', []);
    if (is_string($allowedHosts)) {
        $allowedHosts = array_map('trim', explode(',', $allowedHosts));
    }
    
    // Normalize allowlist entries (strip ports) and callback host
    $normalizedAllowedHosts = array_map(fn($h) => explode(':', $h)[0], $allowedHosts);
    $callbackHostOnly = explode(':', $host)[0];
    
    if (!in_array($callbackHostOnly, $normalizedAllowedHosts)) {
        abort(403, 'CALLBACK HOST NOT ALLOWED');
    }

    // 5. Guest flow
    if (!Auth::check()) {
        $guestQuery = http_build_query([
            'guest' => 1,
            'return_to' => $this->sanitizeReturnTo($returnTo)
        ]);
        return redirect($callback . '?' . $guestQuery);
    }

    // 6. Authenticated flow: Generate bridge token
    $bridgeToken = Str::uuid()->toString();
    $userId = Auth::id();
    
    // 7. Ensure user session version exists
    $userVersionKey = "sso_user_version:{$userId}";
    $userSessionVersion = Cache::get($userVersionKey);
    if (!$userSessionVersion) {
        $userSessionVersion = Str::uuid()->toString();
        $userVersionTtl = config('services.blogs_sso.user_version_ttl_seconds', 604800);
        Cache::put($userVersionKey, $userSessionVersion, now()->addSeconds($userVersionTtl));
    }

    // 8. Cache bridge token data for introspection
    $bridgeTtl = config('services.blogs_sso.bridge_ttl_seconds', 3600);
    Cache::put("sso_bridge:{$bridgeToken}", [
        'user_id' => $userId,
        'session_id' => session()->getId(),
        'issued_at' => now()->timestamp,
        'callback' => $callback,
        'return_to' => $returnTo,
        'user_session_version' => $userSessionVersion,
    ], now()->addSeconds($bridgeTtl));

    // 9. Generate JWT via token service (optional)
    $jwtToken = null;
    if ($this->tokenService) {
        $callbackWithJwt = $this->tokenService->generateToken(Auth::user(), $callback, $returnTo);
        
        // Extract token from generated callback URL
        $parsedResult = parse_url($callbackWithJwt);
        if (isset($parsedResult['query'])) {
            parse_str($parsedResult['query'], $queryParams);
            $jwtToken = $queryParams['token'] ?? null;
        }
    }

    // 10. Redirect to callback with bridge_token (primary) and optional JWT
    $redirectQuery = http_build_query(array_filter([
        'bridge_token' => $bridgeToken,
        'token' => $jwtToken,
        'return_to' => $this->sanitizeReturnTo($returnTo)
    ]));

    return redirect($callback . '?' . $redirectQuery);
}

6. Introspection Endpoint

This API Endpoint checks if the user session is still valid.

POST /api/sso/session

The logic inside this endpoint must compare the current session version:

// Pseudocode for API Logic
$data = Cache::get("sso_bridge:" . $request->header('X-SSO-Bridge'));
$currentVersion = Cache::get("sso_user_version:" . $data['user_id']);

if ($data['user_session_version'] !== $currentVersion) {
    return response()->json(['authenticated' => false]); // Force logout on Blogs
}

return response()->json(['authenticated' => true, ...]);

7. Testing

Manual test checklist to verify your SSO integration works correctly:

Test Checklist
  • Guest Flow: Visit Blogs while logged out → should see guest content or login prompt
  • Authenticated Flow: Login to Main Site → visit Blogs → should auto-login via bridge
  • Return Path: Start from specific Blogs page → login → should return to original page
  • Host Allowlist: Try invalid callback host → should get 403
  • Path Validation: Try wrong callback path → should get 403
  • Logout Invalidation: Logout from Main Site → Blogs should detect and logout on next action

8. Security Best Practices

Security Checklist:
  • Callback Allowlist: Never skip host validation. Maintain a strict allowlist of permitted callback hosts.
  • HTTPS Enforcement: Production must use HTTPS. Allow HTTP only for localhost/dev environments.
  • Return Path Sanitization: Always treat return_to as path-only. Strip domains to prevent open redirects.
  • No Absolute URLs: Never redirect to user-provided absolute URLs without validation.
  • Token Logging: Never log bridge tokens, JWTs, or session data in plain text.
  • Short TTLs: Keep bridge token TTL short (recommended 15-60 minutes). Default example shows 1 hour (3600 seconds), but this is configurable via BLOGS_SSO_BRIDGE_TTL_SECONDS.

Additional Security Considerations

JWT & Signatures
  • • Algorithm: HS256 (HMAC-SHA256)
  • • Signed using tenant-specific api_key
  • • Timing-safe signature comparison
  • • Max TTL: 300 seconds with ±60s clock skew
Replay Protection
  • • Each JWT includes unique jti (nonce)
  • • Consumed nonces stored in database
  • • Duplicate jti usage immediately rejected
  • • Automatic cleanup of expired entries
Session Versioning
  • • Automatic logout detection via version bumping
  • • Cross-session invalidation support
  • • Cache-based version tracking
  • • Introspection validates session currency
Network Security
  • • HTTPS enforcement for production
  • • Host allowlisting prevents open redirects
  • • Path validation and URL sanitization
  • • Comprehensive audit logging

9. Troubleshooting

Infinite Redirect Loop

Ensure your return_to is path-only (e.g., /dashboard) and NOT a full URL. Also, ensure the guest=1 parameter is stripped from the return_to string before redirecting.

403 Callback Host Not Allowed

Add the exact hostname (without port) to your allowed_hosts config. Example: blogs.local.test.

Mismatched Path

The callback path MUST be exactly /auth/bridge. Any other path (e.g. /auth/callback) will be rejected by the security logic.

Ports in Allowed Hosts

Never include ports in allowed_hosts config (e.g. blogs.local.test:8001). Store hostnames only—the code strips ports automatically before comparison.