diff --git a/reviewboard/accounts/tests/test_client_login_confirmation_view.py b/reviewboard/accounts/tests/test_client_login_confirmation_view.py
new file mode 100644
index 0000000000000000000000000000000000000000..712c895ea04633397db79aab8d8ebf09a7c95056
--- /dev/null
+++ b/reviewboard/accounts/tests/test_client_login_confirmation_view.py
@@ -0,0 +1,176 @@
+"""Unit tests for reviewboard.accounts.views.ClientLoginConfirmationView.
+
+Version Added:
+    5.0.5
+"""
+
+from reviewboard.site.urlresolvers import local_site_reverse
+from reviewboard.testing import TestCase
+
+
+class ClientLoginConfirmationViewTests(TestCase):
+    """Unit tests for reviewboard.accounts.views.ClientLoginConfirmationView.
+
+    Version Added:
+        5.0.5
+    """
+
+    fixtures = ['test_users']
+
+    #: The URL to the login page.
+    login_url = local_site_reverse('login')
+
+    #: The URL to the logout page.
+    logout_url = local_site_reverse('logout')
+
+    def test_get(self) -> None:
+        """Testing ClientLoginConfirmationView GET"""
+        self.client.login(username='doc', password='doc')
+        settings = {
+            'client_web_login': True,
+        }
+
+        with self.siteconfig_settings(settings):
+            rsp = self.client.get(
+                local_site_reverse('client-login-confirm'),
+                {
+                    'client-name': 'TestClient',
+                    'client-url': 'http://localhost:1234/test/',
+                })
+
+        context = rsp.context
+
+        self.assertEqual(context['client_name'], 'TestClient')
+        self.assertEqual(context['client_url'], 'http://localhost:1234/test/')
+        self.assertEqual(
+            context['client_login_url'],
+            '/account/client-login/?client-name=TestClient'
+            '&client-url=http://localhost:1234/test/')
+        self.assertEqual(
+            context['logout_url'],
+            (f'{self.logout_url}?next={self.login_url}'
+             '%3Fclient-name%3DTestClient'
+             '%26client-url%3Dhttp%3A//localhost%3A1234/test/'))
+        self.assertEqual(context['username'], 'doc')
+
+    def test_get_with_redirect(self) -> None:
+        """Testing ClientLoginConfirmationView GET with a redirect URL"""
+        self.client.login(username='doc', password='doc')
+        settings = {
+            'client_web_login': True,
+        }
+
+        with self.siteconfig_settings(settings):
+            rsp = self.client.get(
+                local_site_reverse('client-login-confirm'),
+                {
+                    'client-name': 'TestClient',
+                    'client-url': 'http://localhost:1234/test/',
+                    'next': 'http://localhost:1234/page?foo=1',
+                })
+
+        context = rsp.context
+
+        self.assertEqual(context['client_name'], 'TestClient')
+        self.assertEqual(context['client_url'], 'http://localhost:1234/test/')
+        self.assertEqual(
+            context['client_login_url'],
+            '/account/client-login/?client-name=TestClient'
+            '&client-url=http://localhost:1234/test/'
+            '&next=http%3A//localhost%3A1234/page%3Ffoo%3D1')
+
+        # The client redirect part of the URL is encoded twice
+        # in order to preserve any of its query parameters.
+        self.assertEqual(
+            context['logout_url'],
+            (f'{self.logout_url}?next={self.login_url}'
+             '%3Fclient-name%3DTestClient'
+             '%26client-url%3Dhttp%3A//localhost%3A1234/test/'
+             '%26next%3Dhttp%253A//localhost%253A1234/page%253Ffoo%253D1'))
+        self.assertEqual(context['username'], 'doc')
+
+    def test_get_with_unsafe_redirect(self) -> None:
+        """Testing ClientLoginConfirmationView GET with an unsafe
+        redirect URL
+        """
+        self.client.login(username='doc', password='doc')
+        settings = {
+            'client_web_login': True,
+        }
+
+        with self.siteconfig_settings(settings):
+            rsp = self.client.get(
+                local_site_reverse('client-login-confirm'),
+                {
+                    'client-name': 'TestClient',
+                    'client-url': 'http://localhost:1234/test/',
+                    'next': 'http://unsafe-site/page?foo=1',
+                })
+
+        context = rsp.context
+
+        self.assertEqual(context['client_name'], 'TestClient')
+        self.assertEqual(context['client_url'], 'http://localhost:1234/test/')
+        self.assertEqual(
+            context['client_login_url'],
+            '/account/client-login/?client-name=TestClient'
+            '&client-url=http://localhost:1234/test/')
+        self.assertEqual(
+            context['logout_url'],
+            (f'{self.logout_url}?next={self.login_url}'
+             '%3Fclient-name%3DTestClient'
+             '%26client-url%3Dhttp%3A//localhost%3A1234/test/'))
+        self.assertEqual(context['username'], 'doc')
+
+    def test_get_unauthenticated(self) -> None:
+        """Testing ClientLoginConfirmationView GET redirects to the
+        login page when a user is not logged in
+        """
+        settings = {
+            'client_web_login': True,
+        }
+
+        with self.siteconfig_settings(settings):
+            rsp = self.client.get(
+                local_site_reverse('client-login-confirm'),
+                {
+                    'client-name': 'TestClient',
+                    'client-url': 'http://localhost:1234/test/',
+                })
+
+        self.assertEqual(rsp.status_code, 302)
+
+    def test_get_client_web_login_false(self) -> None:
+        """Testing ClientLoginConfirmationView GET with the client
+        web login flow disabled
+        """
+        settings = {
+            'client_web_login': False,
+        }
+
+        with self.siteconfig_settings(settings):
+            rsp = self.client.get(
+                local_site_reverse('client-login-confirm'),
+                {
+                    'client-name': 'TestClient',
+                    'client-url': 'http://localhost:1234/test/',
+                })
+
+        self.assertEqual(rsp.status_code, 404)
+
+    def test_post(self) -> None:
+        """Testing ClientLoginConfirmationView POST"""
+        self.client.login(username='doc', password='doc')
+        settings = {
+            'client_web_login': True,
+        }
+
+        with self.siteconfig_settings(settings):
+            rsp = self.client.post(
+                local_site_reverse('client-login-confirm'),
+                {
+                    'client-name': 'TestClient',
+                    'client-url': 'http://localhost:1234/test/',
+                })
+
+        self.assertEqual(rsp.status_code, 405)
diff --git a/reviewboard/accounts/tests/test_client_login_view.py b/reviewboard/accounts/tests/test_client_login_view.py
new file mode 100644
index 0000000000000000000000000000000000000000..ef5565ab118c30b6fb158532469d50d878635b6b
--- /dev/null
+++ b/reviewboard/accounts/tests/test_client_login_view.py
@@ -0,0 +1,344 @@
+"""Unit tests for reviewboard.accounts.views.ClientLoginView.
+
+Version Added:
+    5.0.5
+"""
+
+import datetime
+from typing import Optional
+from urllib.parse import quote
+
+import kgb
+from django.contrib.auth.models import User
+from django.template import Context
+from django.utils import timezone
+from django.utils.html import escape
+from djblets.webapi.errors import WebAPITokenGenerationError
+
+from reviewboard.site.urlresolvers import local_site_reverse
+from reviewboard.testing import TestCase
+from reviewboard.webapi.models import WebAPIToken
+
+
+class ClientLoginViewTests(kgb.SpyAgency, TestCase):
+    """Unit tests for reviewboard.accounts.views.ClientLoginView.
+
+    Version Added:
+        5.0.5
+    """
+
+    fixtures = ['test_users']
+
+    def test_get(self) -> None:
+        """Testing ClientLoginView GET builds a payload containing
+        authentication data when the user is logged in and the client web
+        login flow is enabled
+        """
+        self.spy_on(timezone.now, op=kgb.SpyOpReturn(
+            timezone.make_aware(datetime.datetime(2023, 5, 20))))
+
+        self.client.login(username='doc', password='doc')
+        settings = {
+            'client_web_login': True,
+            'client_token_expiration': 5
+        }
+
+        with self.siteconfig_settings(settings):
+            rsp = self.client.get(
+                local_site_reverse('client-login'),
+                {
+                    'client-name': 'TestClient',
+                    'client-url': 'http://localhost:1234/test/',
+                })
+
+        self._assert_context_equals(
+            rsp.context,
+            client_allowed=True,
+            client_name='TestClient',
+            client_url='http://localhost:1234/test/',
+            username='doc',
+            check_payload_token=True,
+            token_expires=timezone.make_aware(datetime.datetime(2023, 5, 25)))
+
+    def test_get_with_redirect(self) -> None:
+        """Testing ClientLoginView GET with a redirect URL"""
+        self.spy_on(timezone.now, op=kgb.SpyOpReturn(
+            timezone.make_aware(datetime.datetime(2023, 5, 20))))
+
+        self.client.login(username='doc', password='doc')
+        settings = {
+            'client_web_login': True,
+            'client_token_expiration': 5
+        }
+
+        with self.siteconfig_settings(settings):
+            rsp = self.client.get(
+                local_site_reverse('client-login'),
+                {
+                    'client-name': 'TestClient',
+                    'client-url': 'http://localhost:1234/test/',
+                    'next': 'http://localhost:1234/page?foo=1',
+                })
+
+        self._assert_context_equals(
+            rsp.context,
+            client_allowed=True,
+            client_name='TestClient',
+            client_url='http://localhost:1234/test/',
+            redirect_to='http%3A//localhost%3A1234/page%3Ffoo%3D1',
+            username='doc',
+            check_payload_token=True,
+            token_expires=timezone.make_aware(datetime.datetime(2023, 5, 25)))
+
+    def test_get_with_unsafe_redirect(self) -> None:
+        """Testing ClientLoginView GET with an unsafe redirect URL"""
+        self.spy_on(timezone.now, op=kgb.SpyOpReturn(
+            timezone.make_aware(datetime.datetime(2023, 5, 20))))
+
+        self.client.login(username='doc', password='doc')
+        settings = {
+            'client_web_login': True,
+            'client_token_expiration': 5
+        }
+
+        with self.siteconfig_settings(settings):
+            rsp = self.client.get(
+                local_site_reverse('client-login'),
+                {
+                    'client-name': 'TestClient',
+                    'client-url': 'http://localhost:1234/test/',
+                    'next': 'http://unsafe-site/page?foo=1',
+                })
+
+        self._assert_context_equals(
+            rsp.context,
+            client_allowed=True,
+            client_name='TestClient',
+            client_url='http://localhost:1234/test/',
+            redirect_to='',
+            username='doc',
+            check_payload_token=True,
+            token_expires=timezone.make_aware(datetime.datetime(2023, 5, 25)))
+
+    def test_get_with_token_generation_error(self) -> None:
+        """Testing ClientLoginView GET with an API token generation error"""
+        self.spy_on(WebAPIToken.objects.get_or_create_client_token,
+                    op=kgb.SpyOpRaise(WebAPITokenGenerationError('fail')))
+
+        self.client.login(username='doc', password='doc')
+        settings = {
+            'client_web_login': True,
+            'client_token_expiration': 5
+        }
+
+        with self.siteconfig_settings(settings):
+            rsp = self.client.get(
+                local_site_reverse('client-login'),
+                {
+                    'client-name': 'TestClient',
+                    'client-url': 'http://localhost:1234/test/',
+                })
+
+        tokens = WebAPIToken.objects.filter(
+            user=User.objects.get(username='doc'))
+
+        self.assertEqual(len(tokens), 0)
+        self._assert_context_equals(
+            rsp.context,
+            client_allowed=True,
+            client_name='TestClient',
+            client_url='http://localhost:1234/test/',
+            error='Failed to generate a unique API token for authentication. '
+                  'Please reload the page to try again.',
+            username='doc')
+
+    def test_get_unauthenticated(self) -> None:
+        """Testing ClientLoginView GET redirects to the login page when
+        a user is not logged in
+        """
+        settings = {
+            'client_web_login': True,
+            'client_token_expiration': 5
+        }
+
+        with self.siteconfig_settings(settings):
+            rsp = self.client.get(
+                local_site_reverse('client-login'),
+                {
+                    'client-name': 'TestClient',
+                    'client-url': 'http://localhost:1234/test/',
+                })
+
+        self.assertEqual(rsp.status_code, 302)
+
+    def test_get_with_unsafe_client_url(self) -> None:
+        """Testing ClientLoginView GET with an unsafe client url"""
+
+        self.client.login(username='doc', password='doc')
+        settings = {
+            'client_web_login': True,
+            'client_token_expiration': 5
+        }
+
+        with self.siteconfig_settings(settings):
+            with self.assertLogs() as logs:
+                rsp = self.client.get(
+                    local_site_reverse('client-login'),
+                    {
+                        'client-name': 'TestClient',
+                        'client-url': 'http://unsafe-url.com',
+                    })
+
+        tokens = WebAPIToken.objects.filter(
+            user=User.objects.get(username='doc'))
+
+        self.assertEqual(len(tokens), 0)
+        self._assert_context_equals(
+            rsp.context,
+            client_allowed=False,
+            client_name='TestClient',
+            client_url='http://unsafe-url.com',
+            username='doc')
+        self.assertEqual(
+            logs.records[0].getMessage(),
+            ('Blocking an attempt to send authentication info '
+             'to unsafe URL http://unsafe-url.com'))
+
+    def test_get_with_client_url_no_port(self) -> None:
+        """Testing ClientLoginView GET with a client url that has no port
+        specified
+        """
+        self.spy_on(timezone.now, op=kgb.SpyOpReturn(
+            timezone.make_aware(datetime.datetime(2023, 5, 20))))
+
+        self.client.login(username='doc', password='doc')
+        settings = {
+            'client_web_login': True,
+            'client_token_expiration': 5
+        }
+
+        with self.siteconfig_settings(settings):
+            rsp = self.client.get(
+                local_site_reverse('client-login'),
+                {
+                    'client-name': 'TestClient',
+                    'client-url': 'http://localhost',
+                })
+
+        self._assert_context_equals(
+            rsp.context,
+            client_allowed=True,
+            client_name='TestClient',
+            client_url='http://localhost',
+            username='doc',
+            check_payload_token=True,
+            token_expires=timezone.make_aware(datetime.datetime(2023, 5, 25)))
+
+    def test_get_with_client_web_login_false(self) -> None:
+        """Testing ClientLoginView GET with the client web login flow
+        disabled
+        """
+        settings = {
+            'client_web_login': False,
+            'client_token_expiration': 5
+        }
+
+        with self.siteconfig_settings(settings):
+            rsp = self.client.get(
+                local_site_reverse('client-login'),
+                {
+                    'client-name': 'TestClient',
+                    'client-url': 'http://localhost:1234/test/',
+                })
+
+        self.assertEqual(rsp.status_code, 404)
+
+    def test_post(self) -> None:
+        """Testing ClientLoginView POST"""
+        self.client.login(username='doc', password='doc')
+        settings = {
+            'client_web_login': True,
+            'client_token_expiration': 5
+        }
+
+        with self.siteconfig_settings(settings):
+            rsp = self.client.post(
+                local_site_reverse('client-login'),
+                {
+                    'client-name': 'TestClient',
+                    'client-url': 'http://localhost:1234/test/',
+                })
+
+        self.assertEqual(rsp.status_code, 405)
+
+    def _assert_context_equals(
+        self,
+        context: Context,
+        client_allowed: bool,
+        client_name: str,
+        client_url: str,
+        username: str,
+        check_payload_token: Optional[bool] = False,
+        error: Optional[str] = '',
+        redirect_to: Optional[str] = '',
+        token_expires: Optional[datetime.datetime] = None,
+    ) -> None:
+        """Assert that the context and JS view data matches the given values.
+
+        Args:
+            context (django.template.Context):
+                The context dictionary to be tested.
+
+            client_allowed (bool):
+                The expected value for the client_allowed.
+
+            client_name (str):
+                The expected value for the client_name.
+
+            client_url (str):
+                The expected value for the client_url.
+
+            username (str):
+                The expected value for the username.
+
+            check_payload_token (bool, optional):
+                Whether to check for an API token in the payload.
+
+            error (str, optional):
+                The expected value for the error.
+
+            redirect_to (str, optional):
+                The expected value for the redirect_to.
+
+            token_expires (datetime.datetime, optional):
+                The expected value for the API token expiration.
+
+        Raises:
+            AssertionError:
+                The context did not match the given values.
+        """
+        js_view_data = context['js_view_data']
+        payload = js_view_data['payload']
+
+        self.assertEqual(context['client_allowed'], client_allowed)
+        self.assertEqual(context['client_name'], client_name)
+        self.assertEqual(context['client_url'], client_url)
+        self.assertEqual(context['username'], username)
+
+        if error:
+            self.assertEqual(context['error'], error)
+        else:
+            self.assertNotIn('error', context)
+
+        self.assertEqual(js_view_data['clientName'], escape(client_name))
+        self.assertEqual(js_view_data['clientURL'], quote(client_url))
+        self.assertEqual(js_view_data['username'], username)
+        self.assertEqual(js_view_data['redirectTo'], redirect_to)
+
+        if check_payload_token:
+            token = WebAPIToken.objects.get(token=payload['api_token'])
+            self.assertEqual(token.user.username, username)
+            self.assertEqual(token.expires, token_expires)
+            self.assertEqual(token.extra_data['client_name'], client_name)
+        else:
+            self.assertEquals(payload, {})
diff --git a/reviewboard/accounts/tests/test_login_view.py b/reviewboard/accounts/tests/test_login_view.py
new file mode 100644
index 0000000000000000000000000000000000000000..92bb12b5a46004846372f6503c8a1dcbdb05cc88
--- /dev/null
+++ b/reviewboard/accounts/tests/test_login_view.py
@@ -0,0 +1,135 @@
+"""Unit tests for reviewboard.accounts.views.LoginView.
+
+Version Added:
+    5.0.5
+"""
+
+from reviewboard.site.urlresolvers import local_site_reverse
+from reviewboard.testing import TestCase
+
+
+class LoginViewTests(TestCase):
+    """Unit tests for reviewboard.accounts.views.LoginView.
+
+    Version Added:
+        5.0.5
+    """
+
+    fixtures = ['test_users']
+
+    def test_login_with_redirect(self) -> None:
+        """Testing LoginView GET with a local redirect URL"""
+        rsp = self.client.get(
+            local_site_reverse('login'),
+            {
+                'next': '/users?foo=1&bar=baz'
+            })
+        context = rsp.context
+
+        self.assertEqual(context['next'], '/users?foo=1&bar=baz')
+
+    def test_get_client_web_login(self) -> None:
+        """Testing LoginView GET sets the redirect field to the client web
+        login page when the request indicates the client web login flow and the
+        flow is enabled
+        """
+        settings = {
+            'client_web_login': True,
+        }
+        client_login_url = local_site_reverse('client-login')
+
+        with self.siteconfig_settings(settings):
+            rsp = self.client.get(
+                local_site_reverse('login'),
+                {
+                    'client-name': 'TestClient',
+                    'client-url': 'http://localhost:8080/test/',
+                })
+
+        context = rsp.context
+
+        self.assertEqual(context['client_name'], 'TestClient')
+        self.assertEqual(context['client_url'], 'http://localhost:8080/test/')
+        self.assertEqual(context['next'],
+                         (f'{client_login_url}?client-name=TestClient'
+                          '&client-url=http://localhost:8080/test/'))
+
+    def test_get_client_web_login_with_redirect(self) -> None:
+        """Testing LoginView GET with the client web login flow encodes
+        and passes along a redirect URL if one was given
+        """
+        settings = {
+            'client_web_login': True,
+        }
+        client_login_url = local_site_reverse('client-login')
+
+        with self.siteconfig_settings(settings):
+            rsp = self.client.get(
+                local_site_reverse('login'),
+                {
+                    'client-name': 'TestClient',
+                    'client-url': 'http://localhost:8080/test/',
+                    'next': 'http://localhost:8080/page?foo=1',
+                })
+
+        context = rsp.context
+
+        self.assertEqual(context['client_name'], 'TestClient')
+        self.assertEqual(context['client_url'], 'http://localhost:8080/test/')
+        self.assertEqual(
+            context['next'],
+            (f'{client_login_url}?client-name=TestClient'
+             '&client-url=http://localhost:8080/test/'
+             '&next=http%3A//localhost%3A8080/page%3Ffoo%3D1'))
+
+    def test_get_client_web_login_logged_in(self) -> None:
+        """Testing LoginView GET redirects to the client web login
+        confirmation page when the request indicates the client web login
+        flow and the flow is enabled
+        """
+        settings = {
+            'client_web_login': True,
+        }
+        client_login_confirm_url = local_site_reverse('client-login-confirm')
+
+        self.client.login(username='doc', password='doc')
+
+        with self.siteconfig_settings(settings):
+            rsp = self.client.get(
+                local_site_reverse('login'),
+                {
+                    'client-name': 'TestClient',
+                    'client-url': 'http://localhost:8080/test/',
+                })
+
+        self.assertRedirects(
+            rsp,
+            (f'{client_login_confirm_url}?client-name=TestClient'
+             '&client-url=http://localhost:8080/test/'))
+
+    def test_get_client_web_login_false(self) -> None:
+        """Testing LoginView GET does not set the redirect field to the
+        client web login page when the request indicates the client web login
+        flow and the flow is not enabled
+        """
+        settings = {
+            'client_web_login': False,
+        }
+
+        client_login_url = local_site_reverse('client-login')
+
+        with self.siteconfig_settings(settings):
+            rsp = self.client.get(
+                local_site_reverse('login'),
+                {
+                    'client-name': 'TestClient',
+                    'client-url': 'http://localhost:8080/test/',
+                })
+
+        context = rsp.context
+
+        self.assertNotIn('client_name', context)
+        self.assertNotIn('client_url', context)
+        self.assertNotEqual(context['next'],
+                            (f'{client_login_url}?client-name=TestClient'
+                             '&client-url=http://localhost:8080/test/'))
diff --git a/reviewboard/accounts/urls.py b/reviewboard/accounts/urls.py
index b7845c19b87b35319afc80007533cae63b674f28..5caccbace5561755ed537736c81d2f7e488f658d 100644
--- a/reviewboard/accounts/urls.py
+++ b/reviewboard/accounts/urls.py
@@ -62,4 +62,10 @@ urlpatterns = [
          accounts_views.preview_password_changed_email,
          name='preview-password-change-email'),
     path('sso/', include(([sso_dynamic_urls], 'accounts'), namespace='sso')),
