Repository: praw-dev/praw

Issue:
Support changes to refresh tokens
Reddit is changing how refresh tokens are handled. In a nutshell, refresh tokens will be usable only once: https://old.reddit.com/r/redditdev/comments/kvzaot/oauth2_api_changes_upcoming/

As a result, we need to consider how best to handle dynamic refresh tokens.

Here's what I propose:

1) Remove support for `refresh_token` in both `praw.ini` and as a keyword argument to `praw.Reddit`.

Since the refresh tokens will be dynamic, it doesn't make sense to keep them in a config file. And since we'll need a way to allow people to manually update the refresh token on an existing instance, I don't think we should support two distinct ways to provide a refresh token to a Reddit instance.

2) Provide a method to set the refresh token (and optionally the access token) on a Reddit instance. For example, `reddit.auth.set_token(...)`.

3) Provide a way to register a callback function that is called whenever the refresh token, and the access token is changed. In this way users can save and synchronize these tokens however they please, e.g., via a file, a database, etc. I propose we call this: `reddit.auth.token_change_callback(function)`.

These ideas stemmed from this part of the discussion: https://old.reddit.com/r/redditdev/comments/kvzaot/oauth2_api_changes_upcoming/gj2xcsx/

Discussion:
bboe: Aside from making atomic commits, rather than one large commit, this PR is ready for review.

While I do want to add an sqlite3 example, I'll save that for a separate PR.

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 4ee3f48e..99d9ef4a 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -4,12 +4,23 @@ Change Log
 Unreleased
 ----------
 
+**Added**
+
+* :class:`.Reddit` keyword argument ``token_manager``.
+* :class:`.FileTokenManager` and its parent abstract class :class:`.BaseTokenManager`.
+
 **Deprecated**
 
-* :meth:`.me` will no longer return ``None`` when called in :attr:`.read_only` mode 
+* The configuration setting ``refresh_token`` is deprecated and its use will result in a
+  :py:class:`DeprecationWarning`. This deprecation applies in all ways of setting
+  configuration values, i.e., via ``praw.ini``, as a keyword argument when initializing
+  an instance of :class:`.Reddit`, and via the ``PRAW_REFRESH_TOKEN`` environment
+  variable. To be prepared for PRAW 8, use the new :class:`.Reddit` keyword argument
+  ``token_manager``. See :ref:`refresh_token` in PRAW's documentation for an example.
+* :meth:`.me` will no longer return ``None`` when called in :attr:`.read_only` mode
   starting in PRAW 8. A :py:class:`DeprecationWarning` will be issued. To switch forward
   to the PRAW 8 behavior set ``praw8_raise_exception_on_me=True`` in your
-  ``praw.Reddit(...)`` call.
+  :class:`.Reddit` call.
 
 7.1.4 (2021/02/07)
 ------------------
diff --git a/docs/code_overview/other.rst b/docs/code_overview/other.rst
index 146600c9..51c01dd1 100644
--- a/docs/code_overview/other.rst
+++ b/docs/code_overview/other.rst
@@ -122,5 +122,6 @@ them bound to an attribute of one of the PRAW models.
    other/subredditremovalreasons
    other/subredditrules
    other/redditorstream
+   other/token_manager
    other/trophy
    other/util
diff --git a/docs/code_overview/other/token_manager.rst b/docs/code_overview/other/token_manager.rst
new file mode 100644
index 00000000..3a013f86
--- /dev/null
+++ b/docs/code_overview/other/token_manager.rst
@@ -0,0 +1,5 @@
+Token Manager
+=============
+
+.. automodule:: praw.util.token_manager
+   :inherited-members:
diff --git a/docs/examples/lmgtfy_bot.py b/docs/examples/lmgtfy_bot.py
old mode 100644
new mode 100755
index 139a0e36..aff970a4
--- a/docs/examples/lmgtfy_bot.py
+++ b/docs/examples/lmgtfy_bot.py
@@ -1,3 +1,4 @@
+#!/usr/bin/env python3
 from urllib.parse import quote_plus
 
 import praw
diff --git a/docs/examples/obtain_refresh_token.py b/docs/examples/obtain_refresh_token.py
index d298835c..282443c3 100755
--- a/docs/examples/obtain_refresh_token.py
+++ b/docs/examples/obtain_refresh_token.py
@@ -2,76 +2,48 @@
 
 """This example demonstrates the flow for retrieving a refresh token.
 
-In order for this example to work your application's redirect URI must be set to
-http://localhost:8080.
-
 This tool can be used to conveniently create refresh tokens for later use with your web
 application OAuth2 credentials.
 
-"""
-import random
-import socket
-import sys
+To create a Reddit application visit the following link while logged into the account
+you want to create a refresh token for: https://www.reddit.com/prefs/apps/
 
-import praw
+Create a "web app" with the redirect uri set to: http://localhost:8080
 
+After the application is created, take note of:
 
