← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:candid-authorization into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:candid-authorization into launchpad:master.

Commit message:
Add the ability to get discharge macaroons for Candid

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/406724

This will be used for authorizing uploads to Charmhub.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:candid-authorization into launchpad:master.
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index 2b387cb..719c002 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -922,6 +922,13 @@ openid_provider_root: none
 # the suffix is provided by two providers.
 openid_alternate_provider_roots: none
 
+# URL to the Candid service, used to discharge certain kinds of macaroons.
+candid_service_root: none
+
+# Secret used to create CSRF tokens.  Currently only used in Candid
+# interactions.
+csrf_secret: none
+
 # If true, the main template will be styled so that it is
 # obvious to the end user that they are using a demo system
 # and that any changes they make will be lost at some point.
diff --git a/lib/lp/services/timeout.py b/lib/lp/services/timeout.py
index bf462a3..d4de732 100644
--- a/lib/lp/services/timeout.py
+++ b/lib/lp/services/timeout.py
@@ -341,7 +341,7 @@ class URLFetcher:
 
     @with_timeout(cleanup='cleanup')
     def fetch(self, url, use_proxy=False, allow_ftp=False, allow_file=False,
-              output_file=None, **request_kwargs):
+              output_file=None, check_status=True, **request_kwargs):
         """Fetch the URL using a custom HTTP handler supporting timeout.
 
         :param url: The URL to fetch.
@@ -351,6 +351,8 @@ class URLFetcher:
             pass this if the URL is trusted.)
         :param output_file: If not None, download the response content to
             this file object or path.
+        :param check_status: If True (the default), raise `HTTPError` if the
+            HTTP response status is 4xx or 5xx.
         :param request_kwargs: Additional keyword arguments passed on to
             `Session.request`.
         """
@@ -383,7 +385,8 @@ class URLFetcher:
         if response.status_code is None:
             raise HTTPError(
                 "HTTP request returned no status code", response=response)
-        raise_for_status_redacted(response)
+        if check_status:
+            raise_for_status_redacted(response)
         if output_file is None:
             # Make sure the content has been consumed before returning.
             response.content
