← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/login-discharge-macaroon into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/login-discharge-macaroon into lp:launchpad with lp:~cjwatson/launchpad/snap-store-upload-job as a prerequisite.

Commit message:
Add support to +login for acquiring discharge macaroons from SSO via an OpenID exchange.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1572605 in Launchpad itself: "Automatically upload snap builds to store"
  https://bugs.launchpad.net/launchpad/+bug/1572605

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/login-discharge-macaroon/+merge/294355

Add support to +login for acquiring discharge macaroons from SSO via an OpenID exchange.

This matches and requires https://code.launchpad.net/~cjwatson/canonical-identity-provider/openid-macaroon-discharge-v1/+merge/294272.  The OpenID extension comes from there, with only minor reformatting and adjustments for Launchpad's test suite; it probably eventually ought to end up in some more common library, but putting it directly in Launchpad is good enough for now.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/login-discharge-macaroon into lp:launchpad.
=== modified file 'lib/lp/scripts/utilities/importfascist.py'
--- lib/lp/scripts/utilities/importfascist.py	2015-03-13 19:05:03 +0000
+++ lib/lp/scripts/utilities/importfascist.py	2016-05-11 11:04:06 +0000
@@ -28,6 +28,7 @@
     'bzrlib.lsprof': set(['BzrProfiler']),
     'cookielib': set(['domain_match']),
     'openid.fetchers': set(['Urllib2Fetcher']),
+    'openid.message': set(['NamespaceAliasRegistrationError']),
     'storm.database': set(['STATE_DISCONNECTED']),
     'textwrap': set(['dedent']),
     'testtools.testresult.real': set(['_details_to_str']),

