Skip to content
Merged
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
36 changes: 29 additions & 7 deletions app/Http/Controllers/Auth/LegacyLoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use App\Http\Controllers\Controller;
use App\Models\LegacyUser;
use App\Models\Tenant;
use App\Services\LegacyJwtService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
Expand All @@ -28,33 +29,54 @@ public function login(Request $request): JsonResponse
$username = $request->input('username');
$password = $request->input('password');

/** @var Tenant|null $tenant */
$tenant = tenant();

// Tenants flagged sso_required reject password login for everyone, regardless
// of whether the specific user has finished SSO linking yet.
if ($tenant && $tenant->sso_required) {
return response()->json([
'success' => false,
'error' => 'tenant_requires_sso',
]);
}

// Find user by username
$user = LegacyUser::where('username', $username)->first();

if ($user === null) {
return response()->json([
'success' => false,
'error_code' => 2,
'error' => 'bad_credentials',
]);
}

// SSO-linked users must authenticate via the IdP. A local password is bypass
// surface — refuse the login so the local secret can never substitute for the
// IdP session.
if ($user->sso_sub !== null) {
return response()->json([
'success' => false,
'error' => 'use_sso',
]);
}

// Check if user is active
if (!$user->isActive()) {
return response()->json([
'success' => true,
'error_code' => 2,
'success' => true,
'user_status' => $user->status,
'user_id' => $user->id,
'data' => $this->getReactivationDate($user),
'count' => 1,
'user_id' => $user->id,
'data' => $this->getReactivationDate($user),
'count' => 1,
]);
}

// Verify password
if (!$user->checkPassword($password)) {
return response()->json([
'success' => false,
'error_code' => 2,
'error' => 'bad_credentials',
]);
}

Expand Down
202 changes: 189 additions & 13 deletions app/Http/Controllers/Auth/SsoController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Laravel\Socialite\Facades\Socialite;