-def receive_connection():
-    """Wait for and then return a connected socket..
+- REDDIT_CLIENT_ID; the line just under "web app" in the upper left of the Reddit
+  Application
+- REDDIT_CLIENT_SECRET; the value to the right of "secret"
 
-    Opens a TCP connection on port 8080, and waits for a single client.
+Usage:
 
-    """
-    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
-    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
-    server.bind(("localhost", 8080))
-    server.listen(1)
-    client = server.accept()[0]
-    server.close()
-    return client
+    EXPORT praw_client_id=<REDDIT_CLIENT_ID>
+    EXPORT praw_client_secret=<REDDIT_CLIENT_SECRET>
+    python3 obtain_refresh_token.py
 
+"""
+import random
+import socket
+import sys
 
-def send_message(client, message):
-    """Send message to client and close the connection."""
-    print(message)
-    client.send(f"HTTP/1.1 200 OK\r\n\r\n{message}".encode("utf-8"))
-    client.close()
+import praw
 
 
 def main():
     """Provide the program's entry point when directly executed."""
-    print(
-        "Go here while logged into the account you want to create a token for:"
-        " https://www.reddit.com/prefs/apps/"
+    scope_input = input(
+        "Enter a comma separated list of scopes, or `*` for all scopes: "
     )
-    print(
-        "Click the create an app button. Put something in the name field and select the"
-        " script radio button."
-    )
-    print("Put http://localhost:8080 in the redirect uri field and click create app")
-    client_id = input(
-        "Enter the client ID, it's the line just under Personal use script at the top: "
-    )
-    client_secret = input("Enter the client secret, it's the line next to secret: ")
-    commaScopes = input(
-        "Now enter a comma separated list of scopes, or all for all tokens: "
-    )
-
-    if commaScopes.lower() == "all":
-        scopes = ["*"]
-    else:
-        scopes = commaScopes.strip().split(",")
+    scopes = [scope.strip() for scope in scope_input.strip().split(",")]
 
     reddit = praw.Reddit(
-        client_id=client_id.strip(),
-        client_secret=client_secret.strip(),
         redirect_uri="http://localhost:8080",
-        user_agent="praw_refresh_token_example",
+        user_agent="obtain_refresh_token/v0 by u/bboe",
     )
     state = str(random.randint(0, 65000))
     url = reddit.auth.url(scopes, state, "permanent")
     print(f"Now open this url in your browser: {url}")
-    sys.stdout.flush()
 
     client = receive_connection()
     data = client.recv(1024).decode("utf-8")
@@ -95,5 +67,27 @@ def main():
     return 0
 
 
+def receive_connection():
+    """Wait for and then return a connected socket..
+
+    Opens a TCP connection on port 8080, and waits for a single client.
+
+    """
+    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+    server.bind(("localhost", 8080))
+    server.listen(1)
+    client = server.accept()[0]
+    server.close()
+    return client
+
+
+def send_message(client, message):
+    """Send message to client and close the connection."""
+    print(message)
+    client.send(f"HTTP/1.1 200 OK\r\n\r\n{message}".encode("utf-8"))
+    client.close()
+
+
 if __name__ == "__main__":
     sys.exit(main())
diff --git a/docs/examples/use_file_token_manager.py b/docs/examples/use_file_token_manager.py
new file mode 100755
index 00000000..2af66ce0
--- /dev/null
+++ b/docs/examples/use_file_token_manager.py
@@ -0,0 +1,67 @@
+#!/usr/bin/env python3
+"""This example demonstrates using the file token manager for refresh tokens.
+
+In order to run this program, you will first need to obtain a valid refresh token. You
+can use the `obtain_refresh_token.py` example to help.
+
+In this example, refresh tokens will be saved into a file `refresh_token.txt` relative
+to your current working directory. If your current working directory is under version
+control it is strongly encouraged you add `refresh_token.txt` to the version control
+ignore list.
+
+Usage:
+
+    EXPORT praw_client_id=<REDDIT_CLIENT_ID>
+    EXPORT praw_client_secret=<REDDIT_CLIENT_SECRET>
+    python3 use_file_token_manager.py
+
+"""
+import os
+import sys
+
+import praw
+from praw.util.token_manager import FileTokenManager
+
+REFRESH_TOKEN_FILENAME = "refresh_token.txt"
+
+
+def initialize_refresh_token_file():
+    if os.path.isfile(REFRESH_TOKEN_FILENAME):
+        return
+
+    refresh_token = input("Initial refresh token value: ")
+    with open(REFRESH_TOKEN_FILENAME, "w") as fp:
+        fp.write(refresh_token)
+
+
+def main():
+    if "praw_client_id" not in os.environ:
+        sys.stderr.write("Environment variable ``praw_client_id`` must be defined\n")
+        return 1
+    if "praw_client_secret" not in os.environ:
+        sys.stderr.write(
+            "Environment variable ``praw_client_secret`` must be defined\n"
+        )
+        return 1
+
+    initialize_refresh_token_file()
+
+    refresh_token_manager = FileTokenManager(REFRESH_TOKEN_FILENAME)
+    reddit = praw.Reddit(
+        token_manager=refresh_token_manager,
+        user_agent="use_file_token_manager/v0 by u/bboe",
+    )
+
+    scopes = reddit.auth.scopes()
+    if scopes == {"*"}:
+        print(f"{reddit.user.me()} is authenticated with all scopes")
+    elif "identity" in scopes:
+        print(
+            f"{reddit.user.me()} is authenticated with the following scopes: {scopes}"
+        )
+    else:
+        print(f"You are authenticated with the following scopes: {scopes}")
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/docs/getting_started/authentication.rst b/docs/getting_started/authentication.rst
index 659acbf5..fa07ef16 100644
--- a/docs/getting_started/authentication.rst
+++ b/docs/getting_started/authentication.rst
@@ -3,7 +3,7 @@
 Authenticating via OAuth
 ========================
 
-PRAW supports the three types of applications that can be registered on
+PRAW supports all three types of applications that can be registered on
 Reddit. Those are:
 
 * `Web Applications <https://github.com/reddit-archive/reddit/wiki/OAuth2-App-Types#web-app>`_
@@ -14,10 +14,10 @@ Before you can use any one of these with PRAW, you must first `register
 <https://www.reddit.com/prefs/apps/>`_ an application of the appropriate type
 on Reddit.
 
-If your app does not require a user context, it is :ref:`read-only <read_only_application>`.
+If your application does not require a user context, it is :ref:`read-only <read_only_application>`.
 
 PRAW supports the flows that each of these applications can use. The
-following table defines which tables can use which flows:
+following table defines which application types can use which flows:
 
 .. rst-class:: center_table_items
 
@@ -131,12 +131,11 @@ When registering your application you must provide a valid redirect URI. If you
 running a website you will want to enter the appropriate callback URL and configure that
 endpoint to complete the code flow.
 
-If you aren't actually running a website, you can use the :ref:`refresh_token` script to
-obtain ``refresh_tokens``. Enter ``http://localhost:8080`` as the redirect URI when
-using this script.
+If you aren't actually running a website, you can follow the :ref:`refresh_token`
+tutorial to learn how to obtain and use the initial refresh token.
 
-Whether or not you use the script there are two processes involved in obtaining
-access or refresh tokens.
+Whether or not you follow the :ref:`refresh_token` tutorial there are two processes
+involved in obtaining access or refresh tokens.
 
 .. _auth_url:
 
@@ -155,13 +154,15 @@ URL. You can do that as follows:
     )
     print(reddit.auth.url(["identity"], "...", "permanent"))
 