=== added directory 'lib/lp/services/openid/extensions'
=== added file 'lib/lp/services/openid/extensions/__init__.py'
=== added file 'lib/lp/services/openid/extensions/macaroon.py'
--- lib/lp/services/openid/extensions/macaroon.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/openid/extensions/macaroon.py	2016-05-11 11:04:06 +0000
@@ -0,0 +1,260 @@
+# Copyright 2016 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Support for issuing discharge macaroons via the OpenID request.
+
+RPs may need to use SSO authority to authorise macaroons issued by other
+services.  The simplest way to do this securely as part of a browser
+workflow is to piggyback on the OpenID interaction: this makes it
+straightforward to request login information if necessary and gives us
+CSRF-safe data exchange.
+
+As part of an OpenID authentication request, the RP includes the following
+fields:
+
+  openid.ns.macaroon:
+    An OpenID 2.0 namespace URI for the extension. It is not strictly
+    required for 1.1 requests, but including it is good for forward
+    compatibility.
+
+    It must be set to: http://ns.login.ubuntu.com/2016/openid-macaroon
+
+  openid.macaroon.caveat_id
+    The SSO third-party caveat ID from the root macaroon that the RP wants
+    to discharge.
+
+As part of the positive assertion OpenID response, the following fields
+will be provided:
+
+  openid.ns.macaroon:
+    (as above)
+
+  openid.macaroon.discharge
+    A serialised discharge macaroon for the provided root macaroon.
+"""
+
+__metaclass__ = type
+__all__ = [
+    'MacaroonRequest',
+    'MacaroonResponse',
+    ]
+
+from openid import oidutil
+from openid.extension import Extension
+from openid.message import (
+    NamespaceAliasRegistrationError,
+    registerNamespaceAlias,
+    )
+
+
+MACAROON_NS = 'http://ns.login.ubuntu.com/2016/openid-macaroon'
+
+
+try:
+    registerNamespaceAlias(MACAROON_NS, 'macaroon')
+except NamespaceAliasRegistrationError as e:
+    oidutil.log(
+        'registerNamespaceAlias(%r, %r) failed: %s' % (
+            MACAROON_NS, 'macaroon', e))
+
+
+def get_macaroon_ns(message):
+    """Extract the macaroon namespace URI from the given OpenID message.
+
+    @param message: The OpenID message from which to parse the macaroon.
+        This may be a request or response message.
+    @type message: C{L{openid.message.Message}}
+
+    @returns: the macaroon namespace URI for the supplied message. The
+        message may be modified to define a macaroon namespace.
+    @rtype: C{str}
+
+    @raise ValueError: when using OpenID 1 if the message defines the
+        'macaroon' alias to be something other than a macaroon type.
+    """
+    # See if there exists an alias for the macaroon type.
+    alias = message.namespaces.getAlias(MACAROON_NS)
+    if alias is None:
+        # There is no alias, so try to add one. (OpenID version 1)
+        try:
+            message.namespaces.addAlias(MACAROON_NS, 'macaroon')
+        except KeyError as why:
+            # An alias for the string 'macaroon' already exists, but it's
+            # defined for something other than issuing a discharge macaroon.
+            raise MacaroonNamespaceError(why[0])
+
+    return MACAROON_NS
+
+
+class MacaroonNamespaceError(ValueError):
+    """The macaroon namespace was not found and could not be created using
+    the expected name (there's another extension using the name 'macaroon').
+
+    This is not I{illegal}, for OpenID 2, although it probably indicates a
+    problem, since it's not expected that other extensions will re-use the
+    alias that is in use for OpenID 1.
+
+    If this is an OpenID 1 request, then there is no recourse. This should
+    not happen unless some code has modified the namespaces for the message
+    that is being processed.
+    """
+
+
+class MacaroonRequest(Extension):
+    """An object to hold the state of a discharge macaroon request.
+
+    @ivar caveat_id: The SSO third-party caveat ID from the root macaroon
+        that the RP wants to discharge.
+    @type caveat_id: str
+
+    @group Consumer: requestField, getExtensionArgs, addToOpenIDRequest
+    @group Server: fromOpenIDRequest, parseExtensionArgs
+    """
+
+    ns_alias = 'macaroon'
+
+    def __init__(self, caveat_id=None, macaroon_ns_uri=MACAROON_NS):
+        """Initialize an empty discharge macaroon request."""
+        Extension.__init__(self)
+        self.caveat_id = caveat_id
+        self.ns_uri = macaroon_ns_uri
+
+    @classmethod
+    def fromOpenIDRequest(cls, request):
+        """Create a discharge macaroon request that contains the fields that
+        were requested in the OpenID request with the given arguments.
+
+        @param request: The OpenID request
+        @type request: openid.server.CheckIDRequest
+
+        @returns: The newly-created discharge macaroon request
+        @rtype: C{L{MacaroonRequest}}
+        """
+        self = cls()
+
+        # Since we're going to mess with namespace URI mapping, don't mutate
+        # the object that was passed in.
+        message = request.message.copy()
+
+        self.ns_uri = get_macaroon_ns(message)
+        args = message.getArgs(self.ns_uri)
+        self.parseExtensionArgs(args)
+
+        return self
+
+    def parseExtensionArgs(self, args):
+        """Parse the unqualified macaroon request parameters and add them to
+        this object.
+
+        This method is essentially the inverse of C{L{getExtensionArgs}}. It
+        restores the serialized macaroon request fields.
+
+        If you are extracting arguments from a standard OpenID checkid_*
+        request, you probably want to use C{L{fromOpenIDRequest}}, which
+        will extract the macaroon namespace and arguments from the OpenID
+        request. This method is intended for cases where the OpenID server
+        needs more control over how the arguments are parsed than that
+        method provides.
+
+           args = message.getArgs(MACAROON_NS)
+           request.parseExtensionArgs(args)
+
+        @param args: The unqualified macaroon arguments
+        @type args: {str:str}
+
+        @returns: None; updates this object
+        """
+        self.caveat_id = args.get('caveat_id')
+
+    def getExtensionArgs(self):
+        """Get a dictionary of unqualified macaroon request parameters
+        representing this request.
+
+        This method is essentially the inverse of C{L{parseExtensionArgs}}.
+        It serializes the macaroon request fields.
+
+        @rtype: {str:str}
+        """
+        args = {}
+
+        if self.caveat_id:
+            args['caveat_id'] = self.caveat_id
+
+        return args
+
+
+class MacaroonResponse(Extension):
+    """Represents the data returned in a discharge macaroon response inside
+    an OpenID C{id_res} response. This object will be created by the OpenID
+    server, added to the C{id_res} response object, and then extracted from
+    the C{id_res} message by the Consumer.
+
+    @ivar discharge_macaroon_raw: The serialized discharge macaroon.
+    @type discharge_macaroon_raw: str
+
+    @ivar ns_uri: The URI under which the macaroon data was stored in the
+        response message.
+
+    @group Server: extractResponse
+    @group Consumer: fromSuccessResponse
+    @group Read-only dictionary interface: keys, iterkeys, items, iteritems,
+        __iter__, get, __getitem__, keys, has_key
+    """
+
+    ns_alias = 'macaroon'
+
+    def __init__(self, discharge_macaroon_raw=None,
+                 macaroon_ns_uri=MACAROON_NS):
+        Extension.__init__(self)
+        self.discharge_macaroon_raw = discharge_macaroon_raw
+        self.ns_uri = macaroon_ns_uri
+
+    @classmethod
+    def extractResponse(cls, request, discharge_macaroon_raw):
+        """Take a C{L{MacaroonRequest}} and a serialized discharge macaroon
+        and create a C{L{MacaroonResponse}} object containing that data.
+
+        @param request: The macaroon request object.
+        @type request: MacaroonRequest
+
+        @param discharge_macaroon_raw: The serialized discharge macaroon.
+        @type discharge_macaroon_raw: str
+
+        @returns: a macaroon response object
+        @rtype: MacaroonResponse
+        """
+        return cls(discharge_macaroon_raw, request.ns_uri)
+
+    @classmethod
+    def fromSuccessResponse(cls, success_response, signed_only=True):
+        """Create a C{L{MacaroonResponse}} object from a successful OpenID
+        library response message
+        (C{L{openid.consumer.consumer.SuccessResponse}}).
+
+        @param success_response: A SuccessResponse from consumer.complete().
+        @type success_response: C{L{openid.consumer.consumer.SuccessResponse}}
+
+        @param signed_only: Whether to process only data that was signed in
+            the C{id_res} message from the server.
+        @type signed_only: bool
+
+        @returns: A macaroon response containing the data that was supplied
+            with the C{id_res} response.
+        @rtype: MacaroonResponse
+        """
+        self = cls()
+        self.ns_uri = get_macaroon_ns(success_response.message)
+        if signed_only:
+            args = success_response.getSignedNS(self.ns_uri)
+        else:
+            args = success_response.message.getArgs(self.ns_uri)
+        self.discharge_macaroon_raw = args.get('discharge')
+        return self
+
+    def getExtensionArgs(self):
+        """Get the fields to put in the macaroon namespace when adding them
+        to an C{id_res} message.
+
+        @see: openid.extension
+        """
+        return {'discharge': self.discharge_macaroon_raw}

=== added directory 'lib/lp/services/openid/extensions/tests'
=== added file 'lib/lp/services/openid/extensions/tests/__init__.py'
=== added file 'lib/lp/services/openid/extensions/tests/test_macaroon.py'
--- lib/lp/services/openid/extensions/tests/test_macaroon.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/openid/extensions/tests/test_macaroon.py	2016-05-11 11:04:06 +0000
@@ -0,0 +1,183 @@
+# Copyright 2016 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+
+from openid.consumer.consumer import SuccessResponse
+from openid.message import IDENTIFIER_SELECT
+from openid.server.server import Server
+from zope.component import getUtility
+
+from lp.services.openid.extensions.macaroon import (
+    MACAROON_NS,
+    MacaroonNamespaceError,
+    MacaroonRequest,
+    MacaroonResponse,
+    get_macaroon_ns,
+    )
+from lp.services.openid.interfaces.openidconsumer import IOpenIDConsumerStore
+from lp.testing import (
+    TestCase,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import ZopelessDatabaseLayer
+from lp.testopenid.interfaces.server import get_server_url
+
+
+class TestGetMacaroonNS(TestCase):
+
+    layer = ZopelessDatabaseLayer
+
+    def setUp(self):
+        super(TestGetMacaroonNS, self).setUp()
+        params = {
+            'openid.mode': 'checkid_setup',
+            'openid.trust_root': 'http://localhost/',
+            'openid.return_to': 'http://localhost/',
+            'openid.identity': IDENTIFIER_SELECT,
+            }
+        openid_store = getUtility(IOpenIDConsumerStore)
+        openid_server = Server(openid_store, get_server_url())
+        self.orequest = openid_server.decodeRequest(params)
+
+    def test_get_macaroon_ns(self):
+        message = self.orequest.message
+        self.assertIsNone(message.namespaces.getAlias(MACAROON_NS))
+        uri = get_macaroon_ns(message)
+        self.assertEqual(MACAROON_NS, uri)
+        self.assertEqual('macaroon', message.namespaces.getAlias(MACAROON_NS))
+
+    def test_get_macaroon_ns_alias_already_exists(self):
+        message = self.orequest.message
+        message.namespaces.addAlias('http://localhost/', 'macaroon')
+        self.assertIsNone(message.namespaces.getAlias(MACAROON_NS))
+        self.assertRaises(MacaroonNamespaceError, get_macaroon_ns, message)
+
+
+class TestMacaroonRequest(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def setUp(self):
+        super(TestMacaroonRequest, self).setUp()
+        self.caveat_id = self.factory.getUniqueUnicode()
+        self.req = MacaroonRequest(self.caveat_id)
+
+    def test_init(self):
+        req = MacaroonRequest()
+        self.assertIsNone(req.caveat_id)
+        self.assertEqual(MACAROON_NS, req.ns_uri)
+
+        self.assertEqual(self.caveat_id, self.req.caveat_id)
+
+    def test_fromOpenIDRequest(self):
+        params = {
+            'openid.mode': 'checkid_setup',
+            'openid.trust_root': 'http://localhost/',
+            'openid.return_to': 'http://localhost/',
+            'openid.identity': IDENTIFIER_SELECT,
+            }
+        openid_store = getUtility(IOpenIDConsumerStore)
+        openid_server = Server(openid_store, get_server_url())
+        orequest = openid_server.decodeRequest(params)
+        req = MacaroonRequest.fromOpenIDRequest(orequest)
+        self.assertIsNone(req.caveat_id)
+        self.assertEqual(MACAROON_NS, req.ns_uri)
+
+    def test_fromOpenIDRequest_with_root(self):
+        params = {
+            'openid.mode': 'checkid_setup',
+            'openid.trust_root': 'http://localhost/',
+            'openid.return_to': 'http://localhost/',
+            'openid.identity': IDENTIFIER_SELECT,
+            'openid.macaroon.caveat_id': self.caveat_id,
+            }
+        openid_store = getUtility(IOpenIDConsumerStore)
+        openid_server = Server(openid_store, get_server_url())
+        orequest = openid_server.decodeRequest(params)
+        req = MacaroonRequest.fromOpenIDRequest(orequest)
+        self.assertEqual(self.caveat_id, req.caveat_id)
+        self.assertEqual(MACAROON_NS, req.ns_uri)
+
+    def test_parseExtensionArgs(self):
+        req = MacaroonRequest()
+        req.parseExtensionArgs({'caveat_id': self.caveat_id})
+        self.assertEqual(self.caveat_id, req.caveat_id)
+
+    def test_getExtensionArgs(self):
+        expected = {'caveat_id': self.caveat_id}
+        self.assertEqual(expected, self.req.getExtensionArgs())
+
+    def test_getExtensionArgs_no_root(self):
+        req = MacaroonRequest()
+        self.assertEqual({}, req.getExtensionArgs())
+
+
+class TestMacaroonResponse(TestCase):
+
+    layer = ZopelessDatabaseLayer
+
+    def test_init(self):
+        resp = MacaroonResponse()
+        self.assertIsNone(resp.discharge_macaroon_raw)
+        self.assertEqual(MACAROON_NS, resp.ns_uri)
+
+        resp = MacaroonResponse(discharge_macaroon_raw='dummy')
+        self.assertEqual('dummy', resp.discharge_macaroon_raw)
+
+    def test_extractResponse(self):
+        req = MacaroonRequest()
+        resp = MacaroonResponse.extractResponse(req, 'dummy')
+        self.assertEqual(req.ns_uri, resp.ns_uri, req.ns_uri)
+        self.assertEqual('dummy', resp.discharge_macaroon_raw)
+
+    def test_fromSuccessResponse_signed_present(self):
+        params = {
+            'openid.mode': 'checkid_setup',
+            'openid.trust_root': 'http://localhost/',
+            'openid.return_to': 'http://localhost/',
+            'openid.identity': IDENTIFIER_SELECT,
+            'openid.macaroon.discharge': 'dummy',
+            }
+        openid_store = getUtility(IOpenIDConsumerStore)
+        openid_server = Server(openid_store, get_server_url())
+        orequest = openid_server.decodeRequest(params)
+        signed_fields = ['openid.macaroon.discharge']
+        success_resp = SuccessResponse(
+            orequest, orequest.message, signed_fields=signed_fields)
+        resp = MacaroonResponse.fromSuccessResponse(success_resp)
+        self.assertEqual('dummy', resp.discharge_macaroon_raw)
+
+    def test_fromSuccessResponse_no_signed(self):
+        params = {
+            'openid.mode': 'checkid_setup',
+            'openid.trust_root': 'http://localhost/',
+            'openid.return_to': 'http://localhost/',
+            'openid.identity': IDENTIFIER_SELECT,
+            }
+        openid_store = getUtility(IOpenIDConsumerStore)
+        openid_server = Server(openid_store, get_server_url())
+        orequest = openid_server.decodeRequest(params)
+        success_resp = SuccessResponse(orequest, orequest.message)
+        resp = MacaroonResponse.fromSuccessResponse(success_resp)
+        self.assertIsNone(resp.discharge_macaroon_raw)
+
+    def test_fromSuccessResponse_all(self):
+        params = {
+            'openid.mode': 'checkid_setup',
+            'openid.trust_root': 'http://localhost/',
+            'openid.return_to': 'http://localhost/',
+            'openid.identity': IDENTIFIER_SELECT,
+            'openid.macaroon.discharge': 'dummy',
+            }
+        openid_store = getUtility(IOpenIDConsumerStore)
+        openid_server = Server(openid_store, get_server_url())
+        orequest = openid_server.decodeRequest(params)
+        success_resp = SuccessResponse(orequest, orequest.message)
+        resp = MacaroonResponse.fromSuccessResponse(success_resp, False)
+        self.assertEqual('dummy', resp.discharge_macaroon_raw)
+
+    def test_getExtensionArgs(self):
+        expected = {'discharge': 'dummy'}
+        req = MacaroonResponse(discharge_macaroon_raw='dummy')
+        self.assertEqual(req.getExtensionArgs(), expected)

=== modified file 'lib/lp/services/webapp/login.py'
--- lib/lp/services/webapp/login.py	2016-04-20 14:12:07 +0000
+++ lib/lp/services/webapp/login.py	2016-05-11 11:04:06 +0000
@@ -9,6 +9,10 @@
     timedelta,
     )
 import urllib
+from urlparse import (
+    parse_qsl,
+    urlunsplit,
+    )
 
 from openid.consumer.consumer import (
     CANCEL,
@@ -54,10 +58,14 @@
 from lp.services.config import config
 from lp.services.database.policy import MasterDatabasePolicy
 from lp.services.identity.interfaces.account import AccountSuspendedError
+from lp.services.openid.extensions import macaroon
 from lp.services.openid.interfaces.openidconsumer import IOpenIDConsumerStore
 from lp.services.propertycache import cachedproperty
 from lp.services.timeline.requesttimeline import get_request_timeline
-from lp.services.webapp import canonical_url
+from lp.services.webapp import (
+    canonical_url,
+    urlsplit,
+    )
 from lp.services.webapp.error import SystemErrorView
 from lp.services.webapp.interfaces import (
     CookieAuthLoggedInEvent,
@@ -176,10 +184,12 @@
         return Consumer(session, openid_store)
 
     def render(self):
-        # Reauthentication is called for by a parameter, usually passed in
-        # the query string.
+        # Reauthentication and discharge macaroon issuing are called for by
+        # parameters, usually passed in the query string.
         do_reauth = int(self.request.form.get('reauth', '0'))
-        if self.account is not None and not do_reauth:
+        macaroon_caveat_id = self.request.form.get('macaroon_caveat_id', None)
+        if (self.account is not None and not do_reauth and
+                macaroon_caveat_id is None):
             return AlreadyLoggedInView(self.context, self.request)()
 
         # Allow unauthenticated users to have sessions for the OpenID
@@ -198,6 +208,9 @@
             timeline_action.finish()
         self.openid_request.addExtension(
             sreg.SRegRequest(required=['email', 'fullname']))
+        if macaroon_caveat_id is not None:
+            self.openid_request.addExtension(
+                macaroon.MacaroonRequest(macaroon_caveat_id))
 
         # Force the Open ID handshake to re-authenticate, using
         # pape extension's max_auth_age, if the URL indicates it.
@@ -212,8 +225,13 @@
         # they started the login process (i.e. the current URL without the
         # '+login' bit). To do that we encode that URL as a query arg in the
         # return_to URL passed to the OpenID Provider
-        starting_url = urllib.urlencode(
-            [('starting_url', self.starting_url.encode('utf-8'))])
+        starting_data = [('starting_url', self.starting_url.encode('utf-8'))]
+        discharge_macaroon_field = self.request.form.get(
+            'discharge_macaroon_field', None)
+        if discharge_macaroon_field is not None:
+            starting_data.append(
+                ('discharge_macaroon_field', discharge_macaroon_field))
+        starting_url = urllib.urlencode(starting_data)
         trust_root = allvhosts.configs['mainsite'].rooturl
         return_to = urlappend(trust_root, '+openid-callback')
         return_to = "%s?%s" % (return_to, starting_url)
@@ -245,7 +263,8 @@
         All keys and values are UTF-8-encoded.
         """
         for name, value in self.request.form.items():
-            if name in ('loggingout', 'reauth'):
+            if name in ('loggingout', 'reauth',
+                        'macaroon_caveat_id', 'discharge_macaroon_field'):
                 continue
             if name.startswith('openid.'):
                 continue
@@ -308,6 +327,7 @@
                 self.params, requested_url)
         finally:
             timeline_action.finish()
