Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ database.sqlite-journal
database.sqlite-shm
database.sqlite-wal
ISSUES_TODO.md
storage/jwt.secret
4 changes: 4 additions & 0 deletions app/AuthProviders/AuthProviderInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ public static function getValidator(array $data): Validator;

public static function getEmptyProviderConfig(): array;

public static function getAdvancedProviderConfig(): array;

public static function getAdvancedConfigKeys(): array;

public static function getInformationUrl(): ?string;
}

10 changes: 10 additions & 0 deletions app/AuthProviders/BaseAuthProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ public static function getEmptyProviderConfig(): array
return [];
}

public static function getAdvancedProviderConfig(): array
{
return [];
}

public static function getAdvancedConfigKeys(): array
{
return [];
}

public static function getInformationUrl(): ?string
{
return null;
Expand Down
139 changes: 130 additions & 9 deletions app/AuthProviders/OIDCAuthProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ public function __construct(AuthProviderModel $provider)
$this->provider = $provider;
}

private function getAdvancedConfig($key, $default = '')
{
return $this->provider->provider_config->$key ?? $default;
}

private function createClient()
{

Expand All @@ -41,10 +46,37 @@ private function createClient()
$this->client_secret
);

// Apply custom endpoint overrides if configured
$endpointOverrides = [];
$endpointKeys = [
'authorization_endpoint',
'token_endpoint',
'userinfo_endpoint',
'end_session_endpoint',
];

foreach ($endpointKeys as $key) {
$value = $this->getAdvancedConfig($key);
if (!empty($value)) {
$endpointOverrides[$key] = $value;
}
}

if (!empty($endpointOverrides)) {
$client->providerConfigParam($endpointOverrides);
}

// Set callback URL and required scopes
$route = route('social.provider.callback', ['provider' => $this->provider->uuid]);
$client->setRedirectURL($route);
$client->addScope(['openid', 'email', 'profile']);

// Use custom scopes if configured, otherwise use defaults
$customScopes = $this->getAdvancedConfig('scopes');
if (!empty($customScopes)) {
$client->addScope(array_map('trim', explode(' ', $customScopes)));
} else {
$client->addScope(['openid', 'email', 'profile']);
}

return $client;
}
Expand Down Expand Up @@ -85,16 +117,53 @@ public function handleCallback(): AuthProviderUser
$oidc = $this->createClient();
$oidc->authenticate();

// Get user info
$userInfo = $oidc->requestUserInfo();
// Get verified claims from the ID token (always available after authenticate)
$idTokenClaims = $oidc->getVerifiedClaims();

\Log::info("ID token claims: " . json_encode($idTokenClaims));

// Try to get user info from userinfo endpoint, but don't fail if it errors
// Some providers (e.g. ADFS) don't support the userinfo endpoint properly
// and return all claims in the ID token instead
$userInfo = null;
try {
$userInfo = $oidc->requestUserInfo();
\Log::info("User info: " . json_encode($userInfo));
} catch (\Exception $e) {
\Log::warning("Userinfo endpoint failed, using ID token claims only: " . $e->getMessage());
}

// Resolve claim mappings (custom or default)
$claimSub = $this->getAdvancedConfig('claim_sub', 'sub');
$claimName = $this->getAdvancedConfig('claim_name', 'name');
$claimEmail = $this->getAdvancedConfig('claim_email', 'email');
$claimAvatar = $this->getAdvancedConfig('claim_avatar', 'picture');
$claimVerified = $this->getAdvancedConfig('claim_email_verified', 'email_verified');

// Use empty string as fallback indicator for default claim names
if (empty($claimSub)) $claimSub = 'sub';
if (empty($claimName)) $claimName = 'name';
if (empty($claimEmail)) $claimEmail = 'email';
if (empty($claimAvatar)) $claimAvatar = 'picture';
if (empty($claimVerified)) $claimVerified = 'email_verified';

