summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorColin Watson <cjwatson@debian.org>2017-10-31 10:34:41 +0000
committerColin Watson <cjwatson@debian.org>2017-10-31 10:34:41 +0000
commit79ff2842fa477ee0693ea167c0a74cd7cf080d27 (patch)
treed0eb436672c1a73088fce42f939bdf4cf6f4427b
Import py-macaroon-bakery_0.0.3.orig.tar.gz
-rw-r--r--.gitignore44
-rw-r--r--AUTHORS.rst8
-rw-r--r--CHANGELOG.rst15
-rw-r--r--CONTRIBUTING.rst104
-rw-r--r--LICENSE166
-rw-r--r--MANIFEST.in9
-rw-r--r--Makefile99
-rw-r--r--README.rst35
-rw-r--r--docs/Makefile177
-rw-r--r--docs/_static/TRACKME0
-rw-r--r--docs/authors.rst1
-rwxr-xr-xdocs/conf.py273
-rw-r--r--docs/contributing.rst1
-rw-r--r--docs/index.rst26
-rw-r--r--docs/installation.rst7
-rw-r--r--docs/packaging.rst4
-rw-r--r--docs/readme.rst1
-rw-r--r--docs/usage.rst7
-rw-r--r--macaroonbakery/__init__.py17
-rw-r--r--macaroonbakery/bakery.py237
-rw-r--r--macaroonbakery/checkers.py23
-rw-r--r--macaroonbakery/codec.py299
-rw-r--r--macaroonbakery/httpbakery/__init__.py1
-rw-r--r--macaroonbakery/httpbakery/agent.py53
-rw-r--r--macaroonbakery/httpbakery/client.py157
-rw-r--r--macaroonbakery/json_serializer.py75
-rw-r--r--macaroonbakery/macaroon.py311
-rw-r--r--macaroonbakery/namespace.py115
-rw-r--r--macaroonbakery/tests/__init__.py0
-rw-r--r--macaroonbakery/tests/test_agent.py149
-rw-r--r--macaroonbakery/tests/test_bakery.py166
-rw-r--r--macaroonbakery/tests/test_codec.py178
-rw-r--r--macaroonbakery/tests/test_macaroon.py64
-rw-r--r--macaroonbakery/tests/test_namespace.py58
-rw-r--r--macaroonbakery/utils.py79
-rw-r--r--requirements.txt7
-rw-r--r--setup.cfg2
-rwxr-xr-xsetup.py69
-rw-r--r--sysdeps.mk1
-rw-r--r--test-requirements.txt8
-rw-r--r--tox.ini27
41 files changed, 3073 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..aa4d9f9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,44 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+
+# Distribution / packaging
+.sysdeps-installed
+devenv
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*,cover
+
+# Sphinx documentation
+docs/_build/
+
+# Editor files
+*.sw[op]
+
+# Build canaries.
+.*canary
diff --git a/AUTHORS.rst b/AUTHORS.rst
new file mode 100644
index 0000000..442654b
--- /dev/null
+++ b/AUTHORS.rst
@@ -0,0 +1,8 @@
+=======
+Credits
+=======
+
+Development Lead
+----------------
+
+* Juju UI Team <juju-gui@list.ubuntu.com>
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
new file mode 100644
index 0000000..b7c1fcd
--- /dev/null
+++ b/CHANGELOG.rst
@@ -0,0 +1,15 @@
+.. :changelog:
+
+History
+-------
+
+0.0.2 (2017-07-19)
+++++++++++++++++++
+
+* Fix import.
+
+0.0.1 (2017-06-09)
+++++++++++++++++++
+
+* Initial release.
+
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
new file mode 100644
index 0000000..2993720
--- /dev/null
+++ b/CONTRIBUTING.rst
@@ -0,0 +1,104 @@
+============
+Contributing
+============
+
+Contributions are welcome, and they are greatly appreciated! Every
+little bit helps, and credit will always be given.
+
+You can contribute in many ways:
+
+Types of Contributions
+----------------------
+
+Report Bugs
+~~~~~~~~~~~
+
+Report bugs at https://github.com/go-macaroon-bakery/py-macaroon-bakery/issues.
+
+If you are reporting a bug, please include:
+
+* Your operating system name and version.
+* Any details about your local setup that might be helpful in troubleshooting.
+* Detailed steps to reproduce the bug.
+
+Fix Bugs
+~~~~~~~~
+
+Look through the GitHub issues for bugs. Anything tagged with "bug"
+is open to whoever wants to implement it.
+
+Implement Features
+~~~~~~~~~~~~~~~~~~
+
+Look through the GitHub issues for features. Anything tagged with "feature"
+is open to whoever wants to implement it.
+
+Write Documentation
+~~~~~~~~~~~~~~~~~~~
+
+The macaroon bakery could always use more documentation, whether as part of the
+official macaroon bakery docs, in docstrings, or even on the web in blog posts,
+articles, and such.
+
+Submit Feedback
+~~~~~~~~~~~~~~~
+
+The best way to send feedback is to file an issue at
+https://github.com/go-macaroon-bakery/py-macaroon-bakery/issues.
+
+If you are proposing a feature:
+
+* Explain in detail how it would work.
+* Keep the scope as narrow as possible, to make it easier to implement.
+
+Get Started!
+------------
+
+Ready to contribute? Here's how to set up `py-macaroon-bakery` for local
+development.
+
+1. Fork the `py-macaroon-bakery` repo on GitHub.
+2. Clone your fork locally::
+
+ $ git clone git@github.com:your_name_here/py-macaroon-bakery.git
+
+3. Prepare your development environment::
+
+ $ make devenv
+
+4. Create a branch for local development::
+
+ $ git checkout -b name-of-your-bugfix-or-feature
+
+ Now you can make your changes locally.
+
+5. When you're done making changes, check that your changes pass flake8 and the
+tests, including testing other Python versions with tox::
+
+ $ make check
+
+6. Commit your changes and push your branch to GitHub::
+
+ $ git add .
+ $ git commit -m "Your detailed description of your changes."
+ $ git push origin name-of-your-bugfix-or-feature
+
+7. Submit a pull request through the GitHub website.
+
+Pull Request Guidelines
+-----------------------
+
+Before you submit a pull request, check that it meets these guidelines:
+
+1. The pull request should include tests.
+2. If the pull request adds functionality, the docs should be updated. Put
+ your new functionality into a function with a docstring, and add the
+ feature to the list in README.rst.
+3. The pull request should work for Python 2.7, 3+ and for PyPy.
+
+Tips
+----
+
+To run a subset of tests::
+
+ $ devenv/bin/nosetests macaroonbakery/tests/...
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..341c30b
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,166 @@
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+
+ This version of the GNU Lesser General Public License incorporates
+the terms and conditions of version 3 of the GNU General Public
+License, supplemented by the additional permissions listed below.
+
+ 0. Additional Definitions.
+
+ As used herein, "this License" refers to version 3 of the GNU Lesser
+General Public License, and the "GNU GPL" refers to version 3 of the GNU
+General Public License.
+
+ "The Library" refers to a covered work governed by this License,
+other than an Application or a Combined Work as defined below.
+
+ An "Application" is any work that makes use of an interface provided
+by the Library, but which is not otherwise based on the Library.
+Defining a subclass of a class defined by the Library is deemed a mode
+of using an interface provided by the Library.
+
+ A "Combined Work" is a work produced by combining or linking an
+Application with the Library. The particular version of the Library
+with which the Combined Work was made is also called the "Linked
+Version".
+
+ The "Minimal Corresponding Source" for a Combined Work means the
+Corresponding Source for the Combined Work, excluding any source code
+for portions of the Combined Work that, considered in isolation, are
+based on the Application, and not on the Linked Version.
+
+ The "Corresponding Application Code" for a Combined Work means the
+object code and/or source code for the Application, including any data
+and utility programs needed for reproducing the Combined Work from the
+Application, but excluding the System Libraries of the Combined Work.
+
+ 1. Exception to Section 3 of the GNU GPL.
+
+ You may convey a covered work under sections 3 and 4 of this License
+without being bound by section 3 of the GNU GPL.
+
+ 2. Conveying Modified Versions.
+
+ If you modify a copy of the Library, and, in your modifications, a
+facility refers to a function or data to be supplied by an Application
+that uses the facility (other than as an argument passed when the
+facility is invoked), then you may convey a copy of the modified
+version:
+
+ a) under this License, provided that you make a good faith effort to
+ ensure that, in the event an Application does not supply the
+ function or data, the facility still operates, and performs
+ whatever part of its purpose remains meaningful, or
+
+ b) under the GNU GPL, with none of the additional permissions of
+ this License applicable to that copy.
+
+ 3. Object Code Incorporating Material from Library Header Files.
+
+ The object code form of an Application may incorporate material from
+a header file that is part of the Library. You may convey such object
+code under terms of your choice, provided that, if the incorporated
+material is not limited to numerical parameters, data structure
+layouts and accessors, or small macros, inline functions and templates
+(ten or fewer lines in length), you do both of the following:
+
+ a) Give prominent notice with each copy of the object code that the
+ Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the object code with a copy of the GNU GPL and this license
+ document.
+
+ 4. Combined Works.
+
+ You may convey a Combined Work under terms of your choice that,
+taken together, effectively do not restrict modification of the
+portions of the Library contained in the Combined Work and reverse
+engineering for debugging such modifications, if you also do each of
+the following:
+
+ a) Give prominent notice with each copy of the Combined Work that
+ the Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the Combined Work with a copy of the GNU GPL and this license
+ document.
+
+ c) For a Combined Work that displays copyright notices during
+ execution, include the copyright notice for the Library among
+ these notices, as well as a reference directing the user to the
+ copies of the GNU GPL and this license document.
+
+ d) Do one of the following:
+
+ 0) Convey the Minimal Corresponding Source under the terms of this
+ License, and the Corresponding Application Code in a form
+ suitable for, and under terms that permit, the user to
+ recombine or relink the Application with a modified version of
+ the Linked Version to produce a modified Combined Work, in the
+ manner specified by section 6 of the GNU GPL for conveying
+ Corresponding Source.
+
+ 1) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (a) uses at run time
+ a copy of the Library already present on the user's computer
+ system, and (b) will operate properly with a modified version
+ of the Library that is interface-compatible with the Linked
+ Version.
+
+ e) Provide Installation Information, but only if you would otherwise
+ be required to provide such information under section 6 of the
+ GNU GPL, and only to the extent that such information is
+ necessary to install and execute a modified version of the
+ Combined Work produced by recombining or relinking the
+ Application with a modified version of the Linked Version. (If
+ you use option 4d0, the Installation Information must accompany
+ the Minimal Corresponding Source and Corresponding Application
+ Code. If you use option 4d1, you must provide the Installation
+ Information in the manner specified by section 6 of the GNU GPL
+ for conveying Corresponding Source.)
+
+ 5. Combined Libraries.
+
+ You may place library facilities that are a work based on the
+Library side by side in a single library together with other library
+facilities that are not Applications and are not covered by this
+License, and convey such a combined library under terms of your
+choice, if you do both of the following:
+
+ a) Accompany the combined library with a copy of the same work based
+ on the Library, uncombined with any other library facilities,
+ conveyed under the terms of this License.
+
+ b) Give prominent notice with the combined library that part of it
+ is a work based on the Library, and explaining where to find the
+ accompanying uncombined form of the same work.
+
+ 6. Revised Versions of the GNU Lesser General Public License.
+
+ The Free Software Foundation may publish revised and/or new versions
+of the GNU Lesser General Public License from time to time. Such new
+versions will be similar in spirit to the present version, but may
+differ in detail to address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Library as you received it specifies that a certain numbered version
+of the GNU Lesser General Public License "or any later version"
+applies to it, you have the option of following the terms and
+conditions either of that published version or of any later version
+published by the Free Software Foundation. If the Library as you
+received it does not specify a version number of the GNU Lesser
+General Public License, you may choose any version of the GNU Lesser
+General Public License ever published by the Free Software Foundation.
+
+ If the Library as you received it specifies that a proxy can decide
+whether future versions of the GNU Lesser General Public License shall
+apply, that proxy's public statement of acceptance of any version is
+permanent authorization for you to choose that version for the
+Library.
+
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..68e1903
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,9 @@
+include AUTHORS.rst
+include CONTRIBUTING.rst
+include LICENSE
+include README.rst
+
+recursive-exclude * __pycache__
+recursive-exclude * *.py[co]
+
+recursive-include docs *.rst Makefile
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..4e87e5c
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,99 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+include sysdeps.mk
+
+PYTHON = python
+# Since the python-tox package in Ubuntu uses Python 3, use pip to install tox
+# instead. This also works on OSX where tox is not present in Homebrew.
+PIP_SYSDEPS = tox
+
+PIP = sudo pip install $(1)
+
+SYSDEPS_INSTALLED = .sysdeps-installed
+DEVENV = devenv
+DEVENVPIP = $(DEVENV)/bin/pip
+
+.DEFAULT_GOAL := setup
+
+$(DEVENVPIP):
+ @tox -e devenv
+
+$(SYSDEPS_INSTALLED): sysdeps.mk
+ifeq ($(shell command -v apt-get > /dev/null; echo $$?),0)
+ sudo apt-get install --yes $(APT_SYSDEPS)
+else
+ @echo 'System dependencies can only be installed automatically on'
+ @echo 'systems with "apt-get". On OSX you can manually use Homebrew'
+ @echo 'if there are missing dependencies corresponding to the following'
+ @echo 'Debian packages:'
+ @echo '$(APT_SYSDEPS).'
+endif
+ sudo pip2 install $(PIP_SYSDEPS)
+ touch $(SYSDEPS_INSTALLED)
+
+
+.PHONY: check
+check: setup
+ @tox -e lint
+ @tox
+
+.PHONY: clean
+clean:
+ $(PYTHON) setup.py clean
+ # Remove the development environments.
+ rm -rfv $(DEVENV) .tox/
+ # Remove distribution artifacts.
+ rm -rfv *.egg build/ dist/ macaroonbakery.egg-info MANIFEST
+ # Remove tests artifacts.
+ rm -fv .coverage
+ # Remove the canary file.
+ rm -fv $(SYSDEPS_INSTALLED)
+ # Remove Python compiled bytecode.
+ find . -name '*.pyc' -delete
+ find . -name '__pycache__' -type d -delete
+
+.PHONY: docs
+docs: setup
+ tox -e docs
+
+.PHONY: help
+help:
+ @echo -e 'Macaroon Bakery - list of make targets:\n'
+ @echo 'make - Set up the development and testing environment.'
+ @echo 'make test - Run tests.'
+ @echo 'make lint - Run linter and pep8.'
+ @echo 'make check - Run all the tests and lint in all supported scenarios.'
+ @echo 'make source - Create source package.'
+ @echo 'make clean - Get rid of bytecode files, build and dist dirs, venvs.'
+ @echo 'make release - Register and upload a release on PyPI.'
+ @echo -e '\nAfter creating the development environment with "make", it is'
+ @echo 'also possible to do the following:'
+ @echo '- run a specific subset of the test suite, e.g. with'
+ @echo ' "$(DEVENV)/bin/nosetests bakery/tests/...";'
+ @echo '- use tox as usual on this project;'
+ @echo ' see https://tox.readthedocs.org/en/latest/'
+
+
+.PHONY: lint
+lint: setup
+ @$(DEVENV)/bin/flake8 --ignore E731 --show-source macaroonbakery
+
+.PHONY: release
+release: check
+ $(PYTHON) setup.py register sdist upload
+
+.PHONY: setup
+setup: $(SYSDEPS_INSTALLED) $(DEVENVPIP) setup.py
+
+.PHONY: source
+source:
+ $(PYTHON) setup.py sdist
+
+.PHONY: sysdeps
+sysdeps: $(SYSDEPS_INSTALLED)
+
+.PHONY: test
+test: setup
+ @$(DEVENV)/bin/nosetests \
+ --verbosity 2 --with-coverage --cover-erase \
+ --cover-package macaroonbakery
diff --git a/README.rst b/README.rst
new file mode 100644
index 0000000..c3c06de
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,35 @@
+===============
+Macaroon Bakery
+===============
+
+A Python library for working with macaroons.
+
+
+Installation
+------------
+The easiest way to install macaroonbakery is via pip::
+
+ $ pip install macaroonbakery
+
+macaroonbakery was developed around pymacaroons. On ubuntu, you
+can get libsodium from a ppa::
+
+ $ sudo add-apt-repository ppa:yellow/ppa -y
+ $ apt-get install libsodium13
+
+Usage
+-----
+Interacting with a protected url, you can use the BakeryAuth provided to deal
+with the macaroon bakery
+
+ >>> from macaroonbakery import httpbakery
+ >>> jar = requests.cookies.RequestsCookieJar()
+ >>> resp = requests.get('some protected url',
+ cookies=jar,
+ auth=httpbakery.BakeryAuth(cookies=jar))
+ >>> resp.raise_for_status()
+
+
+You can use any cookie storage you'd like so next subsequent calls the macaroon
+saved in the cookie jar will be directly used and will not require
+any other authentication (for example, cookielib.FileCookieJar).
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..80c937f
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,177 @@
+# Makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS =
+SPHINXBUILD = sphinx-build
+PAPER =
+BUILDDIR = _build
+
+# User-friendly check for sphinx-build
+ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
+$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
+endif
+
+# Internal variables.
+PAPEROPT_a4 = -D latex_paper_size=a4
+PAPEROPT_letter = -D latex_paper_size=letter
+ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+# the i18n builder cannot share the environment and doctrees with the others
+I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+
+.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
+
+help:
+ @echo "Please use \`make <target>' where <target> is one of"
+ @echo " html to make standalone HTML files"
+ @echo " dirhtml to make HTML files named index.html in directories"
+ @echo " singlehtml to make a single large HTML file"
+ @echo " pickle to make pickle files"
+ @echo " json to make JSON files"
+ @echo " htmlhelp to make HTML files and a HTML help project"
+ @echo " qthelp to make HTML files and a qthelp project"
+ @echo " devhelp to make HTML files and a Devhelp project"
+ @echo " epub to make an epub"
+ @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
+ @echo " latexpdf to make LaTeX files and run them through pdflatex"
+ @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
+ @echo " text to make text files"
+ @echo " man to make manual pages"
+ @echo " texinfo to make Texinfo files"
+ @echo " info to make Texinfo files and run them through makeinfo"
+ @echo " gettext to make PO message catalogs"
+ @echo " changes to make an overview of all changed/added/deprecated items"
+ @echo " xml to make Docutils-native XML files"
+ @echo " pseudoxml to make pseudoxml-XML files for display purposes"
+ @echo " linkcheck to check all external links for integrity"
+ @echo " doctest to run all doctests embedded in the documentation (if enabled)"
+
+clean:
+ rm -rf $(BUILDDIR)/*
+
+html:
+ $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
+ @echo
+ @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
+
+dirhtml:
+ $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
+ @echo
+ @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
+
+singlehtml:
+ $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
+ @echo
+ @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
+
+pickle:
+ $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
+ @echo
+ @echo "Build finished; now you can process the pickle files."
+
+json:
+ $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
+ @echo
+ @echo "Build finished; now you can process the JSON files."
+
+htmlhelp:
+ $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
+ @echo
+ @echo "Build finished; now you can run HTML Help Workshop with the" \
+ ".hhp project file in $(BUILDDIR)/htmlhelp."
+
+qthelp:
+ $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
+ @echo
+ @echo "Build finished; now you can run "qcollectiongenerator" with the" \
+ ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
+ @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/py-macaroon-bakery.qhcp"
+ @echo "To view the help file:"
+ @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/py-macaroon-bakery.qhc"
+
+devhelp:
+ $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
+ @echo
+ @echo "Build finished."
+ @echo "To view the help file:"
+ @echo "# mkdir -p $$HOME/.local/share/devhelp/py-macaroon-bakery"
+ @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/py-macaroon-bakery"
+ @echo "# devhelp"
+
+epub:
+ $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
+ @echo
+ @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
+
+latex:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo
+ @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
+ @echo "Run \`make' in that directory to run these through (pdf)latex" \
+ "(use \`make latexpdf' here to do that automatically)."
+
+latexpdf:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo "Running LaTeX files through pdflatex..."
+ $(MAKE) -C $(BUILDDIR)/latex all-pdf
+ @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+latexpdfja:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo "Running LaTeX files through platex and dvipdfmx..."
+ $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
+ @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+text:
+ $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
+ @echo
+ @echo "Build finished. The text files are in $(BUILDDIR)/text."
+
+man:
+ $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
+ @echo
+ @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
+
+texinfo:
+ $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+ @echo
+ @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
+ @echo "Run \`make' in that directory to run these through makeinfo" \
+ "(use \`make info' here to do that automatically)."
+
+info:
+ $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+ @echo "Running Texinfo files through makeinfo..."
+ make -C $(BUILDDIR)/texinfo info
+ @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
+
+gettext:
+ $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
+ @echo
+ @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
+
+changes:
+ $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
+ @echo
+ @echo "The overview file is in $(BUILDDIR)/changes."
+
+linkcheck:
+ $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
+ @echo
+ @echo "Link check complete; look for any errors in the above output " \
+ "or in $(BUILDDIR)/linkcheck/output.txt."
+
+doctest:
+ $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
+ @echo "Testing of doctests in the sources finished, look at the " \
+ "results in $(BUILDDIR)/doctest/output.txt."
+
+xml:
+ $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
+ @echo
+ @echo "Build finished. The XML files are in $(BUILDDIR)/xml."
+
+pseudoxml:
+ $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
+ @echo
+ @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
diff --git a/docs/_static/TRACKME b/docs/_static/TRACKME
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/docs/_static/TRACKME
diff --git a/docs/authors.rst b/docs/authors.rst
new file mode 100644
index 0000000..e122f91
--- /dev/null
+++ b/docs/authors.rst
@@ -0,0 +1 @@
+.. include:: ../AUTHORS.rst
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100755
index 0000000..787735f
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,273 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# Macaroon Bakery documentation build configuration file, created by
+# sphinx-quickstart on Tue Jul 9 22:26:36 2013.
+#
+# This file is execfile()d with the current directory set to its
+# containing dir.
+#
+# Note that not all possible configuration values are present in this
+# autogenerated file.
+#
+# All configuration values have a default; values that are commented out
+# serve to show the default.
+
+import sys
+import os
+
+# If extensions (or modules to document with autodoc) are in another
+# directory, add these directories to sys.path here. If the directory is
+# relative to the documentation root, use os.path.abspath to make it
+# absolute, like shown here.
+#sys.path.insert(0, os.path.abspath('.'))
+
+# Get the project root dir, which is the parent dir of this
+cwd = os.getcwd()
+project_root = os.path.dirname(cwd)
+
+# Insert the project root dir as the first element in the PYTHONPATH.
+# This lets us ensure that the source package is imported, and that its
+# version is used.
+sys.path.insert(0, project_root)
+
+import macaroonbakery
+
+# -- General configuration ---------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+#needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
+extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode']
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix of source filenames.
+source_suffix = '.rst'
+
+# The encoding of source files.
+#source_encoding = 'utf-8-sig'
+
+# The master toctree document.
+master_doc = 'index'
+
+# General information about the project.
+project = u'Python Macaroon Bakery'
+copyright = u'2017, Juju UI Team'
+
+# The version info for the project you're documenting, acts as replacement
+# for |version| and |release|, also used in various other places throughout
+# the built documents.
+#
+# The short X.Y version and the full version.
+version = release = macaroonbakery.get_version()
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#language = None
+
+# There are two options for replacing |today|: either, you set today to
+# some non-false value, then it is used:
+#today = ''
+# Else, today_fmt is used as the format for a strftime call.
+#today_fmt = '%B %d, %Y'
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+exclude_patterns = ['_build']
+
+# The reST default role (used for this markup: `text`) to use for all
+# documents.
+#default_role = None
+
+# If true, '()' will be appended to :func: etc. cross-reference text.
+#add_function_parentheses = True
+
+# If true, the current module name will be prepended to all description
+# unit titles (such as .. function::).
+#add_module_names = True
+
+# If true, sectionauthor and moduleauthor directives will be shown in the
+# output. They are ignored by default.
+#show_authors = False
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = 'sphinx'
+
+# A list of ignored prefixes for module index sorting.
+#modindex_common_prefix = []
+
+# If true, keep warnings as "system message" paragraphs in the built
+# documents.
+#keep_warnings = False
+
+
+# -- Options for HTML output -------------------------------------------
+
+# The theme to use for HTML and HTML Help pages. See the documentation for
+# a list of builtin themes.
+html_theme = 'alabaster'
+
+# Theme options are theme-specific and customize the look and feel of a
+# theme further. For a list of options available for each theme, see the
+# documentation.
+#html_theme_options = {}
+
+# Add any paths that contain custom themes here, relative to this directory.
+#html_theme_path = []
+
+# The name for this set of Sphinx documents. If None, it defaults to
+# "<project> v<release> documentation".
+#html_title = None
+
+# A shorter title for the navigation bar. Default is the same as
+# html_title.
+#html_short_title = None
+
+# The name of an image file (relative to this directory) to place at the
+# top of the sidebar.
+#html_logo = None
+
+# The name of an image file (within the static path) to use as favicon
+# of the docs. This file should be a Windows icon file (.ico) being
+# 16x16 or 32x32 pixels large.
+#html_favicon = None
+
+# Add any paths that contain custom static files (such as style sheets)
+# here, relative to this directory. They are copied after the builtin
+# static files, so a file named "default.css" will overwrite the builtin
+# "default.css".
+html_static_path = ['_static']
+
+# If not '', a 'Last updated on:' timestamp is inserted at every page
+# bottom, using the given strftime format.
+#html_last_updated_fmt = '%b %d, %Y'
+
+# If true, SmartyPants will be used to convert quotes and dashes to
+# typographically correct entities.
+#html_use_smartypants = True
+
+# Custom sidebar templates, maps document names to template names.
+#html_sidebars = {}
+
+# Additional templates that should be rendered to pages, maps page names
+# to template names.
+#html_additional_pages = {}
+
+# If false, no module index is generated.
+#html_domain_indices = True
+
+# If false, no index is generated.
+#html_use_index = True
+
+# If true, the index is split into individual pages for each letter.
+#html_split_index = False
+
+# If true, links to the reST sources are added to the pages.
+#html_show_sourcelink = True
+
+# If true, "Created using Sphinx" is shown in the HTML footer.
+# Default is True.
+#html_show_sphinx = True
+
+# If true, "(C) Copyright ..." is shown in the HTML footer.
+# Default is True.
+#html_show_copyright = True
+
+# If true, an OpenSearch description file will be output, and all pages
+# will contain a <link> tag referring to it. The value of this option
+# must be the base URL from which the finished HTML is served.
+#html_use_opensearch = ''
+
+# This is the file name suffix for HTML files (e.g. ".xhtml").
+#html_file_suffix = None
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = 'bakerydoc'
+
+
+# -- Options for LaTeX output ------------------------------------------
+
+latex_elements = {
+ # The paper size ('letterpaper' or 'a4paper').
+ #'papersize': 'letterpaper',
+
+ # The font size ('10pt', '11pt' or '12pt').
+ #'pointsize': '10pt',
+
+ # Additional stuff for the LaTeX preamble.
+ #'preamble': '',
+}
+
+# Grouping the document tree into LaTeX files. List of tuples
+# (source start file, target name, title, author, documentclass
+# [howto/manual]).
+latex_documents = [
+ ('index', 'bakery.tex',
+ u'Macaroon Bakery Documentation',
+ u'Juju UI Team', 'manual'),
+]
+
+# The name of an image file (relative to this directory) to place at
+# the top of the title page.
+#latex_logo = None
+
+# For "manual" documents, if this is true, then toplevel headings
+# are parts, not chapters.
+#latex_use_parts = False
+
+# If true, show page references after internal links.
+#latex_show_pagerefs = False
+
+# If true, show URL addresses after external links.
+#latex_show_urls = False
+
+# Documents to append as an appendix to all manuals.
+#latex_appendices = []
+
+# If false, no module index is generated.
+#latex_domain_indices = True
+
+
+# -- Options for manual page output ------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [
+ ('index', 'bakery',
+ u'Macaroon Bakery Documentation',
+ [u'Juju UI Team'], 1)
+]
+
+# If true, show URL addresses after external links.
+#man_show_urls = False
+
+
+# -- Options for Texinfo output ----------------------------------------
+
+# Grouping the document tree into Texinfo files. List of tuples
+# (source start file, target name, title, author,
+# dir menu entry, description, category)
+texinfo_documents = [
+ ('index', 'bakery',
+ u'Python Macaroon Bakery Documentation',
+ u'Juju UI Team',
+ 'bakery',
+ 'One line description of project.',
+ 'Miscellaneous'),
+]
+
+# Documents to append as an appendix to all manuals.
+#texinfo_appendices = []
+
+# If false, no module index is generated.
+#texinfo_domain_indices = True
+
+# How to display URL addresses: 'footnote', 'no', or 'inline'.
+#texinfo_show_urls = 'footnote'
+
+# If true, do not generate a @detailmenu in the "Top" node's menu.
+#texinfo_no_detailmenu = False
diff --git a/docs/contributing.rst b/docs/contributing.rst
new file mode 100644
index 0000000..e582053
--- /dev/null
+++ b/docs/contributing.rst
@@ -0,0 +1 @@
+.. include:: ../CONTRIBUTING.rst
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 0000000..5ff2139
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,26 @@
+.. bakery documentation master file, created by
+ sphinx-quickstart on Tue Jul 9 22:26:36 2013.
+ You can adapt this file completely to your liking, but it should at least
+ contain the root `toctree` directive.
+
+Welcome to Macaroon Bakery Library's documentation!
+===================================================
+
+Contents:
+
+.. toctree::
+ :maxdepth: 2
+
+ readme
+ installation
+ usage
+ contributing
+ packaging
+ authors
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
diff --git a/docs/installation.rst b/docs/installation.rst
new file mode 100644
index 0000000..6ccb0ae
--- /dev/null
+++ b/docs/installation.rst
@@ -0,0 +1,7 @@
+============
+Installation
+============
+
+At the command line::
+
+ $ pip install macaroonbakery
diff --git a/docs/packaging.rst b/docs/packaging.rst
new file mode 100644
index 0000000..40009c1
--- /dev/null
+++ b/docs/packaging.rst
@@ -0,0 +1,4 @@
+=========
+Packaging
+=========
+
diff --git a/docs/readme.rst b/docs/readme.rst
new file mode 100644
index 0000000..72a3355
--- /dev/null
+++ b/docs/readme.rst
@@ -0,0 +1 @@
+.. include:: ../README.rst
diff --git a/docs/usage.rst b/docs/usage.rst
new file mode 100644
index 0000000..21fac9c
--- /dev/null
+++ b/docs/usage.rst
@@ -0,0 +1,7 @@
+========
+Usage
+========
+
+To use the macaroon bakery Python library for handling macaroon higher level function::
+
+ import macaroonbakery
diff --git a/macaroonbakery/__init__.py b/macaroonbakery/__init__.py
new file mode 100644
index 0000000..8020901
--- /dev/null
+++ b/macaroonbakery/__init__.py
@@ -0,0 +1,17 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+from __future__ import unicode_literals
+try:
+ import urllib3.contrib.pyopenssl
+except ImportError:
+ pass
+else:
+ urllib3.contrib.pyopenssl.inject_into_urllib3()
+
+VERSION = (0, 0, 3)
+
+
+def get_version():
+ '''Return the macaroon bakery version as a string.'''
+ return '.'.join(map(str, VERSION))
diff --git a/macaroonbakery/bakery.py b/macaroonbakery/bakery.py
new file mode 100644
index 0000000..a3fcf88
--- /dev/null
+++ b/macaroonbakery/bakery.py
@@ -0,0 +1,237 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+import base64
+from collections import namedtuple
+import json
+import requests
+from macaroonbakery import utils
+
+import nacl.utils
+from nacl.public import Box
+
+from pymacaroons import Macaroon
+
+ERR_INTERACTION_REQUIRED = 'interaction required'
+ERR_DISCHARGE_REQUIRED = 'macaroon discharge required'
+TIME_OUT = 30
+DEFAULT_PROTOCOL_VERSION = {'Bakery-Protocol-Version': '1'}
+MAX_DISCHARGE_RETRIES = 3
+
+BAKERY_V0 = 0
+BAKERY_V1 = 1
+BAKERY_V2 = 2
+BAKERY_V3 = 3
+LATEST_BAKERY_VERSION = BAKERY_V3
+NONCE_LEN = 24
+
+
+# A named tuple composed of the visit_url and wait_url coming from the error
+# response in discharge
+_Info = namedtuple('Info', 'visit_url wait_url')
+
+
+class DischargeException(Exception):
+ '''A discharge error occurred.'''
+
+
+def discharge_all(macaroon, visit_page=None, jar=None, key=None):
+ '''Gathers discharge macaroons for all the third party caveats in macaroon.
+
+ All the discharge macaroons will be bound to the primary macaroon.
+ The key parameter may optionally hold the key of the client, in which case
+ it will be used to discharge any third party caveats with the special
+ location "local". In this case, the caveat itself must be "true". This
+ can be used by a server to ask a client to prove ownership of the
+ private key.
+ @param macaroon The macaroon to be discharged.
+ @param visit_page function called when the discharge process requires
+ further interaction.
+ @param jar the storage for the cookies.
+ @param key optional nacl key.
+ @return An array with macaroon as the first element, followed by all the
+ discharge macaroons.
+ '''
+ discharges = [macaroon]
+ if visit_page is None:
+ visit_page = utils.visit_page_with_browser
+ if jar is None:
+ jar = requests.cookies.RequestsCookieJar()
+ client = _Client(visit_page, jar)
+ try:
+ client.discharge_caveats(macaroon, discharges, macaroon, key)
+ except Exception as exc:
+ raise DischargeException('unable to discharge the macaroon', exc)
+ return discharges
+
+
+def discharge(key, id, caveat=None, checker=None, locator=None):
+ '''Creates a macaroon to discharge a third party caveat.
+
+ @param key nacl key holds the key to use to decrypt the third party
+ caveat information and to encrypt any additional
+ third party caveats returned by the caveat checker
+ @param id bytes holding the id to give to the discharge macaroon.
+ If caveat is empty, then the id also holds the encrypted third party caveat
+ @param caveat bytes holding the encrypted third party caveat.
+ If this is None, id will be used
+ @param checker used to check the third party caveat,
+ and may also return further caveats to be added to
+ the discharge macaroon. object that will have a function
+ check_third_party_caveat taking a dict of third party caveat info
+ as parameter.
+ @param locator used to retrieve information on third parties
+ referred to by third party caveats returned by the checker. Object that
+ will have a third_party_info function taking a location as a string.
+ @return macaroon with third party caveat discharged.
+ '''
+ if caveat is None:
+ caveat = id
+ cav_info = _decode_caveat(key, caveat)
+ return Macaroon(location='', key=cav_info['RootKey'], identifier=id)
+
+
+class _Client:
+ def __init__(self, visit_page, jar):
+ self._visit_page = visit_page
+ self._jar = jar
+
+ def discharge_caveats(self, macaroon, discharges,
+ primary_macaroon, key):
+ '''Gathers discharge macaroons for all the third party caveats.
+
+ @param macaroon the macaroon to discharge.
+ @param discharges the list of discharged macaroons.
+ @param primary_macaroon used for the signature of the discharge
+ macaroon.
+ @param key nacl key holds the key to use to decrypt the third party
+ caveat information and to encrypt any additional
+ third party caveats returned by the caveat checker
+ '''
+ caveats = macaroon.third_party_caveats()
+ for caveat in caveats:
+ location = caveat.location
+ b_cav_id = caveat.caveat_id.encode('utf-8')
+ if key is not None and location == 'local':
+ # if tuple is only 2 element otherwise TODO add caveat
+ dm = discharge(key, id=b_cav_id)
+ else:
+ dm = self._get_discharge(location, b_cav_id)
+ dm = primary_macaroon.prepare_for_request(dm)
+ discharges.append(dm)
+ self.discharge_caveats(dm, discharges, primary_macaroon, key)
+
+ def _get_discharge(self, third_party_location,
+ third_party_caveat_condition):
+ '''Get the discharge macaroon from the third party location.
+
+ @param third_party_location where to get a discharge from.
+ @param third_party_caveat_condition encoded 64 string associated to the
+ discharged macaroon.
+ @return a discharge macaroon.
+ @raise DischargeError when an error occurs during the discharge
+ process.
+ '''
+ headers = DEFAULT_PROTOCOL_VERSION
+ payload = {'id': third_party_caveat_condition}
+
+ response = requests.post(third_party_location + '/discharge',
+ headers=headers,
+ data=payload,
+ # timeout=TIME_OUT, TODO: add a time out
+ cookies=self._jar)
+ status_code = response.status_code
+ if status_code == 200:
+ return _extract_macaroon_from_response(response)
+ if (status_code == 401 and
+ response.headers.get('WWW-Authenticate') == 'Macaroon'):
+ error = response.json()
+ if error.get('Code', '') != ERR_INTERACTION_REQUIRED:
+ return DischargeException('unable to get code from discharge')
+ info = _extract_urls(response)
+ self._visit_page(info.visit_url)
+ # Wait on the wait url and then get a macaroon if validated.
+ return _acquire_macaroon_from_wait(info.wait_url)
+
+
+def _decode_caveat(key, caveat):
+ '''Attempts to decode caveat by decrypting the encrypted part using key.
+
+ @param key a nacl key.
+ @param caveat bytes to be decoded.
+ @return a dict of third party caveat info.
+ '''
+ data = base64.b64decode(caveat).decode('utf-8')
+ tpid = json.loads(data)
+ third_party_public_key = nacl.public.PublicKey(
+ base64.b64decode(tpid['ThirdPartyPublicKey']))
+ if key.public_key != third_party_public_key:
+ return 'some error'
+ if tpid.get('FirstPartyPublicKey', None) is None:
+ return 'target service public key not specified'
+ # The encrypted string is base64 encoded in the JSON representation.
+ secret = base64.b64decode(tpid['Id'])
+ first_party_public_key = nacl.public.PublicKey(
+ base64.b64decode(tpid['FirstPartyPublicKey']))
+ box = Box(key,
+ first_party_public_key)
+ c = box.decrypt(secret, base64.b64decode(tpid['Nonce']))
+ record = json.loads(c.decode('utf-8'))
+ return {
+ 'Condition': record['Condition'],
+ 'FirstPartyPublicKey': first_party_public_key,
+ 'ThirdPartyKeyPair': key,
+ 'RootKey': base64.b64decode(record['RootKey']),
+ 'Caveat': caveat,
+ 'MacaroonId': id,
+ }
+
+
+def _extract_macaroon_from_response(response):
+ '''Extract the macaroon from a direct successful discharge.
+
+ @param response from direct successful discharge.
+ @return a macaroon object.
+ @raises DischargeError if any error happens.
+ '''
+ response_json = response.json()
+ return utils.deserialize(response_json['Macaroon'])
+
+
+def _acquire_macaroon_from_wait(wait_url):
+ ''' Return the macaroon acquired from the wait endpoint.
+
+ Note that will block until the user interaction has completed.
+
+ @param wait_url the get url to call to get a macaroon.
+ @return a macaroon object
+ @raises DischargeError if any error happens.
+ '''
+ resp = requests.get(wait_url, headers=DEFAULT_PROTOCOL_VERSION)
+ response_json = resp.json()
+ macaroon = response_json['Macaroon']
+ return utils.deserialize(macaroon)
+
+
+def _extract_urls(response):
+ '''Return _Info of the visit and wait URL from response.
+
+ @param response the response from the discharge endpoint.
+ @return a _Info object of the visit and wait URL.
+ @raises DischargeError for ant error during the process response.
+ '''
+ response_json = response.json()
+ visit_url = response_json['Info']['VisitURL']
+ wait_url = response_json['Info']['WaitURL']
+ return _Info(visit_url=visit_url, wait_url=wait_url)
+
+
+class ThirdPartyInfo:
+ def __init__(self, version, public_key):
+ '''
+ @param version holds latest the bakery protocol version supported
+ by the discharger.
+ @param public_key holds the public nacl key of the third party.
+ '''
+ self.version = version
+ self.public_key = public_key
diff --git a/macaroonbakery/checkers.py b/macaroonbakery/checkers.py
new file mode 100644
index 0000000..8d72eb9
--- /dev/null
+++ b/macaroonbakery/checkers.py
@@ -0,0 +1,23 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+import collections
+
+_Caveat = collections.namedtuple('Caveat', 'condition location namespace')
+
+
+class Caveat(_Caveat):
+ '''Represents a condition that must be true for a check to complete
+ successfully.
+
+ If location is provided, the caveat must be discharged by
+ a third party at the given location (a URL string).
+
+ The namespace parameter holds the namespace URI string of the
+ condition - if it is provided, it will be converted to a namespace prefix
+ before adding to the macaroon.
+ '''
+ __slots__ = ()
+
+ def __new__(cls, condition, location=None, namespace=None):
+ return super(Caveat, cls).__new__(cls, condition, location, namespace)
diff --git a/macaroonbakery/codec.py b/macaroonbakery/codec.py
new file mode 100644
index 0000000..4015bbb
--- /dev/null
+++ b/macaroonbakery/codec.py
@@ -0,0 +1,299 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+import base64
+import json
+import namespace
+
+from nacl.public import Box, PublicKey
+from nacl.encoding import Base64Encoder
+import six
+
+import bakery
+import macaroon
+
+_PUBLIC_KEY_PREFIX_LEN = 4
+_KEY_LEN = 32
+# version3CaveatMinLen holds an underestimate of the
+# minimum length of a version 3 caveat.
+_VERSION3_CAVEAT_MIN_LEN = 1 + 4 + 32 + 24 + 16 + 1
+
+
+def encode_caveat(condition, root_key, third_party_info, key, ns):
+ '''Encrypt a third-party caveat.
+
+ The third_party_info key holds information about the
+ third party we're encrypting the caveat for; the key is the
+ public/private key pair of the party that's adding the caveat.
+
+ The caveat will be encoded according to the version information
+ found in third_party_info.
+
+ @param condition string
+ @param root_key bytes
+ @param third_party_info object
+ @param key nacl key
+ @param ns not used yet
+ @return bytes
+ '''
+ if third_party_info.version == bakery.BAKERY_V1:
+ return _encode_caveat_v1(condition, root_key,
+ third_party_info.public_key, key)
+ if (third_party_info.version == bakery.BAKERY_V2 or
+ third_party_info.version == bakery.BAKERY_V3):
+ return _encode_caveat_v2_v3(third_party_info.version, condition,
+ root_key, third_party_info.public_key, key,
+ ns)
+ raise NotImplementedError('only bakery v1, v2, v3 supported')
+
+
+def _encode_caveat_v1(condition, root_key, third_party_pub_key, key):
+ '''Create a JSON-encoded third-party caveat.
+
+ The third_party_pub_key key represents the public key of the third party
+ we're encrypting the caveat for; the key is the public/private key pair of
+ the party that's adding the caveat.
+
+ @param condition string
+ @param root_key bytes
+ @param third_party_pub_key nacl public key
+ @param key nacl private key
+ @return a base64 encoded bytes
+ '''
+ plain_data = json.dumps({
+ 'RootKey': base64.b64encode(root_key).decode('ascii'),
+ 'Condition': condition
+ })
+ box = Box(key, third_party_pub_key)
+
+ encrypted = box.encrypt(six.b(plain_data))
+ nonce = encrypted[0:Box.NONCE_SIZE]
+ encrypted = encrypted[Box.NONCE_SIZE:]
+ return base64.b64encode(six.b(json.dumps({
+ 'ThirdPartyPublicKey': third_party_pub_key.encode(
+ Base64Encoder).decode('ascii'),
+ 'FirstPartyPublicKey': key.public_key.encode(
+ Base64Encoder).decode('ascii'),
+ 'Nonce': base64.b64encode(nonce).decode('ascii'),
+ 'Id': base64.b64encode(encrypted).decode('ascii')
+ })))
+
+
+def _encode_caveat_v2_v3(version, condition, root_key, third_party_pub_key,
+ key, ns):
+ '''Create a version 2 or version 3 third-party caveat.
+
+ The format has the following packed binary fields (note
+ that all fields up to and including the nonce are the same
+ as the v2 format):
+
+ version 2 or 3 [1 byte]
+ first 4 bytes of third-party Curve25519 public key [4 bytes]
+ first-party Curve25519 public key [32 bytes]
+ nonce [24 bytes]
+ encrypted secret part [rest of message]
+
+ The encrypted part encrypts the following fields
+ with box.Seal:
+
+ version 2 or 3 [1 byte]
+ length of root key [n: uvarint]
+ root key [n bytes]
+ length of encoded namespace [n: uvarint] (Version 3 only)
+ encoded namespace [n bytes] (Version 3 only)
+ condition [rest of encrypted part]
+ '''
+ ns_data = bytearray()
+ if version >= bakery.BAKERY_V3:
+ ns_data = ns.serialize()
+ data = bytearray()
+ data.append(version)
+ data.extend(third_party_pub_key.encode()[:_PUBLIC_KEY_PREFIX_LEN])
+ data.extend(key.public_key.encode()[:])
+ secret = _encode_secret_part_v2_v3(version, condition, root_key, ns_data)
+ box = Box(key, third_party_pub_key)
+ encrypted = box.encrypt(secret)
+ nonce = encrypted[0:Box.NONCE_SIZE]
+ encrypted = encrypted[Box.NONCE_SIZE:]
+ data.extend(nonce[:])
+ data.extend(encrypted)
+ return bytes(data)
+
+
+def _encode_secret_part_v2_v3(version, condition, root_key, ns):
+ '''Creates a version 2 or version 3 secret part of the third party
+ caveat. The returned data is not encrypted.
+
+ The format has the following packed binary fields:
+ version 2 or 3 [1 byte]
+ root key length [n: uvarint]
+ root key [n bytes]
+ namespace length [n: uvarint] (v3 only)
+ namespace [n bytes] (v3 only)
+ predicate [rest of message]
+ '''
+ data = bytearray()
+ data.append(version)
+ _encode_uvarint(len(root_key), data)
+ data.extend(root_key)
+ if version >= bakery.BAKERY_V3:
+ _encode_uvarint(len(ns), data)
+ data.extend(ns)
+ data.extend(condition.encode('utf-8'))
+ return bytes(data)
+
+
+def decode_caveat(key, caveat):
+ '''Decode caveat by decrypting the encrypted part using key.
+
+ @param key the nacl private key to decode.
+ @param caveat bytes.
+ @return ThirdPartyCaveatInfo
+ '''
+ if len(caveat) == 0:
+ raise ValueError('empty third party caveat')
+
+ first = caveat[:1]
+ if first == b'e':
+ # 'e' will be the first byte if the caveatid is a base64
+ # encoded JSON object.
+ return _decode_caveat_v1(key, caveat)
+ first_as_int = six.byte2int(first)
+ if first_as_int == bakery.BAKERY_V2 or first_as_int == bakery.BAKERY_V3:
+ if (len(caveat) < _VERSION3_CAVEAT_MIN_LEN
+ and first_as_int == bakery.BAKERY_V3):
+ # If it has the version 3 caveat tag and it's too short, it's
+ # almost certainly an id, not an encrypted payload.
+ raise ValueError(
+ 'caveat id payload not provided for caveat id {}'.format(
+ caveat))
+ return _decode_caveat_v2_v3(first_as_int, key, caveat)
+ raise NotImplementedError('only bakery v1 supported')
+
+
+def _decode_caveat_v1(key, caveat):
+ '''Decode a base64 encoded JSON id.
+
+ @param key the nacl private key to decode.
+ @param caveat a base64 encoded JSON string.
+ '''
+
+ data = base64.b64decode(caveat).decode('utf-8')
+ wrapper = json.loads(data)
+ tp_public_key = PublicKey(base64.b64decode(wrapper['ThirdPartyPublicKey']))
+ if key.public_key != tp_public_key:
+ raise Exception('public key mismatch') # TODO
+
+ if wrapper.get('FirstPartyPublicKey', None) is None:
+ raise Exception('target service public key not specified')
+
+ # The encrypted string is base64 encoded in the JSON representation.
+ secret = base64.b64decode(wrapper.get('Id'))
+ nonce = base64.b64decode(wrapper.get('Nonce'))
+
+ fp_public_key = PublicKey(base64.b64decode(
+ wrapper.get('FirstPartyPublicKey')))
+
+ box = Box(key, fp_public_key)
+ c = box.decrypt(secret, nonce)
+ record = json.loads(c.decode('utf-8'))
+ fp_key = PublicKey(base64.b64decode(wrapper.get('FirstPartyPublicKey')))
+ return macaroon.ThirdPartyCaveatInfo(
+ record.get('Condition'),
+ fp_key,
+ key,
+ base64.b64decode(record.get('RootKey')),
+ caveat,
+ bakery.BAKERY_V1,
+ macaroon.legacy_namespace()
+ )
+
+
+def _decode_caveat_v2_v3(version, key, caveat):
+ '''Decodes a version 2 or version 3 caveat.
+ '''
+ if (len(caveat) < 1 + _PUBLIC_KEY_PREFIX_LEN +
+ _KEY_LEN + Box.NONCE_SIZE + 16):
+ raise ValueError('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()[:_PUBLIC_KEY_PREFIX_LEN] != pk_prefix:
+ raise ValueError('public key mismatch')
+
+ first_party_pub = caveat[:_KEY_LEN]
+ caveat = caveat[_KEY_LEN:]
+ nonce = caveat[:Box.NONCE_SIZE]
+ caveat = caveat[Box.NONCE_SIZE:]
+ fp_public_key = PublicKey(first_party_pub)
+ box = Box(key, fp_public_key)
+ data = box.decrypt(caveat, nonce)
+ root_key, condition, ns = _decode_secret_part_v2_v3(version, data)
+ return macaroon.ThirdPartyCaveatInfo(
+ condition.decode('utf-8'),
+ fp_public_key,
+ key,
+ root_key,
+ original_caveat,
+ version,
+ ns
+ )
+
+
+def _decode_secret_part_v2_v3(version, data):
+ if len(data) < 1:
+ raise ValueError('secret part too short')
+ got_version = six.byte2int(data[:1])
+ data = data[1:]
+ if version != got_version:
+ raise ValueError(
+ '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.BAKERY_V3:
+ namespace_length, read = _decode_uvarint(data)
+ data = data[read:]
+ ns_data = data[:namespace_length]
+ data = data[namespace_length:]
+ ns = namespace.deserialize_namespace(ns_data)
+ else:
+ ns = macaroon.legacy_namespace()
+ return root_key, data, ns
+
+
+def _encode_uvarint(n, data):
+ '''encodes integer into variable-length format into data.'''
+ if n < 0:
+ raise ValueError('only support positive integer')
+ while True:
+ this_byte = n & 127
+ n >>= 7
+ if n == 0:
+ data.append(this_byte)
+ break
+ data.append(this_byte | 128)
+
+
+def _decode_uvarint(data):
+ '''Decode a variable -length integer.
+
+ Reads a sequence of unsigned integer byte and decodes them into an integer
+ in variable-length format and returns it and the length read.
+ '''
+ n = 0
+ shift = 0
+ length = 0
+ for b in data:
+ if not isinstance(b, int):
+ b = six.byte2int(b)
+ n |= (b & 0x7f) << shift
+ length += 1
+ if (b & 0x80) == 0:
+ break
+ shift += 7
+ return n, length
diff --git a/macaroonbakery/httpbakery/__init__.py b/macaroonbakery/httpbakery/__init__.py
new file mode 100644
index 0000000..4ebcf23
--- /dev/null
+++ b/macaroonbakery/httpbakery/__init__.py
@@ -0,0 +1 @@
+from .client import BakeryAuth # NOQA
diff --git a/macaroonbakery/httpbakery/agent.py b/macaroonbakery/httpbakery/agent.py
new file mode 100644
index 0000000..3676bae
--- /dev/null
+++ b/macaroonbakery/httpbakery/agent.py
@@ -0,0 +1,53 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+import base64
+import json
+
+import nacl.public
+import nacl.encoding
+import requests.cookies
+import six
+from six.moves.urllib.parse import urlparse
+
+
+class AgentFileFormatError(Exception):
+ """ AgentFileFormatError is the exception raised when an agent file has a bad
+ structure.
+ """
+ pass
+
+
+def load_agent_file(filename, cookies=None):
+ """ Loads agent information from the specified file.
+
+ The agent cookies are added to cookies, or a newly created cookie jar
+ if cookies is not specified. The updated cookies is returned along
+ with the private key associated with the agent. These can be passed
+ directly as the cookies and key parameter to BakeryAuth.
+ """
+
+ with open(filename) as f:
+ data = json.load(f)
+ try:
+ key = nacl.public.PrivateKey(data['key']['private'],
+ nacl.encoding.Base64Encoder)
+ if cookies is None:
+ cookies = requests.cookies.RequestsCookieJar()
+ for agent in data['agents']:
+ u = urlparse(agent['url'])
+ value = {'username': agent['username'],
+ 'public_key': data['key']['public']}
+ jv = json.dumps(value)
+ if six.PY3:
+ jv = jv.encode('utf-8')
+ v = base64.b64encode(jv)
+ if six.PY3:
+ v = v.decode('utf-8')
+ cookie = requests.cookies.create_cookie('agent-login', v,
+ domain=u.netloc,
+ path=u.path)
+ cookies.set_cookie(cookie)
+ return cookies, key
+ except (KeyError, ValueError) as e:
+ raise AgentFileFormatError("invalid agent file", e)
diff --git a/macaroonbakery/httpbakery/client.py b/macaroonbakery/httpbakery/client.py
new file mode 100644
index 0000000..32f35dd
--- /dev/null
+++ b/macaroonbakery/httpbakery/client.py
@@ -0,0 +1,157 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+import base64
+import requests
+from six.moves.http_cookiejar import Cookie
+from six.moves.urllib.parse import urljoin
+from six.moves.urllib.parse import urlparse
+
+from macaroonbakery.bakery import discharge_all
+from macaroonbakery import utils
+
+ERR_INTERACTION_REQUIRED = 'interaction required'
+ERR_DISCHARGE_REQUIRED = 'macaroon discharge required'
+TIME_OUT = 30
+MAX_DISCHARGE_RETRIES = 3
+
+
+class BakeryAuth:
+ ''' BakeryAuth holds the context for making HTTP requests with macaroons.
+
+ This will automatically acquire and discharge macaroons around the
+ requests framework.
+ Usage:
+ from macaroonbakery import httpbakery
+ jar = requests.cookies.RequestsCookieJar()
+ resp = requests.get('some protected url',
+ cookies=jar,
+ auth=httpbakery.BakeryAuth(cookies=jar))
+ resp.raise_for_status()
+ '''
+ def __init__(self, visit_page=None, key=None,
+ cookies=requests.cookies.RequestsCookieJar()):
+ '''
+
+ @param visit_page function called when the discharge process requires
+ further interaction taking a visit_url string as parameter.
+ @param key holds the client's private nacl key. If set, the client
+ will try to discharge third party caveats with the special location
+ "local" by using this key.
+ @param cookies storage for the cookies {CookieJar}. It should be the
+ same than in the requests cookies
+ '''
+ if visit_page is None:
+ visit_page = utils.visit_page_with_browser
+ if 'agent-login' in cookies.keys():
+ self._visit_page = _visit_page_for_agent(cookies, key)
+ else:
+ self._visit_page = visit_page
+ self._jar = cookies
+ self._key = key
+
+ def __call__(self, req):
+ req.headers['Bakery-Protocol-Version'] = '1'
+ hook = _prepare_discharge_hook(req.copy(), self._key, self._jar,
+ self._visit_page)
+ req.register_hook(event='response', hook=hook)
+ return req
+
+
+def _prepare_discharge_hook(req, key, jar, visit_page):
+ ''' Return the hook function (called when the response is received.)
+
+ This allows us to intercept the response and do any necessary
+ macaroon discharge before returning.
+ '''
+ class Retry:
+ # Define a local class so that we can use its class variable as
+ # mutable state accessed by the closures below.
+ count = 0
+
+ def hook(response, *args, **kwargs):
+ ''' Requests hooks system, this is the hook for the response.
+ '''
+ status_401 = (response.status_code == 401
+ and response.headers.get('WWW-Authenticate') ==
+ 'Macaroon')
+ if not status_401 and response.status_code != 407:
+ return response
+ if response.headers.get('Content-Type') != 'application/json':
+ return response
+
+ try:
+ error = response.json()
+ except:
+ raise BakeryException(
+ 'unable to read discharge error response')
+ if error.get('Code') != ERR_DISCHARGE_REQUIRED:
+ return response
+ Retry.count += 1
+ if Retry.count > MAX_DISCHARGE_RETRIES:
+ raise BakeryException('too many discharges')
+ info = error.get('Info')
+ if not isinstance(info, dict):
+ raise BakeryException(
+ 'unable to read info in discharge error response')
+ serialized_macaroon = info.get('Macaroon')
+ if not isinstance(serialized_macaroon, dict):
+ raise BakeryException(
+ 'unable to read macaroon in discharge error response')
+
+ macaroon = utils.deserialize(serialized_macaroon)
+ discharges = discharge_all(macaroon, visit_page, jar, key)
+ encoded_discharges = map(utils.serialize_macaroon_string, discharges)
+
+ macaroons = '[' + ','.join(encoded_discharges) + ']'
+ all_macaroons = base64.urlsafe_b64encode(
+ macaroons.encode('utf-8')).decode('ascii')
+
+ full_path = urljoin(response.url,
+ info['MacaroonPath'])
+ parsed_url = urlparse(full_path)
+ if info and info.get('CookieNameSuffix'):
+ name = 'macaroon-' + info['CookieNameSuffix']
+ else:
+ name = 'macaroon-' + discharges[0].signature
+ cookie = Cookie(
+ version=0,
+ name=name,
+ value=all_macaroons,
+ port=None,
+ port_specified=False,
+ domain=parsed_url[1],
+ domain_specified=True,
+ domain_initial_dot=False,
+ path=parsed_url[2],
+ path_specified=True,
+ secure=False,
+ expires=None,
+ discard=False,
+ comment=None,
+ comment_url=None,
+ rest=None,
+ rfc2109=False)
+ jar.set_cookie(cookie)
+ # Replace the private _cookies from req as it is a copy of
+ # the original cookie jar passed into the requests method and we need
+ # to set the cookie for this request.
+ req._cookies = jar
+ req.headers.pop('Cookie', None)
+ req.prepare_cookies(req._cookies)
+ req.headers['Bakery-Protocol-Version'] = '1'
+ with requests.Session() as s:
+ return s.send(req)
+ return hook
+
+
+class BakeryException(requests.RequestException):
+ ''' Bakery exception '''
+
+
+def _visit_page_for_agent(cookies, key):
+ def visit_page_for_agent(visit_url):
+ resp = requests.get(visit_url, cookies=cookies,
+ auth=BakeryAuth(cookies=cookies, key=key))
+ resp.raise_for_status()
+ return visit_page_for_agent
diff --git a/macaroonbakery/json_serializer.py b/macaroonbakery/json_serializer.py
new file mode 100644
index 0000000..2faea00
--- /dev/null
+++ b/macaroonbakery/json_serializer.py
@@ -0,0 +1,75 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+import base64
+import json
+
+from pymacaroons.macaroon import Macaroon
+from pymacaroons.caveat import Caveat
+
+
+class JsonSerializer(object):
+ '''Serializer used to produce JSON macaroon format v1.
+ '''
+ def serialize(self, macaroon):
+ '''Serialize the macaroon in JSON format v1.
+
+ @param macaroon the macaroon to serialize.
+ @return JSON macaroon.
+ '''
+ serialized = {
+ 'identifier': macaroon.identifier,
+ 'signature': macaroon.signature
+ }
+ if macaroon.location:
+ serialized['location'] = macaroon.location
+ if macaroon.caveats:
+ serialized['caveats'] = [
+ caveat_v1_to_dict(caveat) for caveat in macaroon.caveats
+ ]
+ return json.dumps(serialized)
+
+ def deserialize(self, serialized):
+ '''Deserialize a JSON macaroon v1.
+
+ @param serialized the macaroon in JSON format v1.
+ @return the macaroon object.
+ '''
+ from macaroonbakery import utils
+ caveats = []
+ deserialized = json.loads(serialized)
+
+ for c in deserialized['caveats']:
+ caveat = Caveat(
+ caveat_id=c['cid'],
+ verification_key_id=(
+ utils.raw_urlsafe_b64decode(c['vid']) if c.get('vid')
+ else None
+ ),
+ location=(
+ c['cl'] if c.get('cl') else None
+ )
+ )
+ caveats.append(caveat)
+
+ return Macaroon(
+ location=deserialized['location'],
+ identifier=deserialized['identifier'],
+ caveats=caveats,
+ signature=deserialized['signature']
+ )
+
+
+def caveat_v1_to_dict(c):
+ ''' Return a caveat as a dictionary for export as the JSON
+ macaroon v1 format
+ '''
+ serialized = {}
+ if len(c.caveat_id) > 0:
+ serialized['cid'] = c.caveat_id
+ if c.verification_key_id:
+ serialized['vid'] = base64.urlsafe_b64encode(
+ c.verification_key_id).decode('ascii')
+ if c.location:
+ serialized['cl'] = c.location
+ return serialized
diff --git a/macaroonbakery/macaroon.py b/macaroonbakery/macaroon.py
new file mode 100644
index 0000000..b0a89bb
--- /dev/null
+++ b/macaroonbakery/macaroon.py
@@ -0,0 +1,311 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+import base64
+import copy
+import logging
+import os
+
+import bakery
+import codec
+import pymacaroons
+
+import namespace
+
+MACAROON_V1, MACAROON_V2 = 1, 2
+
+log = logging.getLogger(__name__)
+
+
+def legacy_namespace():
+ ''' Standard namespace for pre-version3 macaroons.
+ '''
+ ns = namespace.Namespace(None)
+ ns.register(namespace.STD_NAMESPACE, '')
+ return ns
+
+
+class Macaroon:
+ '''Represent an undischarged macaroon along its first
+ party caveat namespace and associated third party caveat information
+ which should be passed to the third party when discharging a caveat.
+ '''
+ def __init__(self, root_key, id, location=None,
+ version=bakery.LATEST_BAKERY_VERSION, ns=None):
+ '''Creates a new macaroon with the given root key, id and location.
+
+ If the version is more than the latest known version,
+ the latest known version will be used. The namespace should hold the
+ namespace of the service that is creating the macaroon.
+ @param root_key bytes or string
+ @param id bytes or string
+ @param location bytes or string
+ @param version the bakery version.
+ @param ns
+ '''
+ if version > bakery.LATEST_BAKERY_VERSION:
+ log.info('use last known version:{} instead of: {}'.format(
+ bakery.LATEST_BAKERY_VERSION, version
+ ))
+ version = bakery.LATEST_BAKERY_VERSION
+ # m holds the underlying macaroon.
+ self._macaroon = pymacaroons.Macaroon(location=location, key=root_key,
+ identifier=id)
+ # version holds the version of the macaroon.
+ self.version = macaroon_version(version)
+ self.caveat_data = {}
+
+ def add_caveat(self, cav, key=None, loc=None):
+ '''Return a new macaroon with the given caveat added.
+
+ It encrypts it using the given key pair
+ and by looking up the location using the given locator.
+ As a special case, if the caveat's Location field has the prefix
+ "local " the caveat is added as a client self-discharge caveat using
+ the public key base64-encoded in the rest of the location. In this
+ case, the Condition field must be empty. The resulting third-party
+ caveat will encode the condition "true" encrypted with that public
+ key.
+
+ @param cav the checkers.Caveat to be added.
+ @param key the nacl public key to encrypt third party caveat.
+ @param loc locator to find information on third parties when adding
+ third party caveats. It is expected to have a third_party_info method
+ that will be called with a location string and should return a
+ ThirdPartyInfo instance holding the requested information.
+ @return a new macaroon object with the given caveat.
+ '''
+ if cav.location is None:
+ macaroon = self._macaroon.add_first_party_caveat(cav.condition)
+ new_macaroon = copy.copy(self)
+ new_macaroon._macaroon = macaroon
+ return new_macaroon
+ if key is None:
+ raise ValueError(
+ 'no private key to encrypt third party caveat')
+ local_info, ok = parse_local_location(cav.location)
+ if ok:
+ info = local_info
+ cav.location = 'local'
+ if cav.condition is not '':
+ raise ValueError(
+ 'cannot specify caveat condition in '
+ 'local third-party caveat')
+ cav.condition = 'true'
+ else:
+ if loc is None:
+ raise ValueError(
+ 'no locator when adding third party caveat')
+ info = loc.third_party_info(cav.location)
+ root_key = os.urandom(24)
+ # Use the least supported version to encode the caveat.
+ if self.version < info.version:
+ info.version = self.version
+
+ caveat_info = codec.encode_caveat(cav.condition, root_key, info,
+ key, None)
+ if info.version < bakery.BAKERY_V3:
+ # 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.
+ id = caveat_info
+ else:
+ id = self._new_caveat_id(self.caveat_id_prefix)
+ self.caveat_data[id] = caveat_info
+
+ m = self._macaroon.add_third_party_caveat(cav.location, root_key, id)
+ new_macaroon = copy.copy(self)
+ new_macaroon._macaroon = m
+ return new_macaroon
+
+ def add_caveats(self, cavs, key, loc):
+ '''Return a new macaroon with all caveats added.
+
+ This method does not mutate the current object.
+ @param cavs arrary of caveats.
+ @param key the nacl public key to encrypt third party caveat.
+ @param loc locator to find the location object that has a method
+ third_party_info.
+ @return a new macaroon object with the given caveats.
+ '''
+ macaroon = self
+ for cav in cavs:
+ macaroon = macaroon.add_caveat(cav, key, loc)
+ return macaroon
+
+ def serialize(self):
+ '''Return a dictionary holding the macaroon data in V1 JSON format.
+
+ Note that this differs from the underlying macaroon serialize method as
+ it does not return a string. This makes it easier to incorporate the
+ macaroon into other JSON objects.
+
+ @return a dictionary holding the macaroon data
+ in V1 JSON format
+ '''
+ if self.version == bakery.BAKERY_V1:
+ # latest libmacaroons do not support the old format
+ json_macaroon = self._macaroon.serialize('json')
+ val = {
+ 'identifier': _field_v2(json_macaroon, 'i'),
+ 'signature': _field_v2(json_macaroon, 's'),
+ }
+ location = json_macaroon.get('l')
+ if location is not None:
+ val['location'] = location
+ cavs = json_macaroon.get('c')
+ if cavs is not None:
+ val['caveats'] = map(cavs, _cav_v2_to_v1)
+ return val
+ raise NotImplementedError('only bakery v1 supported')
+
+ def _new_caveat_id(self, base):
+ '''Return a third party caveat id
+
+ This does not duplicate any third party caveat ids already inside
+ macaroon. If base is non-empty, it is used as the id prefix.
+
+ @param base string
+ @return string
+ '''
+ raise NotImplementedError
+
+ def first_party_caveats(self):
+ '''Return the first party caveats from this macaroon.
+
+ @return the first party caveats from this macaroon as pymacaroons
+ caveats.
+ '''
+ return self._macaroon.first_party_caveats()
+
+ def third_party_caveats(self):
+ '''Return the third party caveats.
+
+ @return the third party caveats as pymacaroons caveats.
+ '''
+ return self._macaroon.third_party_caveats()
+
+
+def macaroon_version(bakery_version):
+ '''Return the macaroon version given the bakery version.
+
+ @param bakery_version the bakery version
+ @return macaroon_version the derived macaroon version
+ '''
+ if bakery_version in [bakery.BAKERY_V0, bakery.BAKERY_V1]:
+ return MACAROON_V1
+ return MACAROON_V2
+
+
+def parse_local_location(loc):
+ '''Parse a local caveat location as generated by LocalThirdPartyCaveat.
+
+ This is of the form:
+
+ local <version> <pubkey>
+
+ where <version> is the bakery version of the client that we're
+ adding the local caveat for.
+
+ It returns false if the location does not represent a local
+ caveat location.
+ @return a tuple of location and if the location is local.
+ '''
+ if not(loc.startswith('local ')):
+ return (), False
+ v = bakery.BAKERY_V1
+ fields = loc.split()
+ fields = fields[1:] # Skip 'local'
+ if len(fields) == 2:
+ try:
+ v = int(fields[0])
+ except ValueError:
+ return (), False
+ fields = fields[1:]
+ if len(fields) == 1:
+ return (base64.b64decode(fields[0]), v), True
+ return (), False
+
+
+class ThirdPartyLocator:
+ '''Used to find information on third party discharge services.
+ '''
+ def __init__(self):
+ self._store = {}
+
+ def third_party_info(self, loc):
+ '''Return information on the third party at the given location.
+
+ It returns None if no match is found.
+
+ @param loc string
+ @return: string
+ '''
+ return self._store.get(loc)
+
+ def add_info(self, loc, info):
+ '''Associates the given information with the given location.
+
+ It will ignore any trailing slash.
+ '''
+ self._store[loc.rstrip('\\')] = info
+
+
+class ThirdPartyCaveatInfo:
+ '''ThirdPartyCaveatInfo holds the information decoded from
+ a third party caveat id.
+ '''
+ def __init__(self, condition, first_party_public_key, third_party_key_pair,
+ root_key, caveat, version, ns):
+ '''
+ @param condition holds the third party condition to be discharged.
+ This is the only field that most third party dischargers will
+ need to consider.
+ @param first_party_public_key holds the nacl public key of the party
+ that created the third party caveat.
+ @param third_party_key_pair holds the nacl private used to decrypt
+ the caveat - the key pair of the discharging service.
+ @param root_key bytes holds the secret root key encoded by the caveat.
+ @param caveat holds the full encoded base64 string caveat id from
+ which all the other fields are derived.
+ @param version holds the version that was used to encode
+ the caveat id.
+ @params Namespace object that holds the namespace of the first party
+ that created the macaroon, as encoded by the party that added the
+ third party caveat.
+ '''
+ self.condition = condition,
+ self.first_party_public_key = first_party_public_key,
+ self.third_party_key_pair = third_party_key_pair,
+ self.root_key = root_key,
+ self.caveat = caveat,
+ self.version = version,
+ self.ns = ns
+
+ def __eq__(self, other):
+ return (
+ self.condition == other.condition and
+ self.first_party_public_key == other.first_party_public_key and
+ self.third_party_key_pair == other.third_party_key_pair and
+ self.caveat == other.caveat and
+ self.version == other.version and
+ self.ns == other.ns
+ )
+
+
+def _field_v2(dict, field):
+ val = dict.get(field)
+ if val is None:
+ return base64.b64decode(dict.get(field + '64'))
+ return val
+
+
+def _cav_v2_to_v1(cav):
+ val = {
+ 'cid': _field_v2(cav, 'i'),
+ 'vid': _field_v2(cav, 'v')
+ }
+ location = cav.get('l')
+ if location is not None:
+ val['cl'] = location
+ return val
diff --git a/macaroonbakery/namespace.py b/macaroonbakery/namespace.py
new file mode 100644
index 0000000..ae0fa91
--- /dev/null
+++ b/macaroonbakery/namespace.py
@@ -0,0 +1,115 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+import collections
+import six
+
+# StdNamespace holds the URI of the standard checkers schema.
+STD_NAMESPACE = 'std'
+
+
+class Namespace:
+ '''Holds maps from schema URIs to prefixes.
+
+ prefixes that are used to encode them in first party
+ caveats. Several different URIs may map to the same
+ prefix - this is usual when several different backwardly
+ compatible schema versions are registered.
+ '''
+ def __init__(self, uri_to_prefix=None):
+ self._uri_to_prefix = {}
+ if uri_to_prefix is not None:
+ for k in uri_to_prefix:
+ self.register(k, uri_to_prefix[k])
+
+ def __str__(self):
+ '''Returns the namespace representation as returned by serialize
+ :return: str
+ '''
+ return self.serialize().decode('utf-8')
+
+ def __eq__(self, other):
+ return self._uri_to_prefix == other._uri_to_prefix
+
+ def serialize(self):
+ '''Returns a serialize form of the Namepace.
+
+ All the elements in the namespace are sorted by
+ URI, joined to the associated prefix with a colon and
+ separated with spaces.
+ :return: bytes
+ '''
+ if self._uri_to_prefix is None or len(self._uri_to_prefix) == 0:
+ return b''
+ od = collections.OrderedDict(sorted(self._uri_to_prefix.items()))
+ data = []
+ for uri in od:
+ data.append(uri + ':' + od[uri])
+ return six.b(' '.join(data))
+
+ def register(self, uri, prefix):
+ '''Registers the given URI and associates it with the given prefix.
+
+ If the URI has already been registered, this is a no-op.
+
+ :param uri: string
+ :param prefix: string
+ '''
+ if not is_valid_schema_uri(uri):
+ raise KeyError(
+ 'cannot register invalid URI {} (prefix {})'.format(
+ uri, prefix))
+ if not is_valid_prefix(prefix):
+ raise ValueError(
+ 'cannot register invalid prefix %q for URI %q'.format(
+ prefix, uri))
+ if self._uri_to_prefix.get(uri) is None:
+ self._uri_to_prefix[uri] = prefix
+
+ def resolve(self, uri):
+ ''' Returns the prefix associated to the uri.
+
+ returns None if not found.
+ :param uri: string
+ :return: string
+ '''
+ return self._uri_to_prefix.get(uri)
+
+
+def is_valid_schema_uri(uri):
+ '''Reports if uri is suitable for use as a namespace schema URI.
+
+ It must be non-empty and it must not contain white space.
+
+ :param uri string
+ :return bool
+ '''
+ if len(uri) <= 0:
+ return False
+ return uri.find(' ') == -1
+
+
+def is_valid_prefix(prefix):
+ '''Reports if prefix is valid.
+
+ It must not contain white space or semi-colon.
+ :param prefix string
+ :return bool
+ '''
+ return prefix.find(' ') == -1 and prefix.find(':') == -1
+
+
+def deserialize_namespace(data):
+ ''' Deserialize a Namespace object.
+
+ :param data: bytes or str
+ :return: namespace
+ '''
+ if isinstance(data, bytes):
+ data = data.decode('utf-8')
+ kvs = data.split(' ')
+ uri_to_prefix = {}
+ for kv in kvs:
+ k, v = kv.split(':')
+ uri_to_prefix[k] = v
+ return Namespace(uri_to_prefix)
diff --git a/macaroonbakery/tests/__init__.py b/macaroonbakery/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/macaroonbakery/tests/__init__.py
diff --git a/macaroonbakery/tests/test_agent.py b/macaroonbakery/tests/test_agent.py
new file mode 100644
index 0000000..86133fe
--- /dev/null
+++ b/macaroonbakery/tests/test_agent.py
@@ -0,0 +1,149 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+import base64
+import json
+import os
+import tempfile
+from unittest import TestCase
+
+import nacl.encoding
+import requests.cookies
+import six
+
+import macaroonbakery.httpbakery.agent as agent
+
+
+class TestAgents(TestCase):
+ def setUp(self):
+ fd, filename = tempfile.mkstemp()
+ with os.fdopen(fd, 'w') as f:
+ f.write(agent_file)
+ self.agent_filename = filename
+ fd, filename = tempfile.mkstemp()
+ with os.fdopen(fd, 'w') as f:
+ f.write(bad_key_agent_file)
+ self.bad_key_agent_filename = filename
+ fd, filename = tempfile.mkstemp()
+ with os.fdopen(fd, 'w') as f:
+ f.write(no_username_agent_file)
+ self.no_username_agent_filename = filename
+
+ def tearDown(self):
+ os.remove(self.agent_filename)
+ 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):
+ with self.assertRaises(agent.AgentFileFormatError):
+ agent.load_agent_file(self.bad_key_agent_filename)
+
+ def test_load_agents_with_no_username(self):
+ with self.assertRaises(agent.AgentFileFormatError):
+ agent.load_agent_file(self.no_username_agent_filename)
+
+
+agent_file = """
+{
+ "key": {
+ "public": "YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=",
+ "private": "CqoSgj06Zcgb4/S6RT4DpTjLAfKoznEY3JsShSjKJEU="
+ },
+ "agents": [{
+ "url": "https://1.example.com/",
+ "username": "user-1"
+ }, {
+ "url": "https://2.example.com/discharger",
+ "username": "user-2"
+ }]
+}
+"""
+
+
+bad_key_agent_file = """
+{
+ "key": {
+ "public": "YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=",
+ "private": "CqoSgj06Zcgb4/S6RT4DpTjLAfKoznEY3JsShSjKJE=="
+ },
+ "agents": [{
+ "url": "https://1.example.com/",
+ "username": "user-1"
+ }, {
+ "url": "https://2.example.com/discharger",
+ "username": "user-2"
+ }]
+}
+"""
+
+
+no_username_agent_file = """
+{
+ "key": {
+ "public": "YAhRSsth3a36mRYqQGQaLiS4QJax0p356nd+B8x7UQE=",
+ "private": "CqoSgj06Zcgb4/S6RT4DpTjLAfKoznEY3JsShSjKJEU="
+ },
+ "agents": [{
+ "url": "https://1.example.com/"
+ }, {
+ "url": "https://2.example.com/discharger",
+ "username": "user-2"
+ }]
+}
+"""
diff --git a/macaroonbakery/tests/test_bakery.py b/macaroonbakery/tests/test_bakery.py
new file mode 100644
index 0000000..724b264
--- /dev/null
+++ b/macaroonbakery/tests/test_bakery.py
@@ -0,0 +1,166 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+from unittest import TestCase
+
+import requests
+
+from mock import (
+ patch,
+)
+
+from httmock import (
+ HTTMock,
+ urlmatch,
+ response
+)
+
+from macaroonbakery import httpbakery
+
+ID_PATH = 'http://example.com/someprotecteurl'
+
+json_macaroon = {
+ u'identifier': u'macaroon-identifier',
+ u'caveats': [
+ {
+ u'cl': u'http://example.com/identity/v1/discharger',
+ u'vid': u'zgtQa88oS9UF45DlJniRaAUT4qqHhLxQzCeUU9N2O1Uu-'
+ u'yhFulgGbSA0zDGdkrq8YNQAxGiARA_-AGxyoh25kiTycb8u47pD',
+ u'cid': u'eyJUaGlyZFBhcnR5UHV'
+ }, {
+ u'cid': u'allow read-no-terms write'
+ }, {
+ u'cid': u'time-before 2016-07-19T14:29:14.312669464Z'
+ }],
+ u'location': u'charmstore',
+ u'signature': u'52d17cb11f5c84d58441bc0ffd7cc396'
+ u'5115374ce2fa473ecf06265b5d4d9e81'
+}
+
+discharge_token = [{
+ u'identifier': u'token-identifier===',
+ u'caveats': [{
+ u'cid': u'declared username someone'
+ }, {
+ u'cid': u'time-before 2016-08-15T15:55:52.428319076Z'
+ }, {
+ u'cid': u'origin '
+ }],
+ u'location': u'https://example.com/identity',
+ u'signature': u'5ae0e7a2abf806bdd92f510fcd3'
+ u'198f520691259abe76ffae5623dae048769ef'
+}]
+
+discharged_macaroon = {
+ u'identifier': u'discharged-identifier=',
+ u'caveats': [{
+ u'cid': u'declared uuid a1130b10-3deb-59b7-baf0-c2a3f83e7382'
+ }, {
+ u'cid': u'declared username someone'
+ }, {
+ u'cid': u'time-before 2016-07-19T15:55:52.432439055Z'
+ }],
+ u'location': u'',
+ u'signature': u'3513db5503ab17f9576760cd28'
+ u'ce658ce8bf6b43038255969fc3c1cd8b172345'
+}
+
+
+@urlmatch(path='.*/someprotecteurl')
+def first_407_then_200(url, request):
+ if request.headers.get('cookie', '').startswith('macaroon-'):
+ return {
+ 'status_code': 200,
+ 'content': {
+ 'Value': 'some value'
+ }
+ }
+ else:
+ resp = response(status_code=407,
+ content={
+ 'Info': {
+ 'Macaroon': json_macaroon,
+ 'MacaroonPath': '/',
+ 'CookieNameSuffix': 'test'
+ },
+ 'Message': 'verification failed: no macaroon '
+ 'cookies in request',
+ 'Code': 'macaroon discharge required'
+ },
+ headers={'Content-Type': 'application/json'})
+ return request.hooks['response'][0](resp)
+
+
+@urlmatch(path='.*/someprotecteurl')
+def valid_200(url, request):
+ return {
+ 'status_code': 200,
+ 'content': {
+ 'Value': 'some value'
+ }
+ }
+
+
+@urlmatch(path='.*/discharge')
+def discharge_200(url, request):
+ return {
+ 'status_code': 200,
+ 'content': {
+ 'Macaroon': discharged_macaroon
+ }
+ }
+
+
+@urlmatch(path='.*/discharge')
+def discharge_401(url, request):
+ return {
+ 'status_code': 401,
+ 'content': {
+ 'Code': 'interaction required',
+ 'Info': {
+ 'VisitURL': 'http://example.com/visit',
+ 'WaitURL': 'http://example.com/wait'
+ }
+ },
+ 'headers': {
+ 'WWW-Authenticate': 'Macaroon'
+ }
+ }
+
+
+@urlmatch(path='.*/wait')
+def wait_after_401(url, request):
+ if request.url != 'http://example.com/wait':
+ return {'status_code': 500}
+
+ return {
+ 'status_code': 200,
+ 'content': {
+ 'DischargeToken': discharge_token,
+ 'Macaroon': discharged_macaroon
+ }
+ }
+
+
+class TestBakery(TestCase):
+ def test_discharge(self):
+ jar = requests.cookies.RequestsCookieJar()
+ with HTTMock(first_407_then_200):
+ with HTTMock(discharge_200):
+ resp = requests.get(ID_PATH,
+ cookies=jar,
+ auth=httpbakery.BakeryAuth(cookies=jar))
+ resp.raise_for_status()
+ assert 'macaroon-test' in jar.keys()
+
+ @patch('webbrowser.open')
+ def test_407_then_401_on_discharge(self, mock_open):
+ jar = requests.cookies.RequestsCookieJar()
+ with HTTMock(first_407_then_200):
+ with HTTMock(discharge_401):
+ with HTTMock(wait_after_401):
+ resp = requests.get(ID_PATH,
+ auth=httpbakery.BakeryAuth(
+ cookies=jar))
+ resp.raise_for_status()
+ mock_open.assert_called_once_with(u'http://example.com/visit', new=1)
+ assert 'macaroon-test' in jar.keys()
diff --git a/macaroonbakery/tests/test_codec.py b/macaroonbakery/tests/test_codec.py
new file mode 100644
index 0000000..de1631c
--- /dev/null
+++ b/macaroonbakery/tests/test_codec.py
@@ -0,0 +1,178 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+from unittest import TestCase
+
+import base64
+import six
+
+import nacl.utils
+from nacl.public import PrivateKey
+from nacl.encoding import Base64Encoder
+
+from macaroonbakery import bakery, codec, macaroon, namespace, utils
+
+
+class TestCodec(TestCase):
+ def setUp(self):
+ self.fp_key = nacl.public.PrivateKey.generate()
+ self.tp_key = nacl.public.PrivateKey.generate()
+
+ def test_v1_round_trip(self):
+ tp_info = bakery.ThirdPartyInfo(bakery.BAKERY_V1,
+ self.tp_key.public_key)
+ cid = codec.encode_caveat('is-authenticated-user',
+ b'a random string',
+ tp_info,
+ self.fp_key,
+ None)
+
+ res = codec.decode_caveat(self.tp_key, cid)
+ self.assertEquals(res, macaroon.ThirdPartyCaveatInfo(
+ first_party_public_key=self.fp_key.public_key,
+ root_key=b'a random string',
+ condition='is-authenticated-user',
+ caveat=cid,
+ third_party_key_pair=self.tp_key,
+ version=bakery.BAKERY_V1,
+ ns=macaroon.legacy_namespace()
+ ))
+
+ def test_v2_round_trip(self):
+ tp_info = bakery.ThirdPartyInfo(bakery.BAKERY_V2,
+ self.tp_key.public_key)
+ cid = codec.encode_caveat('is-authenticated-user',
+ b'a random string',
+ tp_info,
+ self.fp_key,
+ None)
+ res = codec.decode_caveat(self.tp_key, cid)
+ self.assertEquals(res, macaroon.ThirdPartyCaveatInfo(
+ first_party_public_key=self.fp_key.public_key,
+ root_key=b'a random string',
+ condition='is-authenticated-user',
+ caveat=cid,
+ third_party_key_pair=self.tp_key,
+ version=bakery.BAKERY_V2,
+ ns=macaroon.legacy_namespace()
+ ))
+
+ def test_v3_round_trip(self):
+ tp_info = bakery.ThirdPartyInfo(bakery.BAKERY_V3,
+ self.tp_key.public_key)
+ ns = namespace.Namespace()
+ ns.register('testns', 'x')
+ cid = codec.encode_caveat('is-authenticated-user',
+ b'a random string',
+ tp_info,
+ self.fp_key,
+ ns)
+ res = codec.decode_caveat(self.tp_key, cid)
+ self.assertEquals(res, macaroon.ThirdPartyCaveatInfo(
+ first_party_public_key=self.fp_key.public_key,
+ root_key=b'a random string',
+ condition='is-authenticated-user',
+ caveat=cid,
+ third_party_key_pair=self.tp_key,
+ version=bakery.BAKERY_V3,
+ ns=ns
+ ))
+
+ def test_empty_caveat_id(self):
+ with self.assertRaises(ValueError) as context:
+ codec.decode_caveat(self.tp_key, b'')
+ self.assertTrue('empty third party caveat' in str(context.exception))
+
+ def test_decode_caveat_v1_from_go(self):
+ tp_key = PrivateKey(base64.b64decode(
+ 'TSpvLpQkRj+T3JXnsW2n43n5zP/0X4zn0RvDiWC3IJ0='))
+ fp_key = PrivateKey(base64.b64decode(
+ 'KXpsoJ9ujZYi/O2Cca6kaWh65MSawzy79LWkrjOfzcs='))
+ fp_key.encode(Base64Encoder)
+ # This caveat has been generated from the go code
+ # to check the compatibilty
+ encrypted_cav = six.b(
+ 'eyJUaGlyZFBhcnR5UHVibGljS2V5IjoiOFA3R1ZZc3BlWlN4c'
+ '3hFdmJsSVFFSTFqdTBTSWl0WlIrRFdhWE40cmxocz0iLCJGaX'
+ 'JzdFBhcnR5UHVibGljS2V5IjoiSDlqSFJqSUxidXppa1VKd2o'
+ '5VGtDWk9qeW5oVmtTdHVsaUFRT2d6Y0NoZz0iLCJOb25jZSI6'
+ 'Ii9lWTRTTWR6TGFxbDlsRFc3bHUyZTZuSzJnVG9veVl0IiwiS'
+ 'WQiOiJra0ZuOGJEaEt4RUxtUjd0NkJxTU0vdHhMMFVqaEZjR1'
+ 'BORldUUExGdjVla1dWUjA4Uk1sbGJhc3c4VGdFbkhzM0laeVo'
+ '0V2lEOHhRUWdjU3ljOHY4eUt4dEhxejVEczJOYmh1ZDJhUFdt'
+ 'UTVMcVlNWitmZ2FNaTAxdE9DIn0=')
+ cav = codec.decode_caveat(tp_key, encrypted_cav)
+ self.assertEquals(cav, macaroon.ThirdPartyCaveatInfo(
+ condition='caveat condition',
+ first_party_public_key=fp_key.public_key,
+ third_party_key_pair=tp_key,
+ root_key=b'random',
+ caveat=encrypted_cav,
+ version=bakery.BAKERY_V1,
+ ns=macaroon.legacy_namespace()
+ ))
+
+ def test_decode_caveat_v2_from_go(self):
+ tp_key = PrivateKey(base64.b64decode(
+ 'TSpvLpQkRj+T3JXnsW2n43n5zP/0X4zn0RvDiWC3IJ0='))
+ fp_key = PrivateKey(base64.b64decode(
+ 'KXpsoJ9ujZYi/O2Cca6kaWh65MSawzy79LWkrjOfzcs='))
+ # This caveat has been generated from the go code
+ # to check the compatibilty
+ encrypted_cav = base64.urlsafe_b64decode(
+ utils.add_base64_padding(six.b(
+ 'AvD-xlUf2MdGMgtu7OKRQnCP1OQJk6PKeFWRK26WIBA6DNwKGIHq9xGcHS9IZ'
+ 'Lh0cL6D9qpeKI0mXmCPfnwRQDuVYC8y5gVWd-oCGZaj5TGtk3byp2Vnw6ojmt'
+ 'sULDhY59YA_J_Y0ATkERO5T9ajoRWBxU2OXBoX6bImXA')))
+ cav = codec.decode_caveat(tp_key, encrypted_cav)
+ self.assertEquals(cav, macaroon.ThirdPartyCaveatInfo(
+ condition='third party condition',
+ first_party_public_key=fp_key.public_key,
+ third_party_key_pair=tp_key,
+ root_key=b'random',
+ caveat=encrypted_cav,
+ version=bakery.BAKERY_V2,
+ ns=macaroon.legacy_namespace()
+ ))
+
+ def test_decode_caveat_v3_from_go(self):
+ tp_key = PrivateKey(base64.b64decode(
+ 'TSpvLpQkRj+T3JXnsW2n43n5zP/0X4zn0RvDiWC3IJ0='))
+ fp_key = PrivateKey(base64.b64decode(
+ 'KXpsoJ9ujZYi/O2Cca6kaWh65MSawzy79LWkrjOfzcs='))
+ # This caveat has been generated from the go code
+ # to check the compatibilty
+ encrypted_cav = base64.urlsafe_b64decode(
+ utils.add_base64_padding(six.b(
+ 'A_D-xlUf2MdGMgtu7OKRQnCP1OQJk6PKeFWRK26WIBA6DNwKGNLeFSkD2M-8A'
+ 'EYvmgVH95GWu7T7caKxKhhOQFcEKgnXKJvYXxz1zin4cZc4Q6C7gVqA-J4_j3'
+ '1LX4VKxymqG62UGPo78wOv0_fKjr3OI6PPJOYOQgBMclemlRF2')))
+ cav = codec.decode_caveat(tp_key, encrypted_cav)
+ self.assertEquals(cav, macaroon.ThirdPartyCaveatInfo(
+ condition='third party condition',
+ first_party_public_key=fp_key.public_key,
+ third_party_key_pair=tp_key,
+ root_key=b'random',
+ caveat=encrypted_cav,
+ version=bakery.BAKERY_V3,
+ ns=macaroon.legacy_namespace()
+ ))
+
+ def test_encode_decode_varint(self):
+ tests = [
+ (12, [12]),
+ (127, [127]),
+ (128, [128, 1]),
+ (129, [129, 1]),
+ (1234567, [135, 173, 75]),
+ (12131231231312, [208, 218, 233, 173, 136, 225, 2])
+ ]
+ for test in tests:
+ data = bytearray()
+ expected = bytearray()
+ codec._encode_uvarint(test[0], data)
+ for v in test[1]:
+ expected.append(v)
+ self.assertEquals(data, expected)
+ val = codec._decode_uvarint(bytes(data))
+ self.assertEquals(test[0], val[0])
+ self.assertEquals(len(test[1]), val[1])
diff --git a/macaroonbakery/tests/test_macaroon.py b/macaroonbakery/tests/test_macaroon.py
new file mode 100644
index 0000000..afc7d52
--- /dev/null
+++ b/macaroonbakery/tests/test_macaroon.py
@@ -0,0 +1,64 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+from unittest import TestCase
+
+import six
+
+import nacl.utils
+
+from macaroonbakery import bakery, macaroon, checkers, codec
+
+
+class TestMacaroon(TestCase):
+ def test_new_macaroon(self):
+ m = macaroon.Macaroon(b'rootkey',
+ b'some id',
+ 'here',
+ bakery.LATEST_BAKERY_VERSION)
+ self.assertIsNotNone(m)
+ self.assertEquals(m._macaroon.identifier, 'some id')
+ self.assertEquals(m._macaroon.location, 'here')
+ self.assertEquals(m.version, macaroon.macaroon_version(
+ bakery.LATEST_BAKERY_VERSION))
+
+ def test_add_first_party_caveat(self):
+ m = macaroon.Macaroon('rootkey',
+ 'some id',
+ 'here',
+ bakery.LATEST_BAKERY_VERSION)
+ m = m.add_caveat(checkers.Caveat('test_condition'))
+ caveats = m.first_party_caveats()
+ self.assertEquals(len(caveats), 1)
+ self.assertEquals(caveats[0].caveat_id, 'test_condition')
+
+ def test_add_third_party_caveat(self):
+ m = macaroon.Macaroon('rootkey',
+ 'some id',
+ 'here',
+ bakery.LATEST_BAKERY_VERSION)
+ loc = macaroon.ThirdPartyLocator()
+ fp_key = nacl.public.PrivateKey.generate()
+ tp_key = nacl.public.PrivateKey.generate()
+
+ loc.add_info('test_location',
+ bakery.ThirdPartyInfo(
+ bakery.BAKERY_V1,
+ tp_key.public_key))
+ m = m.add_caveat(checkers.Caveat(condition='test_condition',
+ location='test_location'),
+ fp_key, loc)
+
+ tp_cav = m.third_party_caveats()
+ self.assertEquals(len(tp_cav), 1)
+ self.assertEquals(tp_cav[0].location, 'test_location')
+ cav = codec.decode_caveat(tp_key, six.b(tp_cav[0].caveat_id))
+ self.assertEquals(cav, macaroon.ThirdPartyCaveatInfo(
+ condition='test_condition',
+ first_party_public_key=fp_key.public_key,
+ third_party_key_pair=tp_key,
+ root_key='random',
+ caveat=six.b(tp_cav[0].caveat_id),
+ version=bakery.BAKERY_V1,
+ ns=macaroon.legacy_namespace()
+ ))
diff --git a/macaroonbakery/tests/test_namespace.py b/macaroonbakery/tests/test_namespace.py
new file mode 100644
index 0000000..24eda29
--- /dev/null
+++ b/macaroonbakery/tests/test_namespace.py
@@ -0,0 +1,58 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+from unittest import TestCase
+
+from macaroonbakery import namespace
+
+
+class TestNamespace(TestCase):
+ def test_serialize(self):
+ tests = [
+ ('empty namespace', None, b''),
+ ('standard namespace', {'std': ''}, b'std:'),
+ ('several elements', {
+ 'std': '',
+ 'http://blah.blah': 'blah',
+ 'one': 'two',
+ 'foo.com/x.v0.1': 'z',
+ }, b'foo.com/x.v0.1:z http://blah.blah:blah one:two std:'),
+ ('sort by URI not by field', {
+ 'a': 'one',
+ 'a1': 'two',
+ }, b'a:one a1:two')
+ ]
+ for test in tests:
+ ns = namespace.Namespace(test[1])
+ data = ns.serialize()
+ self.assertEquals(data, test[2])
+ self.assertEquals(str(ns), test[2].decode('utf-8'))
+
+ # Check that it can be deserialize to the same thing:
+ ns1 = namespace.deserialize_namespace(data)
+ self.assertEquals(ns1, ns)
+
+ def test_register(self):
+ ns = namespace.Namespace(None)
+ ns.register('testns', 't')
+ prefix = ns.resolve('testns')
+ self.assertEquals(prefix, 't')
+
+ ns.register('other', 'o')
+ prefix = ns.resolve('other')
+ self.assertEquals(prefix, 'o')
+
+ # If we re-register the same URL, it does nothing.
+ ns.register('other', 'p')
+ prefix = ns.resolve('other')
+ self.assertEquals(prefix, 'o')
+
+ def test_register_bad_uri(self):
+ ns = namespace.Namespace(None)
+ with self.assertRaises(KeyError):
+ ns.register('', 'x')
+
+ def test_register_bad_prefix(self):
+ ns = namespace.Namespace(None)
+ with self.assertRaises(ValueError):
+ ns.register('std', 'x:1')
diff --git a/macaroonbakery/utils.py b/macaroonbakery/utils.py
new file mode 100644
index 0000000..c747ad3
--- /dev/null
+++ b/macaroonbakery/utils.py
@@ -0,0 +1,79 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+import base64
+import json
+import webbrowser
+
+from pymacaroons import Macaroon
+
+from macaroonbakery import json_serializer
+
+
+def deserialize(json_macaroon):
+ '''Deserialize a JSON macaroon into a macaroon object from pymacaroons.
+
+ @param the JSON macaroon to deserialize as a dict.
+ @return the deserialized macaroon object.
+ '''
+ return Macaroon.deserialize(json.dumps(json_macaroon),
+ json_serializer.JsonSerializer())
+
+
+def serialize_macaroon_string(macaroon):
+ '''Serialize macaroon object to string.
+
+ @param macaroon object to be serialized.
+ @return a string serialization form of the macaroon.
+ '''
+ return macaroon.serialize(json_serializer.JsonSerializer())
+
+
+def add_base64_padding(b):
+ '''Add padding to base64 encoded bytes.
+
+ pymacaroons does not give padded base64 bytes from serialization.
+
+ @param bytes b to be padded.
+ @return a padded bytes.
+ '''
+ return b + b'=' * (-len(b) % 4)
+
+
+def remove_base64_padding(b):
+ '''Remove padding from base64 encoded bytes.
+
+ pymacaroons does not give padded base64 bytes from serialization.
+
+ @param bytes b to be padded.
+ @return a padded bytes.
+ '''
+
+ return b.rstrip(b'=')
+
+
+def raw_urlsafe_b64decode(s):
+ '''Base64 decode with added padding and convertion to bytes.
+
+ @param s string decode
+ @return bytes decoded
+ '''
+ return base64.urlsafe_b64decode(add_base64_padding(
+ s.encode('ascii')))
+
+
+def raw_urlsafe_b64encode(b):
+ '''Base64 encode with padding removed.
+
+ @param s string decode
+ @return bytes decoded
+ '''
+ return remove_base64_padding(base64.urlsafe_b64encode(b))
+
+
+def visit_page_with_browser(visit_url):
+ '''Open a browser so the user can validate its identity.
+
+ @param visit_url: where to prove your identity.
+ '''
+ webbrowser.open(visit_url, new=1)
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..8cd09a5
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,7 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+requests>=2.16.5
+PyNaCl>=1.1.2
+pymacaroons==0.10.0
+six>=1.10.0
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..5e40900
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,2 @@
+[wheel]
+universal = 1
diff --git a/setup.py b/setup.py
new file mode 100755
index 0000000..989b8cc
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,69 @@
+#!/usr/bin/env python
+
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+import platform
+
+from setuptools import (
+ find_packages,
+ setup,
+)
+
+
+PROJECT_NAME = 'macaroonbakery'
+project = __import__(PROJECT_NAME)
+
+with open('README.rst') as readme_file:
+ readme = readme_file.read()
+
+requirements = [
+ 'requests>=2.16.5',
+ 'PyNaCl>=1.1.2',
+ 'pymacaroons>=0.10.0',
+ 'six>=1.10.0',
+]
+
+test_requirements = [
+ 'tox',
+ 'httmock==1.2.5',
+]
+
+distribution = platform.dist()
+if len(distribution) == 3 and distribution[2] == 'trusty':
+ # Injected into urllib3 to fix insecure Python 2.
+ requirements.extend([
+ 'cryptography==1.3.2',
+ 'pyOpenSSL==16.0.0',
+ 'pyasn1==0.1.9',
+ 'ndg_httpsclient==0.3.3',
+ ])
+
+
+setup(
+ name=PROJECT_NAME,
+ version=project.get_version(),
+ description='A Python library port for bakery, higher level operation '
+ 'to work with macaroons',
+ long_description=readme,
+ author="Juju UI Team",
+ author_email='juju-gui@lists.ubuntu.com',
+ url='https://github.com/go-macaroon-bakery/py-macaroon-bakery',
+ packages=find_packages(),
+ include_package_data=True,
+ install_requires=requirements,
+ license="LGPL3",
+ zip_safe=False,
+ keywords='macaroon cookie',
+ classifiers=[
+ 'Development Status :: 2 - Pre-Alpha',
+ 'Intended Audience :: Developers',
+ 'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)',
+ 'Natural Language :: English',
+ 'Programming Language :: Python :: 2',
+ 'Programming Language :: Python :: 2.7',
+ 'Programming Language :: Python :: 3',
+ 'Programming Language :: Python :: 3.5',
+ ],
+ test_suite='tests',
+ tests_require=test_requirements,
+)
diff --git a/sysdeps.mk b/sysdeps.mk
new file mode 100644
index 0000000..519844b
--- /dev/null
+++ b/sysdeps.mk
@@ -0,0 +1 @@
+APT_SYSDEPS = python-dev python-pip python-setuptools libsodium-dev python-dev libffi-dev libssl-dev
diff --git a/test-requirements.txt b/test-requirements.txt
new file mode 100644
index 0000000..1a3938a
--- /dev/null
+++ b/test-requirements.txt
@@ -0,0 +1,8 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+coverage==3.7.1
+flake8==2.4.0
+mock==1.0.1
+nose==1.3.6
+httmock==1.2.5
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..0fdf31c
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,27 @@
+# Copyright 2017 Canonical Ltd.
+# Licensed under the LGPLv3, see LICENCE file for details.
+
+[tox]
+envlist = py27, py35, style, docs
+
+[testenv]
+setenv =
+ PYTHONPATH = {toxinidir}:{toxinidir}/macaroonbakery
+commands =
+ nosetests {posargs:--quiet}
+deps =
+ -r{toxinidir}/test-requirements.txt
+
+[testenv:devenv]
+envdir = devenv
+usedevelop = True
+commands =
+
+[testenv:lint]
+usedevelop = True
+commands = flake8 --ignore E731 --show-source macaroonbakery
+
+[testenv:docs]
+changedir = docs
+deps = sphinx
+commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html