+    path('client-login/',
+         accounts_views.ClientLoginView.as_view(),
+         name='client-login'),
+    path('client-login/confirm',
+         accounts_views.ClientLoginConfirmationView.as_view(),
+         name='client-login-confirm'),
 ]
diff --git a/reviewboard/accounts/views.py b/reviewboard/accounts/views.py
index 85603e97085562e42e36f1ead69f7fe98240ca2a..d55d444ee2fc482cfc11c6114e48437b17091670 100644
--- a/reviewboard/accounts/views.py
+++ b/reviewboard/accounts/views.py
@@ -1,5 +1,11 @@
+"""Views for handling authentication and user accounts."""
+
+from __future__ import annotations
+
+import datetime
 import logging
-from urllib.parse import quote
+from typing import Optional
+from urllib.parse import quote, urlparse
 
 from django.conf import settings
 from django.contrib.auth.decorators import login_required
@@ -9,11 +15,16 @@ from django.contrib.auth.views import (
     LogoutView,
     logout_then_login as auth_logout_then_login)
 from django.forms.forms import ErrorDict
-from django.http import HttpResponseRedirect
+from django.http import (Http404,
+                         HttpRequest,
+                         HttpResponse,
+                         HttpResponseRedirect)
 from django.shortcuts import get_object_or_404, render
 from django.urls import reverse
