launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #27372
[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