-The above will output an authorization URL for a permanent token that has only the
-`identity` scope. See :meth:`.url` for more information on these parameters.
+The above will output an authorization URL for a permanent token (i.e., the resulting
+authorization will include both a short-lived ``access_token``, and a longer-lived, single
+use ``refresh_token``) that has only the ``identity`` scope. See :meth:`.url` for more
+information on these parameters.
 
 This URL should be accessed by the account that desires to authorize their Reddit access
 to your application. On completion of that flow, the user's browser will be redirected
-to the specified ``redirect_uri``. After extracting verifying the ``state`` and
-extracting the ``code`` you can obtain the refresh token via:
+to the specified ``redirect_uri``. After verifying the ``state`` and extracting the
+``code`` you can obtain the refresh token via:
 
 .. code-block:: python
 
@@ -169,10 +170,10 @@ extracting the ``code`` you can obtain the refresh token via:
      print(reddit.user.me())
 
 The first line of output is the ``refresh_token``. You can save this for later use (see
-:ref:`using_refresh_token`).
+:ref:`using_refresh_tokens`).
 
 The second line of output reveals the name of the Redditor that completed the code flow.
-It also indicates that the ``reddit`` instance is now associated with that account.
+It also indicates that the :class:`.Reddit` instance is now associated with that account.
 
 The code flow can be used with an **installed** application just as described above with
 one change: set the value of ``client_secret`` to ``None`` when initializing
