diff options
author | Colin Watson <cjwatson@debian.org> | 2017-12-12 15:20:49 +0000 |
---|---|---|
committer | Colin Watson <cjwatson@debian.org> | 2017-12-12 15:20:49 +0000 |
commit | 9e4403035a9953c99117083e6373ae3c441a76b5 (patch) | |
tree | d91b137df6767bfb8cb72de6b9fd21efb0c3dee4 | |
parent | 949b7072cabce0daed6c94993ad44c8ea8648dbd (diff) |
Import py-macaroon-bakery_1.1.0.orig.tar.gz
-rw-r--r-- | Makefile | 15 | ||||
-rwxr-xr-x | docs/conf.py | 2 | ||||
-rw-r--r-- | macaroonbakery/__init__.py | 137 | ||||
-rw-r--r-- | macaroonbakery/_utils/__init__.py (renamed from macaroonbakery/utils.py) | 39 | ||||
-rw-r--r-- | macaroonbakery/bakery/__init__.py | 141 | ||||
-rw-r--r-- | macaroonbakery/bakery/_authorizer.py (renamed from macaroonbakery/authorizer.py) | 5 | ||||
-rw-r--r-- | macaroonbakery/bakery/_bakery.py (renamed from macaroonbakery/bakery.py) | 8 | ||||
-rw-r--r-- | macaroonbakery/bakery/_checker.py (renamed from macaroonbakery/checker.py) | 36 | ||||
-rw-r--r-- | macaroonbakery/bakery/_codec.py (renamed from macaroonbakery/codec.py) | 66 | ||||
-rw-r--r-- | macaroonbakery/bakery/_discharge.py (renamed from macaroonbakery/discharge.py) | 48 | ||||
-rw-r--r-- | macaroonbakery/bakery/_error.py (renamed from macaroonbakery/error.py) | 0 | ||||
-rw-r--r-- | macaroonbakery/bakery/_identity.py (renamed from macaroonbakery/identity.py) | 4 | ||||
-rw-r--r-- | macaroonbakery/bakery/_internal/__init__.py (renamed from macaroonbakery/internal/__init__.py) | 0 | ||||
-rw-r--r-- | macaroonbakery/bakery/_internal/id.proto (renamed from macaroonbakery/internal/id.proto) | 0 | ||||
-rw-r--r-- | macaroonbakery/bakery/_internal/id_pb2.py (renamed from macaroonbakery/internal/id_pb2.py) | 0 | ||||
-rw-r--r-- | macaroonbakery/bakery/_keys.py (renamed from macaroonbakery/keys.py) | 20 | ||||
-rw-r--r-- | macaroonbakery/bakery/_macaroon.py (renamed from macaroonbakery/macaroon.py) | 70 | ||||
-rw-r--r-- | macaroonbakery/bakery/_oven.py (renamed from macaroonbakery/oven.py) | 57 | ||||
-rw-r--r-- | macaroonbakery/bakery/_store.py (renamed from macaroonbakery/store.py) | 0 | ||||
-rw-r--r-- | macaroonbakery/bakery/_third_party.py (renamed from macaroonbakery/third_party.py) | 0 | ||||
-rw-r--r-- | macaroonbakery/bakery/_versions.py (renamed from macaroonbakery/versions.py) | 0 | ||||
-rw-r--r-- | macaroonbakery/checkers/__init__.py | 18 | ||||
-rw-r--r-- | macaroonbakery/checkers/_auth_context.py (renamed from macaroonbakery/checkers/auth_context.py) | 0 | ||||
-rw-r--r-- | macaroonbakery/checkers/_caveat.py (renamed from macaroonbakery/checkers/caveat.py) | 11 | ||||
-rw-r--r-- | macaroonbakery/checkers/_checkers.py (renamed from macaroonbakery/checkers/checkers.py) | 23 | ||||
-rw-r--r-- | macaroonbakery/checkers/_conditions.py (renamed from macaroonbakery/checkers/conditions.py) | 0 | ||||
-rw-r--r-- | macaroonbakery/checkers/_declared.py (renamed from macaroonbakery/checkers/declared.py) | 12 | ||||
-rw-r--r-- | macaroonbakery/checkers/_namespace.py (renamed from macaroonbakery/checkers/namespace.py) | 4 | ||||
-rw-r--r-- | macaroonbakery/checkers/_operation.py (renamed from macaroonbakery/checkers/operation.py) | 2 | ||||
-rw-r--r-- | macaroonbakery/checkers/_time.py (renamed from macaroonbakery/checkers/time.py) | 14 | ||||
-rw-r--r-- | macaroonbakery/checkers/_utils.py (renamed from macaroonbakery/checkers/utils.py) | 0 | ||||
-rw-r--r-- | macaroonbakery/httpbakery/__init__.py | 12 | ||||
-rw-r--r-- | macaroonbakery/httpbakery/_browser.py (renamed from macaroonbakery/httpbakery/browser.py) | 17 | ||||
-rw-r--r-- | macaroonbakery/httpbakery/_client.py (renamed from macaroonbakery/httpbakery/client.py) | 111 | ||||
-rw-r--r-- | macaroonbakery/httpbakery/_discharge.py (renamed from macaroonbakery/httpbakery/discharge.py) | 7 | ||||
-rw-r--r-- | macaroonbakery/httpbakery/_error.py (renamed from macaroonbakery/httpbakery/error.py) | 10 | ||||
-rw-r--r-- | macaroonbakery/httpbakery/_interactor.py (renamed from macaroonbakery/httpbakery/interactor.py) | 9 | ||||
-rw-r--r-- | macaroonbakery/httpbakery/_keyring.py (renamed from macaroonbakery/httpbakery/keyring.py) | 6 | ||||
-rw-r--r-- | macaroonbakery/httpbakery/agent/__init__.py | 8 | ||||
-rw-r--r-- | macaroonbakery/httpbakery/agent/_agent.py (renamed from macaroonbakery/httpbakery/agent/agent.py) | 115 | ||||
-rw-r--r-- | macaroonbakery/tests/common.py | 5 | ||||
-rw-r--r-- | macaroonbakery/tests/test_agent.py | 171 | ||||
-rw-r--r-- | macaroonbakery/tests/test_authorizer.py | 2 | ||||
-rw-r--r-- | macaroonbakery/tests/test_bakery.py | 87 | ||||
-rw-r--r-- | macaroonbakery/tests/test_checker.py | 34 | ||||
-rw-r--r-- | macaroonbakery/tests/test_checkers.py | 7 | ||||
-rw-r--r-- | macaroonbakery/tests/test_client.py | 130 | ||||
-rw-r--r-- | macaroonbakery/tests/test_codec.py | 7 | ||||
-rw-r--r-- | macaroonbakery/tests/test_discharge.py | 5 | ||||
-rw-r--r-- | macaroonbakery/tests/test_discharge_all.py | 5 | ||||
-rw-r--r-- | macaroonbakery/tests/test_keyring.py | 16 | ||||
-rw-r--r-- | macaroonbakery/tests/test_macaroon.py | 12 | ||||
-rw-r--r-- | macaroonbakery/tests/test_oven.py | 8 | ||||
-rw-r--r-- | macaroonbakery/tests/test_store.py | 2 | ||||
-rw-r--r-- | macaroonbakery/tests/test_time.py | 19 | ||||
-rw-r--r-- | macaroonbakery/tests/test_utils.py | 74 | ||||
-rwxr-xr-x | setup.py | 2 | ||||
-rw-r--r-- | tox.ini | 2 |
58 files changed, 955 insertions, 660 deletions
@@ -33,8 +33,7 @@ endif .PHONY: check -check: setup - @tox -e lint +check: setup lint @tox .PHONY: clean @@ -54,7 +53,7 @@ clean: .PHONY: docs docs: setup - tox -e docs + @tox -e docs .PHONY: help help: @@ -76,7 +75,7 @@ help: .PHONY: lint lint: setup - @$(DEVENV)/bin/flake8 --show-source macaroonbakery --exclude macaroonbakery/internal/id_pb2.py + @tox -e lint .PHONY: release release: check @@ -97,3 +96,11 @@ test: setup @$(DEVENV)/bin/nosetests \ --verbosity 2 --with-coverage --cover-erase \ --cover-package macaroonbakery + +.PHONY: isort +isort: + isort \ + --trailing-comma \ + --recursive \ + --multi-line 3 \ + `find macaroonbakery -name '*.py' | grep -v 'internal/id_pb2\.py'` diff --git a/docs/conf.py b/docs/conf.py index a64ec3a..ff5c82e 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -63,7 +63,7 @@ copyright = u'2017, Juju UI Team' # the built documents. # # The short X.Y version and the full version. -version = release = '0.0.6' +version = release = '1.1.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/macaroonbakery/__init__.py b/macaroonbakery/__init__.py index 6397a19..e69de29 100644 --- a/macaroonbakery/__init__.py +++ b/macaroonbakery/__init__.py @@ -1,137 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. - -from macaroonbakery.versions import ( - VERSION_0, - VERSION_1, - VERSION_2, - VERSION_3, - LATEST_VERSION, -) -from macaroonbakery.authorizer import ( - ACLAuthorizer, - Authorizer, - AuthorizerFunc, - ClosedAuthorizer, - EVERYONE, -) -from macaroonbakery.codec import ( - decode_caveat, - encode_caveat, - encode_uvarint, -) -from macaroonbakery.checker import ( - AuthChecker, - AuthInfo, - Checker, - LOGIN_OP, - Op, -) -from macaroonbakery.error import ( - AuthInitError, - CaveatNotRecognizedError, - DischargeRequiredError, - IdentityError, - PermissionDenied, - ThirdPartyCaveatCheckFailed, - ThirdPartyInfoNotFound, - VerificationError, -) -from macaroonbakery.identity import ( - ACLIdentity, - Identity, - IdentityClient, - NoIdentities, - SimpleIdentity, -) -from macaroonbakery.keys import ( - generate_key, - PrivateKey, - PublicKey, -) -from macaroonbakery.store import ( - MemoryOpsStore, - MemoryKeyStore, -) -from macaroonbakery.third_party import ( - ThirdPartyCaveatInfo, - ThirdPartyInfo, - legacy_namespace, -) -from macaroonbakery.macaroon import ( - Macaroon, - MacaroonJSONDecoder, - MacaroonJSONEncoder, - ThirdPartyLocator, - ThirdPartyStore, - macaroon_version, -) -from macaroonbakery.discharge import ( - ThirdPartyCaveatChecker, - discharge, - discharge_all, - local_third_party_caveat, -) -from macaroonbakery.oven import ( - Oven, - canonical_ops, -) -from macaroonbakery.bakery import Bakery -from macaroonbakery.utils import b64decode - -__all__ = [ - 'ACLAuthorizer', - 'ACLIdentity', - 'AuthChecker', - 'AuthInfo', - 'AuthInitError', - 'Authorizer', - 'AuthorizerFunc', - 'VERSION_0', - 'VERSION_1', - 'VERSION_2', - 'VERSION_3', - 'Bakery', - 'CaveatNotRecognizedError', - 'Checker', - 'ClosedAuthorizer', - 'DischargeRequiredError', - 'EVERYONE', - 'Identity', - 'IdentityClient', - 'IdentityError', - 'LATEST_VERSION', - 'LOGIN_OP', - 'Macaroon', - 'MacaroonJSONDecoder', - 'MacaroonJSONEncoder', - 'MemoryKeyStore', - 'MemoryOpsStore', - 'NoIdentities', - 'Op', - 'Oven', - 'PermissionDenied', - 'PrivateKey', - 'PublicKey', - 'SimpleIdentity', - 'ThirdPartyCaveatCheckFailed', - 'ThirdPartyCaveatChecker', - 'ThirdPartyCaveatInfo', - 'ThirdPartyInfo', - 'ThirdPartyInfoNotFound', - 'ThirdPartyLocator', - 'ThirdPartyStore', - 'VERSION', - 'VerificationError', - 'b64decode', - 'canonical_ops', - 'decode_caveat', - 'discharge', - 'discharge_all', - 'encode_caveat', - 'encode_uvarint', - 'generate_key', - 'legacy_namespace', - 'local_third_party_caveat', - 'macaroon_version', -] diff --git a/macaroonbakery/utils.py b/macaroonbakery/_utils/__init__.py index 43b0bf2..f2779e0 100644 --- a/macaroonbakery/utils.py +++ b/macaroonbakery/_utils/__init__.py @@ -1,15 +1,18 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. import base64 +import binascii import json import webbrowser -import six -import six.moves.http_cookiejar as http_cookiejar -from six.moves.urllib.parse import urlparse +from datetime import datetime +import six from pymacaroons import Macaroon from pymacaroons.serializers import json_serializer +import six.moves.http_cookiejar as http_cookiejar +from six.moves.urllib.parse import urlparse + def to_bytes(s): '''Return s as a bytes type, using utf-8 encoding if necessary. @@ -34,6 +37,13 @@ def macaroon_from_dict(json_macaroon): json_serializer.JsonSerializer()) +def macaroon_to_dict(macaroon): + '''Turn macaroon into JSON-serializable dict object + @param pymacaroons.Macaroon. + ''' + return json.loads(macaroon.serialize(json_serializer.JsonSerializer())) + + def macaroon_to_json_string(macaroon): '''Serialize macaroon object to a JSON-encoded string. @@ -72,14 +82,19 @@ def b64decode(s): @param s bytes decode @return bytes decoded + @raises ValueError on failure ''' # add padding if necessary. s = to_bytes(s) - s = s + b'=' * (-len(s) % 4) - if '_' or '-' in s: - return base64.urlsafe_b64decode(s) - else: - return base64.b64decode(s) + if not s.endswith(b'='): + s = s + b'=' * (-len(s) % 4) + try: + if '_' or '-' in s: + return base64.urlsafe_b64decode(s) + else: + return base64.b64decode(s) + except (TypeError, binascii.Error) as e: + raise ValueError(str(e)) def raw_urlsafe_b64encode(b): @@ -111,17 +126,21 @@ def cookie( expires=None): '''Return a new Cookie using a slightly more friendly API than that provided by six.moves.http_cookiejar + @param name The cookie name {str} @param value The cookie value {str} @param url The URL path of the cookie {str} - @param expires The expiry time of the cookie {datetime} + @param expires The expiry time of the cookie {datetime}. If provided, + it must be a naive timestamp in UTC. ''' u = urlparse(url) domain = u.hostname or u.netloc port = str(u.port) if u.port is not None else None secure = u.scheme == 'https' if expires is not None: - expires = expires.strftime("%s") + if expires.tzinfo is not None: + raise ValueError('Cookie expiration must be a naive datetime') + expires = (expires - datetime(1970, 1, 1)).total_seconds() return http_cookiejar.Cookie( version=0, name=name, diff --git a/macaroonbakery/bakery/__init__.py b/macaroonbakery/bakery/__init__.py new file mode 100644 index 0000000..4b973e9 --- /dev/null +++ b/macaroonbakery/bakery/__init__.py @@ -0,0 +1,141 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +from ._versions import ( + VERSION_0, + VERSION_1, + VERSION_2, + VERSION_3, + LATEST_VERSION, +) +from ._authorizer import ( + ACLAuthorizer, + Authorizer, + AuthorizerFunc, + ClosedAuthorizer, + EVERYONE, +) +from ._codec import ( + decode_caveat, + encode_caveat, + encode_uvarint, +) +from ._checker import ( + AuthChecker, + AuthInfo, + Checker, + LOGIN_OP, + Op, +) +from ._error import ( + AuthInitError, + CaveatNotRecognizedError, + DischargeRequiredError, + IdentityError, + PermissionDenied, + ThirdPartyCaveatCheckFailed, + ThirdPartyInfoNotFound, + VerificationError, +) +from ._identity import ( + ACLIdentity, + Identity, + IdentityClient, + NoIdentities, + SimpleIdentity, +) +from ._keys import ( + generate_key, + PrivateKey, + PublicKey, +) +from ._store import ( + MemoryOpsStore, + MemoryKeyStore, +) +from ._third_party import ( + ThirdPartyCaveatInfo, + ThirdPartyInfo, + legacy_namespace, +) +from ._macaroon import ( + Macaroon, + MacaroonJSONDecoder, + MacaroonJSONEncoder, + ThirdPartyLocator, + ThirdPartyStore, + macaroon_version, +) +from ._discharge import ( + ThirdPartyCaveatChecker, + discharge, + discharge_all, + local_third_party_caveat, +) +from ._oven import ( + Oven, + canonical_ops, +) +from ._bakery import Bakery +from macaroonbakery._utils import ( + b64decode, + macaroon_to_dict, +) + +__all__ = [ + 'ACLAuthorizer', + 'ACLIdentity', + 'AuthChecker', + 'AuthInfo', + 'AuthInitError', + 'Authorizer', + 'AuthorizerFunc', + 'VERSION_0', + 'VERSION_1', + 'VERSION_2', + 'VERSION_3', + 'Bakery', + 'CaveatNotRecognizedError', + 'Checker', + 'ClosedAuthorizer', + 'DischargeRequiredError', + 'EVERYONE', + 'Identity', + 'IdentityClient', + 'IdentityError', + 'LATEST_VERSION', + 'LOGIN_OP', + 'Macaroon', + 'MacaroonJSONDecoder', + 'MacaroonJSONEncoder', + 'MemoryKeyStore', + 'MemoryOpsStore', + 'NoIdentities', + 'Op', + 'Oven', + 'PermissionDenied', + 'PrivateKey', + 'PublicKey', + 'SimpleIdentity', + 'ThirdPartyCaveatCheckFailed', + 'ThirdPartyCaveatChecker', + 'ThirdPartyCaveatInfo', + 'ThirdPartyInfo', + 'ThirdPartyInfoNotFound', + 'ThirdPartyLocator', + 'ThirdPartyStore', + 'VERSION', + 'VerificationError', + 'b64decode', + 'canonical_ops', + 'decode_caveat', + 'discharge', + 'discharge_all', + 'encode_caveat', + 'encode_uvarint', + 'generate_key', + 'legacy_namespace', + 'local_third_party_caveat', + 'macaroon_to_dict', + 'macaroon_version', +] diff --git a/macaroonbakery/authorizer.py b/macaroonbakery/bakery/_authorizer.py index ae84104..f900430 100644 --- a/macaroonbakery/authorizer.py +++ b/macaroonbakery/bakery/_authorizer.py @@ -2,8 +2,7 @@ # Licensed under the LGPLv3, see LICENCE file for details. import abc -import macaroonbakery as bakery - +from ._identity import ACLIdentity # EVERYONE is recognized by ACLAuthorizer as the name of a # group that has everyone in it. @@ -90,7 +89,7 @@ class ACLAuthorizer(Authorizer): # Anyone is allowed to do nothing. return [], [] allowed = [False] * len(ops) - has_allow = isinstance(identity, bakery.ACLIdentity) + has_allow = isinstance(identity, ACLIdentity) for i, op in enumerate(ops): acl = self._get_acl(ctx, op) if has_allow: diff --git a/macaroonbakery/bakery.py b/macaroonbakery/bakery/_bakery.py index 5d9d56a..8fac9ce 100644 --- a/macaroonbakery/bakery.py +++ b/macaroonbakery/bakery/_bakery.py @@ -1,10 +1,10 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -from macaroonbakery.checkers import checkers -from macaroonbakery.oven import Oven -from macaroonbakery.checker import Checker -from macaroonbakery.authorizer import ClosedAuthorizer +from ._authorizer import ClosedAuthorizer +from ._checker import Checker +import macaroonbakery.checkers as checkers +from ._oven import Oven class Bakery(object): diff --git a/macaroonbakery/checker.py b/macaroonbakery/bakery/_checker.py index 568fd7c..b796502 100644 --- a/macaroonbakery/checker.py +++ b/macaroonbakery/bakery/_checker.py @@ -3,11 +3,17 @@ from collections import namedtuple from threading import Lock - -import pyrfc3339 - -import macaroonbakery as bakery +from ._authorizer import ClosedAuthorizer +from ._identity import NoIdentities +from ._error import ( + AuthInitError, + VerificationError, + IdentityError, + DischargeRequiredError, + PermissionDenied, +) import macaroonbakery.checkers as checkers +import pyrfc3339 class Op(namedtuple('Op', 'entity, action')): @@ -38,7 +44,7 @@ class Checker(object): See the Oven type (TODO) for one way of doing that. ''' def __init__(self, checker=checkers.Checker(), - authorizer=bakery.ClosedAuthorizer(), + authorizer=ClosedAuthorizer(), identity_client=None, macaroon_opstore=None): ''' @@ -57,7 +63,7 @@ class Checker(object): self._first_party_caveat_checker = checker self._authorizer = authorizer if identity_client is None: - identity_client = bakery.NoIdentities() + identity_client = NoIdentities() self._identity_client = identity_client self._macaroon_opstore = macaroon_opstore @@ -105,8 +111,8 @@ class AuthChecker(object): if not self._executed: self._init_once(ctx) self._executed = True - if self._init_errors is not None and len(self._init_errors) > 0: - raise bakery.AuthInitError(self._init_errors[0]) + if self._init_errors: + raise AuthInitError(self._init_errors[0]) def _init_once(self, ctx): self._auth_indexes = {} @@ -115,7 +121,7 @@ class AuthChecker(object): try: ops, conditions = self.parent._macaroon_opstore.macaroon_ops( ms) - except bakery.VerificationError: + except VerificationError: raise except Exception as exc: self._init_errors.append(exc.args[0]) @@ -157,7 +163,7 @@ class AuthChecker(object): try: identity = self.parent._identity_client.declared_identity( ctx, declared) - except bakery.IdentityError as exc: + except IdentityError as exc: self._init_errors.append( 'cannot decode declared identity: {}'.format(exc.args[0])) continue @@ -171,7 +177,7 @@ class AuthChecker(object): try: identity, cavs = self.parent.\ _identity_client.identity_from_context(ctx) - except bakery.IdentityError: + except IdentityError: self._init_errors.append('could not determine identity') if cavs is None: cavs = [] @@ -292,7 +298,7 @@ class AuthChecker(object): # no caveats to be discharged. return authed, used if self._identity is None and len(self._identity_caveats) > 0: - raise bakery.DischargeRequiredError( + raise DischargeRequiredError( msg='authentication required', ops=[LOGIN_OP], cavs=self._identity_caveats) @@ -303,8 +309,8 @@ class AuthChecker(object): err = '' if len(all_errors) > 0: err = all_errors[0] - raise bakery.PermissionDenied(err) - raise bakery.DischargeRequiredError( + raise PermissionDenied(err) + raise DischargeRequiredError( msg='some operations have extra caveats', ops=ops, cavs=caveats) def allow_capability(self, ctx, ops): @@ -391,7 +397,7 @@ class _CaveatSquasher(object): if cond == checkers.COND_TIME_BEFORE: try: - et = pyrfc3339.parse(args) + et = pyrfc3339.parse(args, utc=True).replace(tzinfo=None) except ValueError: # Again, if it doesn't seem valid, leave it alone. return True diff --git a/macaroonbakery/codec.py b/macaroonbakery/bakery/_codec.py index 2946da9..903e604 100644 --- a/macaroonbakery/codec.py +++ b/macaroonbakery/bakery/_codec.py @@ -3,11 +3,13 @@ import base64 import json -import six -import nacl.public - -import macaroonbakery as bakery +from ._versions import (VERSION_1, VERSION_2, VERSION_3) +from ._third_party import legacy_namespace, ThirdPartyCaveatInfo +from ._keys import PublicKey +from ._error import VerificationError import macaroonbakery.checkers as checkers +import nacl.public +import six _PUBLIC_KEY_PREFIX_LEN = 4 _KEY_LEN = 32 @@ -33,11 +35,11 @@ def encode_caveat(condition, root_key, third_party_info, key, ns): @param ns not used yet @return bytes ''' - if third_party_info.version == bakery.VERSION_1: + if third_party_info.version == VERSION_1: return _encode_caveat_v1(condition, root_key, third_party_info.public_key, key) - if (third_party_info.version == bakery.VERSION_2 or - third_party_info.version == bakery.VERSION_3): + if (third_party_info.version == VERSION_2 or + third_party_info.version == VERSION_3): return _encode_caveat_v2_v3(third_party_info.version, condition, root_key, third_party_info.public_key, key, ns) @@ -67,8 +69,8 @@ def _encode_caveat_v1(condition, root_key, third_party_pub_key, key): nonce = encrypted[0:nacl.public.Box.NONCE_SIZE] encrypted = encrypted[nacl.public.Box.NONCE_SIZE:] return base64.b64encode(six.b(json.dumps({ - 'ThirdPartyPublicKey': third_party_pub_key.encode().decode('ascii'), - 'FirstPartyPublicKey': key.public_key.encode().decode('ascii'), + 'ThirdPartyPublicKey': str(third_party_pub_key), + 'FirstPartyPublicKey': str(key.public_key), 'Nonce': base64.b64encode(nonce).decode('ascii'), 'Id': base64.b64encode(encrypted).decode('ascii') }))) @@ -99,12 +101,12 @@ def _encode_caveat_v2_v3(version, condition, root_key, third_party_pub_key, condition [rest of encrypted part] ''' ns_data = bytearray() - if version >= bakery.VERSION_3: + if version >= VERSION_3: ns_data = ns.serialize_text() data = bytearray() data.append(version) - data.extend(third_party_pub_key.encode(raw=True)[:_PUBLIC_KEY_PREFIX_LEN]) - data.extend(key.public_key.encode(raw=True)[:]) + data.extend(third_party_pub_key.serialize(raw=True)[:_PUBLIC_KEY_PREFIX_LEN]) + data.extend(key.public_key.serialize(raw=True)[:]) secret = _encode_secret_part_v2_v3(version, condition, root_key, ns_data) box = nacl.public.Box(key.key, third_party_pub_key.key) encrypted = box.encrypt(secret) @@ -131,7 +133,7 @@ def _encode_secret_part_v2_v3(version, condition, root_key, ns): data.append(version) encode_uvarint(len(root_key), data) data.extend(root_key) - if version >= bakery.VERSION_3: + if version >= VERSION_3: encode_uvarint(len(ns), data) data.extend(ns) data.extend(condition.encode('utf-8')) @@ -146,7 +148,7 @@ def decode_caveat(key, caveat): @return ThirdPartyCaveatInfo ''' if len(caveat) == 0: - raise bakery.VerificationError('empty third party caveat') + raise VerificationError('empty third party caveat') first = caveat[:1] if first == b'e': @@ -154,17 +156,17 @@ def decode_caveat(key, caveat): # encoded JSON object. return _decode_caveat_v1(key, caveat) first_as_int = six.byte2int(first) - if (first_as_int == bakery.VERSION_2 or - first_as_int == bakery.VERSION_3): + if (first_as_int == VERSION_2 or + first_as_int == VERSION_3): if (len(caveat) < _VERSION3_CAVEAT_MIN_LEN - and first_as_int == bakery.VERSION_3): + and first_as_int == VERSION_3): # If it has the version 3 caveat tag and it's too short, it's # almost certainly an id, not an encrypted payload. - raise bakery.VerificationError( + raise VerificationError( 'caveat id payload not provided for caveat id {}'.format( caveat)) return _decode_caveat_v2_v3(first_as_int, key, caveat) - raise bakery.VerificationError('unknown version for caveat') + raise VerificationError('unknown version for caveat') def _decode_caveat_v1(key, caveat): @@ -196,15 +198,15 @@ def _decode_caveat_v1(key, caveat): record = json.loads(c.decode('utf-8')) fp_key = nacl.public.PublicKey( base64.b64decode(wrapper.get('FirstPartyPublicKey'))) - return bakery.ThirdPartyCaveatInfo( + return ThirdPartyCaveatInfo( condition=record.get('Condition'), - first_party_public_key=bakery.PublicKey(fp_key), + first_party_public_key=PublicKey(fp_key), third_party_key_pair=key, root_key=base64.b64decode(record.get('RootKey')), caveat=caveat, id=None, - version=bakery.VERSION_1, - namespace=bakery.legacy_namespace() + version=VERSION_1, + namespace=legacy_namespace() ) @@ -213,14 +215,14 @@ def _decode_caveat_v2_v3(version, key, caveat): ''' if (len(caveat) < 1 + _PUBLIC_KEY_PREFIX_LEN + _KEY_LEN + nacl.public.Box.NONCE_SIZE + 16): - raise bakery.VerificationError('caveat id too short') + raise VerificationError('caveat id too short') original_caveat = caveat caveat = caveat[1:] # skip version (already checked) pk_prefix = caveat[:_PUBLIC_KEY_PREFIX_LEN] caveat = caveat[_PUBLIC_KEY_PREFIX_LEN:] - if key.public_key.encode(raw=True)[:_PUBLIC_KEY_PREFIX_LEN] != pk_prefix: - raise bakery.VerificationError('public key mismatch') + if key.public_key.serialize(raw=True)[:_PUBLIC_KEY_PREFIX_LEN] != pk_prefix: + raise VerificationError('public key mismatch') first_party_pub = caveat[:_KEY_LEN] caveat = caveat[_KEY_LEN:] @@ -230,9 +232,9 @@ def _decode_caveat_v2_v3(version, key, caveat): box = nacl.public.Box(key.key, fp_public_key) data = box.decrypt(caveat, nonce) root_key, condition, ns = _decode_secret_part_v2_v3(version, data) - return bakery.ThirdPartyCaveatInfo( + return ThirdPartyCaveatInfo( condition=condition.decode('utf-8'), - first_party_public_key=bakery.PublicKey(fp_public_key), + first_party_public_key=PublicKey(fp_public_key), third_party_key_pair=key, root_key=root_key, caveat=original_caveat, @@ -244,25 +246,25 @@ def _decode_caveat_v2_v3(version, key, caveat): def _decode_secret_part_v2_v3(version, data): if len(data) < 1: - raise bakery.VerificationError('secret part too short') + raise VerificationError('secret part too short') got_version = six.byte2int(data[:1]) data = data[1:] if version != got_version: - raise bakery.VerificationError( + raise VerificationError( 'unexpected secret part version, got {} want {}'.format( got_version, version)) root_key_length, read = decode_uvarint(data) data = data[read:] root_key = data[:root_key_length] data = data[root_key_length:] - if version >= bakery.VERSION_3: + if version >= VERSION_3: namespace_length, read = decode_uvarint(data) data = data[read:] ns_data = data[:namespace_length] data = data[namespace_length:] ns = checkers.deserialize_namespace(ns_data) else: - ns = bakery.legacy_namespace() + ns = legacy_namespace() return root_key, data, ns diff --git a/macaroonbakery/discharge.py b/macaroonbakery/bakery/_discharge.py index f54fc97..1831209 100644 --- a/macaroonbakery/discharge.py +++ b/macaroonbakery/bakery/_discharge.py @@ -3,7 +3,19 @@ import abc from collections import namedtuple -import macaroonbakery as bakery +from ._error import ( + ThirdPartyCaveatCheckFailed, + CaveatNotRecognizedError, + VerificationError, +) +from ._codec import decode_caveat +from ._macaroon import ( + Macaroon, + ThirdPartyLocator, +) +from ._versions import VERSION_2 +from ._third_party import ThirdPartyCaveatInfo + import macaroonbakery.checkers as checkers emptyContext = checkers.AuthContext() @@ -46,7 +58,11 @@ def discharge_all(m, get_discharge, local_key=None): while len(need) > 0: cav = need[0] need = need[1:] - if local_key is not None and cav.cav.location == 'local': + if cav.cav.location == 'local': + if local_key is None: + raise ThirdPartyCaveatCheckFailed( + 'found local third party caveat but no private key provided', + ) # TODO use a small caveat id. dm = discharge(ctx=emptyContext, key=local_key, @@ -90,7 +106,7 @@ class ThirdPartyCaveatChecker(object): class _LocalDischargeChecker(ThirdPartyCaveatChecker): def check_third_party_caveat(self, ctx, info): if info.condition != 'true': - raise bakery.CaveatNotRecognizedError() + raise CaveatNotRecognizedError() return [] @@ -125,8 +141,8 @@ def discharge(ctx, id, caveat, key, checker, locator): # caveats are added, use that id as the prefix # for any more ids. caveat_id_prefix = id - cav_info = bakery.decode_caveat(key, caveat) - cav_info = bakery.ThirdPartyCaveatInfo( + cav_info = decode_caveat(key, caveat) + cav_info = ThirdPartyCaveatInfo( condition=cav_info.condition, first_party_public_key=cav_info.first_party_public_key, third_party_key_pair=cav_info.third_party_key_pair, @@ -142,7 +158,7 @@ def discharge(ctx, id, caveat, key, checker, locator): try: cond, arg = checkers.parse_caveat(cav_info.condition) except ValueError as exc: - raise bakery.VerificationError(exc.args[0]) + raise VerificationError(exc.args[0]) if cond == checkers.COND_NEED_DECLARED: cav_info = cav_info._replace(condition=arg.encode('utf-8')) @@ -154,7 +170,7 @@ def discharge(ctx, id, caveat, key, checker, locator): # be stored persistently. Indeed, it would be a problem if # we did, because then the macaroon could potentially be used # for normal authorization with the third party. - m = bakery.Macaroon( + m = Macaroon( cav_info.root_key, id, '', @@ -172,15 +188,15 @@ def _check_need_declared(ctx, cav_info, checker): arg = cav_info.condition.decode('utf-8') i = arg.find(' ') if i <= 0: - raise bakery.VerificationError( + raise VerificationError( 'need-declared caveat requires an argument, got %q'.format(arg), ) need_declared = arg[0:i].split(',') for d in need_declared: if d == '': - raise bakery.VerificationError('need-declared caveat with empty required attribute') + raise VerificationError('need-declared caveat with empty required attribute') if len(need_declared) == 0: - raise bakery.VerificationError('need-declared caveat with no required attributes') + raise VerificationError('need-declared caveat with no required attributes') cav_info = cav_info._replace(condition=arg[i + 1:].encode('utf-8')) caveats = checker.check_third_party_caveat(ctx, cav_info) declared = {} @@ -197,7 +213,7 @@ def _check_need_declared(ctx, cav_info, checker): continue parts = arg.split() if len(parts) != 2: - raise bakery.VerificationError('declared caveat has no value') + raise VerificationError('declared caveat has no value') declared[parts[0]] = True # Add empty declarations for everything mentioned in need-declared # that was not actually declared. @@ -207,7 +223,7 @@ def _check_need_declared(ctx, cav_info, checker): return caveats -class _EmptyLocator(bakery.ThirdPartyLocator): +class _EmptyLocator(ThirdPartyLocator): def third_party_info(self, loc): return None @@ -218,8 +234,8 @@ def local_third_party_caveat(key, version): the given PublicKey. This can be automatically discharged by discharge_all passing a local key. ''' - encoded_key = key.encode().decode('utf-8') - loc = 'local {}'.format(encoded_key) - if version >= bakery.VERSION_2: - loc = 'local {} {}'.format(version, encoded_key) + if version >= VERSION_2: + loc = 'local {} {}'.format(version, key) + else: + loc = 'local {}'.format(key) return checkers.Caveat(location=loc, condition='') diff --git a/macaroonbakery/error.py b/macaroonbakery/bakery/_error.py index b403569..b403569 100644 --- a/macaroonbakery/error.py +++ b/macaroonbakery/bakery/_error.py diff --git a/macaroonbakery/identity.py b/macaroonbakery/bakery/_identity.py index 1579bba..4389cd9 100644 --- a/macaroonbakery/identity.py +++ b/macaroonbakery/bakery/_identity.py @@ -2,7 +2,7 @@ # Licensed under the LGPLv3, see LICENCE file for details. import abc -import macaroonbakery as bakery +from ._error import IdentityError class Identity(object): @@ -123,4 +123,4 @@ class NoIdentities(IdentityClient): return None, None def declared_identity(self, ctx, declared): - raise bakery.IdentityError('no identity declared or possible') + raise IdentityError('no identity declared or possible') diff --git a/macaroonbakery/internal/__init__.py b/macaroonbakery/bakery/_internal/__init__.py index e69de29..e69de29 100644 --- a/macaroonbakery/internal/__init__.py +++ b/macaroonbakery/bakery/_internal/__init__.py diff --git a/macaroonbakery/internal/id.proto b/macaroonbakery/bakery/_internal/id.proto index eb3d614..eb3d614 100644 --- a/macaroonbakery/internal/id.proto +++ b/macaroonbakery/bakery/_internal/id.proto diff --git a/macaroonbakery/internal/id_pb2.py b/macaroonbakery/bakery/_internal/id_pb2.py index 0fd54c0..0fd54c0 100644 --- a/macaroonbakery/internal/id_pb2.py +++ b/macaroonbakery/bakery/_internal/id_pb2.py diff --git a/macaroonbakery/keys.py b/macaroonbakery/bakery/_keys.py index 5cf61c5..1da5f05 100644 --- a/macaroonbakery/keys.py +++ b/macaroonbakery/bakery/_keys.py @@ -34,15 +34,19 @@ class PrivateKey(object): nacl.public.PrivateKey(serialized, encoder=nacl.encoding.Base64Encoder)) - def encode(self, raw=False): - ''' Encode the key in a base64 format by default but when raw is True - it will return a an hex encoded bytes. + def serialize(self, raw=False): + '''Encode the private part of the key in a base64 format by default, + but when raw is True it will return hex encoded bytes. @return: bytes ''' if raw: return self._key.encode() return self._key.encode(nacl.encoding.Base64Encoder) + def __str__(self): + '''Return the private part of the key key as a base64-encoded string''' + return self.serialize().decode('utf-8') + def __eq__(self, other): return self.key == other.key @@ -63,15 +67,19 @@ class PublicKey(object): ''' return self._key - def encode(self, raw=False): - ''' Encode the key in a base64 format by default but when raw is True - it will return a an hex encoded bytes. + def serialize(self, raw=False): + '''Encode the private part of the key in a base64 format by default, + but when raw is True it will return hex encoded bytes. @return: bytes ''' if raw: return self._key.encode() return self._key.encode(nacl.encoding.Base64Encoder) + def __str__(self): + '''Return the key as a base64-encoded string''' + return self.serialize().decode('utf-8') + @classmethod def deserialize(cls, serialized): ''' Create a PublicKey from a base64 encoded bytes. diff --git a/macaroonbakery/macaroon.py b/macaroonbakery/bakery/_macaroon.py index b745282..63091f6 100644 --- a/macaroonbakery/macaroon.py +++ b/macaroonbakery/bakery/_macaroon.py @@ -6,13 +6,29 @@ import json import logging import os +import macaroonbakery.checkers as checkers import pymacaroons +from macaroonbakery._utils import b64decode from pymacaroons.serializers import json_serializer - -import macaroonbakery as bakery -import macaroonbakery.checkers as checkers -from macaroonbakery import utils - +from ._versions import ( + LATEST_VERSION, + VERSION_0, + VERSION_1, + VERSION_2, + VERSION_3, +) +from ._error import ( + ThirdPartyInfoNotFound, +) +from ._codec import ( + encode_uvarint, + encode_caveat, +) +from ._keys import PublicKey +from ._third_party import ( + legacy_namespace, + ThirdPartyInfo, +) log = logging.getLogger(__name__) @@ -24,7 +40,7 @@ class Macaroon(object): ''' def __init__(self, root_key, id, location=None, - version=bakery.LATEST_VERSION, namespace=None): + version=LATEST_VERSION, namespace=None): '''Creates a new macaroon with the given root key, id and location. If the version is more than the latest known version, @@ -36,11 +52,11 @@ class Macaroon(object): @param version the bakery version. @param namespace is that of the service creating it ''' - if version > bakery.LATEST_VERSION: + if version > LATEST_VERSION: log.info('use last known version:{} instead of: {}'.format( - bakery.LATEST_VERSION, version + LATEST_VERSION, version )) - version = bakery.LATEST_VERSION + version = LATEST_VERSION # m holds the underlying macaroon. self._macaroon = pymacaroons.Macaroon( location=location, key=root_key, identifier=id, @@ -115,14 +131,14 @@ class Macaroon(object): # Use the least supported version to encode the caveat. if self._version < info.version: - info = bakery.ThirdPartyInfo( + info = ThirdPartyInfo( version=self._version, public_key=info.public_key, ) - caveat_info = bakery.encode_caveat( + caveat_info = encode_caveat( cav.condition, root_key, info, key, self._namespace) - if info.version < bakery.VERSION_3: + if info.version < VERSION_3: # We're encoding for an earlier client or third party which does # not understand bundled caveat info, so use the encoded # caveat information as the caveat id. @@ -157,7 +173,7 @@ class Macaroon(object): '''Return a dict representation of the macaroon data in JSON format. @return a dict ''' - if self.version < bakery.VERSION_3: + if self.version < VERSION_3: if len(self._caveat_data) > 0: raise ValueError('cannot serialize pre-version3 macaroon with ' 'external caveat data') @@ -191,7 +207,7 @@ class Macaroon(object): m = pymacaroons.Macaroon.deserialize( json.dumps(json_dict), json_serializer.JsonSerializer()) macaroon = Macaroon(root_key=None, id=None, - namespace=bakery.legacy_namespace(), + namespace=legacy_namespace(), version=_bakery_version(m.version)) macaroon._macaroon = m return macaroon @@ -199,8 +215,8 @@ class Macaroon(object): version = json_dict.get('v', None) if version is None: raise ValueError('no version specified') - if (version < bakery.VERSION_3 or - version > bakery.LATEST_VERSION): + if (version < VERSION_3 or + version > LATEST_VERSION): raise ValueError('unknown bakery version {}'.format(version)) m = pymacaroons.Macaroon.deserialize(json.dumps(json_macaroon), json_serializer.JsonSerializer()) @@ -212,8 +228,8 @@ class Macaroon(object): cdata = json_dict.get('cdata', {}) caveat_data = {} for id64 in cdata: - id = utils.b64decode(id64) - data = utils.b64decode(cdata[id64]) + id = b64decode(id64) + data = b64decode(cdata[id64]) caveat_data[id] = data macaroon = Macaroon(root_key=None, id=None, namespace=namespace, @@ -251,7 +267,7 @@ class Macaroon(object): # payload, having this version gives a strong indication # that the payload has been omitted so we can produce # a better error for the user. - id.append(bakery.VERSION_3) + id.append(VERSION_3) # Iterate through integers looking for one that isn't already used, # starting from n so that if everyone is using this same algorithm, @@ -265,7 +281,7 @@ class Macaroon(object): # end up with a duplicate third party caveat id and thus create # a macaroon that cannot be discharged. temp = id[:] - bakery.encode_uvarint(i, temp) + encode_uvarint(i, temp) found = False for cav in caveats: if (cav.verification_key_id is not None @@ -310,7 +326,7 @@ def macaroon_version(bakery_version): @param bakery_version the bakery version @return macaroon_version the derived macaroon version ''' - if bakery_version in [bakery.VERSION_0, bakery.VERSION_1]: + if bakery_version in [VERSION_0, VERSION_1]: return pymacaroons.MACAROON_V1 return pymacaroons.MACAROON_V2 @@ -340,7 +356,7 @@ class ThirdPartyStore(ThirdPartyLocator): def third_party_info(self, loc): info = self._store.get(loc.rstrip('/')) if info is None: - raise bakery.ThirdPartyInfoNotFound( + raise ThirdPartyInfoNotFound( 'cannot retrieve the info for location {}'.format(loc)) return info @@ -369,7 +385,7 @@ def _parse_local_location(loc): ''' if not (loc.startswith('local ')): return None - v = bakery.VERSION_1 + v = VERSION_1 fields = loc.split() fields = fields[1:] # Skip 'local' if len(fields) == 2: @@ -379,8 +395,8 @@ def _parse_local_location(loc): return None fields = fields[1:] if len(fields) == 1: - key = bakery.PublicKey.deserialize(fields[0]) - return bakery.ThirdPartyInfo(public_key=key, version=v) + key = PublicKey.deserialize(fields[0]) + return ThirdPartyInfo(public_key=key, version=v) return None @@ -394,11 +410,11 @@ def _bakery_version(v): if v == pymacaroons.MACAROON_V1: # Use version 1 because we don't know of any existing # version 0 clients. - return bakery.VERSION_1 + return VERSION_1 elif v == pymacaroons.MACAROON_V2: # Note that this could also correspond to Version 3, but # this logic is explicitly for legacy versions. - return bakery.VERSION_2 + return VERSION_2 else: raise ValueError('unknown macaroon version when deserializing legacy ' 'bakery macaroon; got {}'.format(v)) diff --git a/macaroonbakery/oven.py b/macaroonbakery/bakery/_oven.py index bf4bd27..414a164 100644 --- a/macaroonbakery/oven.py +++ b/macaroonbakery/bakery/_oven.py @@ -7,16 +7,31 @@ import itertools import os import google -from pymacaroons import MACAROON_V2, Verifier -from pymacaroons.exceptions import ( - MacaroonUnmetCaveatException, MacaroonInvalidSignatureException +from ._checker import (Op, LOGIN_OP) +from ._store import MemoryKeyStore +from ._error import VerificationError +from ._versions import ( + VERSION_2, + VERSION_3, + LATEST_VERSION, +) +from ._macaroon import ( + Macaroon, + macaroon_version, ) -import six -import macaroonbakery as bakery import macaroonbakery.checkers as checkers -from macaroonbakery import utils -from macaroonbakery.internal import id_pb2 +import six +from macaroonbakery._utils import ( + raw_urlsafe_b64encode, + b64decode, +) +from ._internal import id_pb2 +from pymacaroons import MACAROON_V2, Verifier +from pymacaroons.exceptions import ( + MacaroonInvalidSignatureException, + MacaroonUnmetCaveatException, +) class Oven: @@ -64,7 +79,7 @@ class Oven: self.ops_store = ops_store self.root_keystore_for_ops = root_keystore_for_ops if root_keystore_for_ops is None: - my_store = bakery.MemoryKeyStore() + my_store = MemoryKeyStore() self.root_keystore_for_ops = lambda x: my_store def macaroon(self, version, expiry, caveats, ops): @@ -85,15 +100,15 @@ class Oven: id = self._new_macaroon_id(storage_id, expiry, ops) - id_bytes = six.int2byte(bakery.LATEST_VERSION) + \ + id_bytes = six.int2byte(LATEST_VERSION) + \ id.SerializeToString() - if bakery.macaroon_version(version) < MACAROON_V2: + if macaroon_version(version) < MACAROON_V2: # The old macaroon format required valid text for the macaroon id, # so base64-encode it. - id_bytes = utils.raw_urlsafe_b64encode(id_bytes) + id_bytes = raw_urlsafe_b64encode(id_bytes) - m = bakery.Macaroon( + m = Macaroon( root_key, id_bytes, self.location, @@ -155,7 +170,7 @@ class Oven: storage_id, ops = _decode_macaroon_id(macaroons[0].identifier_bytes) root_key = self.root_keystore_for_ops(ops).get(storage_id) if root_key is None: - raise bakery.VerificationError( + raise VerificationError( 'macaroon key not found in storage') v = Verifier() conditions = [] @@ -170,7 +185,7 @@ class Oven: v.verify(macaroons[0], root_key, macaroons[1:]) except (MacaroonUnmetCaveatException, MacaroonInvalidSignatureException) as exc: - raise bakery.VerificationError( + raise VerificationError( 'verification failed: {}'.format(exc.args[0])) if (self.ops_store is not None @@ -196,7 +211,7 @@ def _decode_macaroon_id(id): # Note that old-style ids always start with an ASCII character >= 4 # (> 32 in fact) so this logic won't be triggered for those. try: - dec = utils.b64decode(id.decode('utf-8')) + dec = b64decode(id.decode('utf-8')) # Set the id only on success. id = dec base64_decoded = True @@ -209,23 +224,23 @@ def _decode_macaroon_id(id): # creating macaroons to make all macaroons unique even if # they're using the same root key. first = six.byte2int(id[:1]) - if first == bakery.VERSION_2: + if first == VERSION_2: # Skip the UUID at the start of the id. storage_id = id[1 + 16:] - if first == bakery.VERSION_3: + if first == VERSION_3: try: id1 = id_pb2.MacaroonId.FromString(id[1:]) except google.protobuf.message.DecodeError: - raise bakery.VerificationError( + raise VerificationError( 'no operations found in macaroon') if len(id1.ops) == 0 or len(id1.ops[0].actions) == 0: - raise bakery.VerificationError( + raise VerificationError( 'no operations found in macaroon') ops = [] for op in id1.ops: for action in op.actions: - ops.append(bakery.Op(op.entity, action)) + ops.append(Op(op.entity, action)) return id1.storageId, ops if not base64_decoded and _is_lower_case_hex_char(first): @@ -234,7 +249,7 @@ def _decode_macaroon_id(id): last = id.rfind(b'-') if last >= 0: storage_id = id[0:last] - return storage_id, [bakery.LOGIN_OP] + return storage_id, [LOGIN_OP] def _is_lower_case_hex_char(b): diff --git a/macaroonbakery/store.py b/macaroonbakery/bakery/_store.py index ae5f7a7..ae5f7a7 100644 --- a/macaroonbakery/store.py +++ b/macaroonbakery/bakery/_store.py diff --git a/macaroonbakery/third_party.py b/macaroonbakery/bakery/_third_party.py index 91eacaf..91eacaf 100644 --- a/macaroonbakery/third_party.py +++ b/macaroonbakery/bakery/_third_party.py diff --git a/macaroonbakery/versions.py b/macaroonbakery/bakery/_versions.py index 7446d31..7446d31 100644 --- a/macaroonbakery/versions.py +++ b/macaroonbakery/bakery/_versions.py diff --git a/macaroonbakery/checkers/__init__.py b/macaroonbakery/checkers/__init__.py index 25c6b7d..b3ea466 100644 --- a/macaroonbakery/checkers/__init__.py +++ b/macaroonbakery/checkers/__init__.py @@ -1,6 +1,6 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -from macaroonbakery.checkers.conditions import ( +from ._conditions import ( STD_NAMESPACE, COND_DECLARED, COND_TIME_BEFORE, @@ -9,7 +9,7 @@ from macaroonbakery.checkers.conditions import ( COND_DENY, COND_NEED_DECLARED, ) -from macaroonbakery.checkers.caveat import ( +from ._caveat import ( allow_caveat, deny_caveat, declared_caveat, @@ -17,35 +17,35 @@ from macaroonbakery.checkers.caveat import ( time_before_caveat, Caveat, ) -from macaroonbakery.checkers.declared import ( +from ._declared import ( context_with_declared, infer_declared, infer_declared_from_conditions, need_declared_caveat, ) -from macaroonbakery.checkers.operation import ( +from ._operation import ( context_with_operations, ) -from macaroonbakery.checkers.namespace import ( +from ._namespace import ( Namespace, deserialize_namespace ) -from macaroonbakery.checkers.time import ( +from ._time import ( context_with_clock, expiry_time, macaroons_expiry_time, ) -from macaroonbakery.checkers.checkers import ( +from ._checkers import ( Checker, CheckerInfo, RegisterError, ) -from macaroonbakery.checkers.auth_context import ( +from ._auth_context import ( AuthContext, ContextKey, ) -from macaroonbakery.checkers.utils import ( +from ._utils import ( condition_with_prefix, ) diff --git a/macaroonbakery/checkers/auth_context.py b/macaroonbakery/checkers/_auth_context.py index dceb015..dceb015 100644 --- a/macaroonbakery/checkers/auth_context.py +++ b/macaroonbakery/checkers/_auth_context.py diff --git a/macaroonbakery/checkers/caveat.py b/macaroonbakery/checkers/_caveat.py index a1e564e..5732f43 100644 --- a/macaroonbakery/checkers/caveat.py +++ b/macaroonbakery/checkers/_caveat.py @@ -3,10 +3,13 @@ import collections import pyrfc3339 - -from macaroonbakery.checkers.conditions import ( - STD_NAMESPACE, COND_TIME_BEFORE, COND_ERROR, COND_DENY, COND_ALLOW, - COND_DECLARED +from ._conditions import ( + COND_ALLOW, + COND_DECLARED, + COND_DENY, + COND_ERROR, + COND_TIME_BEFORE, + STD_NAMESPACE, ) diff --git a/macaroonbakery/checkers/checkers.py b/macaroonbakery/checkers/_checkers.py index 776b50b..71cb56f 100644 --- a/macaroonbakery/checkers/checkers.py +++ b/macaroonbakery/checkers/_checkers.py @@ -6,17 +6,20 @@ from datetime import datetime import pyrfc3339 import pytz - -from macaroonbakery.checkers.declared import DECLARED_KEY -from macaroonbakery.checkers.time import TIME_KEY -from macaroonbakery.checkers.operation import OP_KEY -from macaroonbakery.checkers.namespace import Namespace -from macaroonbakery.checkers.caveat import parse_caveat -from macaroonbakery.checkers.conditions import ( - STD_NAMESPACE, COND_DECLARED, COND_ALLOW, COND_DENY, COND_ERROR, - COND_TIME_BEFORE +from ._caveat import parse_caveat +from ._conditions import ( + COND_ALLOW, + COND_DECLARED, + COND_DENY, + COND_ERROR, + COND_TIME_BEFORE, + STD_NAMESPACE, ) -from macaroonbakery.checkers.utils import condition_with_prefix +from ._declared import DECLARED_KEY +from ._namespace import Namespace +from ._operation import OP_KEY +from ._time import TIME_KEY +from ._utils import condition_with_prefix class RegisterError(Exception): diff --git a/macaroonbakery/checkers/conditions.py b/macaroonbakery/checkers/_conditions.py index 74e863e..74e863e 100644 --- a/macaroonbakery/checkers/conditions.py +++ b/macaroonbakery/checkers/_conditions.py diff --git a/macaroonbakery/checkers/declared.py b/macaroonbakery/checkers/_declared.py index 78a6181..ae4f95b 100644 --- a/macaroonbakery/checkers/declared.py +++ b/macaroonbakery/checkers/_declared.py @@ -1,11 +1,13 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -from macaroonbakery.checkers.namespace import Namespace -from macaroonbakery.checkers.caveat import parse_caveat, Caveat, error_caveat -from macaroonbakery.checkers.conditions import ( - COND_DECLARED, COND_NEED_DECLARED, STD_NAMESPACE +from ._auth_context import ContextKey +from ._caveat import Caveat, error_caveat, parse_caveat +from ._conditions import ( + COND_DECLARED, + COND_NEED_DECLARED, + STD_NAMESPACE, ) -from macaroonbakery.checkers.auth_context import ContextKey +from ._namespace import Namespace DECLARED_KEY = ContextKey('declared-key') diff --git a/macaroonbakery/checkers/namespace.py b/macaroonbakery/checkers/_namespace.py index 31e8801..6c3b1e3 100644 --- a/macaroonbakery/checkers/namespace.py +++ b/macaroonbakery/checkers/_namespace.py @@ -2,8 +2,8 @@ # Licensed under the LGPLv3, see LICENCE file for details. import collections -from macaroonbakery.checkers.utils import condition_with_prefix -from macaroonbakery.checkers.caveat import error_caveat +from ._caveat import error_caveat +from ._utils import condition_with_prefix class Namespace: diff --git a/macaroonbakery/checkers/operation.py b/macaroonbakery/checkers/_operation.py index a3b3805..56b267a 100644 --- a/macaroonbakery/checkers/operation.py +++ b/macaroonbakery/checkers/_operation.py @@ -1,6 +1,6 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -from macaroonbakery.checkers.auth_context import ContextKey +from ._auth_context import ContextKey OP_KEY = ContextKey('op-key') diff --git a/macaroonbakery/checkers/time.py b/macaroonbakery/checkers/_time.py index 0b52131..2ae1d89 100644 --- a/macaroonbakery/checkers/time.py +++ b/macaroonbakery/checkers/_time.py @@ -2,12 +2,10 @@ # Licensed under the LGPLv3, see LICENCE file for details. import pyrfc3339 - -from macaroonbakery.checkers.auth_context import ContextKey -from macaroonbakery.checkers.conditions import COND_TIME_BEFORE, STD_NAMESPACE -from macaroonbakery.checkers.utils import condition_with_prefix -from macaroonbakery.checkers.caveat import parse_caveat - +from ._auth_context import ContextKey +from ._caveat import parse_caveat +from ._conditions import COND_TIME_BEFORE, STD_NAMESPACE +from ._utils import condition_with_prefix TIME_KEY = ContextKey('time-key') @@ -54,12 +52,14 @@ def expiry_time(ns, cavs): prefix, COND_TIME_BEFORE) t = None for cav in cavs: + if not cav.first_party(): + continue cav = cav.caveat_id_bytes.decode('utf-8') name, rest = parse_caveat(cav) if name != time_before_cond: continue try: - et = pyrfc3339.parse(rest) + et = pyrfc3339.parse(rest, utc=True).replace(tzinfo=None) if t is None or et < t: t = et except ValueError: diff --git a/macaroonbakery/checkers/utils.py b/macaroonbakery/checkers/_utils.py index 925e8c7..925e8c7 100644 --- a/macaroonbakery/checkers/utils.py +++ b/macaroonbakery/checkers/_utils.py diff --git a/macaroonbakery/httpbakery/__init__.py b/macaroonbakery/httpbakery/__init__.py index 3f183c5..07a805b 100644 --- a/macaroonbakery/httpbakery/__init__.py +++ b/macaroonbakery/httpbakery/__init__.py @@ -1,11 +1,11 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -from macaroonbakery.httpbakery.client import ( +from ._client import ( BakeryException, Client, extract_macaroons, ) -from macaroonbakery.httpbakery.error import ( +from ._error import ( BAKERY_PROTOCOL_HEADER, DischargeError, ERR_DISCHARGE_REQUIRED, @@ -17,18 +17,18 @@ from macaroonbakery.httpbakery.error import ( discharge_required_response, request_version, ) -from macaroonbakery.httpbakery.keyring import ThirdPartyLocator -from macaroonbakery.httpbakery.interactor import ( +from ._keyring import ThirdPartyLocator +from ._interactor import ( DischargeToken, Interactor, LegacyInteractor, WEB_BROWSER_INTERACTION_KIND, ) -from macaroonbakery.httpbakery.browser import ( +from ._browser import ( WebBrowserInteractionInfo, WebBrowserInteractor, ) -from macaroonbakery.httpbakery.discharge import discharge +from ._discharge import discharge __all__ = [ 'BAKERY_PROTOCOL_HEADER', diff --git a/macaroonbakery/httpbakery/browser.py b/macaroonbakery/httpbakery/_browser.py index e3ce538..a1ccbb0 100644 --- a/macaroonbakery/httpbakery/browser.py +++ b/macaroonbakery/httpbakery/_browser.py @@ -2,15 +2,18 @@ # 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 +import requests +from ._error import InteractionError +from ._interactor import ( + WEB_BROWSER_INTERACTION_KIND, + DischargeToken, + Interactor, + LegacyInteractor, ) -from macaroonbakery.httpbakery.error import InteractionError +from macaroonbakery._utils import visit_page_with_browser + +from six.moves.urllib.parse import urljoin class WebBrowserInteractor(Interactor, LegacyInteractor): diff --git a/macaroonbakery/httpbakery/client.py b/macaroonbakery/httpbakery/_client.py index b3036a1..d877140 100644 --- a/macaroonbakery/httpbakery/client.py +++ b/macaroonbakery/httpbakery/_client.py @@ -2,31 +2,35 @@ # Licensed under the LGPLv3, see LICENCE file for details. import base64 import json -import requests -from six.moves.http_cookies import SimpleCookie -from six.moves.urllib.parse import urljoin +import logging -import macaroonbakery as bakery +import macaroonbakery.bakery 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, +import macaroonbakery._utils as utils +from ._browser import WebBrowserInteractor +from ._error import ( + BAKERY_PROTOCOL_HEADER, ERR_DISCHARGE_REQUIRED, ERR_INTERACTION_REQUIRED, + DischargeError, Error, InteractionError, InteractionMethodNotFound, ) -from macaroonbakery.httpbakery.error import BAKERY_PROTOCOL_HEADER -from macaroonbakery.httpbakery.browser import WebBrowserInteractor +from ._interactor import ( + WEB_BROWSER_INTERACTION_KIND, + LegacyInteractor, +) + +import requests +from six.moves.http_cookies import SimpleCookie +from six.moves.urllib.parse import urljoin TIME_OUT = 30 MAX_DISCHARGE_RETRIES = 3 +log = logging.getLogger('httpbakery') + class BakeryException(requests.RequestException): '''Raised when some errors happen using the httpbakery @@ -58,7 +62,7 @@ class Client: if cookies is None: cookies = requests.cookies.RequestsCookieJar() self._interaction_methods = interaction_methods - self._key = key + self.key = key self.cookies = cookies def auth(self): @@ -76,7 +80,10 @@ class Client: requests.request(method, url, auth=client.auth()) ''' - kwargs.setdefault('auth', self.auth()) + # TODO should we raise an exception if auth or cookies are explicitly + # mentioned in kwargs? + kwargs['auth'] = self.auth() + kwargs['cookies'] = self.cookies return requests.request(method=method, url=url, **kwargs) def handle_error(self, error, url): @@ -84,18 +91,20 @@ class Client: 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. + 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') + raise BakeryException('unable to read info in discharge error ' + 'response') discharges = bakery.discharge_all( error.info.macaroon, self.acquire_discharge, - self._key, + self.key, ) - macaroons = '[' + ','.join(map(utils.macaroon_to_json_string, discharges)) + ']' + 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) @@ -104,7 +113,6 @@ class Client: else: 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'), @@ -128,7 +136,8 @@ class Client: raise DischargeError(cause.message) if cause.info is None: raise DischargeError( - 'interaction-required response with no info: {}'.format(resp.json()) + 'interaction-required response with no info: {}'.format( + resp.json()) ) loc = cav.location if not loc.endswith('/'): @@ -141,10 +150,10 @@ class Client: # 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')) + return bakery.Macaroon.from_dict(resp.json().get('Macaroon')) else: - raise DischargeError() + raise DischargeError( + 'discharge failed with code {}'.format(resp.status_code)) def _acquire_discharge_with_token(self, cav, payload, token): req = {} @@ -167,14 +176,14 @@ class Client: error response. @return DischargeToken, bakery.Macaroon ''' - if self._interaction_methods is None or len(self._interaction_methods) == 0: + 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: @@ -184,7 +193,8 @@ class Client: except InteractionMethodNotFound: continue if token is None: - raise InteractionError('interaction method returned an empty token') + raise InteractionError('interaction method returned an empty ' + 'token') return token, None raise InteractionError('no supported interaction method') @@ -195,13 +205,13 @@ class Client: method_urls = { "interactive": visit_url } - if len(self._interaction_methods) > 1 or \ - self._interaction_methods[0].kind() != WEB_BROWSER_INTERACTION_KIND: + 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: @@ -220,7 +230,10 @@ class Client: interactor.legacy_interact(self, location, visit_url) return _wait_for_macaroon(wait_url) - raise InteractionError('no methods supported') + raise InteractionError('no methods supported; supported [{}]; provided [{}]'.format( + ' '.join([x.kind() for x in self._interaction_methods]), + ' '.join(method_urls.keys()), + )) class _BakeryAuth: @@ -290,11 +303,20 @@ def _prepare_discharge_hook(req, client): return hook -def extract_macaroons(headers): +def extract_macaroons(headers_or_request): ''' 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 + If the argument implements a get_header method, that will be used + instead of the get method to retrieve headers. + @param headers_or_request: dict of headers or a + urllib.request.Request-like object. + @return: A list of list of mpy macaroons ''' + def get_header(key, default=None): + try: + return headers_or_request.get_header(key, default) + except AttributeError: + return headers_or_request.get(key, default) + mss = [] def add_macaroon(data): @@ -303,22 +325,22 @@ def extract_macaroons(headers): ms = [utils.macaroon_from_dict(x) for x in data_as_objs] mss.append(ms) - cookieHeader = headers.get('Cookie') - if cookieHeader is not None: + cookie_header = get_header('Cookie') + if cookie_header 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)) + cs.load(str(cookie_header)) 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(','): + # for HTTP1.1 compatibility anyway (see RFC 7230, section 3.2.2) + macaroon_header = get_header('Macaroons') + if macaroon_header is not None: + for h in macaroon_header.split(','): add_macaroon(h) return mss @@ -345,7 +367,7 @@ def _wait_for_macaroon(wait_url): } resp = requests.get(url=wait_url, headers=headers) if resp.status_code != 200: - return InteractionError('cannot get {}'.format(wait_url)) + raise InteractionError('cannot get {}'.format(wait_url)) return bakery.Macaroon.from_dict(resp.json().get('Macaroon')) @@ -376,8 +398,7 @@ def _legacy_get_interaction_methods(u): 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]) + 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 diff --git a/macaroonbakery/httpbakery/discharge.py b/macaroonbakery/httpbakery/_discharge.py index ef3481a..f868d23 100644 --- a/macaroonbakery/httpbakery/discharge.py +++ b/macaroonbakery/httpbakery/_discharge.py @@ -1,7 +1,7 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -import macaroonbakery.utils as utils -import macaroonbakery as bakery +import macaroonbakery.bakery as bakery +import macaroonbakery._utils as utils def discharge(ctx, content, key, locator, checker): @@ -11,7 +11,8 @@ def discharge(ctx, content, key, locator, checker): @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} + @param checker {macaroonbakery.ThirdPartyCaveatChecker} Used to check third + party caveats. @return The discharge macaroon {macaroonbakery.Macaroon} ''' id = content.get('id') diff --git a/macaroonbakery/httpbakery/error.py b/macaroonbakery/httpbakery/_error.py index 422b346..ff75f13 100644 --- a/macaroonbakery/httpbakery/error.py +++ b/macaroonbakery/httpbakery/_error.py @@ -1,9 +1,9 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -from collections import namedtuple import json +from collections import namedtuple -import macaroonbakery as bakery +import macaroonbakery.bakery as bakery ERR_INTERACTION_REQUIRED = 'interaction required' ERR_DISCHARGE_REQUIRED = 'macaroon discharge required' @@ -19,7 +19,8 @@ class InteractionMethodNotFound(Exception): 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)) + super(DischargeError, self).__init__( + 'third party refused discharge: {}'.format(msg)) class InteractionError(Exception): @@ -27,7 +28,8 @@ class InteractionError(Exception): interaction-required error ''' def __init__(self, msg): - super(InteractionError, self).__init__('cannot start interactive session: {}'.format(msg)) + super(InteractionError, self).__init__( + 'cannot start interactive session: {}'.format(msg)) def discharge_required_response(macaroon, path, cookie_suffix_name, diff --git a/macaroonbakery/httpbakery/interactor.py b/macaroonbakery/httpbakery/_interactor.py index 0c15338..7fba4ef 100644 --- a/macaroonbakery/httpbakery/interactor.py +++ b/macaroonbakery/httpbakery/_interactor.py @@ -18,8 +18,7 @@ class Interactor(object): the Error.interaction_methods type. @return {str} ''' - raise NotImplementedError('kind method must be defined in ' - 'subclass') + 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 @@ -37,8 +36,7 @@ class Interactor(object): take place {Error} @return {DischargeToken} The discharge token. ''' - raise NotImplementedError('interact method must be defined in ' - 'subclass') + raise NotImplementedError('interact method must be defined in subclass') class LegacyInteractor(object): @@ -59,8 +57,7 @@ class LegacyInteractor(object): @param visit_url The visit_url field from the error {str} @return None ''' - raise NotImplementedError('legacy_interact method must be defined in ' - 'subclass') + raise NotImplementedError('legacy_interact method must be defined in subclass') class DischargeToken(namedtuple('DischargeToken', 'kind, value')): diff --git a/macaroonbakery/httpbakery/keyring.py b/macaroonbakery/httpbakery/_keyring.py index 01a4349..8d9ab43 100644 --- a/macaroonbakery/httpbakery/keyring.py +++ b/macaroonbakery/httpbakery/_keyring.py @@ -1,10 +1,10 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -from six.moves.urllib.parse import urlparse +import macaroonbakery.bakery as bakery import requests +from ._error import BAKERY_PROTOCOL_HEADER -import macaroonbakery as bakery -from macaroonbakery.httpbakery.error import BAKERY_PROTOCOL_HEADER +from six.moves.urllib.parse import urlparse class ThirdPartyLocator(bakery.ThirdPartyLocator): diff --git a/macaroonbakery/httpbakery/agent/__init__.py b/macaroonbakery/httpbakery/agent/__init__.py index db252de..c0a7523 100644 --- a/macaroonbakery/httpbakery/agent/__init__.py +++ b/macaroonbakery/httpbakery/agent/__init__.py @@ -1,8 +1,9 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -from macaroonbakery.httpbakery.agent.agent import ( - load_agent_file, +from ._agent import ( + load_auth_info, + read_auth_info, Agent, AgentInteractor, AgentFileFormatError, @@ -13,5 +14,6 @@ __all__ = [ 'AgentFileFormatError', 'AgentInteractor', 'AuthInfo', - 'load_agent_file', + 'load_auth_info', + 'read_auth_info', ] diff --git a/macaroonbakery/httpbakery/agent/agent.py b/macaroonbakery/httpbakery/agent/_agent.py index ad56015..b717261 100644 --- a/macaroonbakery/httpbakery/agent/agent.py +++ b/macaroonbakery/httpbakery/agent/_agent.py @@ -1,20 +1,18 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -import base64 -from collections import namedtuple +import copy import json +import logging +from collections import namedtuple -import nacl.public -import nacl.encoding -import nacl.exceptions +import macaroonbakery.bakery as bakery +import macaroonbakery.httpbakery as httpbakery +import macaroonbakery._utils as utils 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 +log = logging.getLogger(__name__) class AgentFileFormatError(Exception): @@ -24,40 +22,41 @@ class AgentFileFormatError(Exception): 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. +def load_auth_info(filename): + '''Loads agent authentication information from the specified file. + The returned information is suitable for passing as an argument + to the AgentInteractor constructor. + @param filename The name of the file to open (str) + @return AuthInfo The authentication information + @raises AgentFileFormatError when the file format is bad. ''' - with open(filename) as f: - data = json.load(f) + return read_auth_info(f.read()) + + +def read_auth_info(agent_file_content): + '''Loads agent authentication information from the + specified content string, as read from an agents file. + The returned information is suitable for passing as an argument + to the AgentInteractor constructor. + @param agent_file_content The agent file content (str) + @return AuthInfo The authentication information + @raises AgentFileFormatError when the file format is bad. + ''' try: - key = nacl.public.PrivateKey( - data['key']['private'], - nacl.encoding.Base64Encoder, + data = json.loads(agent_file_content) + return AuthInfo( + key=bakery.PrivateKey.deserialize(data['key']['private']), + agents=list( + Agent(url=a['url'], username=a['username']) + for a in data.get('agents', []) + ), ) - 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, nacl.exceptions.TypeError) as e: + except ( + KeyError, + ValueError, + TypeError, + ) as e: raise AgentFileFormatError('invalid agent file', e) @@ -110,9 +109,10 @@ class AgentInteractor(httpbakery.Interactor, httpbakery.LegacyInteractor): if not location.endswith('/'): location += '/' login_url = urljoin(location, p.login_url) + # TODO use client to make the request. resp = requests.get(login_url, json={ 'Username': agent.username, - 'PublicKey': self._auth_info.key.encode().decode('utf-8'), + 'PublicKey': str(self._auth_info.key), }) if resp.status_code != 200: raise httpbakery.InteractionError( @@ -144,30 +144,31 @@ class AgentInteractor(httpbakery.Interactor, httpbakery.LegacyInteractor): 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( + # Shallow-copy the client so that we don't unexpectedly side-effect + # it by changing the key. Another possibility might be to + # set up agent authentication differently, in such a way that + # we're sure that client.key is the same as self._auth_info.key. + client = copy.copy(client) + client.key = self._auth_info.key + resp = client.request( + method='POST', 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()) + json={ + 'username': agent.username, + 'public_key': str(self._auth_info.key.public_key), + }, + ) if resp.status_code != 200: raise httpbakery.InteractionError( - 'cannot acquire agent macaroon: {}'.format(resp.status_code)) - if not resp.json().get('agent-login', False): + 'cannot acquire agent macaroon from {}: {} (response body: {!r})'.format(visit_url, resp.status_code, resp.text)) + 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 url(string) holds the URL of the discharger that knows about + the agent. @param username holds the username agent (string). ''' diff --git a/macaroonbakery/tests/common.py b/macaroonbakery/tests/common.py index f238dfd..cfbfc52 100644 --- a/macaroonbakery/tests/common.py +++ b/macaroonbakery/tests/common.py @@ -2,10 +2,9 @@ # Licensed under the LGPLv3, see LICENCE file for details. from datetime import datetime, timedelta -import pytz - -import macaroonbakery as bakery +import macaroonbakery.bakery as bakery import macaroonbakery.checkers as checkers +import pytz class _StoppedClock(object): diff --git a/macaroonbakery/tests/test_agent.py b/macaroonbakery/tests/test_agent.py index 67f5b84..3b38337 100644 --- a/macaroonbakery/tests/test_agent.py +++ b/macaroonbakery/tests/test_agent.py @@ -1,27 +1,22 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -import base64 -from datetime import datetime, timedelta import json +import logging import os import tempfile +from datetime import datetime, timedelta from unittest import TestCase -import nacl.encoding -import requests.cookies -import six -from six.moves.urllib.parse import parse_qs -from six.moves.http_cookies import SimpleCookie -from httmock import ( - HTTMock, - urlmatch, - response -) - -import macaroonbakery as bakery -import macaroonbakery.httpbakery as httpbakery +import macaroonbakery.bakery as bakery import macaroonbakery.checkers as checkers +import macaroonbakery.httpbakery as httpbakery import macaroonbakery.httpbakery.agent as agent +import requests.cookies + +from httmock import HTTMock, response, urlmatch +from six.moves.urllib.parse import parse_qs + +log = logging.getLogger(__name__) class TestAgents(TestCase): @@ -44,73 +39,31 @@ class TestAgents(TestCase): os.remove(self.bad_key_agent_filename) os.remove(self.no_username_agent_filename) - def test_load_agents(self): - cookies, key = agent.load_agent_file(self.agent_filename) - self.assertEqual(key.encode(nacl.encoding.Base64Encoder), - b'CqoSgj06Zcgb4/S6RT4DpTjLAfKoznEY3JsShSjKJEU=') - self.assertEqual( - key.public_key.encode(nacl.encoding.Base64Encoder), - b'YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=') - - value = cookies.get('agent-login', domain='1.example.com') - jv = base64.b64decode(value) - if six.PY3: - jv = jv.decode('utf-8') - data = json.loads(jv) - self.assertEqual(data['username'], 'user-1') - self.assertEqual(data['public_key'], - 'YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=') - - value = cookies.get('agent-login', domain='2.example.com', - path='/discharger') - jv = base64.b64decode(value) - if six.PY3: - jv = jv.decode('utf-8') - data = json.loads(jv) - self.assertEqual(data['username'], 'user-2') - self.assertEqual(data['public_key'], - 'YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=') - - def test_load_agents_into_cookies(self): - cookies = requests.cookies.RequestsCookieJar() - c1, key = agent.load_agent_file( - self.agent_filename, - cookies=cookies, - ) - self.assertEqual(c1, cookies) - self.assertEqual( - key.encode(nacl.encoding.Base64Encoder), - b'CqoSgj06Zcgb4/S6RT4DpTjLAfKoznEY3JsShSjKJEU=', - ) - self.assertEqual( - key.public_key.encode(nacl.encoding.Base64Encoder), - b'YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=', - ) - - value = cookies.get('agent-login', domain='1.example.com') - jv = base64.b64decode(value) - if six.PY3: - jv = jv.decode('utf-8') - data = json.loads(jv) - self.assertEqual(data['username'], 'user-1') - self.assertEqual(data['public_key'], 'YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=') - - value = cookies.get('agent-login', domain='2.example.com', - path='/discharger') - jv = base64.b64decode(value) - if six.PY3: - jv = jv.decode('utf-8') - data = json.loads(jv) - self.assertEqual(data['username'], 'user-2') - self.assertEqual(data['public_key'], 'YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=') - - def test_load_agents_with_bad_key(self): + def test_load_auth_info(self): + auth_info = agent.load_auth_info(self.agent_filename) + self.assertEqual(str(auth_info.key), 'CqoSgj06Zcgb4/S6RT4DpTjLAfKoznEY3JsShSjKJEU=') + self.assertEqual(str(auth_info.key.public_key), 'YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=') + self.assertEqual(auth_info.agents, [ + agent.Agent(url='https://1.example.com/', username='user-1'), + agent.Agent(url='https://2.example.com/discharger', username='user-2'), + agent.Agent(url='http://0.3.2.1', username='test-user'), + ]) + + def test_invalid_agent_json(self): + with self.assertRaises(agent.AgentFileFormatError): + agent.read_auth_info('}') + + def test_invalid_read_auth_info_arg(self): with self.assertRaises(agent.AgentFileFormatError): - agent.load_agent_file(self.bad_key_agent_filename) + agent.read_auth_info(0) - def test_load_agents_with_no_username(self): + def test_load_auth_info_with_bad_key(self): with self.assertRaises(agent.AgentFileFormatError): - agent.load_agent_file(self.no_username_agent_filename) + agent.load_auth_info(self.bad_key_agent_filename) + + def test_load_auth_info_with_no_username(self): + with self.assertRaises(agent.AgentFileFormatError): + agent.load_auth_info(self.no_username_agent_filename) def test_agent_login(self): discharge_key = bakery.generate_key() @@ -138,7 +91,8 @@ class TestAgents(TestCase): content='done') except bakery.PermissionDenied: caveats = [ - checkers.Caveat(location='http://0.3.2.1', condition='is-ok') + checkers.Caveat(location='http://0.3.2.1', + condition='is-ok') ] m = server_bakery.oven.macaroon( version=bakery.LATEST_VERSION, @@ -177,11 +131,11 @@ class TestAgents(TestCase): return { 'status_code': 200, 'content': { - 'Macaroon': m.serialize_json() + 'Macaroon': m.to_dict() } } - key = bakery.generate_key() + auth_info = agent.load_auth_info(self.agent_filename) @urlmatch(path='.*/login') def login(url, request): @@ -190,7 +144,7 @@ class TestAgents(TestCase): version=bakery.LATEST_VERSION, expiry=datetime.utcnow() + timedelta(days=1), caveats=[bakery.local_third_party_caveat( - key.public_key, + auth_info.key.public_key, version=httpbakery.request_version(request.headers))], ops=[bakery.Op(entity='agent', action='login')]) return { @@ -204,17 +158,7 @@ class TestAgents(TestCase): HTTMock(discharge), \ HTTMock(login): client = httpbakery.Client(interaction_methods=[ - agent.AgentInteractor( - agent.AuthInfo( - key=key, - agents=[ - agent.Agent( - username='test-user', - url=u'http://0.3.2.1' - ) - ], - ), - ), + agent.AgentInteractor(auth_info), ]) resp = requests.get( 'http://0.1.2.3/here', @@ -315,25 +259,26 @@ class TestAgents(TestCase): key = bakery.generate_key() - @urlmatch(path='.*/visit?$') + @urlmatch(path='.*/visit') def visit(url, request): if request.headers.get('Accept') == 'application/json': return { 'status_code': 200, 'content': { - 'agent': request.url + 'agent': '/agent-visit', } } - cs = SimpleCookie() - cookies = request.headers.get('Cookie') - if cookies is not None: - cs.load(str(cookies)) - public_key = None - for c in cs: - if c == 'agent-login': - json_cookie = json.loads( - base64.b64decode(cs[c].value).decode('utf-8')) - public_key = bakery.PublicKey.deserialize(json_cookie.get('public_key')) + raise Exception('unexpected call to visit without Accept header') + + @urlmatch(path='.*/agent-visit') + def agent_visit(url, request): + if request.method != "POST": + raise Exception('unexpected method') + log.info('agent_visit url {}'.format(url)) + body = json.loads(request.body.decode('utf-8')) + if body['username'] != 'test-user': + raise Exception('unexpected username in body {!r}'.format(request.body)) + public_key = bakery.PublicKey.deserialize(body['public_key']) ms = httpbakery.extract_macaroons(request.headers) if len(ms) == 0: b = bakery.Bakery(key=discharge_key) @@ -356,11 +301,11 @@ class TestAgents(TestCase): return { 'status_code': 200, 'content': { - 'agent-login': True + 'agent_login': True } } - @urlmatch(path='.*/wait?$') + @urlmatch(path='.*/wait$') def wait(url, request): class EmptyChecker(bakery.ThirdPartyCaveatChecker): def check_third_party_caveat(self, ctx, info): @@ -385,12 +330,14 @@ class TestAgents(TestCase): with HTTMock(server_get), \ HTTMock(discharge), \ HTTMock(visit), \ - HTTMock(wait): + HTTMock(wait), \ + HTTMock(agent_visit): client = httpbakery.Client(interaction_methods=[ agent.AgentInteractor( agent.AuthInfo( key=key, - agents=[agent.Agent(username='test-user', url=u'http://0.3.2.1')], + agents=[agent.Agent(username='test-user', + url=u'http://0.3.2.1')], ), ), ]) @@ -414,11 +361,13 @@ agent_file = ''' }, { "url": "https://2.example.com/discharger", "username": "user-2" + }, { + "url": "http://0.3.2.1", + "username": "test-user" }] } ''' - bad_key_agent_file = ''' { "key": { diff --git a/macaroonbakery/tests/test_authorizer.py b/macaroonbakery/tests/test_authorizer.py index f90d2b5..d5539b7 100644 --- a/macaroonbakery/tests/test_authorizer.py +++ b/macaroonbakery/tests/test_authorizer.py @@ -2,7 +2,7 @@ # Licensed under the LGPLv3, see LICENCE file for details. from unittest import TestCase -import macaroonbakery as bakery +import macaroonbakery.bakery as bakery import macaroonbakery.checkers as checkers diff --git a/macaroonbakery/tests/test_bakery.py b/macaroonbakery/tests/test_bakery.py index 5a13cff..a6c3e58 100644 --- a/macaroonbakery/tests/test_bakery.py +++ b/macaroonbakery/tests/test_bakery.py @@ -2,19 +2,11 @@ # Licensed under the LGPLv3, see LICENCE file for details. from unittest import TestCase +import macaroonbakery.httpbakery as httpbakery import requests +from mock import patch -from mock import ( - patch, -) - -from httmock import ( - HTTMock, - urlmatch, - response -) - -import macaroonbakery.httpbakery as httpbakery +from httmock import HTTMock, response, urlmatch ID_PATH = 'http://example.com/someprotecteurl' @@ -29,7 +21,7 @@ json_macaroon = { }, { u'cid': u'allow read-no-terms write' }, { - u'cid': u'time-before 2016-07-19T14:29:14.312669464Z' + u'cid': u'time-before 2158-07-19T14:29:14.312669464Z' }], u'location': u'charmstore', u'signature': u'52d17cb11f5c84d58441bc0ffd7cc396' @@ -41,7 +33,7 @@ discharge_token = [{ u'caveats': [{ u'cid': u'declared username someone' }, { - u'cid': u'time-before 2016-08-15T15:55:52.428319076Z' + u'cid': u'time-before 2158-08-15T15:55:52.428319076Z' }, { u'cid': u'origin ' }], @@ -57,7 +49,7 @@ discharged_macaroon = { }, { u'cid': u'declared username someone' }, { - u'cid': u'time-before 2016-07-19T15:55:52.432439055Z' + u'cid': u'time-before 2158-07-19T15:55:52.432439055Z' }], u'location': u'', u'signature': u'3513db5503ab17f9576760cd28' @@ -167,6 +159,17 @@ def wait_after_401(url, request): } +@urlmatch(path='.*/wait') +def wait_on_error(url, request): + return { + 'status_code': 500, + 'content': { + 'DischargeToken': discharge_token, + 'Macaroon': discharged_macaroon + } + } + + class TestBakery(TestCase): def assert_cookie_security(self, cookies, name, secure): @@ -185,12 +188,14 @@ class TestBakery(TestCase): auth=client.auth()) resp.raise_for_status() assert 'macaroon-test' in client.cookies.keys() - self.assert_cookie_security(client.cookies, 'macaroon-test', secure=False) + self.assert_cookie_security(client.cookies, 'macaroon-test', + secure=False) @patch('webbrowser.open') def test_407_then_401_on_discharge(self, mock_open): client = httpbakery.Client() - with HTTMock(first_407_then_200), HTTMock(discharge_401), HTTMock(wait_after_401): + with HTTMock(first_407_then_200), HTTMock(discharge_401), \ + HTTMock(wait_after_401): resp = requests.get( ID_PATH, cookies=client.cookies, @@ -200,6 +205,53 @@ class TestBakery(TestCase): mock_open.assert_called_once_with(u'http://example.com/visit', new=1) assert 'macaroon-test' in client.cookies.keys() + @patch('webbrowser.open') + def test_407_then_error_on_wait(self, mock_open): + client = httpbakery.Client() + with HTTMock(first_407_then_200), HTTMock(discharge_401),\ + HTTMock(wait_on_error): + with self.assertRaises(httpbakery.InteractionError) as exc: + requests.get( + ID_PATH, + cookies=client.cookies, + auth=client.auth(), + ) + self.assertEqual(str(exc.exception), + 'cannot start interactive session: cannot get ' + 'http://example.com/wait') + mock_open.assert_called_once_with(u'http://example.com/visit', new=1) + + def test_407_then_no_interaction_methods(self): + client = httpbakery.Client(interaction_methods=[]) + with HTTMock(first_407_then_200), HTTMock(discharge_401): + with self.assertRaises(httpbakery.InteractionError) as exc: + requests.get( + ID_PATH, + cookies=client.cookies, + auth=client.auth(), + ) + self.assertEqual(str(exc.exception), + 'cannot start interactive session: interaction ' + 'required but not possible') + + def test_407_then_unknown_interaction_methods(self): + class UnknownInteractor(httpbakery.Interactor): + def kind(self): + return 'unknown' + client = httpbakery.Client(interaction_methods=[UnknownInteractor()]) + with HTTMock(first_407_then_200), HTTMock(discharge_401): + with self.assertRaises(httpbakery.InteractionError) as exc: + requests.get( + ID_PATH, + cookies=client.cookies, + auth=client.auth(), + ) + self.assertEqual( + str(exc.exception), + 'cannot start interactive session: no methods supported; ' + 'supported [unknown]; provided [interactive]' + ) + def test_cookie_with_port(self): client = httpbakery.Client() with HTTMock(first_407_then_200_with_port): @@ -219,4 +271,5 @@ class TestBakery(TestCase): auth=client.auth()) resp.raise_for_status() assert 'macaroon-test' in client.cookies.keys() - self.assert_cookie_security(client.cookies, 'macaroon-test', secure=True) + self.assert_cookie_security(client.cookies, 'macaroon-test', + secure=True) diff --git a/macaroonbakery/tests/test_checker.py b/macaroonbakery/tests/test_checker.py index 643c756..6b61768 100644 --- a/macaroonbakery/tests/test_checker.py +++ b/macaroonbakery/tests/test_checker.py @@ -1,17 +1,16 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. import base64 -from collections import namedtuple import json -from unittest import TestCase +from collections import namedtuple from datetime import timedelta +from unittest import TestCase -from pymacaroons.verifier import Verifier, FirstPartyCaveatVerifierDelegate -import pymacaroons - -import macaroonbakery as bakery +import macaroonbakery.bakery as bakery import macaroonbakery.checkers as checkers -from macaroonbakery.tests.common import test_context, epoch, test_checker +import pymacaroons +from macaroonbakery.tests.common import epoch, test_checker, test_context +from pymacaroons.verifier import FirstPartyCaveatVerifierDelegate, Verifier class TestChecker(TestCase): @@ -53,7 +52,8 @@ class TestChecker(TestCase): client = _Client(locator) ctx = test_context.with_value(_DISCHARGE_USER_KEY, 'bob') - auth_info = client.do(ctx, ts, [bakery.Op(entity='something', action='read')]) + auth_info = client.do(ctx, ts, [bakery.Op(entity='something', + action='read')]) self.assertEqual(self._discharges, [_DischargeRecord(location='ids', user='bob')]) self.assertIsNotNone(auth_info) @@ -98,7 +98,8 @@ class TestChecker(TestCase): self.assertIsNotNone(auth_info) self.assertIsNone(auth_info.identity) self.assertEqual(len(auth_info.macaroons), 1) - self.assertEqual(auth_info.macaroons[0][0].identifier_bytes, m[0].identifier_bytes) + self.assertEqual(auth_info.macaroons[0][0].identifier_bytes, + m[0].identifier_bytes) def test_capability_multiple_entities(self): locator = _DischargerLocator() @@ -168,8 +169,10 @@ class TestChecker(TestCase): self.assertIsNotNone(auth_info) self.assertIsNone(auth_info.identity) self.assertEqual(len(auth_info.macaroons), 2) - self.assertEqual(auth_info.macaroons[0][0].identifier_bytes, m1[0].identifier_bytes) - self.assertEqual(auth_info.macaroons[1][0].identifier_bytes, m2[0].identifier_bytes) + self.assertEqual(auth_info.macaroons[0][0].identifier_bytes, + m1[0].identifier_bytes) + self.assertEqual(auth_info.macaroons[1][0].identifier_bytes, + m2[0].identifier_bytes) def test_combine_capabilities(self): locator = _DischargerLocator() @@ -560,8 +563,10 @@ class TestChecker(TestCase): # Try them the other way around and we should authenticate as alice. client3 = _Client(locator) - client3.add_macaroon(ts, '1.alice', client2._macaroons[ts.name()]['authn']) - client3.add_macaroon(ts, '2.bob', client1._macaroons[ts.name()]['authn']) + client3.add_macaroon(ts, '1.alice', + client2._macaroons[ts.name()]['authn']) + client3.add_macaroon(ts, '2.bob', + client1._macaroons[ts.name()]['authn']) auth_info = client3.do(test_context, ts, [bakery.LOGIN_OP]) self.assertEqual(auth_info.identity.id(), 'alice') @@ -890,7 +895,8 @@ class _BasicAuthIdService(bakery.IdentityClient): return bakery.SimpleIdentity(user), None def declared_identity(self, ctx, declared): - raise bakery.IdentityError('no identity declarations in basic auth id service') + raise bakery.IdentityError('no identity declarations in basic auth' + ' id service') _BASIC_AUTH_KEY = checkers.ContextKey('user-key') diff --git a/macaroonbakery/tests/test_checkers.py b/macaroonbakery/tests/test_checkers.py index f552fa4..28da06e 100644 --- a/macaroonbakery/tests/test_checkers.py +++ b/macaroonbakery/tests/test_checkers.py @@ -3,11 +3,10 @@ from datetime import datetime, timedelta from unittest import TestCase -import six -import pytz -from pymacaroons import Macaroon, MACAROON_V2 - import macaroonbakery.checkers as checkers +import pytz +import six +from pymacaroons import MACAROON_V2, Macaroon # A frozen time for the tests. NOW = datetime( diff --git a/macaroonbakery/tests/test_client.py b/macaroonbakery/tests/test_client.py index e1a4009..ab20c3b 100644 --- a/macaroonbakery/tests/test_client.py +++ b/macaroonbakery/tests/test_client.py @@ -3,23 +3,24 @@ import base64 import datetime import json -from unittest import TestCase -try: - from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler -except ImportError: - from http.server import HTTPServer, BaseHTTPRequestHandler import threading +from unittest import TestCase -from httmock import ( - HTTMock, - urlmatch -) +import macaroonbakery.bakery as bakery +import macaroonbakery.checkers as checkers +import macaroonbakery.httpbakery as httpbakery +import pymacaroons import requests +import macaroonbakery._utils as utils + +from httmock import HTTMock, urlmatch from six.moves.urllib.parse import parse_qs +from six.moves.urllib.request import Request -import macaroonbakery as bakery -import macaroonbakery.httpbakery as httpbakery -import macaroonbakery.checkers as checkers +try: + from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler +except ImportError: + from http.server import HTTPServer, BaseHTTPRequestHandler AGES = datetime.datetime.utcnow() + datetime.timedelta(days=1) TEST_OP = bakery.Op(entity='test', action='test') @@ -30,7 +31,7 @@ class TestClient(TestCase): b = new_bakery('loc', None, None) def handler(*args): - GetHandler(b, None, None, None, None, *args) + GetHandler(b, None, None, None, None, AGES, *args) try: httpd = HTTPServer(('', 0), handler) thread = threading.Thread(target=httpd.serve_forever) @@ -58,7 +59,7 @@ class TestClient(TestCase): b = new_bakery('loc', None, None) def handler(*args): - GetHandler(b, None, None, None, None, *args) + GetHandler(b, None, None, None, None, AGES, *args) try: httpd = HTTPServer(('', 0), handler) thread = threading.Thread(target=httpd.serve_forever) @@ -81,7 +82,7 @@ class TestClient(TestCase): finally: httpd.shutdown() - def test_repeated_request_with_body(self): + def test_expiry_cookie_is_set(self): class _DischargerLocator(bakery.ThirdPartyLocator): def __init__(self): self.key = bakery.generate_key() @@ -100,7 +101,8 @@ class TestClient(TestCase): def discharge(url, request): qs = parse_qs(request.body) content = {q: qs[q][0] for q in qs} - m = httpbakery.discharge(checkers.AuthContext(), content, d.key, d, alwaysOK3rd) + m = httpbakery.discharge(checkers.AuthContext(), content, d.key, d, + alwaysOK3rd) return { 'status_code': 200, 'content': { @@ -108,8 +110,10 @@ class TestClient(TestCase): } } + ages = datetime.datetime.utcnow() + datetime.timedelta(days=1) + def handler(*args): - GetHandler(b, 'http://1.2.3.4', None, None, None, *args) + GetHandler(b, 'http://1.2.3.4', None, None, None, ages, *args) try: httpd = HTTPServer(('', 0), handler) thread = threading.Thread(target=httpd.serve_forever) @@ -122,10 +126,64 @@ class TestClient(TestCase): cookies=client.cookies, auth=client.auth()) resp.raise_for_status() + m = bakery.Macaroon.from_dict(json.loads( + base64.b64decode(client.cookies.get('macaroon-test')).decode('utf-8'))[0]) + t = checkers.macaroons_expiry_time( + checkers.Namespace(), [m.macaroon]) + self.assertEquals(ages, t) self.assertEquals(resp.text, 'done') finally: httpd.shutdown() + def test_expiry_cookie_set_in_past(self): + class _DischargerLocator(bakery.ThirdPartyLocator): + def __init__(self): + self.key = bakery.generate_key() + + def third_party_info(self, loc): + if loc == 'http://1.2.3.4': + return bakery.ThirdPartyInfo( + public_key=self.key.public_key, + version=bakery.LATEST_VERSION, + ) + + d = _DischargerLocator() + b = new_bakery('loc', d, None) + + @urlmatch(path='.*/discharge') + def discharge(url, request): + qs = parse_qs(request.body) + content = {q: qs[q][0] for q in qs} + m = httpbakery.discharge(checkers.AuthContext(), content, d.key, d, + alwaysOK3rd) + return { + 'status_code': 200, + 'content': { + 'Macaroon': m.to_dict() + } + } + + ages = datetime.datetime.utcnow() - datetime.timedelta(days=1) + + def handler(*args): + GetHandler(b, 'http://1.2.3.4', None, None, None, ages, *args) + try: + httpd = HTTPServer(('', 0), handler) + thread = threading.Thread(target=httpd.serve_forever) + thread.start() + client = httpbakery.Client() + with HTTMock(discharge): + with self.assertRaises(httpbakery.BakeryException) as ctx: + requests.get( + url='http://' + httpd.server_address[0] + ':' + + str(httpd.server_address[1]), + cookies=client.cookies, + auth=client.auth()) + self.assertEqual(ctx.exception.args[0], + 'too many (3) discharge requests') + finally: + httpd.shutdown() + def test_too_many_discharge(self): class _DischargerLocator(bakery.ThirdPartyLocator): def __init__(self): @@ -155,7 +213,7 @@ class TestClient(TestCase): } def handler(*args): - GetHandler(b, 'http://1.2.3.4', None, None, None, *args) + GetHandler(b, 'http://1.2.3.4', None, None, None, AGES, *args) try: httpd = HTTPServer(('', 0), handler) thread = threading.Thread(target=httpd.serve_forever) @@ -199,7 +257,7 @@ class TestClient(TestCase): ThirdPartyCaveatCheckerF(check)) def handler(*args): - GetHandler(b, 'http://1.2.3.4', None, None, None, *args) + GetHandler(b, 'http://1.2.3.4', None, None, None, AGES, *args) try: httpd = HTTPServer(('', 0), handler) thread = threading.Thread(target=httpd.serve_forever) @@ -244,7 +302,7 @@ class TestClient(TestCase): } def handler(*args): - GetHandler(b, 'http://1.2.3.4', None, None, None, *args) + GetHandler(b, 'http://1.2.3.4', None, None, None, AGES, *args) try: httpd = HTTPServer(('', 0), handler) @@ -273,11 +331,34 @@ class TestClient(TestCase): finally: httpd.shutdown() + def test_extract_macaroons_from_request(self): + def encode_macaroon(m): + macaroons = '[' + utils.macaroon_to_json_string(m) + ']' + return base64.urlsafe_b64encode(utils.to_bytes(macaroons)).decode('ascii') + + req = Request('http://example.com') + m1 = pymacaroons.Macaroon(version=pymacaroons.MACAROON_V2, identifier='one') + req.add_header('Macaroons', encode_macaroon(m1)) + m2 = pymacaroons.Macaroon(version=pymacaroons.MACAROON_V2, identifier='two') + jar = requests.cookies.RequestsCookieJar() + jar.set_cookie(utils.cookie( + name='macaroon-auth', + value=encode_macaroon(m2), + url='http://example.com', + )) + jar.add_cookie_header(req) + + macaroons = httpbakery.extract_macaroons(req) + self.assertEquals(len(macaroons), 2) + macaroons.sort(key=lambda ms: ms[0].identifier) + self.assertEquals(macaroons[0][0].identifier, m1.identifier) + self.assertEquals(macaroons[1][0].identifier, m2.identifier) + class GetHandler(BaseHTTPRequestHandler): '''A mock HTTP server that serves a GET request''' def __init__(self, bakery, auth_location, mutate_error, - caveats, version, *args): + caveats, version, expiry, *args): ''' @param bakery used to check incoming requests and macaroons for discharge-required errors. @@ -288,14 +369,17 @@ class GetHandler(BaseHTTPRequestHandler): discharge-required error before responding to the client. @param caveats called to get caveats to add to the returned macaroon. - @param holds the version of the bakery that the + @param version holds the version of the bakery that the server will purport to serve. + @param expiry holds the expiry for the macaroon that will be created + in _write_discharge_error ''' self._bakery = bakery self._auth_location = auth_location self._mutate_error = mutate_error self._caveats = caveats self._server_version = version + self._expiry = expiry BaseHTTPRequestHandler.__init__(self, *args) def do_GET(self): @@ -333,7 +417,7 @@ class GetHandler(BaseHTTPRequestHandler): caveats.extend(self._caveats) m = self._bakery.oven.macaroon( - version=bakery.LATEST_VERSION, expiry=AGES, + version=bakery.LATEST_VERSION, expiry=self._expiry, caveats=caveats, ops=[TEST_OP]) content, headers = httpbakery.discharge_required_response( diff --git a/macaroonbakery/tests/test_codec.py b/macaroonbakery/tests/test_codec.py index d4fbc57..d82a794 100644 --- a/macaroonbakery/tests/test_codec.py +++ b/macaroonbakery/tests/test_codec.py @@ -3,12 +3,11 @@ import base64 from unittest import TestCase +import macaroonbakery.bakery as bakery +import macaroonbakery.checkers as checkers import nacl.public import six - -import macaroonbakery as bakery -from macaroonbakery import codec -import macaroonbakery.checkers as checkers +from macaroonbakery.bakery import _codec as codec class TestCodec(TestCase): diff --git a/macaroonbakery/tests/test_discharge.py b/macaroonbakery/tests/test_discharge.py index 433483a..27bae63 100644 --- a/macaroonbakery/tests/test_discharge.py +++ b/macaroonbakery/tests/test_discharge.py @@ -2,11 +2,10 @@ # Licensed under the LGPLv3, see LICENCE file for details. import unittest -from pymacaroons import MACAROON_V1, Macaroon - -import macaroonbakery as bakery +import macaroonbakery.bakery as bakery import macaroonbakery.checkers as checkers from macaroonbakery.tests import common +from pymacaroons import MACAROON_V1, Macaroon class TestDischarge(unittest.TestCase): diff --git a/macaroonbakery/tests/test_discharge_all.py b/macaroonbakery/tests/test_discharge_all.py index 7999f5f..cab8a07 100644 --- a/macaroonbakery/tests/test_discharge_all.py +++ b/macaroonbakery/tests/test_discharge_all.py @@ -2,11 +2,10 @@ # Licensed under the LGPLv3, see LICENCE file for details. import unittest -from pymacaroons.verifier import Verifier - -import macaroonbakery as bakery +import macaroonbakery.bakery as bakery import macaroonbakery.checkers as checkers from macaroonbakery.tests import common +from pymacaroons.verifier import Verifier def always_ok(predicate): diff --git a/macaroonbakery/tests/test_keyring.py b/macaroonbakery/tests/test_keyring.py index 438ab1b..3503145 100644 --- a/macaroonbakery/tests/test_keyring.py +++ b/macaroonbakery/tests/test_keyring.py @@ -2,11 +2,11 @@ # Licensed under the LGPLv3, see LICENCE file for details. import unittest -from httmock import urlmatch, HTTMock - -import macaroonbakery as bakery +import macaroonbakery.bakery as bakery import macaroonbakery.httpbakery as httpbakery +from httmock import HTTMock, urlmatch + class TestKeyRing(unittest.TestCase): @@ -19,7 +19,7 @@ class TestKeyRing(unittest.TestCase): 'status_code': 200, 'content': { 'Version': bakery.LATEST_VERSION, - 'PublicKey': key.public_key.encode().decode('utf-8') + 'PublicKey': str(key.public_key), } } @@ -41,7 +41,7 @@ class TestKeyRing(unittest.TestCase): 'status_code': 200, 'content': { 'Version': bakery.LATEST_VERSION, - 'PublicKey': key.public_key.encode().decode('utf-8') + 'PublicKey': str(key.public_key), } } @@ -64,7 +64,7 @@ class TestKeyRing(unittest.TestCase): return { 'status_code': 200, 'content': { - 'PublicKey': key.public_key.encode().decode('utf-8') + 'PublicKey': str(key.public_key), } } @@ -79,7 +79,7 @@ class TestKeyRing(unittest.TestCase): def test_allow_insecure(self): kr = httpbakery.ThirdPartyLocator() - with self.assertRaises(bakery.error.ThirdPartyInfoNotFound): + with self.assertRaises(bakery.ThirdPartyInfoNotFound): kr.third_party_info('http://0.1.2.3/') def test_fallback(self): @@ -96,7 +96,7 @@ class TestKeyRing(unittest.TestCase): return { 'status_code': 200, 'content': { - 'PublicKey': key.public_key.encode().decode('utf-8') + 'PublicKey': str(key.public_key), } } diff --git a/macaroonbakery/tests/test_macaroon.py b/macaroonbakery/tests/test_macaroon.py index 93bbbb8..bcbbf80 100644 --- a/macaroonbakery/tests/test_macaroon.py +++ b/macaroonbakery/tests/test_macaroon.py @@ -3,13 +3,12 @@ import json from unittest import TestCase -import six -import pymacaroons -from pymacaroons import serializers - -import macaroonbakery as bakery +import macaroonbakery.bakery as bakery import macaroonbakery.checkers as checkers +import pymacaroons +import six from macaroonbakery.tests import common +from pymacaroons import serializers class TestMacaroon(TestCase): @@ -25,7 +24,8 @@ class TestMacaroon(TestCase): self.assertEquals(m.version, bakery.LATEST_VERSION) def test_add_first_party_caveat(self): - m = bakery.Macaroon('rootkey', 'some id', 'here', bakery.LATEST_VERSION) + m = bakery.Macaroon('rootkey', 'some id', 'here', + bakery.LATEST_VERSION) m.add_caveat(checkers.Caveat('test_condition')) caveats = m.first_party_caveats() self.assertEquals(len(caveats), 1) diff --git a/macaroonbakery/tests/test_oven.py b/macaroonbakery/tests/test_oven.py index ae235de..3c29767 100644 --- a/macaroonbakery/tests/test_oven.py +++ b/macaroonbakery/tests/test_oven.py @@ -1,11 +1,10 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -from unittest import TestCase - import copy from datetime import datetime, timedelta +from unittest import TestCase -import macaroonbakery as bakery +import macaroonbakery.bakery as bakery EPOCH = datetime(1900, 11, 17, 19, 00, 13, 0, None) AGES = EPOCH + timedelta(days=10) @@ -88,7 +87,8 @@ class TestOven(TestCase): ops_store=bakery.MemoryOpsStore()) ops = [] for i in range(30000): - ops.append(bakery.Op(entity='entity' + str(i), action='action' + str(i))) + ops.append(bakery.Op(entity='entity' + str(i), + action='action' + str(i))) m = test_oven.macaroon(bakery.LATEST_VERSION, AGES, None, ops) diff --git a/macaroonbakery/tests/test_store.py b/macaroonbakery/tests/test_store.py index 5afa7be..8a54f59 100644 --- a/macaroonbakery/tests/test_store.py +++ b/macaroonbakery/tests/test_store.py @@ -2,7 +2,7 @@ # Licensed under the LGPLv3, see LICENCE file for details. from unittest import TestCase -import macaroonbakery as bakery +import macaroonbakery.bakery as bakery class TestOven(TestCase): diff --git a/macaroonbakery/tests/test_time.py b/macaroonbakery/tests/test_time.py index 38826e1..2685e56 100644 --- a/macaroonbakery/tests/test_time.py +++ b/macaroonbakery/tests/test_time.py @@ -1,16 +1,15 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. +from collections import namedtuple from datetime import timedelta from unittest import TestCase -from collections import namedtuple -import pyrfc3339 +import macaroonbakery.checkers as checkers import pymacaroons +import pyrfc3339 from pymacaroons import Macaroon -import macaroonbakery.checkers as checkers - -t1 = pyrfc3339.parse('2017-10-26T16:19:47.441402074Z') +t1 = pyrfc3339.parse('2017-10-26T16:19:47.441402074Z', produce_naive=True) t2 = t1 + timedelta(hours=1) t3 = t2 + timedelta(hours=1) @@ -118,9 +117,17 @@ class TestExpireTime(TestCase): ] for test in tests: print('test ', test.about) - t = checkers.macaroons_expiry_time(checkers.Namespace(), test.macaroons) + t = checkers.macaroons_expiry_time(checkers.Namespace(), + test.macaroons) self.assertEqual(t, test.expectTime) + def test_macaroons_expire_time_skips_third_party(self): + m1 = newMacaroon([checkers.time_before_caveat(t1).condition]) + m2 = newMacaroon() + m2.add_third_party_caveat('https://example.com', 'a-key', '123') + t = checkers.macaroons_expiry_time(checkers.Namespace(), [m1, m2]) + self.assertEqual(t1, t) + def newMacaroon(conds=[]): m = Macaroon(key='key', version=2) diff --git a/macaroonbakery/tests/test_utils.py b/macaroonbakery/tests/test_utils.py new file mode 100644 index 0000000..fcc8839 --- /dev/null +++ b/macaroonbakery/tests/test_utils.py @@ -0,0 +1,74 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +import json +from datetime import datetime +from unittest import TestCase + +import macaroonbakery.bakery as bakery +import pymacaroons +import pytz +from macaroonbakery._utils import cookie +from pymacaroons.serializers import json_serializer + + +class CookieTest(TestCase): + + def test_cookie_expires_naive(self): + timestamp = datetime.utcnow() + c = cookie('http://example.com', 'test', 'value', expires=timestamp) + self.assertEqual( + c.expires, int((timestamp - datetime(1970, 1, 1)).total_seconds())) + + def test_cookie_expires_with_timezone(self): + timestamp = datetime.now(pytz.UTC) + self.assertRaises( + ValueError, cookie, 'http://example.com', 'test', 'value', + expires=timestamp) + + +class TestB64Decode(TestCase): + def test_decode(self): + test_cases = [{ + 'about': 'empty string', + 'input': '', + 'expect': '', + }, { + 'about': 'standard encoding, padded', + 'input': 'Z29+IQ==', + 'expect': 'go~!', + }, { + 'about': 'URL encoding, padded', + 'input': 'Z29-IQ==', + 'expect': 'go~!', + }, { + 'about': 'standard encoding, not padded', + 'input': 'Z29+IQ', + 'expect': 'go~!', + }, { + 'about': 'URL encoding, not padded', + 'input': 'Z29-IQ', + 'expect': 'go~!', + }, { + 'about': 'standard encoding, not enough much padding', + 'input': 'Z29+IQ=', + 'expect_error': 'illegal base64 data at input byte 8', + }] + for test in test_cases: + if test.get('expect_error'): + with self.assertRaises(ValueError, msg=test['about']) as e: + bakery.b64decode(test['input']) + self.assertEqual(str(e.exception), 'Incorrect padding') + else: + self.assertEqual(bakery.b64decode(test['input']), test['expect'].encode('utf-8'), msg=test['about']) + + +class MacaroonToDictTest(TestCase): + def test_macaroon_to_dict(self): + m = pymacaroons.Macaroon( + key=b'rootkey', identifier=b'some id', location='here', version=2) + as_dict = bakery.macaroon_to_dict(m) + data = json.dumps(as_dict) + m1 = pymacaroons.Macaroon.deserialize(data, json_serializer.JsonSerializer()) + self.assertEqual(m1.signature, m.signature) + pymacaroons.Verifier().verify(m1, b'rootkey') @@ -12,7 +12,7 @@ from setuptools import ( PROJECT_NAME = 'macaroonbakery' -VERSION = (0, 0, 6) +VERSION = (1, 1, 0) def get_version(): @@ -22,7 +22,7 @@ commands = [testenv:lint] usedevelop = True -commands = flake8 --ignore E501 --show-source macaroonbakery --exclude macaroonbakery/internal/id_pb2.py +commands = flake8 --ignore E501 --show-source macaroonbakery --exclude macaroonbakery/bakery/_internal/id_pb2.py [testenv:docs] changedir = docs |