failRedirect($request, reasonCode: 'oidc_provider_unavailable'); } $state = (string) Str::uuid(); $request->session()->put('entra_state', $state); $scopes = implode(' ', ['openid', 'profile', 'email']); $url = sprintf( 'https://login.microsoftonline.com/%s/oauth2/v2.0/authorize?%s', $authorityTenant, http_build_query([ 'client_id' => $clientId, 'response_type' => 'code', 'redirect_uri' => $redirectUri, 'response_mode' => 'query', 'scope' => $scopes, 'state' => $state, ]) ); return redirect()->away($url); } public function callback(Request $request): RedirectResponse { $expectedState = $request->session()->pull('entra_state'); if (! is_string($expectedState) || $expectedState === '') { return $this->failRedirect($request, reasonCode: 'oidc_invalid_state'); } if ($expectedState !== $request->string('state')->toString()) { return $this->failRedirect($request, reasonCode: 'oidc_invalid_state'); } if ($request->string('error')->toString() !== '') { return $this->failRedirect($request, reasonCode: 'oidc_user_denied'); } $code = $request->string('code')->toString(); if ($code === '') { return $this->failRedirect($request, reasonCode: 'oidc_provider_unavailable'); } $clientId = (string) config('services.microsoft.client_id'); $clientSecret = (string) config('services.microsoft.client_secret'); $redirectUri = (string) config('services.microsoft.redirect'); $authorityTenant = (string) config('services.microsoft.tenant', 'organizations'); if ($clientId === '' || $clientSecret === '' || $redirectUri === '') { return $this->failRedirect($request, reasonCode: 'oidc_provider_unavailable'); } $response = Http::asForm()->post( sprintf('https://login.microsoftonline.com/%s/oauth2/v2.0/token', $authorityTenant), [ 'client_id' => $clientId, 'client_secret' => $clientSecret, 'code' => $code, 'grant_type' => 'authorization_code', 'redirect_uri' => $redirectUri, ] ); if ($response->failed()) { return $this->failRedirect($request, reasonCode: 'oidc_provider_unavailable'); } $payload = $response->json() ?: []; $idToken = $payload['id_token'] ?? null; if (! is_string($idToken) || $idToken === '') { return $this->failRedirect($request, reasonCode: 'oidc_missing_claims'); } $claims = $this->decodeJwtClaims($idToken); $entraTenantId = is_string($claims['tid'] ?? null) ? (string) $claims['tid'] : ''; $entraObjectId = is_string($claims['oid'] ?? null) ? (string) $claims['oid'] : ''; if ($entraTenantId === '' || $entraObjectId === '') { return $this->failRedirect($request, reasonCode: 'oidc_missing_claims'); } $email = $this->resolveEmailFromClaims($claims, $entraTenantId, $entraObjectId); $name = $this->resolveNameFromClaims($claims, $email); try { $existingUser = User::withTrashed() ->where('entra_tenant_id', $entraTenantId) ->where('entra_object_id', $entraObjectId) ->first(); if ($existingUser?->trashed()) { return $this->failRedirect( $request, reasonCode: 'user_disabled', entraTenantId: $entraTenantId, entraObjectId: $entraObjectId, userId: (int) $existingUser->getKey(), ); } $isNewUser = $existingUser === null; $user = $existingUser ?? new User; $user->fill([ 'entra_tenant_id' => $entraTenantId, 'entra_object_id' => $entraObjectId, 'email' => $email, 'name' => $name, ]); if ($isNewUser) { $user->password = Str::password(64); } $user->save(); } catch (\Throwable $exception) { return $this->failRedirect( $request, reasonCode: 'oidc_user_upsert_failed', entraTenantId: $entraTenantId, entraObjectId: $entraObjectId, ); } Auth::login($user); $request->session()->regenerate(); Log::info('auth.entra.login', $this->logContext($request, success: true, entraTenantId: $entraTenantId, entraObjectId: $entraObjectId, userId: (int) $user->getKey())); $redirectTo = app(PostLoginRedirectResolver::class)->resolve($user); return redirect()->to($redirectTo); } /** * @return array */ private function decodeJwtClaims(string $jwt): array { $parts = explode('.', $jwt); if (count($parts) < 2) { return []; } $payload = $this->base64UrlDecode($parts[1]); if ($payload === null) { return []; } $decoded = json_decode($payload, true); return is_array($decoded) ? $decoded : []; } private function base64UrlDecode(string $value): ?string { $value = str_replace(['-', '_'], ['+', '/'], $value); $padding = strlen($value) % 4; if ($padding > 0) { $value .= str_repeat('=', 4 - $padding); } $decoded = base64_decode($value, true); return $decoded === false ? null : $decoded; } /** * @param array $claims */ private function resolveEmailFromClaims(array $claims, string $entraTenantId, string $entraObjectId): string { $candidate = null; foreach (['preferred_username', 'email', 'upn'] as $key) { $value = $claims[$key] ?? null; if (is_string($value) && $value !== '') { $candidate = $value; break; } } if (! is_string($candidate) || $candidate === '') { $candidate = sprintf('%s@%s.entra.invalid', $entraObjectId, $entraTenantId); } $candidate = strtolower(trim($candidate)); return Str::limit($candidate, 255, ''); } /** * @param array $claims */ private function resolveNameFromClaims(array $claims, string $email): string { $candidate = $claims['name'] ?? null; if (is_string($candidate) && $candidate !== '') { return Str::limit(trim($candidate), 255, ''); } $given = $claims['given_name'] ?? null; $family = $claims['family_name'] ?? null; if (is_string($given) && is_string($family)) { $full = trim($given.' '.$family); if ($full !== '') { return Str::limit($full, 255, ''); } } return Str::limit($email, 255, ''); } private function failRedirect( Request $request, string $reasonCode, ?string $entraTenantId = null, ?string $entraObjectId = null, ?int $userId = null, ): RedirectResponse { Log::warning('auth.entra.login', $this->logContext( $request, success: false, reasonCode: $reasonCode, entraTenantId: $entraTenantId, entraObjectId: $entraObjectId, userId: $userId, )); return redirect() ->to('/admin/login') ->with('error', 'Authentication failed. Please try again.'); } /** * @return array{success:bool,reason_code?:string,user_id?:int,entra_tenant_id?:string,entra_object_id_hash?:string,correlation_id:string,timestamp:string} */ private function logContext( Request $request, bool $success, ?string $reasonCode = null, ?string $entraTenantId = null, ?string $entraObjectId = null, ?int $userId = null, ): array { $correlationId = $request->header('X-Request-Id') ?: ($request->hasSession() ? $request->session()->getId() : null) ?: (string) Str::uuid(); $context = [ 'success' => $success, 'correlation_id' => (string) $correlationId, 'timestamp' => now()->toISOString(), ]; if ($reasonCode !== null) { $context['reason_code'] = $reasonCode; } if ($userId !== null) { $context['user_id'] = $userId; } if ($entraTenantId !== null) { $context['entra_tenant_id'] = $entraTenantId; } if ($entraObjectId !== null) { $context['entra_object_id_hash'] = hash('sha256', $entraObjectId); } return $context; } }