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