diff --git a/lib/lp/services/webapp/candid.py b/lib/lp/services/webapp/candid.py
new file mode 100644
index 0000000..56a1f6c
--- /dev/null
+++ b/lib/lp/services/webapp/candid.py
@@ -0,0 +1,235 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Interaction with the Candid identity service."""
+
+__all__ = [
+    "BadCandidMacaroon",
+    "CandidCallbackView",
+    "CandidFailure",
+    "CandidUnconfiguredError",
+    "request_candid_discharge",
+    ]
+
+from base64 import b64encode
+import hashlib
+import hmac
+import http.client
+import json
+from urllib.parse import urlencode
+import uuid
+
+from lazr.restful.declarations import error_status
+from requests import HTTPError
+from pymacaroons import Macaroon
+from pymacaroons.serializers import JsonSerializer
+from zope.browserpage import ViewPageTemplateFile
+from zope.session.interfaces import ISession
+
+from lp.services.timeline.requesttimeline import get_request_timeline
+from lp.services.config import config
+from lp.services.timeout import (
+    raise_for_status_redacted,
+    urlfetch,
+    )
+from lp.services.webapp.publisher import LaunchpadView
+from lp.services.webapp.url import urlappend
+from lp.services.webapp.vhosts import allvhosts
+
+
+class CandidUnconfiguredError(Exception):
+    """The Candid service is not configured."""
+
+
+class BadCandidMacaroon(Exception):
+    """The macaroon is unsuitable for being discharged by Candid."""
+
+
+@error_status(http.client.BAD_REQUEST)
+class CandidFailure(Exception):
+    """Candid authorization failed."""
+
+    def __init__(self, message, response=None):
+        super().__init__(message)
+        self.response = response
+
+
+def _make_candid_request(request, endpoint_name, expect_401=False, **kwargs):
+    """Make a POST request to the Candid API."""
+    url = urlappend(config.launchpad.candid_service_root, endpoint_name)
+    base_headers = {"Bakery-Protocol-Version": "2"}
+    timeline = get_request_timeline(request)
+    action = timeline.start("candid", url)
+    try:
+        response = urlfetch(
+            url, method="POST", headers=base_headers,
+            check_status=not expect_401, **kwargs)
+        if expect_401 and response.status_code != 401:
+            raise_for_status_redacted(response)
+            raise CandidFailure(
+                "Initial discharge request unexpectedly succeeded without "
+                "authorization",
+                response=response)
+        return response
+    except HTTPError as e:
+        raise CandidFailure(str(e), response=e.response)
+    finally:
+        action.finish()
+
+
+def _deserialize_json_macaroon(macaroon_raw):
+    """Deserialize a macaroon serialized using JSON."""
+    return Macaroon.deserialize(macaroon_raw, JsonSerializer())
+
+
+def _extract_third_party_caveat(macaroon):
+    """Extract the Candid third-party caveat from a macaroon."""
+    try:
+        return next(
+            caveat for caveat in macaroon.caveats
+            if caveat.location == config.launchpad.candid_service_root)
+    except StopIteration:
+        raise BadCandidMacaroon(
+            "Missing Candid caveat: {}".format(
+                config.launchpad.candid_service_root))
+
+
+def _get_candid_login_url_for_discharge(request, macaroon, state,
+                                        callback_url):
+    """Get the login URL to web-discharge a third-party caveat."""
+    caveat = _extract_third_party_caveat(macaroon)
+    response = _make_candid_request(
+        request, "discharge",
+        data={"id64": b64encode(caveat.caveat_id_bytes)}, expect_401=True)
+    try:
+        interaction_methods = response.json()["Info"]["InteractionMethods"]
+        browser_redirect = interaction_methods["browser-redirect"]
+        return "{}?{}".format(
+            browser_redirect["LoginURL"],
+            urlencode({"return_to": callback_url, "state": state}))
+    except KeyError:
+        raise CandidFailure(
+            "Initial discharge request did not contain expected fields",
+            response=response)
+
+
+def request_candid_discharge(request, macaroon_raw):
+    """Redirect to Candid to request a discharge for a given macaroon."""
+    if (not config.launchpad.candid_service_root or
+            not config.launchpad.csrf_secret):
+        raise CandidUnconfiguredError("The Candid service is not configured.")
+
+    macaroon = _deserialize_json_macaroon(macaroon_raw)
+
+    csrf_token = hashlib.sha256(
+        (uuid.uuid4().hex + config.launchpad.csrf_secret).encode()).hexdigest()
+
+    session_data = ISession(request)["launchpad.candid"]
+    session_data["macaroon"] = macaroon_raw
+    session_data["csrf-token"] = csrf_token
+
+    starting_url = request.getURL()
+    form_args = [
+        (key, value) for key, value in request.form.items()
+        if key not in (
+            "discharge_macaroon_action", "discharge_macaroon_field")]
+    query_string = urlencode(form_args, doseq=True)
+    if query_string:
+        starting_url += "?%s" % query_string
+
+    # Once the user authenticates with Candid, they will be redirected to
+    # the /+candid-callback page, which must send them back to the URL they
+    # were when they started the authorization process.  To help with that,
+    # we encode that URL and some additional data as query parameters in the
+    # return_to URL passed to Candid.
+    starting_data = [("starting_url", starting_url)]
+    for passthrough_name in (
+            "discharge_macaroon_action", "discharge_macaroon_field"):
+        passthrough_field = request.form.get(passthrough_name, None)
+        if passthrough_field is not None:
+            starting_data.append((passthrough_name, passthrough_field))
+    return_to = "%s?%s" % (
+        urlappend(allvhosts.configs["mainsite"].rooturl, "+candid-callback"),
+        urlencode(starting_data))
+
+    login_url = _get_candid_login_url_for_discharge(
+        request, macaroon, csrf_token, return_to)
+
+    request.response.redirect(login_url, temporary_if_possible=True)
+
+
+class CandidErrorView(LaunchpadView):
+
+    page_title = "Authorization error"
+    template = ViewPageTemplateFile("templates/candid-error.pt")
+
+    def __init__(self, context, request, candid_error):
+        super().__init__(context, request)
+        self.candid_error = candid_error
+
+
+class CandidCallbackView(LaunchpadView):
+    """Callback view for Candid authorization."""
+
+    template = ViewPageTemplateFile("templates/login-discharge-macaroon.pt")
+
+    def _gatherParams(self, request):
+        params = dict(request.form)
+        for key, value in request.query_string_params.items():
+            if len(value) > 1:
+                raise ValueError("Did not expect multi-valued fields.")
+            params[key] = value[0]
+        return params
+
+    def _get_serialized_discharge(self, request, macaroon, code):
+        """Get the discharge macaroon generated after a Candid web login."""
+        caveat = _extract_third_party_caveat(macaroon)
+        response = _make_candid_request(
+            request, "discharge-token", json={"code": code})
+        token = response.json()["token"]
+        data = {
+            "id64": b64encode(caveat.caveat_id_bytes),
+            "token64": token["value"],
+            "token-kind": token["kind"],
+            }
+        response = _make_candid_request(request, "discharge", data=data)
+        return json.dumps(response.json()["Macaroon"])
+
+    def initialize(self):
+        self.params = self._gatherParams(self.request)
+        session_data = ISession(self.request)["launchpad.candid"]
+
+        try:
+            if ("macaroon" not in session_data or
+                    "csrf-token" not in session_data):
+                raise CandidFailure("Candid session lost or not started")
+            if ("starting_url" not in self.params or
+                    "discharge_macaroon_field" not in self.params or
+                    "code" not in self.params):
+                raise CandidFailure("Missing parameters to Candid callback")
+
+            # Validate CSRF token.
+            if not hmac.compare_digest(
+                    self.params.get("state", ""), session_data["csrf-token"]):
+                raise CandidFailure("CSRF token mismatch")
+
+            # Get unbound discharge macaroon from Candid.
+            code = self.params["code"]
+            macaroon = _deserialize_json_macaroon(session_data["macaroon"])
+            self.discharge_macaroon_raw = self._get_serialized_discharge(
+                self.request, macaroon, code)
+            self.candid_error = None
+        except CandidFailure as e:
+            self.candid_error = str(e)
+        finally:
+            # Prevent replay attacks.  PGSessionPkgData.__delitem__ ensures
+            # that this succeeds even if the key does not exist.
+            del session_data["macaroon"]
+            del session_data["csrf-token"]
+
+    def render(self):
+        if self.candid_error is not None:
+            return CandidErrorView(
+                self.context, self.request, self.candid_error)()
+        else:
+            return super().render()
diff --git a/lib/lp/services/webapp/configure.zcml b/lib/lp/services/webapp/configure.zcml
index cb5b6b4..b349be9 100644
--- a/lib/lp/services/webapp/configure.zcml
+++ b/lib/lp/services/webapp/configure.zcml
@@ -445,4 +445,12 @@
     <class class="lp.services.webapp.status.StatusCheckView">
         <allow attributes="__call__" />
     </class>
+
+    <!-- Candid interaction. -->
+    <browser:page
+        for="lp.services.webapp.interfaces.ILaunchpadApplication"
+        class="lp.services.webapp.candid.CandidCallbackView"
+        permission="launchpad.AnyPerson"
+        name="+candid-callback"
+        />
 </configure>
diff --git a/lib/lp/services/webapp/publication.py b/lib/lp/services/webapp/publication.py
index 6aadefe..12700e2 100644
--- a/lib/lp/services/webapp/publication.py
+++ b/lib/lp/services/webapp/publication.py
@@ -98,8 +98,10 @@ from lp.services.webapp.vhosts import allvhosts
 
 METHOD_WRAPPER_TYPE = type({}.__setitem__)
 
-OFFSITE_POST_WHITELIST = ('/+storeblob', '/+request-token', '/+access-token',
-    '/+openid')
+OFFSITE_POST_WHITELIST = (
+    '/+storeblob', '/+request-token', '/+access-token',
+    '/+openid', '/+candid-callback',
+    )
 
 
 def maybe_block_offsite_form_post(request):
diff --git a/lib/lp/services/webapp/templates/candid-error.pt b/lib/lp/services/webapp/templates/candid-error.pt
new file mode 100644
index 0000000..27d66c8
--- /dev/null
+++ b/lib/lp/services/webapp/templates/candid-error.pt
@@ -0,0 +1,17 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_only"
+  i18n:domain="launchpad">
+
+  <body>
+    <div class="top-portlet" metal:fill-slot="main">
+
+      <h1>Authorization failed</h1>
+      <p class="error" tal:content="view/candid_error" />
+
+    </div>
+  </body>
+</html>
diff --git a/lib/lp/services/webapp/templates/login-discharge-macaroon.pt b/lib/lp/services/webapp/templates/login-discharge-macaroon.pt
index 07f1c0a..01ee7f7 100644
--- a/lib/lp/services/webapp/templates/login-discharge-macaroon.pt
+++ b/lib/lp/services/webapp/templates/login-discharge-macaroon.pt
@@ -24,7 +24,7 @@
 
   <body>
     <div class="top-portlet" metal:fill-slot="main">
-      <h1>OpenID transaction in progress</h1>
+      <h1>Authorization in progress</h1>
 
       <form tal:attributes="action view/params/starting_url"
             id="discharge-form"
diff --git a/lib/lp/services/webapp/tests/test_candid.py b/lib/lp/services/webapp/tests/test_candid.py
new file mode 100644
index 0000000..3bd0a91
--- /dev/null
+++ b/lib/lp/services/webapp/tests/test_candid.py
@@ -0,0 +1,419 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for Candid interaction."""
+
+from base64 import b64encode
+import json
+from urllib.parse import (
+    parse_qs,
+    urlencode,
+    urlsplit,
+    )
+
+from pymacaroons import Macaroon
+from pymacaroons.serializers import JsonSerializer
+import responses
+from testtools.matchers import (
+    AfterPreprocessing,
+    ContainsDict,
+    Equals,
+    Is,
+    MatchesDict,
+    MatchesListwise,
+    MatchesSetwise,
+    MatchesStructure,
+    Not,
+    )
+from zope.session.interfaces import ISession
+
+from lp.services.config import config
+from lp.services.webapp.candid import (
+    CandidFailure,
+    CandidUnconfiguredError,
+    request_candid_discharge,
+    )
+from lp.services.webapp.servers import LaunchpadTestRequest
+from lp.testing import (
+    login_person,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.pages import (
+    extract_text,
+    find_tags_by_class,
+    )
+
+
+class TestRequestCandidDischarge(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_unconfigured(self):
+        request = LaunchpadTestRequest()
+        macaroon_raw = Macaroon(version=2).serialize(JsonSerializer())
+        self.assertRaises(
+            CandidUnconfiguredError, request_candid_discharge,
+            request, macaroon_raw)
+        self.pushConfig(
+            "launchpad", candid_service_root="https://candid.test/";)
+        self.assertRaises(
+            CandidUnconfiguredError, request_candid_discharge,
+            request, macaroon_raw)
+
+    @responses.activate
+    def test_initial_discharge_unexpected_success(self):
+        self.pushConfig(
+            "launchpad",
+            candid_service_root="https://candid.test/";,
+            csrf_secret="test secret")
+        responses.add("POST", "https://candid.test/discharge";, status=200)
+
+        person = self.factory.makePerson()
+        login_person(person)
+        macaroon = Macaroon(version=2)
+        macaroon.add_third_party_caveat("https://candid.test/";, "", "identity")
+        macaroon_raw = macaroon.serialize(JsonSerializer())
+        request = LaunchpadTestRequest(
+            SERVER_URL="http://launchpad.test/after-candid";)
+        self.assertRaisesWithContent(
+            CandidFailure,
+            "Initial discharge request unexpectedly succeeded without "
+            "authorization",
+            request_candid_discharge, request, macaroon_raw)
+
+    @responses.activate
+    def test_initial_discharge_failure(self):
+        self.pushConfig(
+            "launchpad",
+            candid_service_root="https://candid.test/";,
+            csrf_secret="test secret")
+        responses.add("POST", "https://candid.test/discharge";, status=500)
+
+        person = self.factory.makePerson()
+        login_person(person)
+        macaroon = Macaroon(version=2)
+        macaroon.add_third_party_caveat("https://candid.test/";, "", "identity")
+        macaroon_raw = macaroon.serialize(JsonSerializer())
+        request = LaunchpadTestRequest(
+            SERVER_URL="http://launchpad.test/after-candid";)
+        self.assertRaisesWithContent(
+            CandidFailure, "500 Server Error: Internal Server Error",
+            request_candid_discharge, request, macaroon_raw)
+
+    @responses.activate
+    def test_initial_discharge_missing_fields(self):
+        self.pushConfig(
+            "launchpad",
+            candid_service_root="https://candid.test/";,
+            csrf_secret="test secret")
+        responses.add(
+            "POST", "https://candid.test/discharge";, status=401,
+            json={"Info": {}})
+
+        person = self.factory.makePerson()
+        login_person(person)
+        macaroon = Macaroon(version=2)
+        macaroon.add_third_party_caveat("https://candid.test/";, "", "identity")
+        macaroon_raw = macaroon.serialize(JsonSerializer())
+        request = LaunchpadTestRequest(
+            SERVER_URL="http://launchpad.test/after-candid";)
+        self.assertRaisesWithContent(
+            CandidFailure,
+            "Initial discharge request did not contain expected fields",
+            request_candid_discharge, request, macaroon_raw)
+
+    @responses.activate
+    def test_requests_discharge(self):
+        # Requesting a discharge saves some state in the session and
+        # redirects to Candid.
+        self.pushConfig(
+            "launchpad",
+            candid_service_root="https://candid.test/";,
+            csrf_secret="test secret")
+        responses.add(
+            "POST", "https://candid.test/discharge";, status=401,
+            json={
+                "Code": "interaction required",
+                "Message": (
+                    "macaroon discharge required: authentication required"),
+                "Info": {
+                    "InteractionMethods": {
+                        "browser-redirect": {
+                            "LoginURL": "https://candid.test/login-redirect";,
+                            "DischargeTokenURL": (
+                                "https://candid.test/discharge-token";),
+                            },
+                        },
+                    },
+                })
+
+        person = self.factory.makePerson()
+        login_person(person)
+        macaroon = Macaroon(version=2)
+        macaroon.add_third_party_caveat("https://candid.test/";, "", "identity")
+        caveat = macaroon.caveats[0]
+        macaroon_raw = macaroon.serialize(JsonSerializer())
+        form = {
+            "discharge_macaroon_action": "field.actions.complete",
+            "discharge_macaroon_field": "field.discharge_macaroon",
+            "extra_key": "extra value",
+            }
+        request = LaunchpadTestRequest(
+            form=form, SERVER_URL="http://launchpad.test/after-candid";)
+        request_candid_discharge(request, macaroon_raw)
+
+        # State was saved in the session.
+        session_data = ISession(request)["launchpad.candid"]
+        self.assertThat(session_data, MatchesDict({
+            "macaroon": Equals(macaroon_raw),
+            "csrf-token": Not(Is(None)),
+            }))
+
+        # We made the appropriate requests to Candid to initiate
+        # authorization.
+        self.assertThat(responses.calls, MatchesListwise([
+            MatchesStructure(
+                request=MatchesStructure(
+                    url=Equals("https://candid.test/discharge";),
+                    headers=ContainsDict({
+                        "Content-Type": Equals(
+                            "application/x-www-form-urlencoded"),
+                        }),
+                    body=AfterPreprocessing(parse_qs, MatchesDict({
+                        "id64": Equals(
+                            [b64encode(caveat.caveat_id_bytes).decode()]),
+                        })))),
+            ]))
+
+        # We redirect to the correct URL.
+        self.assertEqual(307, request.response.getStatus())
+        self.assertThat(
+            urlsplit(request.response.getHeader("Location")),
+            MatchesStructure(
+                scheme=Equals("https"),
+                netloc=Equals("candid.test"),
+                path=Equals("/login-redirect"),
+                query=AfterPreprocessing(parse_qs, MatchesDict({
+                    "return_to": MatchesListwise([
+                        AfterPreprocessing(urlsplit, MatchesStructure(
+                            scheme=Equals("http"),
+                            netloc=Equals("launchpad.test"),
+                            path=Equals("/+candid-callback"),
+                            query=AfterPreprocessing(parse_qs, MatchesDict({
+                                "starting_url": Equals([
+                                    "http://launchpad.test/after-candid";
+                                    "?extra_key=extra+value"]),
+                                "discharge_macaroon_action": Equals(
+                                    ["field.actions.complete"]),
+                                "discharge_macaroon_field": Equals(
+                                    ["field.discharge_macaroon"]),
+                                })),
+                            fragment=Equals(""))),
+                        ]),
+                    "state": Equals([session_data["csrf-token"]]),
+                    })),
+                fragment=Equals("")))
+
+
+class TestCandidCallbackView(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+        self.pushConfig(
+            "launchpad", candid_service_root="https://candid.test/";)
+
+    @responses.activate
+    def test_no_candid_session(self):
+        browser = self.getUserBrowser()
+        browser.open("http://launchpad.test/+candid-callback";)
+        [top_portlet] = find_tags_by_class(browser.contents, "top-portlet")
+        self.assertEqual(
+            "Authorization failed\nCandid session lost or not started",
+            extract_text(top_portlet))
+
+    def _setUpBrowser(self, macaroon, csrf_token, form):
+        person = self.factory.makePerson()
+        login_person(person)
+        request = LaunchpadTestRequest(form=form, PATH_INFO="/")
+        request.setPrincipal(person)
+        session = ISession(request)
+        session_data = session["launchpad.candid"]
+        session_data["macaroon"] = macaroon.serialize(JsonSerializer())
+        session_data["csrf-token"] = csrf_token
+        browser = self.getUserBrowser(user=person)
+        browser.addHeader(
+            "Cookie",
+            "{}={}".format(config.launchpad_session.cookie, session.client_id))
+        browser.open(
+            "http://launchpad.test/+candid-callback?{}".format(
+                urlencode(form)))
+        return request, browser
+
+    @responses.activate
+    def test_missing_starting_url_parameter(self):
+        form = {
+            "discharge_macaroon_field": "field.discharge_macaroon",
+            "code": "test code",
+            }
+        request, browser = self._setUpBrowser(Macaroon(), "test token", form)
+        [top_portlet] = find_tags_by_class(browser.contents, "top-portlet")
+        self.assertEqual(
+            "Authorization failed\nMissing parameters to Candid callback",
+            extract_text(top_portlet))
+        self.assertEqual({}, ISession(request)["launchpad.candid"])
+
+    @responses.activate
+    def test_missing_discharge_macaroon_field_parameter(self):
+        form = {
+            "starting_url": "http://launchpad.test/after-login";,
+            "code": "test code",
+            }
+        request, browser = self._setUpBrowser(Macaroon(), "test token", form)
+        [top_portlet] = find_tags_by_class(browser.contents, "top-portlet")
+        self.assertEqual(
+            "Authorization failed\nMissing parameters to Candid callback",
+            extract_text(top_portlet))
+        self.assertEqual({}, ISession(request)["launchpad.candid"])
+
+    @responses.activate
+    def test_missing_code_parameter(self):
+        form = {
+            "starting_url": "http://launchpad.test/after-login";,
+            "discharge_macaroon_field": "field.discharge_macaroon",
+            }
+        request, browser = self._setUpBrowser(Macaroon(), "test token", form)
+        [top_portlet] = find_tags_by_class(browser.contents, "top-portlet")
+        self.assertEqual(
+            "Authorization failed\nMissing parameters to Candid callback",
+            extract_text(top_portlet))
+        self.assertEqual({}, ISession(request)["launchpad.candid"])
+
+    @responses.activate
+    def test_csrf_token_mismatch(self):
+        form = {
+            "starting_url": "http://launchpad.test/after-login";,
+            "discharge_macaroon_field": "field.discharge_macaroon",
+            "code": "test code",
+            "state": "wrong token",
+            }
+        request, browser = self._setUpBrowser(Macaroon(), "test token", form)
+        [top_portlet] = find_tags_by_class(browser.contents, "top-portlet")
+        self.assertEqual(
+            "Authorization failed\nCSRF token mismatch",
+            extract_text(top_portlet))
+        self.assertEqual({}, ISession(request)["launchpad.candid"])
+
+    @responses.activate
+    def test_discharge_token_failure(self):
+        responses.add(
+            "POST", "https://candid.test/discharge-token";, status=500)
+
+        macaroon = Macaroon(version=2)
+        macaroon.add_third_party_caveat("https://candid.test/";, "", "identity")
+        csrf_token = "test token"
+        form = {
+            "starting_url": "http://launchpad.test/after-login";,
+            "discharge_macaroon_field": "field.discharge_macaroon",
+            "code": "test code",
+            "state": csrf_token,
+            }
+        request, browser = self._setUpBrowser(macaroon, csrf_token, form)
+        [top_portlet] = find_tags_by_class(browser.contents, "top-portlet")
+        self.assertEqual(
+            "Authorization failed\n500 Server Error: Internal Server Error",
+            extract_text(top_portlet))
+        self.assertEqual({}, ISession(request)["launchpad.candid"])
+
+    @responses.activate
+    def test_discharge_failure(self):
+        responses.add(
+            "POST", "https://candid.test/discharge-token";,
+            json={"token": {"kind": "macaroon", "value": "discharge token"}})
+        responses.add("POST", "https://candid.test/discharge";, status=500)
+
+        macaroon = Macaroon(version=2)
+        macaroon.add_third_party_caveat("https://candid.test/";, "", "identity")
+        csrf_token = "test token"
+        form = {
+            "starting_url": "http://launchpad.test/after-login";,
+            "discharge_macaroon_field": "field.discharge_macaroon",
+            "code": "test code",
+            "state": csrf_token,
+            }
+        request, browser = self._setUpBrowser(macaroon, csrf_token, form)
+        [top_portlet] = find_tags_by_class(browser.contents, "top-portlet")
+        self.assertEqual(
+            "Authorization failed\n500 Server Error: Internal Server Error",
+            extract_text(top_portlet))
+        self.assertEqual({}, ISession(request)["launchpad.candid"])
+
+    @responses.activate
+    def test_discharge_macaroon(self):
+        # If a discharge macaroon was requested and received, the view
+        # returns a form that submits it to the starting URL.
+        responses.add(
+            "POST", "https://candid.test/discharge-token";,
+            json={"token": {"kind": "macaroon", "value": "discharge token"}})
+        discharge = Macaroon(identifier="test", version=2)
+        discharge_raw = discharge.serialize(JsonSerializer())
+        responses.add(
+            "POST", "https://candid.test/discharge";,
+            json={"Macaroon": json.loads(discharge_raw)})
+
+        macaroon = Macaroon(version=2)
+        macaroon.add_third_party_caveat("https://candid.test/";, "", "identity")
+        caveat = macaroon.caveats[0]
+        csrf_token = "test token"
+        form = {
+            "starting_url": "http://launchpad.test/after-login";,
+            "discharge_macaroon_action": "field.actions.complete",
+            "discharge_macaroon_field": "field.discharge_macaroon",
+            "code": "test code",
+            "state": csrf_token,
+            }
+        request, browser = self._setUpBrowser(macaroon, csrf_token, form)
+
+        # We made the appropriate requests to Candid to complete
+        # authorization.
+        self.assertThat(responses.calls, MatchesListwise([
+            MatchesStructure(
+                request=MatchesStructure(
+                    url=Equals("https://candid.test/discharge-token";),
+                    headers=ContainsDict({
+                        "Content-Type": Equals("application/json"),
+                        }),
+                    body=AfterPreprocessing(
+                        lambda b: json.loads(b.decode()),
+                        MatchesDict({"code": Equals("test code")})))),
+            MatchesStructure(
+                request=MatchesStructure(
+                    url=Equals("https://candid.test/discharge";),
+                    headers=ContainsDict({
+                        "Content-Type": Equals(
+                            "application/x-www-form-urlencoded"),
+                        }),
+                    body=AfterPreprocessing(parse_qs, MatchesDict({
+                        "id64": Equals(
+                            [b64encode(caveat.caveat_id_bytes).decode()]),
+                        "token64": Equals(["discharge token"]),
+                        "token-kind": Equals(["macaroon"]),
+                        })))),
+            ]))
+
+        # The presented form has the proper structure and includes the
+        # resulting discharge macaroon.
+        self.assertThat(browser.getForm(id="discharge-form"), MatchesStructure(
+            action=Equals("http://launchpad.test/after-login";),
+            controls=MatchesSetwise(
+                MatchesStructure.byEquality(
+                    name="field.actions.complete", type="hidden", value="1"),
+                MatchesStructure.byEquality(
+                    name="field.discharge_macaroon", type="hidden",
+                    value=discharge_raw),
+                MatchesStructure.byEquality(type="submit"))))
+
+        # The state is removed from the session.
+        self.assertEqual({}, ISession(request)["launchpad.candid"])

Follow ups