+        self.discharge_macaroon_raw = None
 
     def login(self, person, when=None):
         loginsource = getUtility(IPlacelessLoginSource)
@@ -321,6 +341,11 @@
     def sreg_response(self):
         return sreg.SRegResponse.fromSuccessResponse(self.openid_response)
 
+    @cachedproperty
+    def macaroon_response(self):
+        return macaroon.MacaroonResponse.fromSuccessResponse(
+            self.openid_response)
+
     def _getEmailAddressAndFullName(self):
         # Here we assume the OP sent us the user's email address and
         # full name in the response. Note we can only do that because
@@ -372,6 +397,13 @@
         except TeamEmailAddressError:
             return self.team_email_address_template()
 
+        if self.params.get('discharge_macaroon_field'):
+            if self.macaroon_response.discharge_macaroon_raw is None:
+                raise HTTPBadRequest(
+                    "OP didn't include a macaroon extension in the response.")
+            self.discharge_macaroon_raw = (
+                self.macaroon_response.discharge_macaroon_raw)
+
         with MasterDatabasePolicy():
             self.login(person)
 
@@ -416,10 +448,27 @@
         transaction.commit()
         return retval
 
+    @staticmethod
+    def _appendParam(url, key, value):
+        """Append a parameter to a URL's query string."""
+        parts = urlsplit(url)
+        query = parse_qsl(parts.query)
+        query.append((key, value))
+        return urlunsplit(
+            (parts.scheme, parts.netloc, parts.path, urllib.urlencode(query),
+             parts.fragment))
+
     def _redirect(self):
         target = self.params.get('starting_url')
         if target is None:
             target = self.request.getApplicationURL()