+from django.utils import timezone
 from django.utils.decorators import method_decorator
 from django.utils.functional import cached_property
+from django.utils.html import escape
 from django.utils.http import url_has_allowed_host_and_scheme
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
@@ -27,10 +38,12 @@ from djblets.registries.errors import ItemLookupError
 from djblets.siteconfig.models import SiteConfiguration
 from djblets.util.decorators import augment_method_from
 from djblets.views.generic.etag import ETagViewMixin
+from djblets.webapi.errors import WebAPITokenGenerationError
 
 from reviewboard.accounts.backends import get_enabled_auth_backends
 from reviewboard.accounts.forms.registration import RegistrationForm
-from reviewboard.accounts.mixins import CheckLoginRequiredViewMixin
+from reviewboard.accounts.mixins import (CheckLoginRequiredViewMixin,
+                                         LoginRequiredViewMixin)
 from reviewboard.accounts.pages import AccountPage, OAuth2Page, PrivacyPage
 from reviewboard.accounts.privacy import is_consent_missing
 from reviewboard.accounts.sso.backends import sso_backends
@@ -45,6 +58,7 @@ from reviewboard.oauth.forms import (UserApplicationChangeForm,
 from reviewboard.oauth.models import Application
 from reviewboard.site.mixins import CheckLocalSiteAccessViewMixin
 from reviewboard.site.urlresolvers import local_site_reverse
+from reviewboard.webapi.models import WebAPIToken
 
 
 logger = logging.getLogger(__name__)
@@ -53,12 +67,74 @@ logger = logging.getLogger(__name__)
 class LoginView(DjangoLoginView):
     """A view for rendering the login page.
 
+    This view may be called when clients are trying to authenticate to
+    Review Board through a web-based login flow. In that case, callers must
+    include a ``client-name`` query parameter containing the client name,
+    and a ``client-url`` parameter containing the URL of where to send
+    authentication data upon a successful login. Client callers may include a
+    ``next`` parameter containing a URL for redirection, making sure to
+    encode any query parameters in that URL.
+
+    Version Changed:
+        5.0.5:
+        Added the ``client-name`` and ``client-url`` query parameters for
+        authenticating clients.
+
     Version Added:
         5.0
     """
 
     template_name = 'accounts/login.html'
 
+    ######################
+    # Instance variables #
+    ######################
+
+    #: Whether the request is for authenticating a client.
+    #:
+    #: Version Added:
+    #:     5.0.5
+    #:
+    #: Type:
+    #:     bool
+    client_auth_flow: bool
+
+    #: The name of the client who is authenticating.
+    #:
+    #: Version Added:
+    #:     5.0.5
+    #:
+    #: Type:
+    #:     str
+    client_name: Optional[str]
+
+    #: The URL to the client login page.
+    #:
+    #: Version Added:
+    #:     5.0.5
+    #:
+    #: Type:
+    #:     str
+    client_login_url: str
+
+    #: The URL to the client login confirmation page.
+    #:
+    #: Version Added:
+    #:     5.0.5
+    #:
+    #: Type:
+    #:     str
+    client_login_confirm_url: str
+
+    #: The URL of where to send authentication data for the client.
+    #:
+    #: Version Added:
+    #:     5.0.5
+    #:
+    #: Type:
+    #:     str
+    client_url: Optional[str]
+
     def dispatch(self, request, *args, **kwargs):
         """Dispatch the view.
 
@@ -77,6 +153,53 @@ class LoginView(DjangoLoginView):
             The response to send to the client.
         """
         siteconfig = SiteConfiguration.objects.get_current()
+        self.client_name = None
+        self.client_url = None
+
+        if siteconfig.get('client_web_login'):
+            self.client_name = self.request.GET.get(
+                'client-name',
+                self.request.POST.get('client-name', ''))
+            self.client_url = self.request.GET.get(
+                'client-url',
+                self.request.POST.get('client-url', ''))
+            client_url_port = urlparse(self.client_url).port
+            self.success_url_allowed_hosts = \
+                _get_client_allowed_hosts(client_url_port)
+
+        client_name = self.client_name
+        client_url = self.client_url
+        client_auth_flow = bool(client_name and client_url)
+        self.client_auth_flow = client_auth_flow
+        client_redirect_param_str = ''
+        redirect_field_name = self.redirect_field_name
+        redirect_to = quote(self.get_redirect_url())
+
+        if redirect_to and client_auth_flow:
+            client_redirect_param_str = (
+                '&%s=%s' % (redirect_field_name, redirect_to))
+
+        client_login_url = (
+            '%s?client-name=%s&client-url=%s%s'
+            % (local_site_reverse('client-login'),
+               client_name,
+               client_url,
+               client_redirect_param_str))
+        client_login_confirm_url = (
+            '%s?client-name=%s&client-url=%s%s'
+            % (local_site_reverse('client-login-confirm'),
+               client_name,
+               client_url,
+               client_redirect_param_str))
+        self.client_login_url = client_login_url
+        self.client_login_confirm_url = client_login_confirm_url
+
+        if (request.method == 'GET' and client_auth_flow and
+            request.user.is_authenticated):
+            # The request is for client web-based login, with the user already
+            # logged in.
+            return HttpResponseRedirect(client_login_confirm_url)
+
         sso_auto_login_backend = siteconfig.get('sso_auto_login_backend', None)
 
         if sso_auto_login_backend:
@@ -84,12 +207,15 @@ class LoginView(DjangoLoginView):
                 backend = sso_backends.get('backend_id', sso_auto_login_backend)
                 login_url = backend.login_url
 
-                redirect_to = self.get_success_url()
+                if client_auth_flow:
+                    redirect_to = client_login_confirm_url
+                else:
+                    redirect_to = self.get_success_url()
 
                 if url_has_allowed_host_and_scheme(
                     url=redirect_to, allowed_hosts=request.get_host()):
                     login_url = '%s?%s=%s' % (login_url,
-                                              self.redirect_field_name,
+                                              redirect_field_name,
                                               quote(redirect_to))
 
                 return HttpResponseRedirect(login_url)
@@ -118,6 +244,14 @@ class LoginView(DjangoLoginView):
             if sso_backend.is_enabled()
         ]
 
