summaryrefslogtreecommitdiff
path: root/macaroonbakery/bakery.py
blob: 1e031910e246f96b2db8ddf11cdf48852a336c0a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
# Copyright 2017 Canonical Ltd.
# Licensed under the LGPLv3, see LICENCE file for details.
from collections import namedtuple
import requests

from macaroonbakery import utils
from macaroonbakery.discharge import discharge
from macaroonbakery.checkers import checkers
from macaroonbakery.oven import Oven
from macaroonbakery.checker import Checker


ERR_INTERACTION_REQUIRED = 'interaction required'
ERR_DISCHARGE_REQUIRED = 'macaroon discharge required'
TIME_OUT = 30
DEFAULT_PROTOCOL_VERSION = {'Bakery-Protocol-Version': '1'}
MAX_DISCHARGE_RETRIES = 3

NONCE_LEN = 24


# A named tuple composed of the visit_url and wait_url coming from the error
# response in discharge
_Info = namedtuple('Info', 'visit_url wait_url')


class DischargeException(Exception):
    '''A discharge error occurred.'''


def discharge_all(macaroon, visit_page=None, jar=None, key=None):
    '''Gathers discharge macaroons for all the third party caveats in macaroon.

    All the discharge macaroons will be bound to the primary macaroon.
    The key parameter may optionally hold the key of the client, in which case
    it will be used to discharge any third party caveats with the special
    location "local". In this case, the caveat itself must be "true". This
    can be used by a server to ask a client to prove ownership of the
    private key.
    @param macaroon The macaroon to be discharged.
    @param visit_page function called when the discharge process requires
    further interaction.
    @param jar the storage for the cookies.
    @param key optional nacl key.
    @return An array with macaroon as the first element, followed by all the
    discharge macaroons.
    '''
    discharges = [macaroon]
    if visit_page is None:
        visit_page = utils.visit_page_with_browser
    if jar is None:
        jar = requests.cookies.RequestsCookieJar()
    client = _Client(visit_page, jar)
    try:
        client.discharge_caveats(macaroon, discharges, macaroon, key)
    except Exception as exc:
        raise DischargeException('unable to discharge the macaroon', exc)
    return discharges


class _Client:
    def __init__(self, visit_page, jar):
        self._visit_page = visit_page
        self._jar = jar

    def discharge_caveats(self, macaroon, discharges,
                          primary_macaroon, key):
        '''Gathers discharge macaroons for all the third party caveats.

        @param macaroon the macaroon to discharge.
        @param discharges the list of discharged macaroons.
        @param primary_macaroon used for the signature of the discharge
        macaroon.
        @param key nacl key holds the key to use to decrypt the third party
        caveat information and to encrypt any additional
        third party caveats returned by the caveat checker
        '''
        caveats = macaroon.third_party_caveats()
        for caveat in caveats:
            location = caveat.location
            b_cav_id = caveat.caveat_id
            if key is not None and location == 'local':
                # if tuple is only 2 element otherwise TODO add caveat
                dm = discharge(key, id=b_cav_id)
            else:
                dm = self._get_discharge(location, b_cav_id)
            dm = primary_macaroon.prepare_for_request(dm)
            discharges.append(dm)
            self.discharge_caveats(dm, discharges, primary_macaroon, key)

    def _get_discharge(self, third_party_location,
                       third_party_caveat_condition):
        '''Get the discharge macaroon from the third party location.

        @param third_party_location where to get a discharge from.
        @param third_party_caveat_condition encoded 64 string associated to the
        discharged macaroon.
        @return a discharge macaroon.
        @raise DischargeError when an error occurs during the discharge
            process.
        '''
        headers = DEFAULT_PROTOCOL_VERSION
        payload = {'id': third_party_caveat_condition}

        response = requests.post(third_party_location + '/discharge',
                                 headers=headers,
                                 data=payload,
                                 # timeout=TIME_OUT, TODO: add a time out
                                 cookies=self._jar)
        status_code = response.status_code
        if status_code == 200:
            return _extract_macaroon_from_response(response)
        if (status_code == 401 and
                response.headers.get('WWW-Authenticate') == 'Macaroon'):
            error = response.json()
            if error.get('Code', '') != ERR_INTERACTION_REQUIRED:
                return DischargeException('unable to get code from discharge')
            info = _extract_urls(response)
            self._visit_page(info.visit_url)
            # Wait on the wait url and then get a macaroon if validated.
            return _acquire_macaroon_from_wait(info.wait_url)