class SsoController extends Controller
{
private const int LINK_INTENT_TTL_MINUTES = 10;

public function __construct(
protected LegacyJwtService $jwtService,
protected SsoUserService $ssoUserService,
Expand Down Expand Up @@ -89,19 +94,68 @@
/** @var \SocialiteProviders\Manager\OAuth2\User $socialiteUser */
$socialiteUser = $driver->stateless()->user();

$user = $this->ssoUserService->resolveUser($socialiteUser->getEmail(), $socialiteUser->getId());
$idToken = $socialiteUser->accessTokenResponseBody['id_token'] ?? null;
$idTokenData = $this->decodeIdTokenPayload($idToken);

if (! $this->isEmailVerified($idTokenData)) {
Log::warning('SSO: rejecting login because email_verified claim is not true', [
'tenant' => $instanceCode,
'sub' => $socialiteUser->getId(),
'reason' => $idTokenData === null ? 'id_token_missing_or_unparseable' : 'email_verified_not_true',
]);

return $this->frontendError('email_not_verified');
}

/** @var Tenant $callbackTenant */
$callbackTenant = tenant();
$sub = $socialiteUser->getId();
$email = $socialiteUser->getEmail();

$user = $this->ssoUserService->findBySub($sub);

if ($user === null) {
$user = $this->ssoUserService->provisionUser($socialiteUser);
$emailMatch = $this->ssoUserService->findByEmail($email);

if ($emailMatch === null) {
$user = $this->ssoUserService->provisionUser($socialiteUser);
Comment thread
aivuk marked this conversation as resolved.
} else {
if (! $emailMatch->isActive()) {
return $this->frontendError('account_inactive');
}

if ($emailMatch->sso_sub !== null) {
Log::warning('SSO: email matches a user already bound to a different sso_sub', [
'tenant' => $instanceCode,
'incoming_sub' => $sub,
'existing_sub' => $emailMatch->sso_sub,
'matched_user_id' => $emailMatch->id,
]);

return $this->frontendError('sub_collision');
}

$linkToken = $this->storeLinkIntent($emailMatch, $socialiteUser, $callbackTenant);

return $this->frontendError('account_link_required', ['sso_link' => $linkToken]);
}
} else {
$strayEmailMatch = $this->ssoUserService->findByEmail($email);
if ($strayEmailMatch && $strayEmailMatch->id !== $user->id) {
Log::warning('SSO: email and sso_sub match different users — prioritising sso_sub match.', [
'email' => $email,
'sub' => $sub,
'sso_sub_user' => $user->id,
'email_user' => $strayEmailMatch->id,
]);
}
}

if (! $user->isActive()) {
return $this->frontendError('account_inactive');
}

/** @var Tenant $callbackTenant */
$callbackTenant = tenant();
$user->sso_id_token = $socialiteUser->accessTokenResponseBody['id_token'] ?? null;
$user->sso_id_token = $idToken;
$user->sso_refresh_token = $socialiteUser->refreshToken ?? null;
$user->sso_idp_id_token = $this->fetchIdpIdToken($socialiteUser->token, $callbackTenant->sso_provider);
$user->save();
Expand All @@ -111,6 +165,62 @@
return $this->frontendRedirect($token);
}

/**
* Link an SSO identity to an authenticated legacy user.
*
* Auth: bearer JWT (legacy.jwt middleware). The bearer user proves possession
* of the legacy account; the link-intent token proves possession of the IdP
* identity. Both must point to the same user_id.
*/
public function link(Request $request): JsonResponse
{
$request->validate([
'sso_link_token' => 'required|string',
]);

/** @var LegacyUser $authUser */
$authUser = $request->attributes->get('authenticated_user');
$token = $request->input('sso_link_token');

$intent = Cache::get($this->linkIntentCacheKey($token));

if (! is_array($intent)) {
return response()->json(['success' => false, 'error' => 'link_intent_not_found'], 404);
}

if (($intent['user_id'] ?? null) !== $authUser->id) {
Log::warning('SSO: link rejected — bearer JWT user does not match link intent', [
'authenticated_user' => $authUser->id,
'intent_user' => $intent['user_id'] ?? null,
]);

return response()->json(['success' => false, 'error' => 'user_mismatch'], 403);
}

$fresh = LegacyUser::find($authUser->id);

if ($fresh === null) {
return response()->json(['success' => false, 'error' => 'user_not_found'], 404);
}

if ($fresh->sso_sub !== null && $fresh->sso_sub !== $intent['sso_sub']) {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
return response()->json(['success' => false, 'error' => 'already_linked'], 409);
}

DB::transaction(function () use ($fresh, $intent) {
$fresh->sso_sub = $intent['sso_sub'];
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
$fresh->sso_provider = $intent['sso_provider'] ?? null;
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
$fresh->sso_id_token = $intent['sso_id_token'] ?? null;
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
$fresh->sso_refresh_token = $intent['sso_refresh_token'] ?? null;
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
$fresh->sso_idp_id_token = $intent['sso_idp_id_token'] ?? null;
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
$fresh->save();
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
});

Cache::forget($this->linkIntentCacheKey($token));

return response()->json(['success' => true]);
}

/**
* SSO logout endpoint.
*
Expand Down Expand Up @@ -256,21 +366,53 @@
}

/**
* Build an IdP logout URL by discovering the OIDC end_session_endpoint.
* Works for any OIDC-compliant provider (Keycloak realms, iServ, VIDIS, etc.)
* Decode an OIDC id_token (JWT) payload without verifying the signature.
* Returns null when the token is missing, malformed, or the payload is not valid JSON.
*
* @psalm-pure
*/
protected function buildIdpLogoutUrl(?string $idpIdToken, string $redirectUri): ?string
protected function decodeIdTokenPayload(?string $idToken): ?array
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
{
if (! $idpIdToken) {
if ($idToken === null || $idToken === '') {
return null;
}

$parts = explode('.', $idpIdToken);
$parts = explode('.', $idToken);
if (count($parts) !== 3) {
return null;
}

$payload = json_decode(base64_decode(str_pad($parts[1], strlen($parts[1]) + (4 - strlen($parts[1]) % 4) % 4, '=')), true);
$padded = str_pad($parts[1], strlen($parts[1]) + (4 - strlen($parts[1]) % 4) % 4, '=');
$decoded = base64_decode(strtr($padded, '-_', '+/'), true);
if ($decoded === false) {
return null;
}

$payload = json_decode($decoded, true);
if (! is_array($payload)) {
return null;
}

return $payload;
}

/**
* OIDC: the email claim is only trustworthy when email_verified is strictly true.
*
* @psalm-pure
*/
protected function isEmailVerified(?array $idTokenPayload): bool
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
{
return is_array($idTokenPayload) && ($idTokenPayload['email_verified'] ?? null) === true;
}

/**
* Build an IdP logout URL by discovering the OIDC end_session_endpoint.
* Works for any OIDC-compliant provider (Keycloak realms, iServ, VIDIS, etc.)
*/
protected function buildIdpLogoutUrl(?string $idpIdToken, string $redirectUri): ?string
{
$payload = $this->decodeIdTokenPayload($idpIdToken);
$issuer = rtrim($payload['iss'] ?? '', '/');

if (! $issuer) {
Expand Down Expand Up @@ -300,10 +442,44 @@
return redirect("{$frontendUrl}/oauth-login/{$token}");
}

protected function frontendError(string $code): RedirectResponse
protected function frontendError(string $code, array $extra = []): RedirectResponse
{
$frontendUrl = rtrim(config('app.frontend_url', '/'), '/');
$query = http_build_query(['sso_error' => $code] + $extra);

return redirect("{$frontendUrl}/login?{$query}");
}

return redirect("{$frontendUrl}/login?sso_error={$code}");
/**
* Persist an account-link intent in the cache and return the opaque token.
* The intent carries everything the link endpoint needs to stamp the row
* once the user has proven legacy-account possession via password.
*
* @param \SocialiteProviders\Manager\OAuth2\User $socialiteUser
*/
protected function storeLinkIntent(LegacyUser $emailMatch, \Laravel\Socialite\Two\User $socialiteUser, Tenant $tenant): string
{
$token = bin2hex(random_bytes(16));

Cache::put($this->linkIntentCacheKey($token), [
'user_id' => $emailMatch->id,
'email' => $emailMatch->email,
'sso_sub' => $socialiteUser->getId(),
'sso_provider' => $tenant->sso_provider,
'sso_id_token' => $socialiteUser->accessTokenResponseBody['id_token'] ?? null,
'sso_refresh_token' => $socialiteUser->refreshToken ?? null,
'sso_idp_id_token' => $this->fetchIdpIdToken($socialiteUser->token, $tenant->sso_provider),
'instance_code' => $tenant->instance_code,
], now()->addMinutes(self::LINK_INTENT_TTL_MINUTES));

return $token;
}

/**
* @psalm-pure
*/
protected function linkIntentCacheKey(string $token): string
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
{
return "sso_link:{$token}";
}
}
1 change: 1 addition & 0 deletions app/Models/Tenant.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public static function getCustomColumns(): array
'sso_provider',
'sso_idp_config',
'sso_force_logout',
'sso_required',
]);
}

