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.
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
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.
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
┌──────────────┐
│ 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 │
└──────────────┘
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();
}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');
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.
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.
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)
SSO data is stored in Backend\Models\User::metadata field (JSON column):
{
"winter.sso": {
"google": {
"id": "1234567890",
"token": "ya29.a0AfH6...",
"allow_password_auth": false,
"allowConnection": true,
"custom_field": "custom_value"
},
"github": {
"id": "987654",
"token": "gho_abc123..."
}
}
}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
Events are fired at six points during authentication. Each event is provider-specific.
Pattern: winter.sso.{provider}.{stage}
Examples:
winter.sso.google.authenticatingwinter.sso.github.registeredwinter.sso.microsoft.afterLogin
-
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.
// 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']);
});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]);
});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',
],
],
],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.
Provider plugins can integrate with Winter.SSO through several mechanisms:
// In provider plugin's boot() method
Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) {
$event->extendSocialite('microsoft', \SocialiteProviders\Microsoft\Provider::class);
});// 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.
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',
],
];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
});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.
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.
Model: Winter\SSO\Models\Log
Table: winter_sso_logs
[
'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',
]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,
],
]);Backend URL: Settings → Logs → SSO Logs
Permission: winter.sso.view_logs
Controller: Winter\SSO\Controllers\Logs
Prevent duplicate accounts when email providers allow variations (e.g., Gmail allows dots in usernames but treats them as the same account).
// 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;
}John.Doe@Gmail.com→johndoe@gmail.comj.o.h.n@gmail.com→john@gmail.comjohn.doe@example.com→john.doe@example.com(unchanged)
Prevents account takeover by verifying the provider's ID matches on subsequent logins.
When require_explicit_permission is true, users must explicitly allow connections from each provider (backend UI pending).
SSO-created users get 400-character random passwords, making password authentication practically impossible.
The plugin has $elevated = true, allowing it to run on protected routes (necessary for auth controller extension).
Laravel Socialite uses OAuth state parameter for CSRF protection. State is stored in session and verified on callback.
Provider configuration is read and mapped once during boot(), not on every request.
- User lookup: 1 query (by email)
- SSO metadata: stored in JSON column, no join needed
- Log creation: 1 insert
CSS is only loaded on the signin page via backend.auth.extendSigninView event.
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
-
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
- 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
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