+        discharge_macaroon_field = self.params.get('discharge_macaroon_field')
+        if (discharge_macaroon_field is not None and
+                self.discharge_macaroon_raw is not None):
+            # XXX cjwatson 2016-04-18: Do we need to POST this instead due
+            # to size?
+            target = self._appendParam(
+                target, discharge_macaroon_field, self.discharge_macaroon_raw)
         self.request.response.redirect(target, temporary_if_possible=True)
 
 

=== modified file 'lib/lp/services/webapp/tests/test_login.py'
--- lib/lp/services/webapp/tests/test_login.py	2016-04-20 14:12:07 +0000
+++ lib/lp/services/webapp/tests/test_login.py	2016-05-11 11:04:06 +0000
@@ -51,6 +51,10 @@
     IAccountSet,
     )
 from lp.services.identity.interfaces.emailaddress import EmailAddressStatus
+from lp.services.openid.extensions.macaroon import (
+    MacaroonRequest,
+    MacaroonResponse,
+    )
 from lp.services.openid.model.openididentifier import OpenIdIdentifier
 from lp.services.timeline.requesttimeline import get_request_timeline
 from lp.services.webapp.interfaces import ILaunchpadApplication
@@ -88,12 +92,13 @@
 class FakeOpenIDResponse:
 
     def __init__(self, identity_url, status=SUCCESS, message='', email=None,
-                 full_name=None):
+                 full_name=None, discharge_macaroon_raw=None):
         self.message = message
         self.status = status
         self.identity_url = identity_url
         self.sreg_email = email
         self.sreg_fullname = full_name