@@ -256,28 +257,3 @@ such as in installed applications where the end user could retrieve the ``client
     from each other (as the supplied device id *should* be a unique string per both
     device (in the case of a web app, server) and user (in the case of a web app,
     browser session).
-
-.. _using_refresh_token:
-
-Using a Saved Refresh Token
----------------------------
-
-A saved refresh token can be used to immediately obtain an authorized instance of
-:class:`.Reddit` like so:
-
-.. code-block:: python
-
-    reddit = praw.Reddit(client_id="SI8pN3DSbt0zor",
-        client_secret="xaxkj7HNh8kwg8e5t4m6KvSrbTI",
-        refresh_token="WeheY7PwgeCZj4S3QgUcLhKE5S2s4eAYdxM",
-        user_agent="testscript by u/fakebot3"
-    )
-    print(reddit.auth.scopes())
-
-The output from the above code displays which scopes are available on the
-:class:`.Reddit` instance.
-
-.. note::
-
-    Observe that ``redirect_uri`` does not need to be provided in such cases. It is only
-    needed when :meth:`.url` is used.
diff --git a/docs/getting_started/configuration/options.rst b/docs/getting_started/configuration/options.rst
index 2a27ffb4..36e9f75e 100644
--- a/docs/getting_started/configuration/options.rst
+++ b/docs/getting_started/configuration/options.rst
@@ -38,13 +38,6 @@ OAuth Configuration Options
     application. This option is required for all application types, however, the value
     must be set to ``None`` for **installed** applications.
 
-:refresh_token: For either **web** applications, or **installed** applications using the
-    code flow, you can directly provide a previously obtained refresh token. Using a
-    **web** application in conjunction with this option is useful, for example, if you
-    prefer to not have your username and password available to your program, as required
-    for a **script** application. See: :ref:`refresh_token` and
-    :ref:`using_refresh_token`
-
 :redirect_uri: The redirect URI associated with your registered Reddit application. This
     field is unused for **script** applications and is only needed for both **web**
     applications, and **installed** applications when the :meth:`.url` method is used.
diff --git a/docs/tutorials/refresh_token.rst b/docs/tutorials/refresh_token.rst
index 216fbc99..5670181a 100644
--- a/docs/tutorials/refresh_token.rst
+++ b/docs/tutorials/refresh_token.rst
@@ -1,12 +1,103 @@
 .. _refresh_token:
 
-Obtaining a Refresh Token
-=========================
+Working with Refresh Tokens
+===========================
 
-The following program can be used to obtain a refresh token with the desired scopes.
-Such a token can be used in conjunction with the ``refresh_token`` keyword argument
-using in initializing an instance of :class:`~praw.Reddit`. A list of all possible
-scopes can be found in the `reddit API docs <https://www.reddit.com/dev/api/oauth>`_
+.. note::
+
+    The process for using refresh tokens is in the process of changing on Reddit's end.
+    This documentation has been updated to be aligned with the future of how Reddit
+    handles refresh tokens, and will be the only supported method in PRAW 8+. For more
+    information please see:
+    https://old.reddit.com/r/redditdev/comments/kvzaot/oauth2_api_changes_upcoming/
+
+Reddit OAuth2 Scopes
+--------------------
+
+Before working with refresh tokens you should decide which scopes your application requires.
+If you want to use all scopes, you can use the special scope ``*``.
+
+To get an up-to-date listing of all Reddit scopes and their descriptions run the following:
+
+.. code-block:: python
+
+    import requests
+
+    response = requests.get(
+        "https://www.reddit.com/api/v1/scopes.json",
+        headers={"User-Agent": "fetch-scopes by u/bboe"}
+    )
+
+    for scope, data in sorted(response.json().items()):
+        print(f"{scope:>18s}  {data['description']}")
+
+As of February 2021, the available scopes are:
+
+
+================ ======================================================================
+Scope            Description
+================ ======================================================================
+account          Update preferences and related account information. Will not have
+                 access to your email or password.
+creddits         Spend my reddit gold creddits on giving gold to other users.
+edit             Edit and delete my comments and submissions.
+flair            Select my subreddit flair. Change link flair on my submissions.
+history          Access my voting history and comments or submissions I've saved or
+                 hidden.
+identity         Access my reddit username and signup date.
+livemanage       Manage settings and contributors of live threads I contribute to.
+modconfig        Manage the configuration, sidebar, and CSS of subreddits I moderate.
+modcontributors  Add/remove users to approved user lists and ban/unban or mute/unmute
+                 users from subreddits I moderate.
+modflair         Manage and assign flair in subreddits I moderate.
+modlog           Access the moderation log in subreddits I moderate.
+modmail          Access and manage modmail via mod.reddit.com.
+modothers        Invite or remove other moderators from subreddits I moderate.
+modposts         Approve, remove, mark nsfw, and distinguish content in subreddits I
+                 moderate.
+modself          Accept invitations to moderate a subreddit. Remove myself as a
+                 moderator or contributor of subreddits I moderate or contribute to.
+modtraffic       Access traffic stats in subreddits I moderate.
+modwiki          Change editors and visibility of wiki pages in subreddits I moderate.
+mysubreddits     Access the list of subreddits I moderate, contribute to, and subscribe
+                 to.
+privatemessages  Access my inbox and send private messages to other users.
+read             Access posts and comments through my account.
+report           Report content for rules violations. Hide &amp; show individual
+                 submissions.
+save             Save and unsave comments and submissions.
+structuredstyles Edit structured styles for a subreddit I moderate.
+submit           Submit links and comments from my account.
+subscribe        Manage my subreddit subscriptions. Manage "friends" - users whose
+                 content I follow.
+vote             Submit and change my votes on comments and submissions.
+wikiedit         Edit wiki pages on my behalf
+wikiread         Read wiki pages through my account
+================ ======================================================================
+
+Obtaining Refresh Tokens
+------------------------
+
+The following program can be used to obtain a refresh token with the desired scopes:
 
 .. literalinclude:: ../examples/obtain_refresh_token.py
-   :language: python
+    :language: python
+
+
+.. _using_refresh_tokens:
+
+Using and Updating Refresh Tokens
+---------------------------------
+
+Reddit refresh tokens can be used only once. When an authorization is refreshed the existing refresh
+token is consumed and a new access token and refresh token will be issued. While PRAW automatically
+handles refreshing tokens when needed, it does not automatically handle the storage of the refresh
+tokens. However, PRAW provides the facilities for you to manage your refresh tokens via custom
+subclasses of :class:`.BaseTokenManager`. For trivial examples, PRAW provides the
+:class:`.FileTokenManager`.
+
+The following program demonstrates how to prepare a file with an initial refresh token, and configure
+PRAW to both use that refresh token, and keep the file up-to-date with a valid refresh token.
+
+.. literalinclude:: ../examples/use_file_token_manager.py
+    :language: python
diff --git a/praw/config.py b/praw/config.py
index a0854dff..e24fa005 100644
--- a/praw/config.py
+++ b/praw/config.py
@@ -4,6 +4,7 @@ import os
 import sys
 from threading import Lock
 from typing import Optional
+from warnings import warn
 
 from .exceptions import ClientException
 
@@ -84,11 +85,21 @@ class Config:
         self.custom = dict(Config.CONFIG.items(site_name), **settings)
 
         self.client_id = self.client_secret = self.oauth_url = None
-        self.reddit_url = self.refresh_token = self.redirect_uri = None
+        self.reddit_url = self.redirect_uri = None
         self.password = self.user_agent = self.username = None
 
         self._initialize_attributes()
 
+        self._do_not_use_refresh_token = self._fetch_or_not_set("refresh_token")
+        if self._do_not_use_refresh_token != self.CONFIG_NOT_SET:
+            warn(
+                "The ``refresh_token`` configuration setting is deprecated and will be"
+                " removed in PRAW 8. Please use ``token_manager`` to manage your"
+                " refresh tokens.",
+                category=DeprecationWarning,
+                stacklevel=2,
+            )
+
     def _fetch(self, key):
         value = self.custom[key]
         del self.custom[key]
@@ -133,7 +144,6 @@ class Config:
             "client_id",
             "client_secret",
             "redirect_uri",
-            "refresh_token",
             "password",
             "user_agent",
             "username",
diff --git a/praw/models/auth.py b/praw/models/auth.py
index 0d1dcc56..8fa6a78f 100644
--- a/praw/models/auth.py
+++ b/praw/models/auth.py
@@ -104,13 +104,20 @@ class Auth(PRAWBase):
             whom the URL was generated for.
         :param duration: Either ``permanent`` or ``temporary`` (default: permanent).
             ``temporary`` authorizations generate access tokens that last only 1 hour.
-            ``permanent`` authorizations additionally generate a refresh token that can
-            be indefinitely used to generate new hour-long access tokens. This value is
-            ignored when ``implicit=True``.
+            ``permanent`` authorizations additionally generate a single-use refresh
+            token with a significantly longer expiration (~1 year) that is to be used to
+            fetch a new set of tokens. This value is ignored when ``implicit=True``.
         :param implicit: For **installed** applications, this value can be set to use
             the implicit, rather than the code flow. When True, the ``duration``
             argument has no effect as only temporary tokens can be retrieved.
 
+        .. note::
+
+            Reddit's ``refresh_tokens`` currently are reusable, and do not expire.
+            However, that behavior is likely to change in the near future so it's best
+            to no longer rely upon it:
+            https://old.reddit.com/r/redditdev/comments/kvzaot/oauth2_api_changes_upcoming/
+
         """
         authenticator = self._reddit._read_only_core._authorizer._authenticator
         if authenticator.redirect_uri is self._reddit.config.CONFIG_NOT_SET:
diff --git a/praw/reddit.py b/praw/reddit.py
index 8facc51c..3e33bef6 100644
--- a/praw/reddit.py
+++ b/praw/reddit.py
@@ -42,6 +42,7 @@ from .exceptions import (
     RedditAPIException,
 )
 from .objector import Objector
+from .util.token_manager import BaseTokenManager
 
 try:
     from update_checker import update_check
@@ -151,6 +152,8 @@ class Reddit:
         config_interpolation: Optional[str] = None,
         requestor_class: Optional[Type[Requestor]] = None,
         requestor_kwargs: Dict[str, Any] = None,
+        *,
+        token_manager: Optional[BaseTokenManager] = None,
         **config_settings: Union[str, bool],
     ):  # noqa: D207, D301
         """Initialize a Reddit instance.
@@ -166,6 +169,10 @@ class Reddit:
             set, use ``prawcore.Requestor`` (default: None).
         :param requestor_kwargs: Dictionary with additional keyword arguments used to
             initialize the requestor (default: None).
+        :param token_manager: When provided, the passed instance, a subclass of
+            :class:`.BaseTokenManager`, will manage tokens via two callback functions.
+            This parameter must be provided in order to work with refresh tokens
+            (default: None).
 
         Additional keyword arguments will be used to initialize the :class:`.Config`
         object. This can be used to specify configuration settings during instantiation
@@ -212,6 +219,7 @@ class Reddit:
         """
         self._core = self._authorized_core = self._read_only_core = None
         self._objector = None
+        self._token_manager = token_manager
         self._unique_counter = 0
         self._validate_on_submit = False
 
@@ -411,6 +419,29 @@ class Reddit:
             update_check(__package__, __version__)
             Reddit.update_checked = True
 
+    def _prepare_common_authorizer(self, authenticator):
+        if self._token_manager is not None:
+            if self.config._do_not_use_refresh_token != self.config.CONFIG_NOT_SET:
+                raise TypeError(
+                    "legacy ``refresh_token`` setting cannot be provided when providing"
+                    " ``token_manager``"
+                )
+
+            self._token_manager.reddit = self
+            authorizer = Authorizer(
+                authenticator,
+                post_refresh_callback=self._token_manager.post_refresh_callback,
+                pre_refresh_callback=self._token_manager.pre_refresh_callback,
+            )
+        elif self.config._do_not_use_refresh_token != self.config.CONFIG_NOT_SET:
+            authorizer = Authorizer(
+                authenticator, refresh_token=self.config._do_not_use_refresh_token
+            )
+        else:
+            self._core = self._read_only_core
+            return
+        self._core = self._authorized_core = session(authorizer)
+
     def _prepare_objector(self):
         mappings = {
             self.config.kinds["comment"]: models.Comment,
@@ -484,11 +515,8 @@ class Reddit:
                 authenticator, self.config.username, self.config.password
             )
             self._core = self._authorized_core = session(script_authorizer)
-        elif self.config.refresh_token:
-            authorizer = Authorizer(authenticator, self.config.refresh_token)
-            self._core = self._authorized_core = session(authorizer)
         else:
-            self._core = self._read_only_core
+            self._prepare_common_authorizer(authenticator)
 
     def _prepare_untrusted_prawcore(self, requestor):
         authenticator = UntrustedAuthenticator(
@@ -496,11 +524,7 @@ class Reddit:
         )
         read_only_authorizer = DeviceIDAuthorizer(authenticator)
         self._read_only_core = session(read_only_authorizer)
-        if self.config.refresh_token:
-            authorizer = Authorizer(authenticator, self.config.refresh_token)
-            self._core = self._authorized_core = session(authorizer)
-        else:
-            self._core = self._read_only_core
+        self._prepare_common_authorizer(authenticator)
 
     def comment(
         self,  # pylint: disable=invalid-name
diff --git a/praw/util/token_manager.py b/praw/util/token_manager.py
new file mode 100644
index 00000000..066d2f5a
--- /dev/null
+++ b/praw/util/token_manager.py
@@ -0,0 +1,82 @@
+"""Token Manager classes.
+
+There should be a 1-to-1 mapping betwen an instance of a subclass of
+:class:`.BaseTokenManager` and a :class:`.Reddit` instance.
+
+A few trivial token manager classes are provided here, but it is expected that
+PRAW users will create their own token manager classes suitable for their needs.
+
+See ref:`using_refresh_tokens` for examples on how to leverage these classes.
+
+"""
+
+
+class BaseTokenManager:
+    """An abstract class for all token managers."""
+
+    def __init__(self):
+        """Prepare attributes needed by all token manager classes."""
+        self._reddit = None
+
+    @property
+    def reddit(self):
+        """Return the :class:`.Reddit` instance bound to the token manager."""
+        return self._reddit
+
+    @reddit.setter
+    def reddit(self, value):
+        if self._reddit is not None:
+            raise RuntimeError(
+                "``reddit`` can only be set once and is done automatically"
+            )
+        self._reddit = value
+
+    def post_refresh_callback(self, authorizer):
+        """Handle callback that is invoked after a refresh token is used.
+
+        :param authorizer: The ``prawcore.Authorizer`` instance used containing
+            ``access_token`` and ``refresh_token`` attributes.
+
+        This function will be called after refreshing the access and refresh
+        tokens. This callback can be used for saving the updated
+        ``refresh_token``.
+
+        """
+        raise NotImplementedError("``post_refresh_callback`` must be extended.")
+
+    def pre_refresh_callback(self, authorizer):
+        """Handle callback that is invoked before refreshing PRAW's authorization.
+
+        :param authorizer: The ``prawcore.Authorizer`` instance used containing
+            ``access_token`` and ``refresh_token`` attributes.
+
+        This callback can be used to inspect and modify the attributes of the
+        ``prawcore.Authorizer`` instance, such as setting the
+        ``refresh_token``.
+
+        """
+        raise NotImplementedError("``pre_refresh_callback`` must be extended.")
+
+
+class FileTokenManager(BaseTokenManager):
+    """Provides a trivial single-file based token manager."""
+
+    def __init__(self, filename):
+        """Load and save refresh tokens from a file.
+
+        :param filename: The file the contains the refresh token.
+
+        """
+        super().__init__()
+        self._filename = filename
+
+    def post_refresh_callback(self, authorizer):
+        """Update the saved copy of the refresh token."""
+        with open(self._filename, "w") as fp:
+            fp.write(authorizer.refresh_token)
+
+    def pre_refresh_callback(self, authorizer):
+        """Load the refresh token from the file."""
+        if authorizer.refresh_token is None:
+            with open(self._filename) as fp:
+                authorizer.refresh_token = fp.read().strip()
diff --git a/setup.py b/setup.py
index 3a1b1bd3..62f4fc58 100644
--- a/setup.py
+++ b/setup.py
@@ -60,7 +60,7 @@ setup(
     ),
     extras_require=extras,
     install_requires=[
-        "prawcore >=1.5.0, <2.0",
+        "prawcore >=2, <3",
         "update_checker >=0.18",
         "websocket-client >=0.54.0",
     ],


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 7ce95e3e..b4a4cf57 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -5,12 +5,10 @@ import socket
 import sys
 import time
 from base64 import b64encode
-from functools import wraps
 from sys import platform
 
 import betamax
 import pytest
-from betamax.cassette.cassette import Cassette, dispatch_hooks
 from betamax.serializers import JSONSerializer
 
 # pylint: disable=import-error,no-name-in-module
@@ -63,7 +61,7 @@ placeholders = {
     x: env_default(x)
     for x in (
         "auth_code client_id client_secret password redirect_uri test_subreddit"
-        " user_agent username refresh_token"
+        " user_agent username"
     ).split()
 }
 
@@ -91,28 +89,6 @@ with betamax.Betamax.configure() as config:
         config.define_cassette_placeholder(f"<{key.upper()}>", value)
 
 
-def add_init_hook(original_init):
-    """Wrap an __init__ method to also call some hooks."""
-
-    @wraps(original_init)
-    def wrapper(self, *args, **kwargs):
-        original_init(self, *args, **kwargs)
-        dispatch_hooks("after_init", self)
-
-    return wrapper
-
-
-Cassette.__init__ = add_init_hook(Cassette.__init__)
-
-
-def init_hook(cassette):
-    if cassette.is_recording():
-        pytest.set_up_record()  # dynamically defined in __init__.py
-
-
-Cassette.hooks["after_init"].append(init_hook)
-
-
 class Placeholders:
     def __init__(self, _dict):
         self.__dict__ = _dict
diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py
index 79c9f7c6..f82734cd 100644
--- a/tests/integration/__init__.py
+++ b/tests/integration/__init__.py
@@ -16,7 +16,6 @@ class IntegrationTest:
 
     def setup(self):
         """Setup runs before all test cases."""
-        self._overrode_reddit_setup = True
         self.setup_reddit()
         self.setup_betamax()
 
@@ -32,11 +31,7 @@ class IntegrationTest:
         # Require tests to explicitly disable read_only mode.
         self.reddit.read_only = True
 
-        pytest.set_up_record = self.set_up_record  # used in conftest.py
-
     def setup_reddit(self):
-        self._overrode_reddit_setup = False
-
         self._session = requests.Session()
 
         self.reddit = Reddit(
@@ -48,17 +43,6 @@ class IntegrationTest:
             username=pytest.placeholders.username,
         )
 
-    def set_up_record(self):
-        if not self._overrode_reddit_setup:
-            if pytest.placeholders.refresh_token != "placeholder_refresh_token":
-                self.reddit = Reddit(
-                    requestor_kwargs={"session": self._session},
-                    client_id=pytest.placeholders.client_id,
-                    client_secret=pytest.placeholders.client_secret,
-                    user_agent=pytest.placeholders.user_agent,
-                    refresh_token=pytest.placeholders.refresh_token,
-                )
-
     def use_cassette(self, cassette_name=None, **kwargs):
         """Use a cassette. The cassette name is dynamically generated.
 
diff --git a/tests/unit/test_deprecations.py b/tests/unit/test_deprecations.py
index 20b3be31..b0e035df 100644
--- a/tests/unit/test_deprecations.py
+++ b/tests/unit/test_deprecations.py
@@ -2,6 +2,7 @@
 
 import pytest
 
+from praw import Reddit
 from praw.exceptions import APIException, WebSocketException
 
 from . import UnitTest
@@ -60,3 +61,13 @@ class TestDeprecation(UnitTest):
     def test_reddit_user_me_read_only(self):
         with pytest.raises(DeprecationWarning):
             self.reddit.user.me()
+
+    def test_reddit_refresh_token(self):
+        with pytest.raises(DeprecationWarning):
+            Reddit(
+                client_id="dummy",
+                client_secret=None,
+                redirect_uri="dummy",
+                refresh_token="dummy",
+                user_agent="dummy",
+            )
diff --git a/tests/unit/test_reddit.py b/tests/unit/test_reddit.py
index c5ce539a..7ad366cf 100644
--- a/tests/unit/test_reddit.py
+++ b/tests/unit/test_reddit.py
@@ -11,6 +11,7 @@ from prawcore.exceptions import BadRequest
 from praw import Reddit, __version__
 from praw.config import Config
 from praw.exceptions import ClientException, RedditAPIException
+from praw.util.token_manager import BaseTokenManager
 
 from . import UnitTest
 
@@ -70,6 +71,18 @@ class TestReddit(UnitTest):
     def test_comment(self):
         assert self.reddit.comment("cklfmye").id == "cklfmye"
 
+    def test_conflicting_settings(self):
+        with pytest.raises(TypeError) as excinfo:
+            Reddit(
+                refresh_token="dummy",
+                token_manager="dummy",
+                **self.REQUIRED_DUMMY_SETTINGS,
+            )
+        assert (
+            str(excinfo.value)
+            == "legacy ``refresh_token`` setting cannot be provided when providing ``token_manager``"
+        )
+
     def test_context_manager(self):
         with Reddit(**self.REQUIRED_DUMMY_SETTINGS) as reddit:
             assert not reddit.config.check_for_updates
@@ -187,6 +200,19 @@ class TestReddit(UnitTest):
         assert self.reddit.post("test") == {}
 
     def test_read_only__with_authenticated_core(self):
+        with Reddit(
+            password=None,
+            token_manager=BaseTokenManager(),
+            username=None,
+            **self.REQUIRED_DUMMY_SETTINGS,
+        ) as reddit:
+            assert not reddit.read_only
+            reddit.read_only = True
+            assert reddit.read_only
+            reddit.read_only = False
+            assert not reddit.read_only
+
+    def test_read_only__with_authenticated_core__legacy_refresh_token(self):
         with Reddit(
             password=None,
             refresh_token="refresh",
@@ -204,8 +230,24 @@ class TestReddit(UnitTest):
             client_id="dummy",
             client_secret=None,
             redirect_uri="dummy",
+            token_manager=BaseTokenManager(),
             user_agent="dummy",
+        ) as reddit:
+            assert not reddit.read_only
+            reddit.read_only = True
+            assert reddit.read_only
+            reddit.read_only = False
+            assert not reddit.read_only
+
+    def test_read_only__with_authenticated_core__non_confidential__legacy_refresh_token(
+        self,
+    ):
+        with Reddit(
+            client_id="dummy",
+            client_secret=None,
+            redirect_uri="dummy",
             refresh_token="dummy",
+            user_agent="dummy",
         ) as reddit:
             assert not reddit.read_only
             reddit.read_only = True
diff --git a/tests/unit/util/test_token_manager.py b/tests/unit/util/test_token_manager.py
new file mode 100644
index 00000000..5ec700ee
--- /dev/null
+++ b/tests/unit/util/test_token_manager.py
@@ -0,0 +1,67 @@
+"""Test praw.util.refresh_token_manager."""
+from unittest import mock
+
+import pytest
+
+from praw.util.token_manager import BaseTokenManager, FileTokenManager
+
+from .. import UnitTest
+
+
+class DummyAuthorizer:
+    def __init__(self, refresh_token):
+        self.refresh_token = refresh_token
+
+
+class TestBaseTokenManager(UnitTest):
+    def test_post_refresh_token_callback__raises_not_implemented(self):
+        manager = BaseTokenManager()
+        with pytest.raises(NotImplementedError) as excinfo:
+            manager.post_refresh_callback(None)
+        assert str(excinfo.value) == "``post_refresh_callback`` must be extended."
+
+    def test_pre_refresh_token_callback__raises_not_implemented(self):
+        manager = BaseTokenManager()
+        with pytest.raises(NotImplementedError) as excinfo:
+            manager.pre_refresh_callback(None)
+        assert str(excinfo.value) == "``pre_refresh_callback`` must be extended."
+
+    def test_reddit(self):
+        manager = BaseTokenManager()
+        manager.reddit = "dummy"
+        assert manager.reddit == "dummy"
+
+    def test_reddit__must_only_be_set_once(self):
+        manager = BaseTokenManager()
+        manager.reddit = "dummy"
+        with pytest.raises(RuntimeError) as excinfo:
+            manager.reddit = None
+        assert (
+            str(excinfo.value)
+            == "``reddit`` can only be set once and is done automatically"
+        )
+
+
+class TestFileTokenManager(UnitTest):
+    def test_post_refresh_token_callback__writes_to_file(self):
+        authorizer = DummyAuthorizer("token_value")
+        manager = FileTokenManager("mock/dummy_path")
+        mock_open = mock.mock_open()
+
+        with mock.patch("builtins.open", mock_open):
+            manager.post_refresh_callback(authorizer)
+
+        assert authorizer.refresh_token == "token_value"
+        mock_open.assert_called_once_with("mock/dummy_path", "w")
+        mock_open().write.assert_called_once_with("token_value")
+
+    def test_pre_refresh_token_callback__reads_from_file(self):
+        authorizer = DummyAuthorizer(None)
+        manager = FileTokenManager("mock/dummy_path")
+        mock_open = mock.mock_open(read_data="token_value")
+
+        with mock.patch("builtins.open", mock_open):
+            manager.pre_refresh_callback(authorizer)
+
+        assert authorizer.refresh_token == "token_value"
+        mock_open.assert_called_once_with("mock/dummy_path")