summaryrefslogtreecommitdiff
path: root/macaroonbakery/httpbakery
diff options
context:
space:
mode:
Diffstat (limited to 'macaroonbakery/httpbakery')
-rw-r--r--macaroonbakery/httpbakery/__init__.py48
-rw-r--r--macaroonbakery/httpbakery/agent.py52
-rw-r--r--macaroonbakery/httpbakery/agent/__init__.py17
-rw-r--r--macaroonbakery/httpbakery/agent/agent.py180
-rw-r--r--macaroonbakery/httpbakery/browser.py86
-rw-r--r--macaroonbakery/httpbakery/client.py442
-rw-r--r--macaroonbakery/httpbakery/discharge.py33
-rw-r--r--macaroonbakery/httpbakery/error.py151
-rw-r--r--macaroonbakery/httpbakery/interactor.py73
-rw-r--r--macaroonbakery/httpbakery/keyring.py26
10 files changed, 913 insertions, 195 deletions
diff --git a/macaroonbakery/httpbakery/__init__.py b/macaroonbakery/httpbakery/__init__.py
index 3b40dc2..3f183c5 100644
--- a/macaroonbakery/httpbakery/__init__.py
+++ b/macaroonbakery/httpbakery/__init__.py
@@ -1,17 +1,55 @@
# Copyright 2017 Canonical Ltd.
# Licensed under the LGPLv3, see LICENCE file for details.
-from macaroonbakery.httpbakery.client import BakeryAuth, extract_macaroons
+from macaroonbakery.httpbakery.client import (
+ BakeryException,
+ Client,
+ extract_macaroons,
+)
from macaroonbakery.httpbakery.error import (
- BAKERY_PROTOCOL_HEADER, discharged_required_response, request_version
+ BAKERY_PROTOCOL_HEADER,
+ DischargeError,
+ ERR_DISCHARGE_REQUIRED,
+ ERR_INTERACTION_REQUIRED,
+ Error,
+ ErrorInfo,
+ InteractionError,
+ InteractionMethodNotFound,
+ discharge_required_response,
+ request_version,
)
from macaroonbakery.httpbakery.keyring import ThirdPartyLocator
-
+from macaroonbakery.httpbakery.interactor import (
+ DischargeToken,
+ Interactor,
+ LegacyInteractor,
+ WEB_BROWSER_INTERACTION_KIND,
+)
+from macaroonbakery.httpbakery.browser import (
+ WebBrowserInteractionInfo,
+ WebBrowserInteractor,
+)
+from macaroonbakery.httpbakery.discharge import discharge
__all__ = [
'BAKERY_PROTOCOL_HEADER',
- 'BakeryAuth',
+ 'BakeryException',
+ 'Client',
+ 'DischargeError',
+ 'DischargeToken',
+ 'ERR_DISCHARGE_REQUIRED',
+ 'ERR_INTERACTION_REQUIRED',
+ 'Error',
+ 'ErrorInfo',
+ 'InteractionError',
+ 'InteractionMethodNotFound',
+ 'Interactor',
+ 'LegacyInteractor',
'ThirdPartyLocator',
- 'discharged_required_response',
+ 'WEB_BROWSER_INTERACTION_KIND',
+ 'WebBrowserInteractionInfo',
+ 'WebBrowserInteractor',
+ 'discharge',
+ 'discharge_required_response',
'extract_macaroons',
'request_version',
]
diff --git a/macaroonbakery/httpbakery/agent.py b/macaroonbakery/httpbakery/agent.py
deleted file mode 100644
index e5a09e4..0000000
--- a/macaroonbakery/httpbakery/agent.py
+++ /dev/null
@@ -1,52 +0,0 @@
-# Copyright 2017 Canonical Ltd.
-# Licensed under the LGPLv3, see LICENCE file for details.
-import base64
-import json
-
-import nacl.public
-import nacl.encoding
-import requests.cookies
-import six
-from six.moves.urllib.parse import urlparse
-
-
-class AgentFileFormatError(Exception):
- ''' AgentFileFormatError is the exception raised when an agent file has a bad
- structure.
- '''
- pass
-
-
-def load_agent_file(filename, cookies=None):
- ''' Loads agent information from the specified file.
-
- The agent cookies are added to cookies, or a newly created cookie jar
- if cookies is not specified. The updated cookies is returned along
- with the private key associated with the agent. These can be passed
- directly as the cookies and key parameter to BakeryAuth.
- '''
-
- with open(filename) as f:
- data = json.load(f)
- try:
- key = nacl.public.PrivateKey(data['key']['private'],
- nacl.encoding.Base64Encoder)
- if cookies is None:
- cookies = requests.cookies.RequestsCookieJar()
- for agent in data['agents']:
- u = urlparse(agent['url'])
- value = {'username': agent['username'],
- 'public_key': data['key']['public']}
- jv = json.dumps(value)
- if six.PY3:
- jv = jv.encode('utf-8')
- v = base64.b64encode(jv)
- if six.PY3:
- v = v.decode('utf-8')
- cookie = requests.cookies.create_cookie('agent-login', v,
- domain=u.netloc,
- path=u.path)
- cookies.set_cookie(cookie)
- return cookies, key
- except (KeyError, ValueError) as e:
- raise AgentFileFormatError('invalid agent file', e)
diff --git a/macaroonbakery/httpbakery/agent/__init__.py b/macaroonbakery/httpbakery/agent/__init__.py
new file mode 100644
index 0000000..db252de
--- /dev/null
+++ b/macaroonbakery/httpbakery/agent/__init__.py
@@ -0,0 +1,17 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+from macaroonbakery.httpbakery.agent.agent import (
+ load_agent_file,
+ Agent,
+ AgentInteractor,
+ AgentFileFormatError,
+ AuthInfo,
+)
+__all__ = [
+ 'Agent',
+ 'AgentFileFormatError',
+ 'AgentInteractor',
+ 'AuthInfo',
+ 'load_agent_file',
+]
diff --git a/macaroonbakery/httpbakery/agent/agent.py b/macaroonbakery/httpbakery/agent/agent.py
new file mode 100644
index 0000000..862f00e
--- /dev/null
+++ b/macaroonbakery/httpbakery/agent/agent.py
@@ -0,0 +1,180 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+import base64
+from collections import namedtuple
+import json
+
+import nacl.public
+import nacl.encoding
+import requests.cookies
+import six
+from six.moves.urllib.parse import urlparse
+from six.moves.urllib.parse import urljoin
+
+import macaroonbakery as bakery
+import macaroonbakery.utils as utils
+import macaroonbakery.httpbakery as httpbakery
+
+
+class AgentFileFormatError(Exception):
+ ''' AgentFileFormatError is the exception raised when an agent file has a
+ bad structure.
+ '''
+ pass
+
+
+def load_agent_file(filename, cookies=None):
+ ''' Loads agent information from the specified file.
+
+ The agent cookies are added to cookies, or a newly created cookie jar
+ if cookies is not specified. The updated cookies is returned along
+ with the private key associated with the agent. These can be passed
+ directly as the cookies and key parameter to BakeryAuth.
+ '''
+
+ with open(filename) as f:
+ data = json.load(f)
+ try:
+ key = nacl.public.PrivateKey(data['key']['private'],
+ nacl.encoding.Base64Encoder)
+ if cookies is None:
+ cookies = requests.cookies.RequestsCookieJar()
+ for agent in data['agents']:
+ u = urlparse(agent['url'])
+ value = {'username': agent['username'],
+ 'public_key': data['key']['public']}
+ jv = json.dumps(value)
+ if six.PY3:
+ jv = jv.encode('utf-8')
+ v = base64.b64encode(jv)
+ if six.PY3:
+ v = v.decode('utf-8')
+ cookie = requests.cookies.create_cookie('agent-login', v,
+ domain=u.netloc,
+ path=u.path)
+ cookies.set_cookie(cookie)
+ return cookies, key
+ except (KeyError, ValueError) as e:
+ raise AgentFileFormatError('invalid agent file', e)
+
+
+class InteractionInfo(object):
+ '''Holds the information expected in the agent interaction entry in an
+ interaction-required error.
+ '''
+ def __init__(self, login_url):
+ self._login_url = login_url
+
+ @property
+ def login_url(self):
+ ''' Return the URL from which to acquire a macaroon that can be used
+ to complete the agent login. To acquire the macaroon, make a POST
+ request to the URL with user and public-key parameters.
+ :return string
+ '''
+ return self._login_url
+
+ @classmethod
+ def from_dict(cls, json_dict):
+ '''Return an InteractionInfo obtained from the given dictionary as
+ deserialized from JSON.
+ @param json_dict The deserialized JSON object.
+ '''
+ return InteractionInfo(json_dict.get('login-url'))
+
+
+class AgentInteractor(httpbakery.Interactor, httpbakery.LegacyInteractor):
+ ''' Interactor that performs interaction using the agent login protocol.
+ '''
+ def __init__(self, auth_info):
+ self._auth_info = auth_info
+
+ def kind(self):
+ '''Implement Interactor.kind by returning the agent kind'''
+ return 'agent'
+
+ def interact(self, client, location, interaction_required_err):
+ '''Implement Interactor.interact by obtaining obtaining
+ a macaroon from the discharger, discharging it with the
+ local private key using the discharged macaroon as
+ a discharge token'''
+ p = interaction_required_err.interaction_method('agent',
+ InteractionInfo)
+ if p.login_url is None or p.login_url == '':
+ raise httpbakery.InteractionError(
+ 'no login-url field found in agent interaction method')
+ agent = self._find_agent(location)
+ if not location.endswith('/'):
+ location += '/'
+ login_url = urljoin(location, p.login_url)
+ resp = requests.get(login_url, json={
+ 'Username': agent.username,
+ 'PublicKey': self._auth_info.key.encode().decode('utf-8'),
+ })
+ if resp.status_code != 200:
+ raise httpbakery.InteractionError(
+ 'cannot acquire agent macaroon: {}'.format(resp.status_code)
+ )
+ m = resp.json().get('macaroon')
+ if m is None:
+ raise httpbakery.InteractionError('no macaroon in response')
+ m = bakery.Macaroon.from_dict(m)
+ ms = bakery.discharge_all(m, None, self._auth_info.key)
+ b = bytearray()
+ for m in ms:
+ b.extend(utils.b64decode(m.serialize()))
+ return httpbakery.DischargeToken(kind='agent', value=bytes(b))
+
+ def _find_agent(self, location):
+ ''' Finds an appropriate agent entry for the given location.
+ :return Agent
+ '''
+ for a in self._auth_info.agents:
+ # Don't worry about trailing slashes
+ if a.url.rstrip('/') == location.rstrip('/'):
+ return a
+ raise httpbakery.InteractionMethodNotFound(
+ 'cannot find username for discharge location {}'.format(location))
+
+ def legacy_interact(self, client, location, visit_url):
+ '''Implement LegacyInteractor.legacy_interact by obtaining
+ the discharge macaroon using the client's private key
+ '''
+ agent = self._find_agent(location)
+ pk_encoded = self._auth_info.key.public_key.encode().decode('utf-8')
+ value = {
+ 'username': agent.username,
+ 'public_key': pk_encoded,
+ }
+ # TODO(rogpeppe) use client passed into interact method.
+ client = httpbakery.Client(key=self._auth_info.key)
+ client.cookies.set_cookie(utils.cookie(
+ url=visit_url,
+ name='agent-login',
+ value=base64.urlsafe_b64encode(
+ json.dumps(value).encode('utf-8')).decode('utf-8'),
+ ))
+ resp = requests.get(url=visit_url, cookies=client.cookies, auth=client.auth())
+ if resp.status_code != 200:
+ raise httpbakery.InteractionError(
+ 'cannot acquire agent macaroon: {}'.format(resp.status_code))
+ if not resp.json().get('agent-login', False):
+ raise httpbakery.InteractionError('agent login failed')
+
+
+class Agent(namedtuple('Agent', 'url, username')):
+ ''' Represents an agent that can be used for agent authentication.
+ @param url holds the URL of the discharger that knows about the agent (string).
+ @param username holds the username agent (string).
+ '''
+
+
+class AuthInfo(namedtuple('AuthInfo', 'key, agents')):
+ ''' Holds the agent information required to set up agent authentication
+ information.
+
+ It holds the agent's private key and information about the username
+ associated with each known agent-authentication server.
+ @param key the agent's private key (bakery.PrivateKey).
+ @param agents information about the known agents (list of Agent).
+ '''
diff --git a/macaroonbakery/httpbakery/browser.py b/macaroonbakery/httpbakery/browser.py
new file mode 100644
index 0000000..e3ce538
--- /dev/null
+++ b/macaroonbakery/httpbakery/browser.py
@@ -0,0 +1,86 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+import base64
+from collections import namedtuple
+import requests
+from six.moves.urllib.parse import urljoin
+
+from macaroonbakery.utils import visit_page_with_browser
+from macaroonbakery.httpbakery.interactor import (
+ Interactor, LegacyInteractor, WEB_BROWSER_INTERACTION_KIND,
+ DischargeToken
+)
+from macaroonbakery.httpbakery.error import InteractionError
+
+
+class WebBrowserInteractor(Interactor, LegacyInteractor):
+ ''' Handles web-browser-based interaction-required errors by opening a
+ web browser to allow the user to prove their credentials interactively.
+ '''
+ def __init__(self, open=visit_page_with_browser):
+ '''Create a WebBrowserInteractor that uses the given function
+ to open a browser window. The open function is expected to take
+ a single argument of string type, the URL to open.
+ '''
+ self._open_web_browser = open
+
+ def kind(self):
+ return WEB_BROWSER_INTERACTION_KIND
+
+ def legacy_interact(self, ctx, location, visit_url):
+ '''Implement LegacyInteractor.legacy_interact by opening the
+ web browser window'''
+ self._open_web_browser(visit_url)
+
+ def interact(self, ctx, location, ir_err):
+ '''Implement Interactor.interact by opening the browser window
+ and waiting for the discharge token'''
+ p = ir_err.interaction_method(self.kind(), WebBrowserInteractionInfo)
+ if not location.endswith('/'):
+ location += '/'
+ visit_url = urljoin(location, p.visit_url)
+ wait_token_url = urljoin(location, p.wait_token_url)
+ self._open_web_browser(visit_url)
+ return self._wait_for_token(ctx, wait_token_url)
+
+ def _wait_for_token(self, ctx, wait_token_url):
+ ''' Returns a token from a the wait token URL
+ @param wait_token_url URL to wait for (string)
+ :return DischargeToken
+ '''
+ resp = requests.get(wait_token_url)
+ if resp.status_code != 200:
+ raise InteractionError('cannot get {}'.format(wait_token_url))
+ json_resp = resp.json()
+ kind = json_resp.get('kind')
+ if kind is None:
+ raise InteractionError(
+ 'cannot get kind token from {}'.format(wait_token_url))
+ token_val = json_resp.get('token')
+ if token_val is None:
+ token_val = json_resp.get('token64')
+ if token_val is None:
+ raise InteractionError(
+ 'cannot get token from {}'.format(wait_token_url))
+ token_val = base64.b64decode(token_val)
+ return DischargeToken(kind=kind, value=token_val)
+
+
+class WebBrowserInteractionInfo(namedtuple('WebBrowserInteractionInfo',
+ 'visit_url, wait_token_url')):
+ ''' holds the information expected in the browser-window interaction
+ entry in an interaction-required error.
+
+ :param visit_url holds the URL to be visited in a web browser.
+ :param wait_token_url holds a URL that will block on GET until the browser
+ interaction has completed.
+ '''
+ @classmethod
+ def from_dict(cls, info_dict):
+ '''Create a new instance of WebBrowserInteractionInfo, as expected
+ by the Error.interaction_method method.
+ @param info_dict The deserialized JSON object
+ @return a new WebBrowserInteractionInfo object.
+ '''
+ return WebBrowserInteractionInfo(visit_url=info_dict.get('VisitURL'),
+ wait_token_url=info_dict('WaitURL'))
diff --git a/macaroonbakery/httpbakery/client.py b/macaroonbakery/httpbakery/client.py
index b62c61d..b3036a1 100644
--- a/macaroonbakery/httpbakery/client.py
+++ b/macaroonbakery/httpbakery/client.py
@@ -4,65 +4,246 @@ import base64
import json
import requests
from six.moves.http_cookies import SimpleCookie
-from six.moves.http_cookiejar import Cookie
from six.moves.urllib.parse import urljoin
-from six.moves.urllib.parse import urlparse
-from pymacaroons import Macaroon
-from pymacaroons.serializers.json_serializer import JsonSerializer
-
-from macaroonbakery.bakery import discharge_all
+import macaroonbakery as bakery
+import macaroonbakery.checkers as checkers
from macaroonbakery import utils
+from macaroonbakery.httpbakery.interactor import (
+ LegacyInteractor,
+ WEB_BROWSER_INTERACTION_KIND,
+)
+from macaroonbakery.httpbakery.error import (
+ DischargeError,
+ ERR_DISCHARGE_REQUIRED,
+ ERR_INTERACTION_REQUIRED,
+ Error,
+ InteractionError,
+ InteractionMethodNotFound,
+)
+from macaroonbakery.httpbakery.error import BAKERY_PROTOCOL_HEADER
+from macaroonbakery.httpbakery.browser import WebBrowserInteractor
-ERR_INTERACTION_REQUIRED = 'interaction required'
-ERR_DISCHARGE_REQUIRED = 'macaroon discharge required'
TIME_OUT = 30
MAX_DISCHARGE_RETRIES = 3
-class BakeryAuth:
- ''' BakeryAuth holds the context for making HTTP requests with macaroons.
+class BakeryException(requests.RequestException):
+ '''Raised when some errors happen using the httpbakery
+ authorizer'''
+
- This will automatically acquire and discharge macaroons around the
- requests framework.
- Usage:
- from macaroonbakery import httpbakery
- jar = requests.cookies.RequestsCookieJar()
- resp = requests.get('some protected url',
- cookies=jar,
- auth=httpbakery.BakeryAuth(cookies=jar))
- resp.raise_for_status()
+class Client:
+ '''Client holds the context for making HTTP requests with macaroons.
+ To make a request, use the auth method to obtain
+ an HTTP authorizer suitable for passing as the auth parameter
+ to a requests method. Note that the same cookie jar
+ should be passed to requests as is used to initialize
+ the client.
+ For example:
+ import macaroonbakery.httpbakery
+ client = httpbakery.Client()
+ resp = requests.get('some protected url',
+ cookies=client.cookies,
+ auth=client.auth())
+ @param interaction_methods A list of Interactor implementations.
+ @param key The private key of the client {bakery.PrivateKey}
+ @param cookies storage for the cookies {CookieJar}. It should be the
+ same as in the requests cookies. If not provided, one
+ will be created.
'''
- def __init__(self, visit_page=None, key=None,
- cookies=requests.cookies.RequestsCookieJar()):
+ def __init__(self, interaction_methods=None, key=None, cookies=None):
+ if interaction_methods is None:
+ interaction_methods = [WebBrowserInteractor()]
+ if cookies is None:
+ cookies = requests.cookies.RequestsCookieJar()
+ self._interaction_methods = interaction_methods
+ self._key = key
+ self.cookies = cookies
+
+ def auth(self):
+ '''Return an authorizer object suitable for passing
+ to requests methods that accept one.
+ If a request returns a discharge-required error,
+ the authorizer will acquire discharge macaroons
+ and retry the request.
'''
+ return _BakeryAuth(self)
- @param visit_page function called when the discharge process requires
- further interaction taking a visit_url string as parameter.
- @param key holds the client's private nacl key. If set, the client
- will try to discharge third party caveats with the special location
- "local" by using this key.
- @param cookies storage for the cookies {CookieJar}. It should be the
- same than in the requests cookies
+ def request(self, method, url, **kwargs):
+ '''Use the requests library to make a request.
+ Using this method is like doing:
+
+ requests.request(method, url, auth=client.auth())
'''
- if visit_page is None:
- visit_page = utils.visit_page_with_browser
- if 'agent-login' in cookies.keys():
- self._visit_page = _visit_page_for_agent(cookies, key)
+ kwargs.setdefault('auth', self.auth())
+ return requests.request(method=method, url=url, **kwargs)
+
+ def handle_error(self, error, url):
+ '''Try to resolve the given error, which should be a response
+ to the given URL, by discharging any macaroon contained in
+ it. That is, if error.code is ERR_DISCHARGE_REQUIRED
+ then it will try to discharge err.info.macaroon. If the discharge
+ succeeds, the discharged macaroon will be saved to the client's cookie jar,
+ otherwise an exception will be raised.
+ '''
+ if error.info is None or error.info.macaroon is None:
+ raise BakeryException('unable to read info in discharge error response')
+
+ discharges = bakery.discharge_all(
+ error.info.macaroon,
+ self.acquire_discharge,
+ self._key,
+ )
+ macaroons = '[' + ','.join(map(utils.macaroon_to_json_string, discharges)) + ']'
+ all_macaroons = base64.urlsafe_b64encode(utils.to_bytes(macaroons))
+
+ full_path = relative_url(url, error.info.macaroon_path)
+ if error.info.cookie_name_suffix is not None:
+ name = 'macaroon-' + error.info.cookie_name_suffix
else:
- self._visit_page = visit_page
- self._jar = cookies
- self._key = key
+ name = 'macaroon-auth'
+ expires = checkers.macaroons_expiry_time(checkers.Namespace(), discharges)
+ expires = None # TODO(rogpeppe) remove this line after fixing the tests.
+ self.cookies.set_cookie(utils.cookie(
+ name=name,
+ value=all_macaroons.decode('ascii'),
+ url=full_path,
+ expires=expires,
+ ))
+
+ def acquire_discharge(self, cav, payload):
+ ''' Request a discharge macaroon from the caveat location
+ as an HTTP URL.
+ @param cav Third party {pymacaroons.Caveat} to be discharged.
+ @param payload External caveat data {bytes}.
+ @return The acquired macaroon {macaroonbakery.Macaroon}
+ '''
+ resp = self._acquire_discharge_with_token(cav, payload, None)
+ # TODO Fabrice what is the other http response possible ??
+ if resp.status_code == 200:
+ return bakery.Macaroon.from_dict(resp.json().get('Macaroon'))
+ cause = Error.from_dict(resp.json())
+ if cause.code != ERR_INTERACTION_REQUIRED:
+ raise DischargeError(cause.message)
+ if cause.info is None:
+ raise DischargeError(
+ 'interaction-required response with no info: {}'.format(resp.json())
+ )
+ loc = cav.location
+ if not loc.endswith('/'):
+ loc = loc + '/'
+ token, m = self._interact(loc, cause, payload)
+ if m is not None:
+ # We've acquired the macaroon directly via legacy interaction.
+ return m
+ # Try to acquire the discharge again, but this time with
+ # the token acquired by the interaction method.
+ resp = self._acquire_discharge_with_token(cav, payload, token)
+ if resp.status_code == 200:
+ return bakery.Macaroon.deserialize_json(
+ resp.json().get('Macaroon'))
+ else:
+ raise DischargeError()
+
+ def _acquire_discharge_with_token(self, cav, payload, token):
+ req = {}
+ _add_json_binary_field(cav.caveat_id_bytes, req, 'id')
+ if token is not None:
+ _add_json_binary_field(token.value, req, 'token')
+ req['token-kind'] = token.kind
+ if payload is not None:
+ req['caveat64'] = base64.urlsafe_b64encode(payload).rstrip(
+ b'=').decode('utf-8')
+ target = relative_url(cav.location, 'discharge')
+ headers = {
+ BAKERY_PROTOCOL_HEADER: str(bakery.LATEST_VERSION)
+ }
+ return self.request('POST', target, data=req, headers=headers)
+
+ def _interact(self, location, error_info, payload):
+ '''Gathers a macaroon by directing the user to interact with a
+ web page. The error_info argument holds the interaction-required
+ error response.
+ @return DischargeToken, bakery.Macaroon
+ '''
+ if self._interaction_methods is None or len(self._interaction_methods) == 0:
+ raise InteractionError('interaction required but not possible')
+ # TODO(rogpeppe) make the robust against a wider range of error info.
+ if error_info.info.interaction_methods is None and \
+ error_info.info.visit_url is not None:
+ # It's an old-style error; deal with it differently.
+ return None, self._legacy_interact(location, error_info)
+
+ for interactor in self._interaction_methods:
+ found = error_info.info.interaction_methods.get(interactor.kind())
+ if found is None:
+ continue
+ try:
+ token = interactor.interact(self, location, error_info)
+ except InteractionMethodNotFound:
+ continue
+ if token is None:
+ raise InteractionError('interaction method returned an empty token')
+ return token, None
+
+ raise InteractionError('no supported interaction method')
+
+ def _legacy_interact(self, location, error_info):
+ visit_url = relative_url(location, error_info.info.visit_url)
+ wait_url = relative_url(location, error_info.info.wait_url)
+ method_urls = {
+ "interactive": visit_url
+ }
+ if len(self._interaction_methods) > 1 or \
+ self._interaction_methods[0].kind() != WEB_BROWSER_INTERACTION_KIND:
+ # We have several possible methods or we only support a non-window
+ # method, so we need to fetch the possible methods supported by
+ # the discharger.
+ method_urls = _legacy_get_interaction_methods(visit_url)
+
+ for interactor in self._interaction_methods:
+ kind = interactor.kind()
+ if kind == WEB_BROWSER_INTERACTION_KIND:
+ # This is the old name for browser-window interaction.
+ kind = "interactive"
+
+ if not isinstance(interactor, LegacyInteractor):
+ # Legacy interaction mode isn't supported.
+ continue
+
+ visit_url = method_urls.get(kind)
+ if visit_url is None:
+ continue
+
+ visit_url = relative_url(location, visit_url)
+ interactor.legacy_interact(self, location, visit_url)
+ return _wait_for_macaroon(wait_url)
+
+ raise InteractionError('no methods supported')
+
+
+class _BakeryAuth:
+ '''_BakeryAuth implements an authorizer as required
+ by the requests HTTP client.
+ '''
+ def __init__(self, client):
+ '''
+ @param interaction_methods A list of Interactor implementations.
+ @param key The private key of the client (macaroonbakery.PrivateKey)
+ @param cookies storage for the cookies {CookieJar}. It should be the
+ same as in the requests cookies.
+ '''
+ self._client = client
def __call__(self, req):
- req.headers['Bakery-Protocol-Version'] = '1'
- hook = _prepare_discharge_hook(req.copy(), self._key, self._jar,
- self._visit_page)
+ req.headers[BAKERY_PROTOCOL_HEADER] = str(bakery.LATEST_VERSION)
+ hook = _prepare_discharge_hook(req.copy(), self._client)
req.register_hook(event='response', hook=hook)
return req
-def _prepare_discharge_hook(req, key, jar, visit_page):
+def _prepare_discharge_hook(req, client):
''' Return the hook function (called when the response is received.)
This allows us to intercept the response and do any necessary
@@ -76,106 +257,131 @@ def _prepare_discharge_hook(req, key, jar, visit_page):
def hook(response, *args, **kwargs):
''' Requests hooks system, this is the hook for the response.
'''
- status_401 = (response.status_code == 401
- and response.headers.get('WWW-Authenticate') ==
- 'Macaroon')
- if not status_401 and response.status_code != 407:
+ status_code = response.status_code
+
+ if status_code != 407 and status_code != 401:
return response
- if response.headers.get('Content-Type') != 'application/json':
+ if (status_code == 401 and response.headers.get('WWW-Authenticate') !=
+ 'Macaroon'):
return response
- try:
- error = response.json()
- except:
- raise BakeryException(
- 'unable to read discharge error response')
- if error.get('Code') != ERR_DISCHARGE_REQUIRED:
+ if response.headers.get('Content-Type') != 'application/json':
return response
+ errorJSON = response.json()
+ if errorJSON.get('Code') != ERR_DISCHARGE_REQUIRED:
+ return response
+ error = Error.from_dict(errorJSON)
Retry.count += 1
- if Retry.count > MAX_DISCHARGE_RETRIES:
- raise BakeryException('too many discharges')
- info = error.get('Info')
- if not isinstance(info, dict):
- raise BakeryException(
- 'unable to read info in discharge error response')
- serialized_macaroon = info.get('Macaroon')
- if not isinstance(serialized_macaroon, dict):
- raise BakeryException(
- 'unable to read macaroon in discharge error response')
-
- macaroon = utils.deserialize(serialized_macaroon)
- discharges = discharge_all(macaroon, visit_page, jar, key)
- encoded_discharges = map(utils.serialize_macaroon_string, discharges)
-
- macaroons = '[' + ','.join(encoded_discharges) + ']'
- all_macaroons = base64.urlsafe_b64encode(
- macaroons.encode('utf-8')).decode('ascii')
-
- full_path = urljoin(response.url,
- info['MacaroonPath'])
- parsed_url = urlparse(full_path)
- if info and info.get('CookieNameSuffix'):
- name = 'macaroon-' + info['CookieNameSuffix']
- else:
- name = 'macaroon-' + discharges[0].signature
- cookie = Cookie(
- version=0,
- name=name,
- value=all_macaroons,
- port=None,
- port_specified=False,
- domain=parsed_url[1],
- domain_specified=True,
- domain_initial_dot=False,
- path=parsed_url[2],
- path_specified=True,
- secure=False,
- expires=None,
- discard=False,
- comment=None,
- comment_url=None,
- rest=None,
- rfc2109=False)
- jar.set_cookie(cookie)
+ if Retry.count >= MAX_DISCHARGE_RETRIES:
+ raise BakeryException('too many ({}) discharge requests'.format(
+ Retry.count)
+ )
+ client.handle_error(error, req.url)
# Replace the private _cookies from req as it is a copy of
# the original cookie jar passed into the requests method and we need
# to set the cookie for this request.
- req._cookies = jar
+ req._cookies = client.cookies
req.headers.pop('Cookie', None)
req.prepare_cookies(req._cookies)
- req.headers['Bakery-Protocol-Version'] = '1'
+ req.headers[BAKERY_PROTOCOL_HEADER] = \
+ str(bakery.LATEST_VERSION)
with requests.Session() as s:
return s.send(req)
return hook
-class BakeryException(requests.RequestException):
- ''' Bakery exception '''
-
-
-def _visit_page_for_agent(cookies, key):
- def visit_page_for_agent(visit_url):
- resp = requests.get(visit_url, cookies=cookies,
- auth=BakeryAuth(cookies=cookies, key=key))
- resp.raise_for_status()
- return visit_page_for_agent
-
-
def extract_macaroons(headers):
''' Returns an array of any macaroons found in the given slice of cookies.
@param headers: dict of headers
@return: An array of array of mpy macaroons
'''
- cookie_string = "\n".join(headers.get_all('Cookie', failobj=[]))
- cs = SimpleCookie()
- cs.load(cookie_string)
mss = []
- for c in cs:
- if not c.startswith('macaroon-'):
- continue
- data = base64.b64decode(cs[c].value)
+
+ def add_macaroon(data):
+ data = utils.b64decode(data)
data_as_objs = json.loads(data.decode('utf-8'))
- ms = [Macaroon.deserialize(json.dumps(x), serializer=JsonSerializer())
- for x in data_as_objs]
+ ms = [utils.macaroon_from_dict(x) for x in data_as_objs]
mss.append(ms)
+
+ cookieHeader = headers.get('Cookie')
+ if cookieHeader is not None:
+ cs = SimpleCookie()
+ # The cookie might be a unicode object, so convert it
+ # to ASCII. This may cause an exception under Python 2.
+ # TODO is that a problem?
+ cs.load(str(cookieHeader))
+ for c in cs:
+ if c.startswith('macaroon-'):
+ add_macaroon(cs[c].value)
+ # Python doesn't make it easy to have multiple values for a
+ # key, so split the header instead, which is necessary
+ # for HTTP1.1 compatibility anyway.
+ macaroonHeader = headers.get('Macaroons')
+ if macaroonHeader is not None:
+ for h in macaroonHeader.split(','):
+ add_macaroon(h)
return mss
+
+
+def _add_json_binary_field(b, serialized, field):
+ '''' Set the given field to the given val (bytes) in the serialized
+ dictionary.
+ If the value isn't valid utf-8, we base64 encode it and use field+"64"
+ as the field name.
+ '''
+ try:
+ val = b.decode('utf-8')
+ serialized[field] = val
+ except UnicodeDecodeError:
+ val = base64.b64encode(b).decode('utf-8')
+ serialized[field + '64'] = val
+
+
+def _wait_for_macaroon(wait_url):
+ ''' Returns a macaroon from a legacy wait endpoint.
+ '''
+ headers = {
+ BAKERY_PROTOCOL_HEADER: str(bakery.LATEST_VERSION)
+ }
+ resp = requests.get(url=wait_url, headers=headers)
+ if resp.status_code != 200:
+ return InteractionError('cannot get {}'.format(wait_url))
+
+ return bakery.Macaroon.from_dict(resp.json().get('Macaroon'))
+
+
+def relative_url(base, new):
+ ''' Returns new path relative to an original URL.
+ '''
+ if new == '':
+ return base
+ if not base.endswith('/'):
+ base += '/'
+ return urljoin(base, new)
+
+
+def _legacy_get_interaction_methods(u):
+ ''' Queries a URL as found in an ErrInteractionRequired VisitURL field to
+ find available interaction methods.
+ It does this by sending a GET request to the URL with the Accept
+ header set to "application/json" and parsing the resulting
+ response as a dict.
+ '''
+ headers = {
+ BAKERY_PROTOCOL_HEADER: str(bakery.LATEST_VERSION),
+ 'Accept': 'application/json'
+ }
+ resp = requests.get(url=u, headers=headers)
+ method_urls = {}
+ if resp.status_code == 200:
+ json_resp = resp.json()
+ for m in json_resp:
+ relative_url(u, json_resp[m])
+ method_urls[m] = relative_url(u, json_resp[m])
+
+ if method_urls.get('interactive') is None:
+ # There's no "interactive" method returned, but we know
+ # the server does actually support it, because all dischargers
+ # are required to, so fill it in with the original URL.
+ method_urls['interactive'] = u
+ return method_urls
diff --git a/macaroonbakery/httpbakery/discharge.py b/macaroonbakery/httpbakery/discharge.py
new file mode 100644
index 0000000..ef3481a
--- /dev/null
+++ b/macaroonbakery/httpbakery/discharge.py
@@ -0,0 +1,33 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+import macaroonbakery.utils as utils
+import macaroonbakery as bakery
+
+
+def discharge(ctx, content, key, locator, checker):
+ '''Handles a discharge request as received by the /discharge
+ endpoint.
+ @param ctx The context passed to the checker {checkers.AuthContext}
+ @param content URL and form parameters {dict}
+ @param locator Locator used to add third party caveats returned by
+ the checker {macaroonbakery.ThirdPartyLocator}
+ @param checker Used to check third party caveats {macaroonbakery.ThirdPartyCaveatChecker}
+ @return The discharge macaroon {macaroonbakery.Macaroon}
+ '''
+ id = content.get('id')
+ if id is None:
+ id = content.get('id64')
+ if id is not None:
+ id = utils.b64decode(id)
+ caveat = content.get('caveat64')
+ if caveat is not None:
+ caveat = utils.b64decode(caveat)
+
+ return bakery.discharge(
+ ctx,
+ id=id,
+ caveat=caveat,
+ key=key,
+ checker=checker,
+ locator=locator,
+ )
diff --git a/macaroonbakery/httpbakery/error.py b/macaroonbakery/httpbakery/error.py
index e138c66..422b346 100644
--- a/macaroonbakery/httpbakery/error.py
+++ b/macaroonbakery/httpbakery/error.py
@@ -1,11 +1,37 @@
# Copyright 2017 Canonical Ltd.
# Licensed under the LGPLv3, see LICENCE file for details.
+from collections import namedtuple
import json
-import macaroonbakery
+import macaroonbakery as bakery
+ERR_INTERACTION_REQUIRED = 'interaction required'
+ERR_DISCHARGE_REQUIRED = 'macaroon discharge required'
-def discharged_required_response(macaroon, path, cookie_suffix_name):
+
+class InteractionMethodNotFound(Exception):
+ '''This is thrown by client-side interaction methods when
+ they find that a given interaction isn't supported by the
+ client for a location'''
+ pass
+
+
+class DischargeError(Exception):
+ '''This is thrown by Client when a third party has refused a discharge'''
+ def __init__(self, msg):
+ super(DischargeError, self).__init__('third party refused discharge: {}'.format(msg))
+
+
+class InteractionError(Exception):
+ '''This is thrown by Client when it fails to deal with an
+ interaction-required error
+ '''
+ def __init__(self, msg):
+ super(InteractionError, self).__init__('cannot start interactive session: {}'.format(msg))
+
+
+def discharge_required_response(macaroon, path, cookie_suffix_name,
+ message=None):
''' Get response content and headers from a discharge macaroons error.
@param macaroon may hold a macaroon that, when discharged, may
@@ -18,17 +44,19 @@ def discharged_required_response(macaroon, path, cookie_suffix_name):
older clients will always use ("macaroon-" + macaroon.signature() in hex)
@return content(bytes) and the headers to set on the response(dict).
'''
+ if message is None:
+ message = 'discharge required'
content = json.dumps(
{
'Code': 'macaroon discharge required',
- 'Message': 'discharge required',
+ 'Message': message,
'Info': {
'Macaroon': macaroon.to_dict(),
'MacaroonPath': path,
'CookieNameSuffix': cookie_suffix_name
},
}
- )
+ ).encode('utf-8')
return content, {
'WWW-Authenticate': 'Macaroon',
'Content-Type': 'application/json'
@@ -49,19 +77,124 @@ def request_version(req_headers):
version is used, which is OK because versions are backwardly compatible.
@param req_headers: the request headers as a dict.
- @return: bakery protocol version (for example macaroonbakery.BAKERY_V1)
+ @return: bakery protocol version (for example macaroonbakery.VERSION_1)
'''
vs = req_headers.get(BAKERY_PROTOCOL_HEADER)
if vs is None:
# No header - use backward compatibility mode.
- return macaroonbakery.BAKERY_V1
+ return bakery.VERSION_1
try:
x = int(vs)
except ValueError:
# Badly formed header - use backward compatibility mode.
- return macaroonbakery.BAKERY_V1
- if x > macaroonbakery.LATEST_BAKERY_VERSION:
+ return bakery.VERSION_1
+ if x > bakery.LATEST_VERSION:
# Later version than we know about - use the
# latest version that we can.
- return macaroonbakery.LATEST_BAKERY_VERSION
+ return bakery.LATEST_VERSION
return x
+
+
+class Error(namedtuple('Error', 'code, message, version, info')):
+ '''This class defines an error value as returned from
+ an httpbakery API.
+ '''
+ @classmethod
+ def from_dict(cls, serialized):
+ '''Create an error from a JSON-deserialized object
+ @param serialized the object holding the serialized error {dict}
+ '''
+ code = serialized.get('Code')
+ message = serialized.get('Message')
+ info = ErrorInfo.from_dict(serialized.get('Info'))
+ return Error(code=code, message=message, info=info,
+ version=bakery.LATEST_VERSION)
+
+ def interaction_method(self, kind, x):
+ ''' Checks whether the error is an InteractionRequired error
+ that implements the method with the given name, and JSON-unmarshals the
+ method-specific data into x by calling its from_dict method
+ with the deserialized JSON object.
+ @param kind The interaction method kind (string).
+ @param x A class with a class method from_dict that returns a new
+ instance of the interaction info for the given kind.
+ @return The result of x.from_dict.
+ '''
+ if self.info is None or self.code != ERR_INTERACTION_REQUIRED:
+ raise InteractionError(
+ 'not an interaction-required error (code {})'.format(
+ self.code)
+ )
+ entry = self.info.interaction_methods.get(kind)
+ if entry is None:
+ raise InteractionMethodNotFound(
+ 'interaction method {} not found'.format(kind)
+ )
+ return x.from_dict(entry)
+
+
+class ErrorInfo(
+ namedtuple('ErrorInfo', 'macaroon, macaroon_path, cookie_name_suffix, '
+ 'interaction_methods, visit_url, wait_url')):
+ ''' Holds additional information provided
+ by an error.
+
+ @param macaroon may hold a macaroon that, when
+ discharged, may allow access to a service.
+ This field is associated with the ERR_DISCHARGE_REQUIRED
+ error code.
+
+ @param macaroon_path holds the URL path to be associated
+ with the macaroon. The macaroon is potentially
+ valid for all URLs under the given path.
+ If it is empty, the macaroon will be associated with
+ the original URL from which the error was returned.
+
+ @param cookie_name_suffix holds the desired cookie name suffix to be
+ associated with the macaroon. The actual name used will be
+ ("macaroon-" + cookie_name_suffix). Clients may ignore this field -
+ older clients will always use ("macaroon-" +
+ macaroon.signature() in hex).
+
+ @param visit_url holds a URL that the client should visit
+ in a web browser to authenticate themselves.
+
+ @param wait_url holds a URL that the client should visit
+ to acquire the discharge macaroon. A GET on
+ this URL will block until the client has authenticated,
+ and then it will return the discharge macaroon.
+ '''
+
+ __slots__ = ()
+
+ @classmethod
+ def from_dict(cls, serialized):
+ '''Create a new ErrorInfo object from a JSON deserialized
+ dictionary
+ @param serialized The JSON object {dict}
+ @return ErrorInfo object
+ '''
+ if serialized is None:
+ return None
+ macaroon = serialized.get('Macaroon')
+ if macaroon is not None:
+ macaroon = bakery.Macaroon.from_dict(macaroon)
+ path = serialized.get('MacaroonPath')
+ cookie_name_suffix = serialized.get('CookieNameSuffix')
+ visit_url = serialized.get('VisitURL')
+ wait_url = serialized.get('WaitURL')
+ interaction_methods = serialized.get('InteractionMethods')
+ return ErrorInfo(macaroon=macaroon, macaroon_path=path,
+ cookie_name_suffix=cookie_name_suffix,
+ visit_url=visit_url, wait_url=wait_url,
+ interaction_methods=interaction_methods)
+
+ def __new__(cls, macaroon=None, macaroon_path=None,
+ cookie_name_suffix=None, interaction_methods=None,
+ visit_url=None, wait_url=None):
+ '''Override the __new__ method so that we can
+ have optional arguments, which namedtuple doesn't
+ allow'''
+ return super(ErrorInfo, cls).__new__(
+ cls, macaroon, macaroon_path, cookie_name_suffix,
+ interaction_methods, visit_url, wait_url)
diff --git a/macaroonbakery/httpbakery/interactor.py b/macaroonbakery/httpbakery/interactor.py
new file mode 100644
index 0000000..0c15338
--- /dev/null
+++ b/macaroonbakery/httpbakery/interactor.py
@@ -0,0 +1,73 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+import abc
+from collections import namedtuple
+
+WEB_BROWSER_INTERACTION_KIND = 'browser-window'
+
+
+class Interactor(object):
+ ''' Represents a way of persuading a discharger that it should grant a
+ discharge macaroon.
+ '''
+ __metaclass__ = abc.ABCMeta
+
+ @abc.abstractmethod
+ def kind(self):
+ '''Returns the interaction method name. This corresponds to the key in
+ the Error.interaction_methods type.
+ @return {str}
+ '''
+ raise NotImplementedError('kind method must be defined in '
+ 'subclass')
+
+ def interact(self, client, location, interaction_required_err):
+ ''' Performs the interaction, and returns a token that can be
+ used to acquire the discharge macaroon. The location provides
+ the third party caveat location to make it possible to use
+ relative URLs. The client holds the client being used to do the current
+ request.
+
+ If the given interaction isn't supported by the client for
+ the given location, it may raise an InteractionMethodNotFound
+ which will cause the interactor to be ignored that time.
+ @param client The client being used for the current request {Client}
+ @param location Third party caveat location {str}
+ @param interaction_required_err The error causing the interaction to
+ take place {Error}
+ @return {DischargeToken} The discharge token.
+ '''
+ raise NotImplementedError('interact method must be defined in '
+ 'subclass')
+
+
+class LegacyInteractor(object):
+ ''' May optionally be implemented by Interactor implementations that
+ implement the legacy interaction-required error protocols.
+ '''
+ __metaclass__ = abc.ABCMeta
+
+ @abc.abstractmethod
+ def legacy_interact(self, client, location, visit_url):
+ ''' Implements the "visit" half of a legacy discharge
+ interaction. The "wait" half will be implemented by httpbakery.
+ The location is the location specified by the third party
+ caveat. The client holds the client being used to do the current
+ request.
+ @param client The client being used for the current request {Client}
+ @param location Third party caveat location {str}
+ @param visit_url The visit_url field from the error {str}
+ @return None
+ '''
+ raise NotImplementedError('legacy_interact method must be defined in '
+ 'subclass')
+
+
+class DischargeToken(namedtuple('DischargeToken', 'kind, value')):
+ ''' Holds a token that is intended to persuade a discharger to discharge
+ a third party caveat.
+ @param kind holds the kind of the token. By convention this
+ matches the name of the interaction method used to
+ obtain the token, but that's not required {str}
+ @param value holds the token data. {bytes}
+ '''
diff --git a/macaroonbakery/httpbakery/keyring.py b/macaroonbakery/httpbakery/keyring.py
index f4e93f7..01a4349 100644
--- a/macaroonbakery/httpbakery/keyring.py
+++ b/macaroonbakery/httpbakery/keyring.py
@@ -3,10 +3,11 @@
from six.moves.urllib.parse import urlparse
import requests
-import macaroonbakery
+import macaroonbakery as bakery
+from macaroonbakery.httpbakery.error import BAKERY_PROTOCOL_HEADER
-class ThirdPartyLocator(macaroonbakery.ThirdPartyLocator):
+class ThirdPartyLocator(bakery.ThirdPartyLocator):
''' Implements macaroonbakery.ThirdPartyLocator by first looking in the
backing cache and, if that fails, making an HTTP request to find the
information associated with the given discharge location.
@@ -23,33 +24,36 @@ class ThirdPartyLocator(macaroonbakery.ThirdPartyLocator):
def third_party_info(self, loc):
u = urlparse(loc)
if u.scheme != 'https' and not self._allow_insecure:
- raise macaroonbakery.ThirdPartyInfoNotFound(
+ raise bakery.ThirdPartyInfoNotFound(
'untrusted discharge URL {}'.format(loc))
loc = loc.rstrip('/')
info = self._cache.get(loc)
if info is not None:
return info
url_endpoint = '/discharge/info'
- resp = requests.get(loc + url_endpoint)
+ headers = {
+ BAKERY_PROTOCOL_HEADER: str(bakery.LATEST_VERSION)
+ }
+ resp = requests.get(url=loc + url_endpoint, headers=headers)
status_code = resp.status_code
if status_code == 404:
url_endpoint = '/publickey'
- resp = requests.get(loc + url_endpoint)
+ resp = requests.get(url=loc + url_endpoint, headers=headers)
status_code = resp.status_code
if status_code != 200:
- raise macaroonbakery.ThirdPartyInfoNotFound(
+ raise bakery.ThirdPartyInfoNotFound(
'unable to get info from {}'.format(url_endpoint))
json_resp = resp.json()
if json_resp is None:
- raise macaroonbakery.ThirdPartyInfoNotFound(
+ raise bakery.ThirdPartyInfoNotFound(
'no response from /discharge/info')
pk = json_resp.get('PublicKey')
if pk is None:
- raise macaroonbakery.ThirdPartyInfoNotFound(
+ raise bakery.ThirdPartyInfoNotFound(
'no public key found in /discharge/info')
- idm_pk = macaroonbakery.PublicKey.deserialize(pk)
- version = json_resp.get('Version', macaroonbakery.BAKERY_V1)
- self._cache[loc] = macaroonbakery.ThirdPartyInfo(
+ idm_pk = bakery.PublicKey.deserialize(pk)
+ version = json_resp.get('Version', bakery.VERSION_1)
+ self._cache[loc] = bakery.ThirdPartyInfo(
version=version,
public_key=idm_pk
)