+        self.discharge_macaroon_raw = discharge_macaroon_raw
 
 
 class StubbedOpenIDCallbackView(OpenIDCallbackView):
@@ -136,10 +141,29 @@
     # FakeOpenIDResponses instead of real ones.
     sreg.SRegResponse.fromSuccessResponse = classmethod(
         sregFromFakeSuccessResponse)
-
-    yield
-
-    sreg.SRegResponse.fromSuccessResponse = orig_method
+    try:
+        yield
+    finally:
+        sreg.SRegResponse.fromSuccessResponse = orig_method
+
+
+@contextmanager
+def MacaroonResponse_fromSuccessResponse_stubbed():
+
+    def macaroonFromFakeSuccessResponse(cls, success_response,
+                                        signed_only=True):
+        return MacaroonResponse(
+            discharge_macaroon_raw=success_response.discharge_macaroon_raw)
+
+    orig_method = MacaroonResponse.fromSuccessResponse
+    # Use a stub MacaroonResponse.fromSuccessResponse that works with
+    # FakeOpenIDResponses instead of real ones.
+    MacaroonResponse.fromSuccessResponse = classmethod(
+        macaroonFromFakeSuccessResponse)
+    try:
+        yield
+    finally:
+        MacaroonResponse.fromSuccessResponse = orig_method
 
 
 @contextmanager