def _extract_macaroon_from_response(response):
    '''Extract the macaroon from a direct successful discharge.

    @param response from direct successful discharge.
    @return a macaroon object.
    @raises DischargeError if any error happens.
    '''
    response_json = response.json()
    return utils.deserialize(response_json['Macaroon'])


def _acquire_macaroon_from_wait(wait_url):
    ''' Return the macaroon acquired from the wait endpoint.

    Note that will block until the user interaction has completed.

    @param wait_url the get url to call to get a macaroon.
    @return a macaroon object
    @raises DischargeError if any error happens.
    '''
    resp = requests.get(wait_url, headers=DEFAULT_PROTOCOL_VERSION)
    response_json = resp.json()
    macaroon = response_json['Macaroon']
    return utils.deserialize(macaroon)


def _extract_urls(response):
    '''Return _Info of the visit and wait URL from response.

    @param response the response from the discharge endpoint.
    @return a _Info object of the visit and wait URL.
    @raises DischargeError for ant error during the process response.
    '''
    response_json = response.json()
    visit_url = response_json['Info']['VisitURL']
    wait_url = response_json['Info']['WaitURL']
    return _Info(visit_url=visit_url, wait_url=wait_url)


class Bakery(object):
    '''Convenience class that contains both an Oven and a Checker.
    '''
    def __init__(self, location=None, locator=None, ops_store=None, key=None,
                 identity_client=None, checker=None, root_key_store=None,
                 authorizer=None):
        '''Returns a new Bakery instance which combines an Oven with a
        Checker for the convenience of callers that wish to use both
        together.
        :param: checker holds the checker used to check first party caveats.
        If this is None, it will use checkers.Checker(None).
        :param: root_key_store holds the root key store to use.
        If you need to use a different root key store for different operations,
        you'll need to pass a root_key_store_for_ops value to Oven directly.
        :param: root_key_store If this is None, it will use MemoryKeyStore().
        Note that that is almost certain insufficient for production services
        that are spread across multiple instances or that need
        to persist keys across restarts.
        :param: locator is used to find out information on third parties when
        adding third party caveats. If this is None, no non-local third
        party caveats can be added.
        :param: key holds the private key of the oven. If this is None,
        no third party caveats may be added.
        :param: identity_client holds the identity implementation to use for
        authentication. If this is None, no authentication will be possible.
        :param: authorizer is used to check whether an authenticated user is
        allowed to perform operations. If it is None, it will use
        a ClosedAuthorizer.
        The identity parameter passed to authorizer.allow will
        always have been obtained from a call to
        IdentityClient.declared_identity.
        :param: ops_store used to persistently store the association of
        multi-op entities with their associated operations
        when oven.macaroon is called with multiple operations.
        :param: location holds the location to use when creating new macaroons.
        '''

        if checker is None:
            checker = checkers.Checker()
        root_keystore_for_ops = None
        if root_key_store is not None:
            def root_keystore_for_ops(ops):
                return root_key_store

        oven = Oven(key=key,
                    location=location,
                    locator=locator,
                    namespace=checker.namespace(),
                    root_keystore_for_ops=root_keystore_for_ops,
                    ops_store=ops_store)
        self._oven = oven

        self._checker = Checker(checker=checker, authorizer=authorizer,
                                identity_client=identity_client,
                                macaroon_opstore=oven)

    @property
    def oven(self):
        return self._oven

    @property
    def checker(self):
        return self._checker