+        if self.client_auth_flow:
+            context['client_name'] = self.client_name
+            context['client_url'] = self.client_url
+
+            # While in the client web-based login flow, redirect to
+            # the client login page upon successful login.
+            context[self.redirect_field_name] = self.client_login_url
+
         return context
 
 
@@ -466,3 +600,357 @@ def edit_oauth_app(request, app_id=None):
             'oauth2_page_url': OAuth2Page.get_absolute_url(),
             'request': request,
         })
+
+
+class BaseClientLoginView(LoginRequiredViewMixin,
+                          TemplateView):
+    """Base view for views dealing with the client web-based login flow.
+
+    Callers must include a ``client-name`` query parameter containing the
+    client name, and a ``client-url`` parameter containing the URL of where
+    to send authentication data upon a successful login. Callers may include
+    a ``next`` parameter containing a URL for redirection, making sure to
+    encode any query parameters in that URL.
+
+    Version Added:
+        5.0.5
+    """
+
+    #: The name of the redirect field.
+    #:
+    #: Type:
+    #:     str
+    redirect_field_name: str = LoginView.redirect_field_name
+
+    ######################
+    # Instance variables #
+    ######################
+
+    #: Whether the client is safe to redirect and POST to.
+    #:
+    #: Type:
+    #:     bool
+    client_allowed: bool
+
+    #: The hosts that are allowed to authenticate clients to Review Board.
+    #:
+    #: This will only allow the local host server at the given client
+    #: port and the Review Board server.
+    #:
+    #: Type:
+    #:     set
+    client_allowed_hosts: set
+
+    #: The name of the client who is authenticating.
+    #:
+    #: Type:
+    #:     str
+    client_name: str
+
+    #: The URL of where to send authentication data for the client.
+    #:
+    #: Type:
+    #:     str
+    client_url: str
+
+    #: The URL of where to redirect to upon a successful login.
+    #:
+    #: This is URL encoded.
+    #:
+    #: Type:
+    #:     str
+    redirect_to: str
+
+    #: The name of the template to render.
+    #:
+    #: This must be set by the subclass.
+    #:
+    #: Type:
+    #:     str
+    template_name: str
+
+    def dispatch(
+        self,
+        request: HttpRequest,
+        *args,
+        **kwargs,
+    ) -> HttpResponse:
+        """Dispatch the view.
+
+        Args:
+            request (django.http.HttpRequest):
+                The HTTP request.
+
+            *args (tuple):
+                Positional arguments to pass through to the parent class.
+
+            **kwargs (dict):
+                Keyword arguments to pass through to the parent class.
+
+        Returns:
+            django.http.HttpResponse:
+            The response to send to the client.
+        """
+        self.siteconfig = SiteConfiguration.objects.get_current()
+        request_GET = self.request.GET
+
+        if self.siteconfig.get('client_web_login'):
+            self.client_name = request_GET.get('client-name', '')
+            self.client_url = request_GET.get('client-url', '')
+
+            client_url_port = urlparse(self.client_url).port
+            client_allowed_hosts = _get_client_allowed_hosts(client_url_port)
+            client_allowed_hosts.add(request.get_host())
+            self.client_allowed_hosts = client_allowed_hosts
+
+            self.client_allowed = self._url_is_safe(self.client_url)
+            self.redirect_to = self._get_redirect_url()
+
+            return super().dispatch(request, *args, **kwargs)
+
+        raise Http404
+
+    def get_context_data(self, **kwargs) -> dict:
+        """Return extra data for rendering the template.
+
+        Args:
+            **kwargs (dict):
+                Keyword arguments to pass to the parent class.
+
+        Returns:
+            dict:
+            Context to use when rendering the template.
+        """
+        context = super().get_context_data(**kwargs)
+
+        context['client_allowed'] = self.client_allowed
+        context['client_name'] = self.client_name
+        context['client_url'] = self.client_url
+        context['username'] = self.request.user.username
+
+        return context
+
+    def _url_is_safe(
+        self,
+        url: str,
+    ) -> bool:
+        """Return whether the given URL is safe to redirect and/or POST to.
+
+        Args:
+            url (str):
+                The URL.
+
+        Returns:
+            bool:
+            Whether the url is safe to redirect and/or POST to.
+        """
+        return url_has_allowed_host_and_scheme(
+            url=url,
+            allowed_hosts=self.client_allowed_hosts)
+
+    def _get_redirect_url(self) -> str:
+        """Return the redirect URL.
+
+        This encodes the URL.
+
+        Returns:
+            str:
+            The redirect URL or an empty string if the redirect URL is
+            not safe.
+        """
+        redirect_to = self.request.POST.get(
+            self.redirect_field_name,
+            self.request.GET.get(self.redirect_field_name, ''))
+        assert isinstance(redirect_to, str)
+
+        if self._url_is_safe(redirect_to):
+            return quote(redirect_to)
+
+        return ''
+
+
+class ClientLoginView(BaseClientLoginView):
+    """View for rendering the client login page.
+
+    The client login page handles authenticating a client to Review Board
+    by POSTing authentication data to the client.
+
+    Callers must include a ``client-name`` query parameter containing the
+    client name, and a ``client-url`` parameter containing the URL of where
+    to send authentication data upon a successful login. Callers may include
+    a ``next`` parameter containing a URL for redirection, making sure to
+    encode any query parameters in that URL.
+
+    Version Added:
+        5.0.5
+    """
+
+    template_name = 'accounts/client_login.html'
+
+    def get_context_data(self, **kwargs) -> dict:
+        """Return extra data for rendering the template.
+
+        Args:
+            **kwargs (dict):
+                Keyword arguments to pass to the parent class.
+
+        Returns:
+            dict:
+            Context to use when rendering the template.
+        """
+        context = super().get_context_data(**kwargs)
+
+        context['js_view_data'] = self.get_js_view_data()
+        error = context['js_view_data'].pop('error', '')
+
+        if error:
+            context['error'] = error
+
+        return context
+
+    def get_js_view_data(self) -> dict:
+        """Return the data for the ClientLoginView JavaScript view.
+
+        Returns:
+            dict:
+            Data to be passed to the JavaScript view.
+        """
+        client_allowed = self.client_allowed
+        client_name = self.client_name
+        client_url = self.client_url
+        payload = {}
+        error = ''
+
+        if client_allowed:
+            expire_amount = \
+                self.siteconfig.get('client_token_expiration')
+
+            if expire_amount:
+                assert isinstance(expire_amount, int)
+                expires = (timezone.now() +
+                           datetime.timedelta(days=expire_amount))
+            else:
+                expires = None
+
+            try:
+                api_token = WebAPIToken.objects.get_or_create_client_token(
+                    client_name=client_name,
+                    expires=expires,
+                    user=self.request.user)[0]
+
+                payload = {
+                    'api_token': api_token.token,
+                }
+            except WebAPITokenGenerationError:
+                error = _(
+                    'Failed to generate a unique API token for '
+                    'authentication. Please reload the page to try again.')
+        else:
+            logger.warning('Blocking an attempt to send authentication info '
+                           'to unsafe URL %s', client_url)
+        return {
+            'clientName': escape(client_name),
+            'clientURL': quote(client_url),
+            'error': error,
+            'payload': payload,
+            'redirectTo': self.redirect_to,
+            'username': self.request.user.username,
+        }
+
+
+class ClientLoginConfirmationView(BaseClientLoginView):
+    """View for rendering the client login confirmation page.
+
+    This page asks the user if they want to authenticate the client as
+    the current user who is logged in to Review Board. If yes, they will be
+    redirected to the client login page. If not, they will be logged
+    out and redirected to the login page.
+
+    Callers must include a ``client-name`` query parameter containing the
+    client name, and a ``client-url`` parameter containing the URL of where
+    to send authentication data upon a successful login. Callers may include
+    a ``next`` parameter containing a URL for redirection, making sure to
+    encode any query parameters in that URL.
+
+    Version Added:
+        5.0.5
+    """
+
+    template_name = 'accounts/client_login_confirm.html'
+
+    def get_context_data(self, **kwargs) -> dict:
+        """Return extra data for rendering the template.
+
+        Args:
+            **kwargs (dict):
+                Keyword arguments to pass to the parent class.
+
+        Returns:
+            dict:
+            Context to use when rendering the template.
+        """
+        context = super().get_context_data(**kwargs)
+
+        client_name = self.client_name
+        client_url = self.client_url
+        redirect_field_name = self.redirect_field_name
+        redirect_to = self.redirect_to
+        client_redirect_param_str = ''
+
+        if redirect_to:
+            client_redirect_param_str = (
+                '&%s=%s' % (redirect_field_name, redirect_to))
+
+        context['client_login_url'] = (
+            '%s?client-name=%s&client-url=%s%s'
+            % (local_site_reverse('client-login'),
+               client_name,
+               client_url,
+               client_redirect_param_str))
+
+        # The client redirect part of the URL is encoded twice
+        # in order to preserve all of its query parameters.
+        logout_redirect = quote(
+            '%s?client-name=%s&client-url=%s%s'
+            % (local_site_reverse('login'),
+               client_name,
+               client_url,
+               client_redirect_param_str))
+        context['logout_url'] = (
+            '%s?%s=%s'
+            % (local_site_reverse('logout'),
+               redirect_field_name,
+               logout_redirect))
+
+        return context
+
+
+def _get_client_allowed_hosts(
+    port: Optional[int],
+) -> set:
+    """Return the set of hosts that are allowed to authenticate clients.
+
+    This will return a set of the local host names at the given port,
+    or with no port specified if one is not given.
+
+    Version Added:
+        5.0.5
+
+    Args:
+        port (int):
+            The specific port to allow for the hosts. If this is
+            ``None`` then no port will be specified.
+
+    Returns:
+        set:
+        The set of allowed hosts.
+    """
+    if port:
+        suffix = f':{port}'
+    else:
+        suffix = ''
+
+    return {
+        f'127.0.0.1{suffix}',
+        f'localhost{suffix}'
+    }
diff --git a/reviewboard/admin/siteconfig.py b/reviewboard/admin/siteconfig.py
index 9faab7b26d554f8d97ee99a7613649c2e0b45fd6..c868d063af1e647b678c230b6bd1d41256bb2311 100644
--- a/reviewboard/admin/siteconfig.py
+++ b/reviewboard/admin/siteconfig.py
@@ -163,6 +163,11 @@ defaults.update({
     'site_domain_method': 'http',
     'site_read_only': False,
     'sso_auto_login_backend': '',
+    'client_web_login': True,
+
+    # The number of days in which client API tokens should expire
+    # after creation.
+    'client_token_expiration': 365,
 
     'privacy_enable_user_consent': False,
     'privacy_info_html': None,
diff --git a/reviewboard/static/rb/js/views/clientLoginView.es6.js b/reviewboard/static/rb/js/views/clientLoginView.es6.js
new file mode 100644
index 0000000000000000000000000000000000000000..93269fed418375d77e0235e53e9bb06a367a7786
--- /dev/null
+++ b/reviewboard/static/rb/js/views/clientLoginView.es6.js
@@ -0,0 +1,142 @@
+/**
+ * A view for the client login page.
+ *
+ * This view sends authentication data to a client to authenticate
+ * it to Review Board for a user.
+ *
+ * Version Added:
+ *     5.0.5
+ */
+RB.ClientLoginView = Backbone.View.extend({
+    contentTemplate: _.template(dedent`
+        <h1><%- header %></h1>
+        <p><%- message %><span id="redirect-counter"><%- count %></span></p>`
+    ),
+
+    /**
+     * Initialize the view.
+     *
+     * Args:
+     *     options (object):
+     *         The view options.
+     *
+     * Option Args:
+     *     clientName (string):
+     *         The name of the client.
+     *
+     *     clientURL (string):
+     *         The URL of where to send the authentication data.
+     *
+     *     payload (string):
+     *         A JSON string containing the authentication data to send to
+     *         the client.
+     *
+     *     redirectTo (string):
+     *         An optional URL of where to redirect to after successfully
+     *         sending the authentication data to the client.
+     *
+     *     username (string):
+     *         The username of the user who is authenticating the client.
+     */
+    initialize(options) {
+        this._clientName = options.clientName;
+        this._clientURL = decodeURIComponent(options.clientURL);
+        this._payload = options.payload;
+        this._redirectTo = decodeURIComponent(options.redirectTo);
+        this._username = options.username;
+        this._redirectCounter = 3;
+    },
+
+    /**
+     * Render the view.
+     *
+     * Returns:
+     *     RB.ClientLoginView:
+     *     This view.
+     */
+    async render() {
+        let rsp;
+        const clientName = this._clientName;
+        const username = this._username;
+        const redirectCounter = this._redirectCounter;
+        const $content = this.$('.auth-header');
+
+        try {
+            rsp = await this._sendDataToClient();
+        } catch (error) {
+            $content.html(this.contentTemplate({
+                count: '',
+                header: _`Failed to log in for ${clientName}`,
+                message: _`Could not connect to ${clientName}.
+                           Please contact your administrator.`,
+            }));
+
+            return this;
+        }
+
+        if (rsp.ok) {
+            if (this._redirectTo) {
+                $content.html(this.contentTemplate({
+                    count: ` ${redirectCounter}...`,
+                    header: _`Logged in to ${clientName}`,
+                    message: _`You have successfully logged in to
+                               ${clientName} as ${username}. Redirecting in`,
+                }));
+
+                this._$counter = $('#redirect-counter');
+                this._interval = setInterval(
+                    this._redirectCountdown.bind(this),
+                    1000);
+            } else {
+                $content.html(this.contentTemplate({
+                    count: '',
+                    header: _`Logged in to ${clientName}`,
+                    message: _`You have successfully logged in to
+                               ${clientName} as ${username}. You can now
+                               close this page.`,
+                }));
+            }
+        } else {
+            $content.html(this.contentTemplate({
+                count: '',
+                header: _`Failed to log in for ${clientName}`,
+                message: _`Failed to log in for ${clientName} as ${username}.
+                           Please contact your administrator.`,
+            }));
+        }
+
+        return this;
+    },
+
+    /**
+     * Display a countdown and then redirect to a URL.
+     */
+    _redirectCountdown() {
+        const redirectCounter = --this._redirectCounter;
+        this._$counter.text(` ${redirectCounter}...`);
+
+        if (redirectCounter <= 0) {
+            clearInterval(this._interval);
+            RB.navigateTo(this._redirectTo);
+        }
+    },
+
+    /**
+     * Send authentication data to the client.
+     *
+     * Returns:
+     *     A promise which resolves to a Response object when the request
+     *     is complete.
+     */
+    async _sendDataToClient() {
+        let rsp = await fetch(this._clientURL, {
+            method: 'POST',
+            headers: {
+                'Content-Type': 'application/json; charset=UTF-8',
+            },
+            body: JSON.stringify(this._payload),
+        });
+
+        return rsp;
+    },
+});
\ No newline at end of file
diff --git a/reviewboard/static/rb/js/views/tests/clientLoginViewTests.es6.js b/reviewboard/static/rb/js/views/tests/clientLoginViewTests.es6.js
new file mode 100644
index 0000000000000000000000000000000000000000..a08b81446cfe10fdc1dc35272fd939fd8892e959
--- /dev/null
+++ b/reviewboard/static/rb/js/views/tests/clientLoginViewTests.es6.js
@@ -0,0 +1,130 @@
+suite('rb/views/ClientLoginView', function() {
+    let view;
+    const pageTemplate = dedent`
+        <div id="auth_container">
+         <div class="auth-header">
+         </div>
+        </div>
+    `;
+
+    beforeEach(function() {
+        view = new RB.ClientLoginView({
+            el: $(pageTemplate),
+            clientName: 'Client',
+            clientURL: 'http%3A//localhost%3A1234',
+            payload: {
+                api_token: 'token',
+            },
+            redirectTo: '',
+            username: 'User',
+        });
+    });
+
+    describe('Rendering', function() {
+        it('With failing to connect to the client server', async function() {
+            spyOn(window, 'fetch').and.throwError('Test Error');
+            await view.render();
+
+            expect(view.$('h1').text()).toBe('Failed to log in for Client');
+            expect(view.$('p').text()).toBe([
+                'Could not connect to Client. Please contact your ',
+                'administrator.'
+            ].join(''));
+            expect(view.$('#redirect-counter').text()).toBe('');
+
+        });
+
+        it('With successfully sending data to the client', async function() {
+            const response = new Response(JSON.stringify({}), { status: 200 });
+            spyOn(window, 'fetch').and.resolveTo(response);
+            await view.render();
+
+            expect(view.$('h1').text()).toBe('Logged in to Client');
+            expect(view.$('p').text()).toBe([
+                'You have successfully logged in to Client as User. ',
+                'You can now close this page.'
+            ].join(''));
+            expect(view.$('#redirect-counter').text()).toBe('');
+        });
+
+        it('With sending data to the client and a redirect', async function() {
+            view = new RB.ClientLoginView({
+                el: $(pageTemplate),
+                clientName: 'Client',
+                clientURL: 'http%3A//localhost%3A1234',
+                payload: {
+                    api_token: 'token',
+                },
+                redirectTo: 'http%3A//localhost%3A1234/test/',
+                username: 'User',
+            });
+            const response = new Response(JSON.stringify({}), { status: 200 });
+            spyOn(window, 'fetch').and.resolveTo(response);
+            spyOn(window, 'setInterval');
+            await view.render();
+
+            expect(view.$('h1').text()).toBe('Logged in to Client');
+            expect(view.$('p').text()).toBe([
+                'You have successfully logged in to Client as User. ',
+                'Redirecting in 3...'
+            ].join(''));
+            expect(view.$('#redirect-counter').text()).toBe(' 3...');
+            expect(setInterval).toHaveBeenCalled();
+        });
+
+        it('With failing to send data to the client', async function() {
+            const response = new Response(JSON.stringify({}), { status: 400 });
+            spyOn(window, 'fetch').and.resolveTo(response);
+            await view.render();
+
+            expect(view.$('h1').text())
+                .toBe('Failed to log in for Client');
+            expect(view.$('p').text()).toBe([
+                'Failed to log in for Client as User. Please contact ',
+                'your administrator.'
+            ].join(''));
+            expect(view.$('#redirect-counter').text()).toBe('');
+        });
+    });
+
+    describe('_redirectCountDown', function() {
+        beforeEach(function() {
+            view = new RB.ClientLoginView({
+                el: $(`<div><span id="redirect-counter"></span></div>`),
+                clientName: 'Client',
+                clientURL: 'http%3A//localhost%3A1234',
+                payload: {
+                    api_token: 'token',
+                },
+                redirectTo: 'http%3A//localhost%3A1234/test/',
+                username: 'User',
+            });
+        });
+
+        it('With a counter greater than 1', function() {
+            view._$counter = view.$('#redirect-counter');
+
+            expect(view._redirectCounter).toBe(3);
+
+            view._redirectCountdown();
+
+            expect(view._redirectCounter).toBe(2);
+            expect(view._$counter.text()).toBe(' 2...');
+        });
+
+        it('With a counter at 1', function() {
+            view._$counter = view.$('#redirect-counter');
+            view._redirectCounter = 1;
+            spyOn(RB, 'navigateTo');
+            spyOn(window, 'clearInterval');
+
+            view._redirectCountdown();
+
+            expect(view._redirectCounter).toBe(0);
+            expect(view._$counter.text()).toBe(' 0...');
+            expect(RB.navigateTo).toHaveBeenCalledWith(
+                'http://localhost:1234/test/'
+            );
+        });
+    });
+});
diff --git a/reviewboard/staticbundles.py b/reviewboard/staticbundles.py
index f2848ac38fb554f5715363812cbe7a216db744d6..70ebc63e574266b63ac83198d4c550701935ecc9 100644
--- a/reviewboard/staticbundles.py
+++ b/reviewboard/staticbundles.py
@@ -139,6 +139,7 @@ PIPELINE_JAVASCRIPT = dict({
             'rb/js/utils/tests/keyBindingUtilsTests.es6.js',
             'rb/js/utils/tests/linkifyUtilsTests.es6.js',
             'rb/js/utils/tests/urlUtilsTests.es6.js',
+            'rb/js/views/tests/clientLoginViewTests.es6.js',
             'rb/js/views/tests/collectionViewTests.es6.js',
             'rb/js/views/tests/commentDialogViewTests.es6.js',
             'rb/js/views/tests/commentIssueBarViewTests.es6.js',
@@ -232,6 +233,7 @@ PIPELINE_JAVASCRIPT = dict({
             'rb/js/ui/views/userInfoboxView.es6.js',
             'rb/js/models/starManagerModel.es6.js',
             'rb/js/models/userSessionModel.es6.js',
+            'rb/js/views/clientLoginView.es6.js',
             'rb/js/views/headerView.es6.js',
             'rb/js/views/collectionView.es6.js',
             'rb/js/views/starManagerView.es6.js',
diff --git a/reviewboard/templates/accounts/client_login.html b/reviewboard/templates/accounts/client_login.html
new file mode 100644
index 0000000000000000000000000000000000000000..7ab9f8ea7675e69c5667456e0ad388ca5cf33868
--- /dev/null
+++ b/reviewboard/templates/accounts/client_login.html
@@ -0,0 +1,40 @@
+{% extends "accounts/base.html" %}
+{% load djblets_js i18n %}
+
+{% block title %}
+{%  blocktrans %}{{client_name}} Login{% endblocktrans %}
+{% endblock title %}
+
+{% block auth_content %}
+<div class="auth-header">
+ <p>
+{%  if not client_allowed or not client_name %}
+{%   blocktrans %}
+For security reasons, clients logging in to the Review Board API must
+pass a <tt>client-url</tt> that points to the local host and a
+<tt>client-name</tt>. This may be a bug in your software, please contact
+your administrator if this issue persists.
+{%   endblocktrans %}
+{%  elif error %}
+{{error}}
+{%  else %}
+  <span class="djblets-o-spinner"></span>
+{%   blocktrans %}Logging in for {{client_name}}...{% endblocktrans %}
+{%  endif %}
+ </p>
+</div>
+{% endblock auth_content %}
+
+{% block scripts-post %}
+{%  if client_allowed and not error %}
+<script>
+$(document).ready(async function() {
+  const view = new RB.ClientLoginView({
+    el: $('#auth_container'),
+    {{js_view_data|json_dumps_items:','}}
+  });
+  await view.render();
+});
+</script>
+{%  endif %}
+{% endblock scripts-post %}
\ No newline at end of file
diff --git a/reviewboard/templates/accounts/client_login_confirm.html b/reviewboard/templates/accounts/client_login_confirm.html
new file mode 100644
index 0000000000000000000000000000000000000000..d9d7f8737e72e84fda24fecc2569827de83905ea
--- /dev/null
+++ b/reviewboard/templates/accounts/client_login_confirm.html
@@ -0,0 +1,27 @@
+{% extends "accounts/base.html" %}
+{% load avatars i18n %}
+
+{% block title %}
+{%  blocktrans %}{{client_name}} Login{% endblocktrans %}
+{% endblock title %}
+
+{% block auth_content %}
+<div class="auth-header">
+{%  avatar request.user 128 %}
+ <h1>{% blocktrans %}Log in to {{client_name}} as {{username}}?{% endblocktrans %}</h1>
+</div>
+<div class="auth-section main-auth-section">
+ <div class="auth-form-row">
+  <div class="auth-button-container">
+   <a href="{{client_login_url}}">
+    <button type="button" class="rb-c-button primary">Yes</button>
+   </a>
+  </div>
+  <div class="auth-button-container">
+   <a href="{{logout_url}}">
+    <button type="button" class="rb-c-button">No, login as a different user</button>
+   </a>
+  </div>
+ </div>
+</div>
+{% endblock auth_content %}
diff --git a/reviewboard/templates/accounts/login.html b/reviewboard/templates/accounts/login.html
index ae83d43ccf46ab7febff3ca40fae85d9be609dbe..a1c77ca6cf52c68f868d6d05ae5180c35ce85076 100644
--- a/reviewboard/templates/accounts/login.html
+++ b/reviewboard/templates/accounts/login.html
@@ -20,7 +20,11 @@
       id="login_form">
 {%  block hidden_fields %}
  <input type="hidden" name="next" value="{{next}}" />
-  {% csrf_token %}
+{%   if client and client_url %}
+ <input type="hidden" name="client-name" value="{{client_name}}">
+ <input type="hidden" name="client-url" value="{{client_url}}">
+{%   endif %}
+ {% csrf_token %}
 {%  endblock %}
  <div class="auth-form-row auth-field-row">
   {{form.username.label_tag}}
@@ -38,7 +42,7 @@
   <div class="auth-button-container">
    <input type="submit" class="primary" value="{% trans "Log in" %}" />
 {%  for sso_backend in enabled_sso_backends %}
-   <a href="{{sso_backend.login_url}}{% if next %}?next={{next}}{% endif %}">
+   <a href="{{sso_backend.login_url}}{% if next %}?next={{next|urlencode}}{% endif %}">
     <button type="button" class="rb-c-button">{{sso_backend.login_label}}</button>
    </a>
 {%  endfor %}
diff --git a/reviewboard/webapi/server_info.py b/reviewboard/webapi/server_info.py
index bfd3d92c4d95280f76af1fd5382018a4f324d64c..7eac0b834a07b9444404b29eb7c5012cdadb4b0b 100644
--- a/reviewboard/webapi/server_info.py
+++ b/reviewboard/webapi/server_info.py
@@ -4,6 +4,7 @@ import logging
 from copy import deepcopy
 
 from django.conf import settings
+from djblets.siteconfig.models import SiteConfiguration
 
 from reviewboard import get_version_string, get_package_version, is_release
 from reviewboard.admin.server import get_server_url
@@ -15,6 +16,11 @@ logger = logging.getLogger(__name__)
 
 _registered_capabilities = {}
 _capabilities_defaults = {
+    'authentication': {
+        # Whether to allow clients to authenticate to Review Board
+        # via a web browser.
+        'client_web_login': True,
+    },
     'diffs': {
         'base_commit_ids': True,
         'moved_files': True,
@@ -111,6 +117,10 @@ def get_capabilities(request=None):
     for category, cap, enabled in get_feature_gated_capabilities(request):
         capabilities.setdefault(category, {})[cap] = enabled
 
+    siteconfig = SiteConfiguration.objects.get_current()
+    capabilities['authentication']['client_web_login'] = \
+        siteconfig.get('client_web_login', False)
+
     return capabilities
 
 
