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

Proposed by Colin Watson
Status: Merged
Merged at revision: 18043
Proposed branch: lp:~cjwatson/launchpad/login-discharge-macaroon
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/snap-store-upload-job
Diff against target: 788 lines (+615/-16)
5 files modified
lib/lp/scripts/utilities/importfascist.py (+1/-0)
lib/lp/services/openid/extensions/macaroon.py (+260/-0)
lib/lp/services/openid/extensions/tests/test_macaroon.py (+183/-0)
lib/lp/services/webapp/login.py (+56/-7)
lib/lp/services/webapp/tests/test_login.py (+115/-9)
To merge this branch: bzr merge lp:~cjwatson/launchpad/login-discharge-macaroon
Reviewer Review Type Date Requested Status
William Grant code Approve
Ricardo Kirkner (community) Approve
Review via email: mp+294355@code.launchpad.net

Commit message

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

Description of the change

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.

To post a comment you must log in.
review: Approve
William Grant (wgrant) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/scripts/utilities/importfascist.py'
2--- lib/lp/scripts/utilities/importfascist.py 2015-03-13 19:05:03 +0000
3+++ lib/lp/scripts/utilities/importfascist.py 2016-05-12 15:18:57 +0000
4@@ -28,6 +28,7 @@
5 'bzrlib.lsprof': set(['BzrProfiler']),
6 'cookielib': set(['domain_match']),
7 'openid.fetchers': set(['Urllib2Fetcher']),
8+ 'openid.message': set(['NamespaceAliasRegistrationError']),
9 'storm.database': set(['STATE_DISCONNECTED']),
10 'textwrap': set(['dedent']),
11 'testtools.testresult.real': set(['_details_to_str']),
12
13=== added directory 'lib/lp/services/openid/extensions'
14=== added file 'lib/lp/services/openid/extensions/__init__.py'
15=== added file 'lib/lp/services/openid/extensions/macaroon.py'
16--- lib/lp/services/openid/extensions/macaroon.py 1970-01-01 00:00:00 +0000
17+++ lib/lp/services/openid/extensions/macaroon.py 2016-05-12 15:18:57 +0000
18@@ -0,0 +1,260 @@
19+# Copyright 2016 Canonical Ltd. This software is licensed under the
20+# GNU Affero General Public License version 3 (see the file LICENSE).
21+
22+"""Support for issuing discharge macaroons via the OpenID request.
23+
24+RPs may need to use SSO authority to authorise macaroons issued by other
25+services. The simplest way to do this securely as part of a browser
26+workflow is to piggyback on the OpenID interaction: this makes it
27+straightforward to request login information if necessary and gives us
28+CSRF-safe data exchange.
29+
30+As part of an OpenID authentication request, the RP includes the following
31+fields:
32+
33+ openid.ns.macaroon:
34+ An OpenID 2.0 namespace URI for the extension. It is not strictly
35+ required for 1.1 requests, but including it is good for forward
36+ compatibility.
37+
38+ It must be set to: http://ns.login.ubuntu.com/2016/openid-macaroon
39+
40+ openid.macaroon.caveat_id
41+ The SSO third-party caveat ID from the root macaroon that the RP wants
42+ to discharge.
43+
44+As part of the positive assertion OpenID response, the following fields
45+will be provided:
46+
47+ openid.ns.macaroon:
48+ (as above)
49+
50+ openid.macaroon.discharge
51+ A serialised discharge macaroon for the provided root macaroon.
52+"""
53+
54+__metaclass__ = type
55+__all__ = [
56+ 'MacaroonRequest',
57+ 'MacaroonResponse',
58+ ]
59+
60+from openid import oidutil
61+from openid.extension import Extension
62+from openid.message import (
63+ NamespaceAliasRegistrationError,
64+ registerNamespaceAlias,
65+ )
66+
67+
68+MACAROON_NS = 'http://ns.login.ubuntu.com/2016/openid-macaroon'
69+
70+
71+try:
72+ registerNamespaceAlias(MACAROON_NS, 'macaroon')
73+except NamespaceAliasRegistrationError as e:
74+ oidutil.log(
75+ 'registerNamespaceAlias(%r, %r) failed: %s' % (
76+ MACAROON_NS, 'macaroon', e))
77+
78+
79+def get_macaroon_ns(message):
80+ """Extract the macaroon namespace URI from the given OpenID message.
81+
82+ @param message: The OpenID message from which to parse the macaroon.
83+ This may be a request or response message.
84+ @type message: C{L{openid.message.Message}}
85+
86+ @returns: the macaroon namespace URI for the supplied message. The
87+ message may be modified to define a macaroon namespace.
88+ @rtype: C{str}
89+
90+ @raise ValueError: when using OpenID 1 if the message defines the
91+ 'macaroon' alias to be something other than a macaroon type.
92+ """
93+ # See if there exists an alias for the macaroon type.
94+ alias = message.namespaces.getAlias(MACAROON_NS)
95+ if alias is None:
96+ # There is no alias, so try to add one. (OpenID version 1)
97+ try:
98+ message.namespaces.addAlias(MACAROON_NS, 'macaroon')
99+ except KeyError as why:
100+ # An alias for the string 'macaroon' already exists, but it's
101+ # defined for something other than issuing a discharge macaroon.
102+ raise MacaroonNamespaceError(why[0])
103+
104+ return MACAROON_NS
105+
106+
107+class MacaroonNamespaceError(ValueError):
108+ """The macaroon namespace was not found and could not be created using
109+ the expected name (there's another extension using the name 'macaroon').
110+
111+ This is not I{illegal}, for OpenID 2, although it probably indicates a
112+ problem, since it's not expected that other extensions will re-use the
113+ alias that is in use for OpenID 1.
114+
115+ If this is an OpenID 1 request, then there is no recourse. This should
116+ not happen unless some code has modified the namespaces for the message
117+ that is being processed.
118+ """
119+
120+
121+class MacaroonRequest(Extension):
122+ """An object to hold the state of a discharge macaroon request.
123+
124+ @ivar caveat_id: The SSO third-party caveat ID from the root macaroon
125+ that the RP wants to discharge.
126+ @type caveat_id: str
127+
128+ @group Consumer: requestField, getExtensionArgs, addToOpenIDRequest
129+ @group Server: fromOpenIDRequest, parseExtensionArgs
130+ """
131+
132+ ns_alias = 'macaroon'
133+
134+ def __init__(self, caveat_id=None, macaroon_ns_uri=MACAROON_NS):
135+ """Initialize an empty discharge macaroon request."""
136+ Extension.__init__(self)
137+ self.caveat_id = caveat_id
138+ self.ns_uri = macaroon_ns_uri
139+
140+ @classmethod
141+ def fromOpenIDRequest(cls, request):
142+ """Create a discharge macaroon request that contains the fields that
143+ were requested in the OpenID request with the given arguments.
144+
145+ @param request: The OpenID request
146+ @type request: openid.server.CheckIDRequest
147+
148+ @returns: The newly-created discharge macaroon request
149+ @rtype: C{L{MacaroonRequest}}
150+ """
151+ self = cls()
152+
153+ # Since we're going to mess with namespace URI mapping, don't mutate
154+ # the object that was passed in.
155+ message = request.message.copy()
156+
157+ self.ns_uri = get_macaroon_ns(message)
158+ args = message.getArgs(self.ns_uri)
159+ self.parseExtensionArgs(args)
160+
161+ return self
162+
163+ def parseExtensionArgs(self, args):
164+ """Parse the unqualified macaroon request parameters and add them to
165+ this object.
166+
167+ This method is essentially the inverse of C{L{getExtensionArgs}}. It
168+ restores the serialized macaroon request fields.
169+
170+ If you are extracting arguments from a standard OpenID checkid_*
171+ request, you probably want to use C{L{fromOpenIDRequest}}, which
172+ will extract the macaroon namespace and arguments from the OpenID
173+ request. This method is intended for cases where the OpenID server
174+ needs more control over how the arguments are parsed than that
175+ method provides.
176+
177+ args = message.getArgs(MACAROON_NS)
178+ request.parseExtensionArgs(args)
179+
180+ @param args: The unqualified macaroon arguments
181+ @type args: {str:str}
182+
183+ @returns: None; updates this object
184+ """
185+ self.caveat_id = args.get('caveat_id')
186+
187+ def getExtensionArgs(self):
188+ """Get a dictionary of unqualified macaroon request parameters
189+ representing this request.
190+
191+ This method is essentially the inverse of C{L{parseExtensionArgs}}.
192+ It serializes the macaroon request fields.
193+
194+ @rtype: {str:str}
195+ """
196+ args = {}
197+
198+ if self.caveat_id:
199+ args['caveat_id'] = self.caveat_id
200+
201+ return args
202+
203+
204+class MacaroonResponse(Extension):
205+ """Represents the data returned in a discharge macaroon response inside
206+ an OpenID C{id_res} response. This object will be created by the OpenID
207+ server, added to the C{id_res} response object, and then extracted from
208+ the C{id_res} message by the Consumer.
209+
210+ @ivar discharge_macaroon_raw: The serialized discharge macaroon.
211+ @type discharge_macaroon_raw: str
212+
213+ @ivar ns_uri: The URI under which the macaroon data was stored in the
214+ response message.
215+
216+ @group Server: extractResponse
217+ @group Consumer: fromSuccessResponse
218+ @group Read-only dictionary interface: keys, iterkeys, items, iteritems,
219+ __iter__, get, __getitem__, keys, has_key
220+ """
221+
222+ ns_alias = 'macaroon'
223+
224+ def __init__(self, discharge_macaroon_raw=None,
225+ macaroon_ns_uri=MACAROON_NS):
226+ Extension.__init__(self)
227+ self.discharge_macaroon_raw = discharge_macaroon_raw
228+ self.ns_uri = macaroon_ns_uri
229+
230+ @classmethod
231+ def extractResponse(cls, request, discharge_macaroon_raw):
232+ """Take a C{L{MacaroonRequest}} and a serialized discharge macaroon
233+ and create a C{L{MacaroonResponse}} object containing that data.
234+
235+ @param request: The macaroon request object.
236+ @type request: MacaroonRequest
237+
238+ @param discharge_macaroon_raw: The serialized discharge macaroon.
239+ @type discharge_macaroon_raw: str
240+
241+ @returns: a macaroon response object
242+ @rtype: MacaroonResponse
243+ """
244+ return cls(discharge_macaroon_raw, request.ns_uri)
245+
246+ @classmethod
247+ def fromSuccessResponse(cls, success_response, signed_only=True):
248+ """Create a C{L{MacaroonResponse}} object from a successful OpenID
249+ library response message
250+ (C{L{openid.consumer.consumer.SuccessResponse}}).
251+
252+ @param success_response: A SuccessResponse from consumer.complete().
253+ @type success_response: C{L{openid.consumer.consumer.SuccessResponse}}
254+
255+ @param signed_only: Whether to process only data that was signed in
256+ the C{id_res} message from the server.
257+ @type signed_only: bool
258+
259+ @returns: A macaroon response containing the data that was supplied
260+ with the C{id_res} response.
261+ @rtype: MacaroonResponse
262+ """
263+ self = cls()
264+ self.ns_uri = get_macaroon_ns(success_response.message)
265+ if signed_only:
266+ args = success_response.getSignedNS(self.ns_uri)
267+ else:
268+ args = success_response.message.getArgs(self.ns_uri)
269+ self.discharge_macaroon_raw = args.get('discharge')
270+ return self
271+
272+ def getExtensionArgs(self):
273+ """Get the fields to put in the macaroon namespace when adding them
274+ to an C{id_res} message.
275+
276+ @see: openid.extension
277+ """
278+ return {'discharge': self.discharge_macaroon_raw}
279
280=== added directory 'lib/lp/services/openid/extensions/tests'
281=== added file 'lib/lp/services/openid/extensions/tests/__init__.py'
282=== added file 'lib/lp/services/openid/extensions/tests/test_macaroon.py'
283--- lib/lp/services/openid/extensions/tests/test_macaroon.py 1970-01-01 00:00:00 +0000
284+++ lib/lp/services/openid/extensions/tests/test_macaroon.py 2016-05-12 15:18:57 +0000
285@@ -0,0 +1,183 @@
286+# Copyright 2016 Canonical Ltd. This software is licensed under the
287+# GNU Affero General Public License version 3 (see the file LICENSE).
288+
289+__metaclass__ = type
290+
291+from openid.consumer.consumer import SuccessResponse
292+from openid.message import IDENTIFIER_SELECT
293+from openid.server.server import Server
294+from zope.component import getUtility
295+
296+from lp.services.openid.extensions.macaroon import (
297+ MACAROON_NS,
298+ MacaroonNamespaceError,
299+ MacaroonRequest,
300+ MacaroonResponse,
301+ get_macaroon_ns,
302+ )
303+from lp.services.openid.interfaces.openidconsumer import IOpenIDConsumerStore
304+from lp.testing import (
305+ TestCase,
306+ TestCaseWithFactory,
307+ )
308+from lp.testing.layers import ZopelessDatabaseLayer
309+from lp.testopenid.interfaces.server import get_server_url
310+
311+
312+class TestGetMacaroonNS(TestCase):
313+
314+ layer = ZopelessDatabaseLayer
315+
316+ def setUp(self):
317+ super(TestGetMacaroonNS, self).setUp()
318+ params = {
319+ 'openid.mode': 'checkid_setup',
320+ 'openid.trust_root': 'http://localhost/',
321+ 'openid.return_to': 'http://localhost/',
322+ 'openid.identity': IDENTIFIER_SELECT,
323+ }
324+ openid_store = getUtility(IOpenIDConsumerStore)
325+ openid_server = Server(openid_store, get_server_url())
326+ self.orequest = openid_server.decodeRequest(params)
327+
328+ def test_get_macaroon_ns(self):
329+ message = self.orequest.message
330+ self.assertIsNone(message.namespaces.getAlias(MACAROON_NS))
331+ uri = get_macaroon_ns(message)
332+ self.assertEqual(MACAROON_NS, uri)
333+ self.assertEqual('macaroon', message.namespaces.getAlias(MACAROON_NS))
334+
335+ def test_get_macaroon_ns_alias_already_exists(self):
336+ message = self.orequest.message
337+ message.namespaces.addAlias('http://localhost/', 'macaroon')
338+ self.assertIsNone(message.namespaces.getAlias(MACAROON_NS))
339+ self.assertRaises(MacaroonNamespaceError, get_macaroon_ns, message)
340+
341+
342+class TestMacaroonRequest(TestCaseWithFactory):
343+
344+ layer = ZopelessDatabaseLayer
345+
346+ def setUp(self):
347+ super(TestMacaroonRequest, self).setUp()
348+ self.caveat_id = self.factory.getUniqueUnicode()
349+ self.req = MacaroonRequest(self.caveat_id)
350+
351+ def test_init(self):
352+ req = MacaroonRequest()
353+ self.assertIsNone(req.caveat_id)
354+ self.assertEqual(MACAROON_NS, req.ns_uri)
355+
356+ self.assertEqual(self.caveat_id, self.req.caveat_id)
357+
358+ def test_fromOpenIDRequest(self):
359+ params = {
360+ 'openid.mode': 'checkid_setup',
361+ 'openid.trust_root': 'http://localhost/',
362+ 'openid.return_to': 'http://localhost/',
363+ 'openid.identity': IDENTIFIER_SELECT,
364+ }
365+ openid_store = getUtility(IOpenIDConsumerStore)
366+ openid_server = Server(openid_store, get_server_url())
367+ orequest = openid_server.decodeRequest(params)
368+ req = MacaroonRequest.fromOpenIDRequest(orequest)
369+ self.assertIsNone(req.caveat_id)
370+ self.assertEqual(MACAROON_NS, req.ns_uri)
371+
372+ def test_fromOpenIDRequest_with_root(self):
373+ params = {
374+ 'openid.mode': 'checkid_setup',
375+ 'openid.trust_root': 'http://localhost/',
376+ 'openid.return_to': 'http://localhost/',
377+ 'openid.identity': IDENTIFIER_SELECT,
378+ 'openid.macaroon.caveat_id': self.caveat_id,
379+ }
380+ openid_store = getUtility(IOpenIDConsumerStore)
381+ openid_server = Server(openid_store, get_server_url())
382+ orequest = openid_server.decodeRequest(params)
383+ req = MacaroonRequest.fromOpenIDRequest(orequest)
384+ self.assertEqual(self.caveat_id, req.caveat_id)
385+ self.assertEqual(MACAROON_NS, req.ns_uri)
386+
387+ def test_parseExtensionArgs(self):
388+ req = MacaroonRequest()
389+ req.parseExtensionArgs({'caveat_id': self.caveat_id})
390+ self.assertEqual(self.caveat_id, req.caveat_id)
391+
392+ def test_getExtensionArgs(self):
393+ expected = {'caveat_id': self.caveat_id}
394+ self.assertEqual(expected, self.req.getExtensionArgs())
395+
396+ def test_getExtensionArgs_no_root(self):
397+ req = MacaroonRequest()
398+ self.assertEqual({}, req.getExtensionArgs())
399+
400+
401+class TestMacaroonResponse(TestCase):
402+
403+ layer = ZopelessDatabaseLayer
404+
405+ def test_init(self):
406+ resp = MacaroonResponse()
407+ self.assertIsNone(resp.discharge_macaroon_raw)
408+ self.assertEqual(MACAROON_NS, resp.ns_uri)
409+
410+ resp = MacaroonResponse(discharge_macaroon_raw='dummy')
411+ self.assertEqual('dummy', resp.discharge_macaroon_raw)
412+
413+ def test_extractResponse(self):
414+ req = MacaroonRequest()
415+ resp = MacaroonResponse.extractResponse(req, 'dummy')
416+ self.assertEqual(req.ns_uri, resp.ns_uri, req.ns_uri)
417+ self.assertEqual('dummy', resp.discharge_macaroon_raw)
418+
419+ def test_fromSuccessResponse_signed_present(self):
420+ params = {
421+ 'openid.mode': 'checkid_setup',
422+ 'openid.trust_root': 'http://localhost/',
423+ 'openid.return_to': 'http://localhost/',
424+ 'openid.identity': IDENTIFIER_SELECT,
425+ 'openid.macaroon.discharge': 'dummy',
426+ }
427+ openid_store = getUtility(IOpenIDConsumerStore)
428+ openid_server = Server(openid_store, get_server_url())
429+ orequest = openid_server.decodeRequest(params)
430+ signed_fields = ['openid.macaroon.discharge']
431+ success_resp = SuccessResponse(
432+ orequest, orequest.message, signed_fields=signed_fields)
433+ resp = MacaroonResponse.fromSuccessResponse(success_resp)
434+ self.assertEqual('dummy', resp.discharge_macaroon_raw)
435+
436+ def test_fromSuccessResponse_no_signed(self):
437+ params = {
438+ 'openid.mode': 'checkid_setup',
439+ 'openid.trust_root': 'http://localhost/',
440+ 'openid.return_to': 'http://localhost/',
441+ 'openid.identity': IDENTIFIER_SELECT,
442+ }
443+ openid_store = getUtility(IOpenIDConsumerStore)
444+ openid_server = Server(openid_store, get_server_url())
445+ orequest = openid_server.decodeRequest(params)
446+ success_resp = SuccessResponse(orequest, orequest.message)
447+ resp = MacaroonResponse.fromSuccessResponse(success_resp)
448+ self.assertIsNone(resp.discharge_macaroon_raw)
449+
450+ def test_fromSuccessResponse_all(self):
451+ params = {
452+ 'openid.mode': 'checkid_setup',
453+ 'openid.trust_root': 'http://localhost/',
454+ 'openid.return_to': 'http://localhost/',
455+ 'openid.identity': IDENTIFIER_SELECT,
456+ 'openid.macaroon.discharge': 'dummy',
457+ }
458+ openid_store = getUtility(IOpenIDConsumerStore)
459+ openid_server = Server(openid_store, get_server_url())
460+ orequest = openid_server.decodeRequest(params)
461+ success_resp = SuccessResponse(orequest, orequest.message)
462+ resp = MacaroonResponse.fromSuccessResponse(success_resp, False)
463+ self.assertEqual('dummy', resp.discharge_macaroon_raw)
464+
465+ def test_getExtensionArgs(self):
466+ expected = {'discharge': 'dummy'}
467+ req = MacaroonResponse(discharge_macaroon_raw='dummy')
468+ self.assertEqual(req.getExtensionArgs(), expected)
469
470=== modified file 'lib/lp/services/webapp/login.py'
471--- lib/lp/services/webapp/login.py 2016-04-20 14:12:07 +0000
472+++ lib/lp/services/webapp/login.py 2016-05-12 15:18:57 +0000
473@@ -9,6 +9,10 @@
474 timedelta,
475 )
476 import urllib
477+from urlparse import (
478+ parse_qsl,
479+ urlunsplit,
480+ )
481
482 from openid.consumer.consumer import (
483 CANCEL,
484@@ -54,10 +58,14 @@
485 from lp.services.config import config
486 from lp.services.database.policy import MasterDatabasePolicy
487 from lp.services.identity.interfaces.account import AccountSuspendedError
488+from lp.services.openid.extensions import macaroon
489 from lp.services.openid.interfaces.openidconsumer import IOpenIDConsumerStore
490 from lp.services.propertycache import cachedproperty
491 from lp.services.timeline.requesttimeline import get_request_timeline
492-from lp.services.webapp import canonical_url
493+from lp.services.webapp import (
494+ canonical_url,
495+ urlsplit,
496+ )
497 from lp.services.webapp.error import SystemErrorView
498 from lp.services.webapp.interfaces import (
499 CookieAuthLoggedInEvent,
500@@ -176,10 +184,12 @@
501 return Consumer(session, openid_store)
502
503 def render(self):
504- # Reauthentication is called for by a parameter, usually passed in
505- # the query string.
506+ # Reauthentication and discharge macaroon issuing are called for by
507+ # parameters, usually passed in the query string.
508 do_reauth = int(self.request.form.get('reauth', '0'))
509- if self.account is not None and not do_reauth:
510+ macaroon_caveat_id = self.request.form.get('macaroon_caveat_id', None)
511+ if (self.account is not None and not do_reauth and
512+ macaroon_caveat_id is None):
513 return AlreadyLoggedInView(self.context, self.request)()
514
515 # Allow unauthenticated users to have sessions for the OpenID
516@@ -198,6 +208,9 @@
517 timeline_action.finish()
518 self.openid_request.addExtension(
519 sreg.SRegRequest(required=['email', 'fullname']))
520+ if macaroon_caveat_id is not None:
521+ self.openid_request.addExtension(
522+ macaroon.MacaroonRequest(macaroon_caveat_id))
523
524 # Force the Open ID handshake to re-authenticate, using
525 # pape extension's max_auth_age, if the URL indicates it.
526@@ -212,8 +225,13 @@
527 # they started the login process (i.e. the current URL without the
528 # '+login' bit). To do that we encode that URL as a query arg in the
529 # return_to URL passed to the OpenID Provider
530- starting_url = urllib.urlencode(
531- [('starting_url', self.starting_url.encode('utf-8'))])
532+ starting_data = [('starting_url', self.starting_url.encode('utf-8'))]
533+ discharge_macaroon_field = self.request.form.get(
534+ 'discharge_macaroon_field', None)
535+ if discharge_macaroon_field is not None:
536+ starting_data.append(
537+ ('discharge_macaroon_field', discharge_macaroon_field))
538+ starting_url = urllib.urlencode(starting_data)
539 trust_root = allvhosts.configs['mainsite'].rooturl
540 return_to = urlappend(trust_root, '+openid-callback')
541 return_to = "%s?%s" % (return_to, starting_url)
542@@ -245,7 +263,8 @@
543 All keys and values are UTF-8-encoded.
544 """
545 for name, value in self.request.form.items():
546- if name in ('loggingout', 'reauth'):
547+ if name in ('loggingout', 'reauth',
548+ 'macaroon_caveat_id', 'discharge_macaroon_field'):
549 continue
550 if name.startswith('openid.'):
551 continue
552@@ -308,6 +327,7 @@
553 self.params, requested_url)
554 finally:
555 timeline_action.finish()
556+ self.discharge_macaroon_raw = None
557
558 def login(self, person, when=None):
559 loginsource = getUtility(IPlacelessLoginSource)
560@@ -321,6 +341,11 @@
561 def sreg_response(self):
562 return sreg.SRegResponse.fromSuccessResponse(self.openid_response)
563
564+ @cachedproperty
565+ def macaroon_response(self):
566+ return macaroon.MacaroonResponse.fromSuccessResponse(
567+ self.openid_response)
568+
569 def _getEmailAddressAndFullName(self):
570 # Here we assume the OP sent us the user's email address and
571 # full name in the response. Note we can only do that because
572@@ -372,6 +397,13 @@
573 except TeamEmailAddressError:
574 return self.team_email_address_template()
575
576+ if self.params.get('discharge_macaroon_field'):
577+ if self.macaroon_response.discharge_macaroon_raw is None:
578+ raise HTTPBadRequest(
579+ "OP didn't include a macaroon extension in the response.")
580+ self.discharge_macaroon_raw = (
581+ self.macaroon_response.discharge_macaroon_raw)
582+
583 with MasterDatabasePolicy():
584 self.login(person)
585
586@@ -416,10 +448,27 @@
587 transaction.commit()
588 return retval
589
590+ @staticmethod
591+ def _appendParam(url, key, value):
592+ """Append a parameter to a URL's query string."""
593+ parts = urlsplit(url)
594+ query = parse_qsl(parts.query)
595+ query.append((key, value))
596+ return urlunsplit(
597+ (parts.scheme, parts.netloc, parts.path, urllib.urlencode(query),
598+ parts.fragment))
599+
600 def _redirect(self):
601 target = self.params.get('starting_url')
602 if target is None:
603 target = self.request.getApplicationURL()
604+ discharge_macaroon_field = self.params.get('discharge_macaroon_field')
605+ if (discharge_macaroon_field is not None and
606+ self.discharge_macaroon_raw is not None):
607+ # XXX cjwatson 2016-04-18: Do we need to POST this instead due
608+ # to size?
609+ target = self._appendParam(
610+ target, discharge_macaroon_field, self.discharge_macaroon_raw)
611 self.request.response.redirect(target, temporary_if_possible=True)
612
613
614
615=== modified file 'lib/lp/services/webapp/tests/test_login.py'
616--- lib/lp/services/webapp/tests/test_login.py 2016-04-20 14:12:07 +0000
617+++ lib/lp/services/webapp/tests/test_login.py 2016-05-12 15:18:57 +0000
618@@ -51,6 +51,10 @@
619 IAccountSet,
620 )
621 from lp.services.identity.interfaces.emailaddress import EmailAddressStatus
622+from lp.services.openid.extensions.macaroon import (
623+ MacaroonRequest,
624+ MacaroonResponse,
625+ )
626 from lp.services.openid.model.openididentifier import OpenIdIdentifier
627 from lp.services.timeline.requesttimeline import get_request_timeline
628 from lp.services.webapp.interfaces import ILaunchpadApplication
629@@ -88,12 +92,13 @@
630 class FakeOpenIDResponse:
631
632 def __init__(self, identity_url, status=SUCCESS, message='', email=None,
633- full_name=None):
634+ full_name=None, discharge_macaroon_raw=None):
635 self.message = message
636 self.status = status
637 self.identity_url = identity_url
638 self.sreg_email = email
639 self.sreg_fullname = full_name
640+ self.discharge_macaroon_raw = discharge_macaroon_raw
641
642
643 class StubbedOpenIDCallbackView(OpenIDCallbackView):
644@@ -136,10 +141,29 @@
645 # FakeOpenIDResponses instead of real ones.
646 sreg.SRegResponse.fromSuccessResponse = classmethod(
647 sregFromFakeSuccessResponse)
648-
649- yield
650-
651- sreg.SRegResponse.fromSuccessResponse = orig_method
652+ try:
653+ yield
654+ finally:
655+ sreg.SRegResponse.fromSuccessResponse = orig_method
656+
657+
658+@contextmanager
659+def MacaroonResponse_fromSuccessResponse_stubbed():
660+
661+ def macaroonFromFakeSuccessResponse(cls, success_response,
662+ signed_only=True):
663+ return MacaroonResponse(
664+ discharge_macaroon_raw=success_response.discharge_macaroon_raw)
665+
666+ orig_method = MacaroonResponse.fromSuccessResponse
667+ # Use a stub MacaroonResponse.fromSuccessResponse that works with
668+ # FakeOpenIDResponses instead of real ones.
669+ MacaroonResponse.fromSuccessResponse = classmethod(
670+ macaroonFromFakeSuccessResponse)
671+ try:
672+ yield
673+ finally:
674+ MacaroonResponse.fromSuccessResponse = orig_method
675
676
677 @contextmanager
678@@ -183,10 +207,10 @@
679 openid_response, view_class=view_class)
680
681 def _createAndRenderView(self, response,
682- view_class=StubbedOpenIDCallbackView):
683- request = LaunchpadTestRequest(
684- form={'starting_url': 'http://launchpad.dev/after-login'},
685- environ={'PATH_INFO': '/'})
686+ view_class=StubbedOpenIDCallbackView, form=None):
687+ if form is None:
688+ form = {'starting_url': 'http://launchpad.dev/after-login'}
689+ request = LaunchpadTestRequest(form=form, environ={'PATH_INFO': '/'})
690 # The layer we use sets up an interaction (by calling login()), but we
691 # want to use our own request in the interaction, so we logout() and
692 # setup a newInteraction() using our request.
693@@ -456,6 +480,57 @@
694 'No email address or full name found in sreg response',
695 main_content)
696
697+ def test_discharge_macaroon(self):
698+ # If a discharge macaroon was requested and received, it is added to
699+ # the starting URL as a query string parameter.
700+ test_email = 'test-example@example.com'
701+ person = self.factory.makePerson(email=test_email)
702+ identifier = ITestOpenIDPersistentIdentity(
703+ person.account).openid_identity_url
704+ openid_response = FakeOpenIDResponse(
705+ identifier, status=SUCCESS, message='', email=test_email,
706+ full_name='Foo User', discharge_macaroon_raw='dummy discharge')
707+ form = {
708+ 'starting_url': 'http://launchpad.dev/after-login',
709+ 'discharge_macaroon_field': 'field.discharge_macaroon',
710+ }
711+ with SRegResponse_fromSuccessResponse_stubbed():
712+ with MacaroonResponse_fromSuccessResponse_stubbed():
713+ view, html = self._createAndRenderView(
714+ openid_response, form=form)
715+ self.assertTrue(view.login_called)
716+ self.assertEqual('dummy discharge', view.discharge_macaroon_raw)
717+ response = view.request.response
718+ self.assertEqual(httplib.TEMPORARY_REDIRECT, response.getStatus())
719+ self.assertEqual(
720+ form['starting_url'] +
721+ '?field.discharge_macaroon=dummy+discharge',
722+ response.getHeader('Location'))
723+
724+ def test_discharge_macaroon_missing(self):
725+ # If a discharge macaroon was requested but not received, the login
726+ # error page is shown.
727+ test_email = 'test-example@example.com'
728+ person = self.factory.makePerson(email=test_email)
729+ identifier = ITestOpenIDPersistentIdentity(
730+ person.account).openid_identity_url
731+ openid_response = FakeOpenIDResponse(
732+ identifier, status=SUCCESS, message='', email=test_email,
733+ full_name='Foo User')
734+ form = {
735+ 'starting_url': 'http://launchpad.dev/after-login',
736+ 'discharge_macaroon_field': 'field.discharge_macaroon',
737+ }
738+ with SRegResponse_fromSuccessResponse_stubbed():
739+ with MacaroonResponse_fromSuccessResponse_stubbed():
740+ view, html = self._createAndRenderView(
741+ openid_response, form=form)
742+ self.assertFalse(view.login_called)
743+ main_content = extract_text(find_main_content(html))
744+ self.assertIn(
745+ "OP didn't include a macaroon extension in the response.",
746+ main_content)
747+
748 def test_negative_openid_assertion(self):
749 # The OpenID provider responded with a negative assertion, so the
750 # login error page is shown.
751@@ -771,6 +846,37 @@
752 self.assertIsInstance(pape_extension, pape.Request)
753 self.assertEqual(0, pape_extension.max_auth_age)
754
755+ def test_macaroon_extension_added_with_macaroon_query(self):
756+ # We can signal that we need to acquire a discharge macaroon via a
757+ # macaroon_caveat_id form parameter, which should add the macaroon
758+ # extension.
759+ caveat_id = 'ask SSO'
760+ form = {
761+ 'field.callback': '1',
762+ 'macaroon_caveat_id': caveat_id,
763+ 'discharge_macaroon_field': 'field.discharge_macaroon',
764+ }
765+ request = LaunchpadTestRequest(form=form, method='POST')
766+ request.processInputs()
767+ # This is a hack to make the request.getURL(1) call issued by the view
768+ # not raise an IndexError.
769+ request._app_names = ['foo']
770+ view = StubbedOpenIDLogin(object(), request)
771+ view()
772+ extensions = view.openid_request.extensions
773+ self.assertIsNot(None, extensions)
774+ macaroon_extension = extensions[1]
775+ self.assertIsInstance(macaroon_extension, MacaroonRequest)
776+ self.assertEqual(caveat_id, macaroon_extension.caveat_id)
777+ return_to_args = dict(urlparse.parse_qsl(
778+ urlparse.urlsplit(view.openid_request.return_to).query))
779+ self.assertEqual(
780+ 'field.discharge_macaroon',
781+ return_to_args['discharge_macaroon_field'])
782+ starting_url_args = dict(urlparse.parse_qsl(
783+ urlparse.urlsplit(return_to_args['starting_url']).query))
784+ self.assertEqual('1', starting_url_args['field.callback'])
785+
786 def test_logs_to_timeline(self):
787 # Beginning an OpenID association makes an HTTP request to the
788 # OP, so it's a potentially long action. It is logged to the