diff --git a/docs/source/conf.py b/docs/source/conf.py
index 6d003e0832fd5d2adec8e199c138f2c2426fdb0a..2bdc7c581a452182855b1db418a3243c65f5954c 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -1,6 +1,11 @@
 import sys
 import os
 import time
+import re
+import inspect
+
+import sphinx_autodoc_typehints
+from sphinx_autodoc_typehints import get_annotation_module, get_annotation_class_name, get_annotation_args, format_annotation
 
 parentdir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
 sys.path.insert(0, parentdir)
@@ -30,6 +35,8 @@
     'sorted-toctree',
     #'sphinx.ext.autosectionlabel',
     'sphinx.ext.intersphinx',
+    'sphinx_autodoc_typehints',
+    "sphinxcontrib.jquery",
     #'sphinxcontrib.autoprogram',
 
 ]
@@ -99,12 +106,67 @@
 # Have a class doc along with its __init__
 #autoclass_content = 'monokai'
 autodoc_member_order = 'bysource'
+autodoc_preserve_defaults = True
 
 intersphinx_mapping = {
     'py': ('https://docs.python.org/3', None),
     'requests': ('https://requests.readthedocs.io/en/latest/', None)
 }
 
+typehints_defaults = 'braces-after'
+simplify_optional_unions = False
+
+# Better display of default values
+def new_format_default(app, default, is_annotated=True):
+    if default is inspect.Parameter.empty:
+        return None
+    formatted = repr(default).replace("\\", "\\\\")
+
+    m = re.match("<class '(.*)'>", formatted)
+    if m:
+        formatted = f':class:`~{m.group(1)}`'
+    else:
+        m = re.match("<(.*) object.*>", formatted)
+        if m:
+            formatted = f':class:`~{m.group(1)}()`'
+        else:
+            m = re.match('<(.*)>', formatted)
+            if m:
+                formatted = f':class:`~{m.group(1)}`'
+            else:
+                formatted = f':class:`{formatted}`'
+
+    if is_annotated:
+        if app.config.typehints_defaults.startswith("braces"):
+            return f" (default: {formatted})"
+        else:  # other option is comma
+            return f", default: {formatted}"
+    else:
+        if app.config.typehints_defaults == "braces-after":
+            return f" (default: {formatted})"
+        else:
+            return f"default: {formatted}"
+
+sphinx_autodoc_typehints.format_default = new_format_default
+
+# Do not display neither Optional nor Union, but only pipes.
+def formatter(annotation, config):
+    try:
+        module = get_annotation_module(annotation)
+        class_name = get_annotation_class_name(annotation, module)
+        args = get_annotation_args(annotation, module, class_name)
+    except ValueError:
+        return str(annotation).strip("'")
+
+
+    full_name = f"{module}.{class_name}" if module != "builtins" else class_name
+    if full_name in ("types.UnionType", "typing.Union", "typing.Optional"):
+        return " | ".join([format_annotation(arg, config) for arg in args])
+
+    return None
+
+typehints_formatter = formatter
+
 # -- Options for HTML output ---------------------------------------------------
 
 # The theme to use for HTML and HTML Help pages.  Major themes that come with
diff --git a/docs/source/genapi.py b/docs/source/genapi.py
index ba79c8ea09afe7e2795ec91574b431eacb23defd..adf6eb5516c3b88c70e78a2b2d8b65a45af55c45 100755
--- a/docs/source/genapi.py
+++ b/docs/source/genapi.py
@@ -24,7 +24,7 @@ def genapi():
                 continue
 
             f, ext = f.rsplit('.', 1)
-            if ext != 'py' or f == '__init__':
+            if ext != 'py' or f.startswith('__'):
                 continue
 
             subs.add(f)
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 56cbebf278ec7c71e31d975b781c0e5d90ac353d..e9faf56a2d2596c721a14cc5e72046fcf71d311c 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -9,6 +9,8 @@ pyflakes==2.5.0
 pytest-cov==4.0.0
 pytest==7.2.1
 sphinx
+sphinx-autodoc-typehints
+sphinxcontrib-jquery
 virtualenv
 xunitparser>=1.3.4
 pre-commit