// Helper to resolve a claim value from userinfo first, then ID token as fallback
$resolveClaim = function ($claimKey) use ($userInfo, $idTokenClaims) {
if (isset($userInfo->$claimKey)) {
return $userInfo->$claimKey;
}
if (isset($idTokenClaims->$claimKey)) {
return $idTokenClaims->$claimKey;
}
return null;
};

\Log::info("User info: " . json_encode($userInfo));
$userdata = [
'sub' => $userInfo->sub,
'name' => $userInfo->name,
'email' => $userInfo->email,
'avatar' => $userInfo->picture ?? null,
'verified' => $userInfo->email_verified ?? false
'sub' => $resolveClaim($claimSub),
'name' => $resolveClaim($claimName),
'email' => $resolveClaim($claimEmail),
'avatar' => $resolveClaim($claimAvatar),
'verified' => $resolveClaim($claimVerified) ?? false
];

\Log::info("User data: " . json_encode($userdata));
Expand Down Expand Up @@ -124,6 +193,16 @@ public static function getValidator(array $data): Validator
'client_id' => ['required', 'string'],
'client_secret' => ['required', 'string'],
'base_url' => ['required', 'url'],
'authorization_endpoint' => ['nullable', 'url'],
'token_endpoint' => ['nullable', 'url'],
'userinfo_endpoint' => ['nullable', 'url'],
'end_session_endpoint' => ['nullable', 'url'],
'scopes' => ['nullable', 'string'],
'claim_sub' => ['nullable', 'string'],
'claim_name' => ['nullable', 'string'],
'claim_email' => ['nullable', 'string'],
'claim_avatar' => ['nullable', 'string'],
'claim_email_verified' => ['nullable', 'string'],
]);
}

Expand All @@ -138,6 +217,48 @@ public static function getEmptyProviderConfig(): array
'client_id' => '',
'client_secret' => '',
'base_url' => '',
'authorization_endpoint' => '',
'token_endpoint' => '',
'userinfo_endpoint' => '',
'end_session_endpoint' => '',
'scopes' => '',
'claim_sub' => '',
'claim_name' => '',
'claim_email' => '',
'claim_avatar' => '',
'claim_email_verified' => '',
];
}

public static function getAdvancedProviderConfig(): array
{
return [
'authorization_endpoint' => '',
'token_endpoint' => '',
'userinfo_endpoint' => '',
'end_session_endpoint' => '',
'scopes' => '',
'claim_sub' => '',
'claim_name' => '',
'claim_email' => '',
'claim_avatar' => '',
'claim_email_verified' => '',
];
}

public static function getAdvancedConfigKeys(): array
{
return [
'authorization_endpoint',
'token_endpoint',
'userinfo_endpoint',
'end_session_endpoint',
'scopes',
'claim_sub',
'claim_name',
'claim_email',
'claim_avatar',
'claim_email_verified',
];
}
}
14 changes: 10 additions & 4 deletions app/Http/Controllers/AuthProvidersController.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,14 @@ public function index()
'provider_description' => $class::getDescription(),
'enabled' => $authProvider->enabled == "true",
'allow_registration' => (bool) $authProvider->allow_registration,
'allow_unlink' => (bool) $authProvider->allow_unlink,
'class' => $authProvider->provider_class,
'provider_config' => $authProvider->provider_config,
'icon' => $class::getIcon(),
'information_url' => $class::getInformationUrl(),
'uuid' => $authProvider->uuid,
'callback_url' => $this->buildCallbackUrl($authProvider->uuid)
'callback_url' => $this->buildCallbackUrl($authProvider->uuid),
'advanced_config_keys' => $class::getAdvancedConfigKeys()
];
});

Expand Down Expand Up @@ -208,6 +210,7 @@ private function saveProvider($provider)
$authProvider->provider_config = $provider['provider_config'];
$authProvider->enabled = $provider['enabled'];
$authProvider->allow_registration = $provider['allow_registration'] ?? false;
$authProvider->allow_unlink = $provider['allow_unlink'] ?? false;
$authProvider->uuid = $provider['uuid'];
$authProvider->save();

