summaryrefslogtreecommitdiff
path: root/macaroonbakery/httpbakery/error.py
blob: 422b34693ead097e8411e1bb68158cbfbec90b0e (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
# Copyright 2017 Canonical Ltd.
# Licensed under the LGPLv3, see LICENCE file for details.
from collections import namedtuple
import json

import macaroonbakery as bakery

ERR_INTERACTION_REQUIRED = 'interaction required'
ERR_DISCHARGE_REQUIRED = 'macaroon discharge required'


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
    allow access to a service.
    @param path holds the URL path to be associated with the macaroon.
    The macaroon is potentially valid for all URLs under the given path.
    @param cookie_suffix_name holds the desired cookie name suffix to be
    associated with the macaroon. The actual name used will be
    ("macaroon-" + CookieName). Clients may ignore this field -
    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': message,
            'Info': {
                'Macaroon': macaroon.to_dict(),
                'MacaroonPath': path,
                'CookieNameSuffix': cookie_suffix_name
            },
        }
    ).encode('utf-8')
    return content, {
        'WWW-Authenticate': 'Macaroon',
        'Content-Type': 'application/json'
    }

# BAKERY_PROTOCOL_HEADER is the header that HTTP clients should set
# to determine the bakery protocol version. If it is 0 or missing,
# a discharge-required error response will be returned with HTTP status 407;
# if it is greater than 0, the response will have status 401 with the
# WWW-Authenticate header set to "Macaroon".
BAKERY_PROTOCOL_HEADER = 'Bakery-Protocol-Version'


def request_version(req_headers):
    ''' Determines the bakery protocol version from a client request.
    If the protocol cannot be determined, or is invalid, the original version
    of the protocol is used. If a later version is found, the latest known
    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.VERSION_1)
    '''
    vs = req_headers.get(BAKERY_PROTOCOL_HEADER)
    if vs is None:
        # No header - use backward compatibility mode.
        return bakery.VERSION_1
    try:
        x = int(vs)
    except ValueError:
        # Badly formed header - use backward compatibility mode.
        return bakery.VERSION_1
    if x > bakery.LATEST_VERSION:
        # Later version than we know about - use the
        # latest version that we can.
        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)