diff --git a/woob/browser/browsers.py b/woob/browser/browsers.py
index d7a2e732c038ef8cda2bd15a7df1ec539b1d58da..5fc04df4a4c51613561a0ab3cae91cb3a3489043 100644
--- a/woob/browser/browsers.py
+++ b/woob/browser/browsers.py
@@ -17,6 +17,7 @@
 
 from __future__ import annotations
 
+from abc import abstractmethod
 from collections import OrderedDict
 from functools import wraps
 import importlib
@@ -1084,38 +1085,77 @@ def pagination(self, func: Callable, *args, **kwargs):
                 return
 
 
-def need_login(func):
+class NotAuthenticated(Exception):
+    """
+    Exception to indicate the browser is not authenticated.
+
+    Use this exception in :meth:`AuthenticationMixin.check_authentication` if
+    the browser is not logged, then :func:`require_authentication` may call
+    :meth:`AuthenticationMixin.authenticate`.
+    """
+
+
+class AuthenticationExpired(NotAuthenticated):
+    """
+    Exception to indicate the authentication of the browser is expired and
+    should be done again.
+
+    Use this exception in :meth:`AuthenticationMixin.check_authentication` if
+    the browser is not logged, then :func:`require_authentication` may call
+    :meth:`AuthenticationMixin.authenticate`.
     """
-    Decorator used to require to be logged to access to this function.
+
+
+def require_authentication(func):
+    """
+    Decorator used to require to be authenticated to access to this function.
 
     This decorator can be used on any method whose first argument is a
-    browser (typically a :class:`LoginBrowser`). It checks for the
-    ``logged`` attribute in the current browser's page: when this
-    attribute is set to ``True`` (e.g., when the page inherits
-    :class:`~woob.browser.pages.LoggedPage`), then nothing special happens.
-
-    In all other cases (when the browser isn't on any defined page or
-    when the page's ``logged`` attribute is ``False``), the
-    :meth:`LoginBrowser.do_login` method of the browser is called before
-    calling :`func`.
+    browser implementing the :class:`AuthenticationMixin` mixin (typically a
+    :class:`LoginBrowser`).
+
+    It calls :meth:`AuthenticationMixin.check_authentication` which may raise
+    a :class:`NotAuthenticated` exception if the browser is not logged-in.
+
+    When the browser is not authenticated, it calls the method
+    :meth:`AuthenticationMixin.authenticate` before calling the decorated
+    function.
+
+    >>> class MyBrowser(Browser, AuthenticationMixin):
+    ...     logged = False
+    ...
+    ...     def check_authentication(self):
+    ...         if not self.logged:
+    ...             raise NotAuthenticated()
+    ...
+    ...     def authenticate(self):
+    ...         self.logged = True
+    ...
+    ...     @require_authentication
+    ...     def do_something(self):
+    ...         return self.logged
+    ...
+    >>> b = MyBrowser()
+    >>> b.do_something()
+    True
     """
 
     @wraps(func)
     def inner(browser: LoginBrowser, *args, **kwargs):
-        if (
-            (
-                not hasattr(browser, 'logged') or
-                (
-                    hasattr(browser, 'logged') and
-                    not browser.logged
-                )
-            ) and (
-                not hasattr(browser, 'page') or
-                browser.page is None or
-                not browser.page.logged
-            )
-        ):
-            browser.do_login()
+        try:
+            if not isinstance(browser, AuthenticationMixin):
+                warnings.warn(
+                    'Do not use need_login/need_authentication on browsers not '
+                    'implementing LoginBrowser/AuthenticationMixin. Assuming we are '
+                    'not authenticated',
+                    DeprecationWarning,
+                    stacklevel=2)
+                raise NotAuthenticated()
+
+            browser.check_authentication()
+        except NotAuthenticated:
+            browser.authenticate()
+
             if browser.logger.settings.get('export_session'):
                 browser.logger.debug('logged in with session: %s', json.dumps(browser.export_session()))
         return func(browser, *args, **kwargs)