@@ -183,10 +207,10 @@
             openid_response, view_class=view_class)
 
     def _createAndRenderView(self, response,
-                             view_class=StubbedOpenIDCallbackView):
-        request = LaunchpadTestRequest(
-            form={'starting_url': 'http://launchpad.dev/after-login'},
-            environ={'PATH_INFO': '/'})
+                             view_class=StubbedOpenIDCallbackView, form=None):
+        if form is None:
+            form = {'starting_url': 'http://launchpad.dev/after-login'}
+        request = LaunchpadTestRequest(form=form, environ={'PATH_INFO': '/'})
         # The layer we use sets up an interaction (by calling login()), but we
         # want to use our own request in the interaction, so we logout() and
         # setup a newInteraction() using our request.
@@ -456,6 +480,57 @@
             'No email address or full name found in sreg response',
             main_content)
 
+    def test_discharge_macaroon(self):
+        # If a discharge macaroon was requested and received, it is added to
+        # the starting URL as a query string parameter.
+        test_email = 'test-example@xxxxxxxxxxx'
+        person = self.factory.makePerson(email=test_email)
+        identifier = ITestOpenIDPersistentIdentity(
+            person.account).openid_identity_url
+        openid_response = FakeOpenIDResponse(
+            identifier, status=SUCCESS, message='', email=test_email,
+            full_name='Foo User', discharge_macaroon_raw='dummy discharge')
+        form = {
+            'starting_url': 'http://launchpad.dev/after-login',
+            'discharge_macaroon_field': 'field.discharge_macaroon',
+            }
+        with SRegResponse_fromSuccessResponse_stubbed():
+            with MacaroonResponse_fromSuccessResponse_stubbed():
+                view, html = self._createAndRenderView(
+                    openid_response, form=form)
+        self.assertTrue(view.login_called)
+        self.assertEqual('dummy discharge', view.discharge_macaroon_raw)
+        response = view.request.response
+        self.assertEqual(httplib.TEMPORARY_REDIRECT, response.getStatus())
+        self.assertEqual(
+            form['starting_url'] +
+            '?field.discharge_macaroon=dummy+discharge',
+            response.getHeader('Location'))
+
+    def test_discharge_macaroon_missing(self):
+        # If a discharge macaroon was requested but not received, the login
+        # error page is shown.
+        test_email = 'test-example@xxxxxxxxxxx'
+        person = self.factory.makePerson(email=test_email)
+        identifier = ITestOpenIDPersistentIdentity(
+            person.account).openid_identity_url
+        openid_response = FakeOpenIDResponse(
+            identifier, status=SUCCESS, message='', email=test_email,
+            full_name='Foo User')
+        form = {
+            'starting_url': 'http://launchpad.dev/after-login',
+            'discharge_macaroon_field': 'field.discharge_macaroon',
+            }
+        with SRegResponse_fromSuccessResponse_stubbed():
+            with MacaroonResponse_fromSuccessResponse_stubbed():
+                view, html = self._createAndRenderView(
+                    openid_response, form=form)
+        self.assertFalse(view.login_called)
+        main_content = extract_text(find_main_content(html))
+        self.assertIn(
+            "OP didn't include a macaroon extension in the response.",
+            main_content)
+
     def test_negative_openid_assertion(self):
         # The OpenID provider responded with a negative assertion, so the
         # login error page is shown.