Expand Down
37 changes: 7 additions & 30 deletions app/Services/SsoUserService.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,45 +5,22 @@
use App\Models\LegacyUser;
use App\Models\Tenant;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Laravel\Socialite\Contracts\User as SocialiteUser;

class SsoUserService
{
/**
* Find an existing user by email or sso_sub in a single query.
*
* If both columns match different rows (corrupt state), the sso_sub match
* takes priority and a warning is logged so the duplicate can be cleaned up
* manually.
*/
public function resolveUser(?string $email, string $sub): ?LegacyUser
public function findBySub(string $sub): ?LegacyUser
{
$candidates = LegacyUser::where('email', $email)
->orWhere('sso_sub', $sub)
->get();
return LegacyUser::where('sso_sub', $sub)->first();
}

if ($candidates->isEmpty()) {
public function findByEmail(?string $email): ?LegacyUser
{
if ($email === null || $email === '') {
return null;
}

if ($candidates->count() === 1) {
return $candidates->first();
}

$bySubMatch = $candidates->firstWhere('sso_sub', $sub);
$byEmailMatch = $candidates->firstWhere('email', $email);

if ($bySubMatch && $byEmailMatch && $bySubMatch->id !== $byEmailMatch->id) {
Log::warning('SSO: email and sso_sub match different users — prioritising sso_sub match.', [
'email' => $email,
'sub' => $sub,
'sso_sub_user' => $bySubMatch->id,
'email_user' => $byEmailMatch->id,
]);
}

return $bySubMatch ?? $byEmailMatch;
return LegacyUser::where('email', $email)->first();
}

/**
Expand Down
Loading
Loading