Expand All @@ -219,7 +222,8 @@ private function saveProvider($provider)
'name' => $provider['name'],
'provider_config' => $provider['provider_config'],
'enabled' => $provider['enabled'],
'allow_registration' => $provider['allow_registration'] ?? false
'allow_registration' => $provider['allow_registration'] ?? false,
'allow_unlink' => $provider['allow_unlink'] ?? false
]);

return $authProvider;
Expand Down Expand Up @@ -346,7 +350,8 @@ public function listAvailableProviderTypes()
'description' => $class::getDescription(),
'icon' => $class::getIcon(),
'class' => $providerType,
'provider_config' => $class::getEmptyProviderConfig()
'provider_config' => $class::getEmptyProviderConfig(),
'advanced_config_keys' => $class::getAdvancedConfigKeys()
];
}
return response()->json(
Expand Down Expand Up @@ -386,7 +391,8 @@ public function list()
return [
'id' => $authProvider->id,
'name' => $authProvider->name,
'icon' => $icon
'icon' => $icon,
'allow_unlink' => (bool) $authProvider->allow_unlink
];
});

Expand Down
43 changes: 39 additions & 4 deletions app/Http/Controllers/ExternalAuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ private function getProviderClass($class)
public function __construct()
{
// Force HTTPS for all redirect URLs for security
URL::forceScheme('https');
URL::forceScheme('https');

// Inject the settings service
$this->settings = app()->make(SettingsService::class);
Expand Down Expand Up @@ -234,7 +234,7 @@ private function handleNormalLogin($provider, $authProviderUser)
->first();

if ($linkedAuth) {
return $this->loginWithLinkedAccount($linkedAuth);
return $this->loginWithLinkedAccount($linkedAuth, $authProviderUser);
}

// No linked account found, try to find a user with the same email
Expand All @@ -247,14 +247,19 @@ private function handleNormalLogin($provider, $authProviderUser)
* @param UserAuthProvider $linkedAuth The linked auth provider record
* @return \Illuminate\Http\RedirectResponse
*/
private function loginWithLinkedAccount($linkedAuth)
private function loginWithLinkedAccount($linkedAuth, $authProviderUser = null)
{
$user = User::find($linkedAuth->user_id);

if (!$user) {
return redirect('/')->with('error', 'User not found');
}

// Sync profile data from the identity provider on each login
if ($authProviderUser) {
$this->syncUserProfile($user, $authProviderUser);
}

return $this->authenticateAndRedirect($user);
}

Expand Down Expand Up @@ -302,6 +307,9 @@ private function loginWithEmail($provider, $authProviderUser)
// Link the provider to the user since we found them by email
$this->linkProviderToUser($user, $provider, $authProviderUser);

// Sync profile data from the identity provider
$this->syncUserProfile($user, $authProviderUser);

return $this->createAuthCookieAndRedirect($user);
}

Expand Down Expand Up @@ -367,6 +375,34 @@ private function authenticateAndRedirect($user)
return $this->createAuthCookieAndRedirect($user);
}

/**
* Sync user profile data from the identity provider
* Updates name and email on every SSO login to keep them in sync
*
* @param User $user The local user
* @param AuthProviderUser $authProviderUser The user data from the provider
* @return void
*/
private function syncUserProfile($user, $authProviderUser)
{
$updated = false;

if (!empty($authProviderUser->name) && $user->name !== $authProviderUser->name) {
$user->name = $authProviderUser->name;
$updated = true;
}

if (!empty($authProviderUser->email) && $user->email !== $authProviderUser->email) {
$user->email = $authProviderUser->email;
$updated = true;
}

if ($updated) {
$user->save();
\Log::info("Synced SSO profile for user {$user->id}: name={$user->name}, email={$user->email}");
}
}

/**
* Create auth cookie and redirect
*
Expand All @@ -381,7 +417,6 @@ private function createAuthCookieAndRedirect($user)

// Create HTTP-only secure cookie with refresh token
$cookie = cookie('refresh_token', urlencode($refreshToken), $twentyFourHours, null, null, true, true);

// Redirect to home with the cookie
return redirect('/')->withCookie($cookie);
}
Expand Down
Loading