@@ -771,6 +846,37 @@
         self.assertIsInstance(pape_extension, pape.Request)
         self.assertEqual(0, pape_extension.max_auth_age)
 
+    def test_macaroon_extension_added_with_macaroon_query(self):
+        # We can signal that we need to acquire a discharge macaroon via a
+        # macaroon_caveat_id form parameter, which should add the macaroon
+        # extension.
+        caveat_id = 'ask SSO'
+        form = {
+            'field.callback': '1',
+            'macaroon_caveat_id': caveat_id,
+            'discharge_macaroon_field': 'field.discharge_macaroon',
+            }
+        request = LaunchpadTestRequest(form=form, method='POST')
+        request.processInputs()
+        # This is a hack to make the request.getURL(1) call issued by the view
+        # not raise an IndexError.
+        request._app_names = ['foo']
+        view = StubbedOpenIDLogin(object(), request)
+        view()
+        extensions = view.openid_request.extensions
+        self.assertIsNot(None, extensions)
+        macaroon_extension = extensions[1]
+        self.assertIsInstance(macaroon_extension, MacaroonRequest)
+        self.assertEqual(caveat_id, macaroon_extension.caveat_id)
+        return_to_args = dict(urlparse.parse_qsl(
+            urlparse.urlsplit(view.openid_request.return_to).query))
+        self.assertEqual(
+            'field.discharge_macaroon',
+            return_to_args['discharge_macaroon_field'])
+        starting_url_args = dict(urlparse.parse_qsl(
+            urlparse.urlsplit(return_to_args['starting_url']).query))
+        self.assertEqual('1', starting_url_args['field.callback'])
+
     def test_logs_to_timeline(self):
         # Beginning an OpenID association makes an HTTP request to the
         # OP, so it's a potentially long action. It is logged to the


Follow ups