@@ -1123,7 +1163,53 @@ def inner(browser: LoginBrowser, *args, **kwargs):
     return inner
 
 
-class LoginBrowser(PagesBrowser):
+def need_login(func):
+    warnings.warn(
+        'Use `require_authentication` decorator instead of `need_login`.',
+        DeprecationWarning,
+        stacklevel=2
+    )
+    return require_authentication(func)
+
+
+class AuthenticationMixin:
+    """
+    Mixin to handle authentication on a browser.
+
+    When a browser implements this mixin, it can use the
+    :func:`require_authentication` decorator on methods which requires to be
+    authenticated.
+
+    There are two mandatory methods to overload (:meth:`check_authentication()`
+    and :meth:`authenticate`) and one optional method
+    (:meth:`clear_authentication`).
+    """
+
+    @abstractmethod
+    def check_authentication(self) -> None:
+        """
+        Method to overload called to check if the browser is authenticated or not.
+
+        :raises:NotAuthenticated: the browser is currently not authenticated
+        :raises:AuthenticationExpired: the authentication has expired
+        """
+
+    @abstractmethod
+    def authenticate(self) -> None:
+        """
+        Method to overload to authenticate.
+
+        This method is called by the :func:`require_authentication` decorator
+        if the browser is not authenticated.
+        """
+
+    def clear_authentication(self) -> None:
+        """
+        Method to overload to clear authentication.
+        """
+
+
+class LoginBrowser(PagesBrowser, AuthenticationMixin):
     """
     A browser which supports login.
     """
@@ -1133,19 +1219,61 @@ def __init__(self, username: str, password: str, *args, **kwargs):
         self.username = username
         self.password = password
 
-    def do_login(self):
+    def check_authentication(self) -> None:
+        """
+        Method which checks if the browser is authenticated.
+
+        If not authenticated, it may raises one of the following exception:
+
+        * :class:`NotAuthenticated`
+        * :class:`AuthenticationExpired`
+
+        The default implementation checks a potential `logged` attribute on a
+        browser, or a `logged` attribute on the current page if there is one.
+        """
+        if hasattr(self, 'logged') and not self.logged:
+            raise NotAuthenticated()
+
+        if self.page is not None and not self.page.logged:
+            raise NotAuthenticated()
+
+    @abstractmethod
+    def authenticate(self) -> None:
+        """
+        Abstract method to overload to authenticate.
+
+        This method is called by the :func:`require_authentication` decorator
+        if the browser is not authenticated.
+        """
+        return self.do_login()
+
+    @abstractmethod
+    def do_login(self) -> None:
         """
         Abstract method to implement to login on website.
 
         It is called when a login is needed.
+
+        .. deprecated:: 3.5
+           Overload :meth:`authenticate` instead.
+        """
+
+    def clear_authentication(self) -> None:
         """
-        raise NotImplementedError()
+        Clear authentication.
 
-    def do_logout(self):
+        By default, simply clears the cookies.
+        """
+        return self.do_logout()
+
+    def do_logout(self) -> None:
         """
         Logout from website.
 
         By default, simply clears the cookies.
+
+        .. deprecated:: 3.5
+           Overload :meth:`clear_authentication` instead.
         """
         self.session.cookies.clear()
 
diff --git a/woob/tools/application/console.py b/woob/tools/application/console.py
index 40f7cfa92f28787b1c4c839b94e701b2b6e9bedd..14b17f142cc9d0f8ea68b9a8d32daceb804f6cc9 100644
--- a/woob/tools/application/console.py
+++ b/woob/tools/application/console.py
@@ -115,6 +115,11 @@ class ConsoleApplication(Application):
 
     @classproperty
     def BOLD(self):
