Winter.SSO Architecture
January 17, 2026 · View on GitHub
This document describes the internal architecture of the Winter.SSO plugin, including how it integrates with Laravel Socialite, the authentication flow, configuration system, and extension points.
Overview
Winter.SSO bridges Winter CMS's backend authentication system with Laravel Socialite's OAuth implementation. The plugin:
- Registers Laravel Socialite as a service provider
- Maps Winter.SSO configuration to Laravel's
servicesconfig - Extends the backend auth controller to add SSO buttons
- Handles OAuth redirect/callback flow
- Matches/creates users based on email
- Fires events for customization at every step
Integration with Laravel Socialite
Service Provider Registration
In Plugin.php::register():
protected function registerSocialite(): void
{
$this->app->register(SocialiteServiceProvider::class);
$this->app->alias('Socialite', Socialite::class);
}
This makes the Socialite facade available throughout the application.
Configuration Mapping
Socialite expects provider configuration in config/services.php under each provider's key. Winter.SSO handles this mapping automatically in Plugin.php::configureProviders():
protected function configureProviders(): void
{
$providers = Config::get('winter.sso::providers', []);
$enabledProviders = Config::get('winter.sso::enabled_providers', []);
foreach ($providers as $provider => $config) {
if (!in_array($provider, $enabledProviders) || empty($config['client_id'])) {
continue;
}
// Add redirect URL automatically
$config = array_merge([
'redirect' => Backend::url('winter/sso/handle/callback/' . $provider),
], $config);
// Map to Socialite's expected config location
Config::set("services.{$provider}", $config);
}
}
Configuration Flow:
- User sets
config/winter/sso/config.php::providers.github - Plugin reads
winter.sso::providersat boot time - Plugin checks if provider is enabled and has
client_id - Plugin merges redirect URL
- Plugin copies to
services.github - Socialite reads from
services.githubwhenSocialite::with('github')is called
OAuth Authentication Flow
User Journey
┌──────────────┐
│ User clicks │
│ SSO button │
└──────┬───────┘
│
v
┌─────────────────────────────────────────────────┐
│ 1. Redirect Phase │
│ /backend/winter/sso/handle/redirect/{provider}│
│ - Validates provider is enabled │
│ - Checks user not already logged in │
│ - Redirects to provider's OAuth page │
└────────┬────────────────────────────────────────┘
│
v
┌─────────────────────────────────────────────────┐
│ 2. User authenticates with provider │
│ (Happens on provider's website) │
└────────┬────────────────────────────────────────┘
│
v
┌─────────────────────────────────────────────────┐
│ 3. Callback Phase │
│ /backend/winter/sso/handle/callback/{provider}│
│ - Exchanges code for access token │
│ - Fetches user data from provider │
│ - Normalizes email │
│ - Finds or creates user │
│ - Stores SSO metadata │
│ - Creates session │
│ - Logs attempt │
└────────┬────────────────────────────────────────┘
│
v
┌──────────────┐
│ User logged │
│ in to │
│ backend │
└──────────────┘
Redirect Phase
Controller: Handle::redirect($provider)
Route: /backend/winter/sso/handle/redirect/{provider}
Location: plugins/winter/sso/controllers/Handle.php:272
Steps:
- Validate provider is in
enabled_providersarray - Check user is not already logged in
- Validate
client_idis configured - Call
Socialite::with($provider)->scopes(...)->redirect() - Return redirect response to provider's OAuth page
Code:
public function redirect(string $provider): RedirectResponse
{
// Validation
if (!in_array($provider, $this->enabledProviders)) {
return $this->redirectToSignInPage('Provider not enabled');
}
// Get scopes from config
$config = Config::get('services.' . $provider, []);
// Redirect to provider
return Socialite::with($provider)
->scopes($config['scopes'] ?? [])
->redirect();
}
Callback Phase
Controller: Handle::callback($provider)
Route: /backend/winter/sso/handle/callback/{provider}
Location: plugins/winter/sso/controllers/Handle.php:63
Steps:
-
Validate Request
- Provider is enabled
- OAuth code is present
-
Fire
winter.sso.{provider}.authenticatingEvent- Can return
falseto abort
- Can return
-
Exchange Code for User Data
$ssoUser = Socialite::with($provider)->user();$ssoUserisLaravel\Socialite\AbstractUser- Contains:
id,email,name,avatar,token, etc.
-
Fire
winter.sso.{provider}.authenticatedEvent- Receives
$ssoUserparameter
- Receives
-
Normalize Email
- Lowercase entire email
- Remove dots from Gmail usernames
- See
Handle::normalizeEmail()at line 306
-
Find or Create User
Option A: User Exists
$user = $this->authManager->findUserByCredentials(['email' => $email]);Then validate:
- If
require_explicit_permissionis true, check user allowed this provider - If SSO ID already stored, verify it matches current ID (security feature)
Option B: User Doesn't Exist
- If
allow_registrationis false: throw error - If
allow_registrationis true:- Fire
winter.sso.{provider}.beforeRegisterevent - Create user with random password
- Mark as SSO-created
- Fire
winter.sso.{provider}.registeredevent
- Fire
- If
-
Store/Update SSO Metadata
$user->setSsoValues($provider, [ 'id' => $ssoUser->getId(), 'token' => $ssoUser->token, ]); -
Fire
winter.sso.{provider}.beforeLoginEvent -
Create Session
$this->authManager->login($user, $remember); -
Fire
winter.sso.{provider}.afterLoginEvent -
Log Authentication
- Creates
Winter\SSO\Models\Logrecord - Includes provider, email, IP, user ID, etc.
- Creates
-
Redirect to Backend
return Backend::redirectIntended('backend');
User Matching and Creation
Email as Login Attribute
The plugin forces email-based login on callback routes:
// plugins/winter/sso/Plugin.php:92
if (str_starts_with(Request::url(), Backend::url('winter/sso/handle/callback/'))) {
User::$loginAttribute = 'email';
}
This ensures findUserByCredentials(['email' => $email]) works correctly.
User Creation
When allow_registration is true and user doesn't exist:
// plugins/winter/sso/controllers/Handle.php:145
$password = Str::random(400); // Unguessable password
$user = $this->authManager->register(
credentials: [
'email' => $email,
'password' => $password,
'password_confirmation' => $password,
'login' => $ssoUser->getNickname() ?: $email,
],
autoLogin: true
);
// Mark as SSO-created (TODO: actually enforce this)
$user->setSsoValues($provider, ['allow_password_auth' => false]);
The user is created with a random 400-character password, making password authentication essentially impossible.
SSO ID Security
After first successful authentication, the provider's ID for the user is stored:
$user->setSsoValues('google', ['id' => '1234567890']);
On subsequent logins, the ID must match:
// plugins/winter/sso/controllers/Handle.php:111
$ssoId = $user->getSsoValue($provider, 'id');
if (!is_null($ssoId) && $ssoUser->getId() !== $ssoId) {
throw new InvalidSsoIdException();
}
This prevents account takeover if:
- User A registers with
john@example.comvia Google - User B later tries to authenticate with same email via Google (but different Google account)
Metadata Storage
SSO data is stored in Backend\Models\User::metadata field (JSON column):
Structure
{
"winter.sso": {
"google": {
"id": "1234567890",
"token": "ya29.a0AfH6...",
"allow_password_auth": false,
"allowConnection": true,
"custom_field": "custom_value"
},
"github": {
"id": "987654",
"token": "gho_abc123..."
}
}
}
Helper Methods
Added to Backend\Models\User via dynamic methods:
// Get value
$googleId = $user->getSsoValue('google', 'id');
$token = $user->getSsoValue('google', 'token', 'default');
// Set values (saves automatically)
$user->setSsoValues('google', [
'id' => '123',
'token' => 'abc',
'custom' => 'value',
]);
Implementation: plugins/winter/sso/Plugin.php:97-109
Event System
Events are fired at six points during authentication. Each event is provider-specific.
Event Naming
Pattern: winter.sso.{provider}.{stage}
Examples:
winter.sso.google.authenticatingwinter.sso.github.registeredwinter.sso.microsoft.afterLogin
Event Sequence
-
winter.sso.{provider}.authenticating- When: Before redirecting to Socialite
- Parameters: None
- Return:
falseto abort - Location:
Handle.php:73
-
winter.sso.{provider}.authenticated- When: After OAuth, before user lookup
- Parameters:
$ssoUser(AbstractUser) - Return: N/A (informational)
- Location:
Handle.php:82
-
winter.sso.{provider}.beforeRegister- When: Before creating new user
- Parameters:
$ssoUser(AbstractUser) - Throw: Exception to prevent registration
- Location:
Handle.php:143 - Only fires if: User doesn't exist and
allow_registrationis true
-
winter.sso.{provider}.registered- When: After user created, before login
- Parameters:
$user(User),$ssoUser(AbstractUser) - Return: N/A
- Location:
Handle.php:173 - Use for: Setting first/last name, avatar, etc.
-
winter.sso.{provider}.beforeLogin- When: Before session creation
- Parameters:
$user(User),$ssoUser(AbstractUser) - Throw: Exception to prevent login
- Location:
Handle.php:229
-
winter.sso.{provider}.afterLogin- When: After session created
- Parameters:
$user(User),$ssoUser(AbstractUser) - Return: N/A
- Location:
Handle.php:248 - Use for: Tracking, notifications, etc.
Event Examples
// Restrict registration to company emails
Event::listen('winter.sso.google.beforeRegister', function ($ssoUser) {
if (!str_ends_with($ssoUser->getEmail(), '@mycompany.com')) {
throw new AuthenticationException('Only company emails allowed');
}
});
// Populate user fields on registration
Event::listen('winter.sso.google.registered', function ($user, $ssoUser) {
$user->fill([
'first_name' => $ssoUser->user['given_name'] ?? null,
'last_name' => $ssoUser->user['family_name'] ?? null,
]);
$user->save();
});
// Log successful logins
Event::listen('winter.sso.google.afterLogin', function ($user, $ssoUser) {
Log::info('SSO login', ['user' => $user->email, 'provider' => 'google']);
});
Backend Auth Controller Extension
Adding SSO Buttons
In Plugin.php::extendAuthController(), the plugin listens to backend.auth.extendSigninView:
Event::listen('backend.auth.extendSigninView', function ($controller) {
// Add CSS
$controller->addCss('/plugins/winter/sso/assets/dist/css/sso.css', 'Winter.SSO');
// Get enabled providers
$providers = $this->getProviders();
$enabledProviders = Config::get('winter.sso::enabled_providers', []);
// Build provider data
$processedProviders = [];
foreach ($enabledProviders as $provider) {
$processedProviders[$provider] = $providers[$provider]['button'];
}
// Save signin URL for redirect after login
Session::put('signin_url', Request::url());
// Render buttons
echo View::make("winter.sso::providers", ['providers' => $processedProviders]);
});
Button Configuration
Each provider has button configuration auto-generated in Plugin.php::getProviders():
$config['button'] = array_merge([
'view' => "winter.sso::buttons.provider",
'logoUrl' => Url::asset('/plugins/winter/sso/assets/images/providers/' . $provider . '.svg'),
'logoAlt' => Lang::get('winter.sso::lang.provider_btn.alt_text', ['provider' => ucfirst($provider)]),
'url' => Backend::url('winter/sso/handle/redirect/' . $provider),
'label' => Lang::get('winter.sso::lang.provider_btn.label', ['provider' => ucfirst($provider)]),
], $config['button'] ?? []);
Providers can override button config in their provider configuration:
'providers' => [
'github' => [
'client_id' => '...',
'client_secret' => '...',
'button' => [
'label' => 'Custom Label',
'view' => 'my.custom.view',
],
],
],
Preventing Native Authentication
When prevent_native_auth is true:
\Backend\Controllers\Auth::extend(function ($controller) {
// Visual: Hide login form
$controller->bindEvent('page.beforeDisplay', function () use ($controller) {
$controller->addViewPath(plugins_path('winter/sso/controllers/auth/prevent_native'));
});
// Security: Disable AJAX handler
$controller->bindEvent('ajax.beforeRunHandler', function ($handler) {
if ($handler === 'onSubmit') {
throw new ApplicationException("Native authentication is disabled.");
}
});
});
This both hides the form and blocks the server-side handler.
Extension Points for Provider Plugins
Provider plugins can integrate with Winter.SSO through several mechanisms:
1. Socialite Provider Registration
// In provider plugin's boot() method
Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) {
$event->extendSocialite('microsoft', \SocialiteProviders\Microsoft\Provider::class);
});
2. Configuration Merging
// Merge provider config into Winter.SSO
$this->app['config']->set(
'winter.sso::providers.microsoft',
$this->app['config']->get('winter.ssoprovidermicrosoft::config')
);
This allows provider plugins to maintain their own config/config.php file that gets merged into the main SSO configuration.
3. Custom Button/Logo
Provider plugins can include their own assets and register them through config:
// In provider plugin config
return [
'client_id' => env('MICROSOFT_CLIENT_ID'),
'client_secret' => env('MICROSOFT_CLIENT_SECRET'),
'button' => [
'label' => 'Sign in with Microsoft',
'logoUrl' => '/plugins/winter/ssoprovidermicrosoft/assets/images/microsoft.svg',
],
];
4. Provider-specific Events
Provider plugins can listen to their own events:
// In provider plugin's boot() method
Event::listen('winter.sso.microsoft.registered', function ($user, $ssoUser) {
// Microsoft-specific logic
});
Session and Cookie Considerations
Same-Site Cookie Adjustment
OAuth requires cookies to be sent during redirects. The plugin automatically adjusts session.same_site from strict to lax when secure sessions are enabled:
// plugins/winter/sso/Plugin.php:119
if (Config::get('session.secure') === true && Config::get('session.same_site') === 'strict') {
Config::set('session.same_site', 'lax');
}
Why: With same_site=strict, session cookies aren't sent during OAuth callbacks, causing "Invalid state" errors.
State Verification
Laravel Socialite stores OAuth state in the session. This state is verified on callback to prevent CSRF attacks. If the session is lost between redirect and callback, you'll see InvalidStateException.
Logging System
Log Model
Model: Winter\SSO\Models\Log
Table: winter_sso_logs
Log Structure
[
'provider' => 'google',
'action' => 'authenticated',
'user_type' => 'Backend\Models\User',
'user_id' => 1,
'provided_id' => '1234567890',
'provided_email' => 'user@example.com',
'ip' => '192.168.1.1',
'metadata' => [
'remember' => true,
],
'created_at' => '2024-01-17 10:30:00',
]
Log Creation
Logs are created after successful login in Handle::callback():
// plugins/winter/sso/controllers/Handle.php:250
SsoLog::create([
'provider' => $provider,
'action' => 'authenticated',
'user_type' => get_class($user),
'user_id' => $user->getKey(),
'provided_id' => $ssoUser->getId(),
'provided_email' => $email,
'ip' => Request::ip(),
'metadata' => [
'remember' => $remember,
],
]);
Viewing Logs
Backend URL: Settings → Logs → SSO Logs
Permission: winter.sso.view_logs
Controller: Winter\SSO\Controllers\Logs
Email Normalization
Purpose
Prevent duplicate accounts when email providers allow variations (e.g., Gmail allows dots in usernames but treats them as the same account).
Implementation
// plugins/winter/sso/controllers/Handle.php:306
protected function normalizeEmail($email)
{
[$user, $domain] = explode('@', strtolower($email));
// Google emails can have "." anywhere but the actual account has none
if (in_array($domain, ['gmail.com', 'googlemail.com'])) {
$user = str_replace('.', '', $user);
}
return $user . '@' . $domain;
}
Examples
John.Doe@Gmail.com→johndoe@gmail.comj.o.h.n@gmail.com→john@gmail.comjohn.doe@example.com→john.doe@example.com(unchanged)
Security Considerations
1. SSO ID Verification
Prevents account takeover by verifying the provider's ID matches on subsequent logins.
2. Explicit Permission System
When require_explicit_permission is true, users must explicitly allow connections from each provider (backend UI pending).
3. Unguessable Passwords
SSO-created users get 400-character random passwords, making password authentication practically impossible.
4. Elevated Plugin
The plugin has $elevated = true, allowing it to run on protected routes (necessary for auth controller extension).
5. CSRF Protection
Laravel Socialite uses OAuth state parameter for CSRF protection. State is stored in session and verified on callback.
Performance Considerations
Provider Configuration Caching
Provider configuration is read and mapped once during boot(), not on every request.
Minimal Database Queries
- User lookup: 1 query (by email)
- SSO metadata: stored in JSON column, no join needed
- Log creation: 1 insert
Asset Loading
CSS is only loaded on the signin page via backend.auth.extendSigninView event.
File Structure
plugins/winter/sso/
├── Plugin.php # Main plugin class
├── controllers/
│ ├── Handle.php # OAuth redirect/callback
│ ├── Logs.php # Log viewer
│ └── auth/prevent_native/ # Views for hiding native auth
├── models/
│ └── Log.php # SSO log model
├── config/
│ └── config.php # Default configuration
├── exceptions/
│ ├── InvalidSsoIdException.php
│ └── ProviderBlockedException.php
├── assets/
│ └── images/providers/ # Provider logos (SVG)
├── views/
│ ├── providers.php # Button container
│ └── buttons/provider.php # Individual button template
└── docs/
├── architecture.md # This file
├── creating-provider-plugins.md
└── providers/ # Provider setup guides
└── google.md
Configuration Loading Order
-
Plugin Registration (
Plugin::register())- Forces email login on callback routes
- Registers Socialite service provider
-
Plugin Boot (
Plugin::boot())- Adjusts session.same_site if needed
- Calls
configureProviders()- maps config to services.* - Calls
extendAuthController()- adds SSO buttons
-
Request Handling
- Socialite reads from
services.{provider} - Handle controller uses
winter.sso::*config
- Socialite reads from
Future TODOs (from code comments)
- Backend DB Configuration - UI for managing providers and settings
- User Preferences - Allow users to manage their SSO connections
- Default Role Implementation - Actually assign roles to new users
- Password Auth Blocking - Enforce
allow_password_authflag - Remember Me - Support user-selected "remember me" checkbox
- Account Attachment - Allow logged-in users to attach SSO providers
- Protection Against Fake Auth - Verify trusted services
- Session Configuration Warning - Dashboard widget for session config issues
Testing Checklist
When testing SSO integration:
- Provider shows button on signin page
- Clicking button redirects to provider
- After authentication, redirected back to Winter
- Existing user can login
- New user can register (if enabled)
- SSO ID is stored on first login
- Second login with same account succeeds
- Second login with different account fails (ID mismatch)
- Log entry is created
- User metadata contains SSO data
- Events fire correctly
- Email normalization works
- Session persists correctly