+        """
+        .. deprecated:: 3.6
+            This attribute is deprecated, use :attr:`woob.tools.application.pretty.BOLD` instead.
+            That's also better to use :func:`woob.tools.application.pretty.colored`.
+        """
         warnings.warn(
             'Use woob.tools.application.pretty.BOLD instead.\n'
             'That\'s also better to use woob.tools.application.pretty.colored.',
@@ -125,6 +130,11 @@ def BOLD(self):
 
     @classproperty
     def NC(self):
+        """
+        .. deprecated:: 3.6
+            This attribute is deprecated, use :attr:`woob.tools.application.pretty.NC` instead.
+            That's also better to use :func:`woob.tools.application.pretty.colored`.
+        """
         warnings.warn(
             'Use woob.tools.application.pretty.NC instead.\n'
             'That\'s also better to use woob.tools.application.pretty.colored.',
diff --git a/woob/tools/captcha/virtkeyboard.py b/woob/tools/captcha/virtkeyboard.py
index 411f5daf8784b9d8527d1fe09cac887e8711a6ff..d1c039f5dae6a5c0c839074301ddf84143686a68 100644
--- a/woob/tools/captcha/virtkeyboard.py
+++ b/woob/tools/captcha/virtkeyboard.py
@@ -15,8 +15,15 @@
 # You should have received a copy of the GNU Lesser General Public License
 # along with woob. If not, see <http://www.gnu.org/licenses/>.
 
+from __future__ import annotations
+
 import hashlib
 import tempfile
+from typing import IO, ClassVar, TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from woob.browser import Browser
+
 
 try:
     from PIL import Image
@@ -31,17 +38,19 @@ class VirtKeyboardError(Exception):
 class VirtKeyboard:
     """
     Handle a virtual keyboard.
-
-    :attribute margin: Margin used by :meth:`get_symbol_coords` to reduce size
-        of each "key" of the virtual keyboard. This attribute is always
-        converted to a 4-tuple, and has the same semantic as the CSS
-        ``margin`` property (top, right, bottom, right), in pixels.
-    :type margin: int or float or (2|3|4)-tuple
     """
+
     margin = None
+    """
+    Margin used by :meth:`get_symbol_coords` to reduce size
+    of each "key" of the virtual keyboard. This attribute is always
+    converted to a 4-tuple, and has the same semantic as the CSS
+    ``margin`` property (top, right, bottom, right), in pixels.
+    """
 
     codesep = ''
-    """Output separator between code strings.
+    """
+    Output separator between code strings.
 
     See :func:`get_string_code`.
     """
@@ -209,26 +218,22 @@ class GridVirtKeyboard(VirtKeyboard):
     Make a virtual keyboard where "keys" are distributed on a grid.
     Example here: https://www.e-sgbl.com/portalserver/sgbl-web/login
 
-    Parameters:
-        :param symbols: Sequence of symbols, ordered in the grid from left to
-            right and up to down
-        :type symbols: iterable
-        :param cols: Column count of the grid
-        :type cols: int
-        :param rows: Row count of the grid
-        :type rows: int
-        :param image: File-like object to be used as data source
-        :type image: file
-        :param color: Color of the meaningful pixels
-        :type color: 3-tuple
-        :param convert: Mode to which convert color of pixels, see
-            :meth:`Image.Image.convert` for more information
-
-    Attributes:
-        :attribute symbols: Association table between symbols and md5s
-        :type symbols: dict
+    :param symbols: Sequence of symbols, ordered in the grid from left to
+        right and up to down
+    :type symbols: iterable
+    :param cols: Column count of the grid
+    :type cols: int
+    :param rows: Row count of the grid
+    :type rows: int
+    :param image: File-like object to be used as data source
+    :type image: file
+    :param color: Color of the meaningful pixels
+    :type color: 3-tuple
+    :param convert: Mode to which convert color of pixels, see
+        :meth:`Image.Image.convert` for more information
     """
     symbols = {}
+    """Assocation table between symbols and md5s"""
 
     def __init__(self, symbols, cols, rows, image, color, convert=None):
         self.load_image(image, color, convert)
@@ -249,11 +254,9 @@ class SplitKeyboard:
     """Virtual keyboard for when the chars are in individual images, not a single grid"""
 
     char_to_hash = None
-
     """dict mapping password characters to image hashes"""
 
     codesep = ''
-
     """Output separator between symbols"""
 
     def __init__(self, code_to_filedata):
@@ -319,52 +322,59 @@ def __init__(self, matching_symbol, coords, image=None, md5=None):
 class SimpleVirtualKeyboard:
     """Handle a virtual keyboard where "keys" are distributed on a simple grid.
 
-    Parameters:
-        :param cols: Column count of the grid
-        :type cols: int
-        :param rows: Row count of the grid
-        :type rows: int
-        :param image: File-like object to be used as data source
-        :type image: file
-        :param convert: Mode to which convert color of pixels, see
-            :meth:`Image.Image.convert` for more information
-        :param matching_symbols: symbol that match all case of image grid from left to right and top
-                                 to down, European reading way.
-        :type matching_symbols: iterable
-        :param matching_symbols_coords: dict mapping matching website symbols to their image coords
-                                        (x0, y0, x1, y1) on grid image from left to right and top to
-                                        down, European reading way. It's not symbols in the image.
-        :type matching_symbols_coords: dict[str:4-tuple(int)]
-        :param browser: Browser of woob session.
-                        Allow to dump tiles files in same directory than session folder
-        :type browser: obj(Browser)
-
-    Attributes:
-        :attribute codesep: Output separator between matching symbols
-        :type codesep: str
-        :param margin: Useless image pixel to cut.
-                       See :func:`cut_margin`.
-        :type margin: 4-tuple(int), same as HTML margin: (top, right, bottom, left).
-                      or 2-tuple(int), (top = bottom, right = left),
-                      or int, top = right = bottom = left
-        :attribute tile_margin: Useless tile pixel to cut.
-                                See :func:`cut_margin`.
-        :attribute symbols: Association table between image symbols and md5s
-        :type symbols: dict[str:str] or dict[str:n-tuple(str)]
-        :attribute convert: Mode to which convert color of pixels, see
-            :meth:`Image.Image.convert` for more information
-        :attribute alter: Allow custom main image alteration. Then overwrite :func:`alter_image`.
-        :type alter: boolean
+    :param cols: Column count of the grid
+    :param rows: Row count of the grid
+    :param file: File-like object to be used as data source
+    :param convert: Mode to which convert color of pixels, see
+        :meth:`Image.Image.convert` for more information
+    :param matching_symbols: symbol that match all case of image grid from left to right and top
+        to down, European reading way.
+    :param matching_symbols_coords: dict mapping matching website symbols to their image coords
+        (x0, y0, x1, y1) on grid image from left to right and top to
+        down, European reading way. It's not symbols in the image.
+    :param browser: Browser of woob session.
+        Allow to dump tiles files in same directory than session folder
+    """
+
+    codesep: ClassVar[str] = ''
+    """Output separator between matching symbols"""
+
+    margin: ClassVar[tuple[int, int, int, int] | tuple[int, int] | int | None] = None
+    """
+    4-tuple(int), same as HTML margin: (top, right, bottom, left).
+    or 2-tuple(int), (top = bottom, right = left),
+    or int, top = right = bottom = left
+    """
+
+    tile_margin: ClassVar[tuple[int, int, int, int] | tuple[int, int] | int | None] = None
+    """
+    4-tuple(int), same as HTML margin: (top, right, bottom, left).
+    or 2-tuple(int), (top = bottom, right = left),
+    or int, top = right = bottom = left
+    """
+
+    symbols: ClassVar[dict[str, str | tuple[str, ...]]] = None
+    """
+    Association table between image symbols and md5s
+    """
+
+    convert: ClassVar[str | None] = None
+    """
+    Mode to which convert color of pixels, see
+    :meth:`Image.Image.convert` for more information
     """
 
-    codesep = ''
-    margin = None
-    tile_margin = None
-    symbols = None
-    convert = None
     tile_klass = Tile
 
-    def __init__(self, file, cols, rows, matching_symbols=None, matching_symbols_coords=None, browser=None):
+    def __init__(
+        self,
+        file: IO,
+        cols: int,
+        rows: int,
+        matching_symbols: list[str] | None = None,
+        matching_symbols_coords: dict[str, tuple[int, int, int, int]] | None = None,
+        browser: Browser | None = None
+    ):
         self.cols = cols
         self.rows = rows