├── .coveragerc ├── .gitignore ├── .readthedocs.yml ├── LICENSE ├── Makefile ├── README.md ├── authl ├── __init__.py ├── __version__.py ├── disposition.py ├── flask.py ├── flask_templates │ ├── authl.css │ ├── login.html │ ├── notify.html │ └── post-needed.html ├── handlers │ ├── __init__.py │ ├── email_addr.py │ ├── fediverse.py │ ├── indieauth.py │ └── test_handler.py ├── icons │ ├── email_addr.svg │ ├── indieauth.svg │ ├── mastodon.svg │ └── pleroma.svg ├── tokens.py ├── utils.py └── webfinger.py ├── docs ├── authl.rst ├── conf.py ├── flask.rst ├── flow.rst ├── handlers.rst ├── index.rst └── requirements.txt ├── poetry.lock ├── pylintrc ├── pyproject.toml ├── pytest.ini ├── raw_assets └── email icon.ai ├── setup.cfg ├── test.sh ├── test_app.py └── tests ├── __init__.py ├── handlers ├── __init__.py ├── test_emailaddr.py ├── test_fediverse.py ├── test_indieauth.py └── test_test_handler.py ├── test_base.py ├── test_disposition.py ├── test_flask_wrapper.py ├── test_main.py ├── test_tokens.py ├── test_utils.py └── test_webfinger.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source=authl/ 3 | omit=*/.local/* 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | cache/ 106 | 107 | *.sublime-workspace 108 | 109 | .DS_Store 110 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | sphinx: 3 | configuration: docs/conf.py 4 | python: 5 | install: 6 | - requirements: docs/requirements.txt 7 | - method: pip 8 | path: . 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.11" 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 PlaidWeb 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: setup version format mypy cov pylint flake8 doc 2 | 3 | .PHONY: setup 4 | setup: 5 | poetry install 6 | 7 | .PHONY: format 8 | format: 9 | poetry run isort . 10 | poetry run autopep8 -r --in-place . 11 | 12 | .PHONY: pylint 13 | pylint: 14 | poetry run pylint authl tests 15 | 16 | .PHONY: flake8 17 | flake8: 18 | poetry run flake8 19 | 20 | .PHONY: mypy 21 | mypy: 22 | poetry run mypy -p authl -m test_app --ignore-missing-imports 23 | 24 | .PHONY: preflight 25 | preflight: 26 | @echo "Checking commit status..." 27 | @git status --porcelain | grep -q . \ 28 | && echo "You have uncommitted changes" 1>&2 \ 29 | && exit 1 || exit 0 30 | @echo "Checking branch..." 31 | @[ "$(shell git rev-parse --abbrev-ref HEAD)" != "main" ] \ 32 | && echo "Can only build from main" 1>&2 \ 33 | && exit 1 || exit 0 34 | @echo "Checking upstream..." 35 | @git fetch \ 36 | && [ "$(shell git rev-parse main)" != "$(shell git rev-parse main@{upstream})" ] \ 37 | && echo "main differs from upstream" 1>&2 \ 38 | && exit 1 || exit 0 39 | 40 | .PHONY: test 41 | test: 42 | poetry run coverage run -m pytest -vv -Werror 43 | 44 | .PHONY: cov 45 | cov: test 46 | poetry run coverage html 47 | poetry run coverage report 48 | 49 | .PHONY: version 50 | version: authl/__version__.py 51 | authl/__version__.py: pyproject.toml 52 | # Kind of a hacky way to get the version updated, until the poetry folks 53 | # settle on a better approach 54 | printf '""" version """\n__version__ = "%s"\n' \ 55 | `poetry version | cut -f2 -d\ ` > authl/__version__.py 56 | 57 | .PHONY: build 58 | build: version preflight pylint flake8 59 | poetry build 60 | 61 | .PHONY: clean 62 | clean: 63 | rm -rf build dist .mypy_cache docs/_build 64 | find . -type d -name __pycache__ -print0 | xargs -0 rm -r 65 | 66 | .PHONY: upload 67 | upload: clean build 68 | poetry publish 69 | 70 | .PHONY: doc 71 | doc: 72 | poetry run sphinx-build -b html docs/ docs/_build 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Authl 2 | A Python library for managing federated identity 3 | 4 | [![Documentation Status](https://readthedocs.org/projects/authl/badge/?version=latest)](https://authl.readthedocs.io/en/latest/?badge=latest) 5 | 6 | ## About 7 | 8 | Authl is intended to make it easy to add federated identity to Python-based web 9 | apps without requiring the creation of site-specific user accounts, but also 10 | without requiring the user to choose from a myriad of buttons or links to select 11 | any specific login provider. 12 | 13 | All it should take is a single login form that asks for how the user wants to be 14 | identified. 15 | 16 | ## Current state 17 | 18 | The basic API works, and provides an easy drop-in set of endpoints for 19 | [Flask](http://flask.pocoo.org). 20 | 21 | Currently supported authentication mechanisms: 22 | 23 | * Directly authenticating against email using a magic link 24 | * Federated authentication against Fediverse providers 25 | ([Mastodon](https://joinmastodon.org/), [Pleroma](https://pleroma.social)) 26 | * Federated authentication against [IndieAuth](https://indieauth.net/) 27 | * Silo authentication against [Twitter](https://twitter.com/) 28 | * Test/loopback authentication for development purposes 29 | 30 | Planned functionality: 31 | 32 | * Pluggable OAuth mechanism to easily support additional identity providers such as: 33 | * OpenID Connect (Google et al) 34 | * Facebook 35 | * GitHub 36 | * OpenID 1.x (Wordpress, LiveJournal, Dreamwidth, etc.) 37 | * A more flexible configuration system 38 | 39 | ## Rationale 40 | 41 | Identity is hard, and there are so many competing standards which try to be the 42 | be-all end-all Single Solution. OAuth and OpenID Connect want lock-in to silos, 43 | IndieAuth wants every user to self-host their own identity site, and OpenID 1.x 44 | has fallen by the wayside. Meanwhile, users just want to be able to log in with 45 | the social media they're already using (siloed or not). 46 | 47 | Any solution which requires all users to have a certain minimum level of 48 | technical ability is not a workable solution. 49 | 50 | All of these solutions are prone to the so-called "[NASCAR 51 | problem](https://indieweb.org/NASCAR_problem)" where every supported login 52 | provider needs its own UI. But being able to experiment with a more unified UX 53 | might help to fix some of that. 54 | 55 | ## Documentation 56 | 57 | Full API documentation is hosted on [readthedocs](https://authl.readthedocs.io). 58 | 59 | ## Usage 60 | 61 | Basic usage is as follows: 62 | 63 | 1. Create an Authl object with your configured handlers 64 | 65 | This can be done by instancing individual handlers yourself, or you can use 66 | `authl.from_config` 67 | 68 | 2. Make endpoints for initiation and progress callbacks 69 | 70 | The initiation callback receives an identity string (email address/URL/etc.) 71 | from the user, queries Authl for the handler and its ID, and builds a 72 | callback URL for that handler to use. Typically you'll have a single 73 | callback endpoint that includes the handler's ID as part of the URL scheme. 74 | 75 | The callback endpoint needs to be able to receive a `GET` or `POST` request 76 | and use that to validate the returned data from the authorization handler. 77 | 78 | Your callback endpoint (and generated URL thereof) should also include 79 | whatever intended forwarding destination. 80 | 81 | 3. Handle the `authl.disposition` object types accordingly 82 | 83 | A `disposition` is what should be done with the agent that initiated the 84 | endpoint call. Currently there are the following: 85 | 86 | * `Redirect`: return an HTTP redirection to forward it along to another URL 87 | * `Notify`: return a notification to the user that they must take another 88 | action (e.g. check their email) 89 | * `Verified`: indicates that the user has been verified; set a session 90 | cookie (or whatever) and forward them along to their intended destination 91 | * `Error`: An error occurred; return it to the user as appropriate 92 | 93 | ## Flask usage 94 | 95 | To make life easier with Flask, Authl provides an `authl.flask.AuthlFlask` 96 | wrapper. You can use it from a Flask app with something like the below: 97 | 98 | ```python 99 | import uuid 100 | import logging 101 | 102 | import flask 103 | import authl.flask 104 | 105 | logging.basicConfig(level=logging.INFO) 106 | LOGGER = logging.getLogger(__name__) 107 | 108 | app = flask.Flask('authl-test') 109 | 110 | app.secret_key = str(uuid.uuid4()) 111 | authl = authl.flask.AuthlFlask( 112 | app, 113 | { 114 | 'SMTP_HOST': 'localhost', 115 | 'SMTP_PORT': 25, 116 | 'EMAIL_FROM': 'authl@example.com', 117 | 'EMAIL_SUBJECT': 'Login attempt for Authl test', 118 | 'INDIELOGIN_CLIENT_ID': authl.flask.client_id, 119 | 'TEST_ENABLED': True, 120 | 'MASTODON_NAME': 'authl testing', 121 | 'MASTODON_HOMEPAGE': 'https://github.com/PlaidWeb/Authl' 122 | }, 123 | tester_path='/check_url' 124 | ) 125 | 126 | 127 | @app.route('/') 128 | @app.route('/some-page') 129 | def index(): 130 | """ Just displays a very basic login form """ 131 | LOGGER.info("Session: %s", flask.session) 132 | LOGGER.info("Request path: %s", flask.request.path) 133 | 134 | if 'me' in flask.session: 135 | return 'Hello {me}. Want to log out?'.format( 136 | me=flask.session['me'], logout=flask.url_for( 137 | 'logout', redir=flask.request.path[1:]) 138 | ) 139 | 140 | return 'You are not logged in. Want to log in?'.format( 141 | login=flask.url_for('authl.login', redir=flask.request.path[1:])) 142 | 143 | 144 | @app.route('/logout/') 145 | @app.route('/logout/') 146 | def logout(redir=''): 147 | """ Log out from the thing """ 148 | LOGGER.info("Logging out") 149 | LOGGER.info("Redir: %s", redir) 150 | LOGGER.info("Request path: %s", flask.request.path) 151 | 152 | flask.session.clear() 153 | return flask.redirect('/' + redir) 154 | ``` 155 | 156 | This will configure the Flask app to allow IndieLogin, Mastodon, and email-based 157 | authentication (using the server's local sendmail), and use the default login 158 | endpoint of `/login/`. The `index()` endpoint handler always redirects logins 159 | and logouts back to the same page when you log in or log out (the `[1:]` is to 160 | trim off the initial `/` from the path). The logout handler simply clears the 161 | session and redirects back to the redirection path. 162 | 163 | The above configuration uses Flask's default session lifetime of one month (this 164 | can be configured by setting `app.permanent_session_lifetime` to a `timedelta` 165 | object, e.g. `app.permanent_session_lifetime = datetime.timedelta(hours=20)`). 166 | Sessions will also implicitly expire whenever the application server is 167 | restarted, as `app.secret_key` is generated randomly at every startup. 168 | 169 | ### Accessing the default stylesheet 170 | 171 | If you would like to access `authl.flask`'s default stylesheet, you can do it by 172 | passing the argument `asset='css'` to the login endpoint. For example, if you 173 | are using the default endpoint name of `authl.login`, you can use: 174 | 175 | ```python 176 | flask.url_for('authl.login', asset='css') 177 | ``` 178 | 179 | from Python, or e.g. 180 | 181 | ```html 182 | 183 | ``` 184 | 185 | from a Jinja template. 186 | -------------------------------------------------------------------------------- /authl/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Authl instance 3 | ============== 4 | 5 | An :py:class:`Authl` instance acts as the initial coordinator between the 6 | configured :py:class:`handler.Handler` instances; given an identity address 7 | (such as an email address, WebFinger address, or Internet URL) it looks up the 8 | appropriate handler to use to initiate the login transaction, and it will also 9 | look up the handler for a transaction in progress. 10 | 11 | """ 12 | 13 | import collections 14 | import logging 15 | import typing 16 | from typing import Optional 17 | 18 | import expiringdict 19 | from bs4 import BeautifulSoup 20 | 21 | from . import handlers, tokens, utils, webfinger 22 | 23 | LOGGER = logging.getLogger(__name__) 24 | 25 | 26 | class Authl: 27 | """ The authentication wrapper instance. 28 | 29 | :param cfg_handlers: The list of configured handlers, in decreasing priority 30 | order. 31 | 32 | """ 33 | 34 | def __init__(self, cfg_handlers: Optional[typing.List[handlers.Handler]] = None): 35 | """ Initialize an Authl library instance. """ 36 | self._handlers: typing.Dict[str, handlers.Handler] = collections.OrderedDict() 37 | if cfg_handlers: 38 | for handler in cfg_handlers: 39 | self.add_handler(handler) 40 | 41 | def add_handler(self, handler: handlers.Handler): 42 | """ 43 | Adds another handler to the configured handler list at the lowest priority. 44 | """ 45 | cb_id = handler.cb_id 46 | if cb_id in self._handlers: 47 | raise ValueError("Already have handler with id " + cb_id) 48 | self._handlers[cb_id] = handler 49 | 50 | def _match_url(self, url: str): 51 | for hid, handler in self._handlers.items(): 52 | result = handler.handles_url(url) 53 | if result: 54 | LOGGER.debug("%s URL matches %s", url, handler) 55 | return handler, hid, result 56 | return None, None, None 57 | 58 | def get_handler_for_url(self, url: str) -> typing.Tuple[typing.Optional[handlers.Handler], 59 | str, 60 | str]: 61 | """ 62 | 63 | Get the appropriate handler for the specified identity address. If 64 | more than one handler knows how to handle an address, it will use the 65 | one with the highest priority. 66 | 67 | :param str url: The identity address; typically a URL but can also be a 68 | WebFinger or email address. 69 | 70 | :returns: a tuple of ``(handler, hander_id, profile_url)``. 71 | 72 | """ 73 | # pylint:disable=too-many-return-statements 74 | 75 | url = url.strip() 76 | if not url: 77 | return None, '', '' 78 | 79 | # check webfinger profiles 80 | resp = self.check_profiles(webfinger.get_profiles(url)) 81 | if resp and resp[0]: 82 | return resp 83 | 84 | by_url = self._match_url(url) 85 | if by_url[0]: 86 | return by_url 87 | 88 | request = utils.request_url(url) 89 | if request: 90 | profile = utils.permanent_url(request) 91 | if profile != url: 92 | LOGGER.debug("%s: got permanent redirect to %s", url, profile) 93 | # the profile URL is different than the request URL, so re-run 94 | # the URL matching logic just in case 95 | by_url = self._match_url(profile) 96 | if by_url[0]: 97 | return by_url 98 | 99 | soup = BeautifulSoup(request.text, 'html.parser') 100 | for hid, handler in self._handlers.items(): 101 | if handler.handles_page(profile, request.headers, soup, request.links): 102 | LOGGER.debug("%s response matches %s", profile, handler) 103 | return handler, hid, request.url 104 | 105 | # check for RelMeAuth candidates 106 | resp = self.check_profiles(utils.extract_rel('me', profile, soup, request.links)) 107 | if resp and resp[0]: 108 | return resp 109 | 110 | LOGGER.debug("No handler found for URL %s", url) 111 | return None, '', '' 112 | 113 | def get_handler_by_id(self, handler_id): 114 | """ Get the handler with the given ID, for a transaction in progress. """ 115 | return self._handlers.get(handler_id) 116 | 117 | def check_profiles(self, profiles) -> typing.Tuple[typing.Optional[handlers.Handler], str, str]: 118 | """ Given a list of profile URLs, check them for a handle-able identity """ 119 | for profile in profiles: 120 | LOGGER.debug("Checking profile %s", profile) 121 | resp = self.get_handler_for_url(profile) 122 | if resp and resp[0]: 123 | return resp 124 | 125 | return None, '', '' 126 | 127 | @property 128 | def handlers(self): 129 | """ Provides a list of all of the registered handlers. """ 130 | return self._handlers.values() 131 | 132 | 133 | def from_config(config: typing.Dict[str, typing.Any], 134 | state_storage: Optional[dict] = None, 135 | token_storage: Optional[tokens.TokenStore] = None) -> Authl: 136 | """ Generate an Authl handler set from provided configuration directives. 137 | 138 | :param dict config: a configuration dictionary. See the individual handlers' 139 | from_config functions to see possible configuration values. 140 | 141 | :param dict state_storage: a dict-like object that will store session 142 | state for methods that need it. Defaults to an instance-local 143 | ExpiringDict; this will not work well in load-balanced scenarios. This 144 | can be safely stored in a user session, if available. 145 | 146 | :param tokens.TokenStore token_storage: a TokenStore for storing session 147 | state for methods that need it. Defaults to an instance-local DictStore 148 | backed by an ExpiringDict; this will not work well in load-balanced 149 | scenarios. 150 | 151 | Handlers will be enabled based on truthy values of the following keys: 152 | 153 | * ``EMAIL_FROM`` / ``EMAIL_SENDMAIL``: enable :py:mod:`authl.handlers.email_addr` 154 | 155 | * ``FEDIVERSE_NAME``: enable :py:mod:`authl.handlers.fediverse` 156 | 157 | * ``INDIEAUTH_CLIENT_ID``: enable :py:mod:`authl.handlers.indieauth` 158 | 159 | * ``TEST_ENABLED``: enable :py:mod:`authl.handlers.test_handler` 160 | 161 | For additional configuration settings, see each handler's respective 162 | ``from_config()``. 163 | 164 | """ 165 | 166 | if token_storage is None: 167 | token_storage = tokens.DictStore() 168 | 169 | if state_storage is None: 170 | state_storage = expiringdict.ExpiringDict(max_len=1024, max_age_seconds=3600) 171 | 172 | instance = Authl() 173 | 174 | if config.get('EMAIL_FROM') or config.get('EMAIL_SENDMAIL'): 175 | from .handlers import email_addr 176 | instance.add_handler(email_addr.from_config(config, token_storage)) 177 | 178 | if config.get('INDIEAUTH_CLIENT_ID'): 179 | from .handlers import indieauth 180 | instance.add_handler(indieauth.from_config(config, token_storage)) 181 | 182 | if config.get('FEDIVERSE_NAME') or config.get('MASTODON_NAME'): 183 | from .handlers import fediverse 184 | instance.add_handler(fediverse.from_config(config, token_storage)) 185 | 186 | if config.get('TEST_ENABLED'): 187 | from .handlers import test_handler 188 | instance.add_handler(test_handler.TestHandler()) 189 | 190 | return instance 191 | -------------------------------------------------------------------------------- /authl/__version__.py: -------------------------------------------------------------------------------- 1 | """ version """ 2 | __version__ = "0.7.3" 3 | -------------------------------------------------------------------------------- /authl/disposition.py: -------------------------------------------------------------------------------- 1 | """ 2 | Dispositions 3 | ============ 4 | 5 | A :py:class:`Disposition` represents the result of a phase of an authentication 6 | transaction, and tells the top-level application what to do next. 7 | 8 | """ 9 | # pylint:disable=too-few-public-methods 10 | 11 | 12 | from abc import ABC 13 | from typing import Optional 14 | 15 | 16 | class Disposition(ABC): 17 | """ Base class for all response dispositions. """ 18 | # pylint:disable=too-few-public-methods 19 | 20 | 21 | class Redirect(Disposition): 22 | """ Indicates that the authenticating user should be redirected to another 23 | URL for the next step. 24 | 25 | :param str url: The URL to redirect the user to 26 | 27 | """ 28 | 29 | def __init__(self, url: str): 30 | self.url = url 31 | 32 | def __str__(self): 33 | return f'REDIR:{self.url}' 34 | 35 | 36 | class Verified(Disposition): 37 | """ 38 | Indicates that the user is now verified; it is now up to 39 | the application to add that authorization to the user session and redirect the 40 | client to the actual view. 41 | 42 | :param str identity: The verified identity URL 43 | :param str redir: Where to redirect the user to 44 | :param dict profile: The user's profile information. Standardized keys: 45 | 46 | * ``avatar``: A URL to the user's avatar image 47 | * ``bio``: Brief biographical information 48 | * ``homepage``: The user's personal homepage 49 | * ``location``: The user's stated location 50 | * ``name``: The user's display/familiar name 51 | * ``pronouns``: The user's declared pronouns 52 | * ``profile_url``: A human-readable URL to link to the user's 53 | service-specific profile (which may or may not be the same as their 54 | identity URL). 55 | * ``endpoints``: A dictionary of key-value pairs referring to the user's 56 | various integration services 57 | 58 | """ 59 | 60 | def __init__(self, identity: str, redir: str, profile: Optional[dict] = None): 61 | self.identity = identity 62 | self.redir = redir 63 | self.profile = profile or {} 64 | 65 | def __str__(self): 66 | return f'VERIFIED:{self.identity}' 67 | 68 | 69 | class Notify(Disposition): 70 | """ 71 | Indicates that a notification should be sent to the user to take an external 72 | action, such as checking email or waiting for a text message or the like. 73 | 74 | The actual notification client data is to be configured in the underlying 75 | handler by the application, and will typically be a string. 76 | 77 | :param cdata: Notification client data 78 | """ 79 | 80 | def __init__(self, cdata): 81 | self.cdata = cdata 82 | 83 | def __str__(self): 84 | return f'NOTIFY:{str(self.cdata)}' 85 | 86 | 87 | class Error(Disposition): 88 | """ 89 | Indicates that authorization failed. 90 | 91 | :param str message: The error message to display 92 | :param str redir: The original redirection target of the auth attempt, if 93 | available 94 | """ 95 | 96 | def __init__(self, message, redir: str): 97 | self.message = str(message) 98 | self.redir = redir 99 | 100 | def __str__(self): 101 | return f'ERROR:{self.message}' 102 | 103 | 104 | class NeedsPost(Disposition): 105 | """ 106 | Indicates that the callback needs to be re-triggered as a POST request. 107 | 108 | :param str url: The URL that needs to be POSTed to 109 | :param str message: A user-friendly message to display 110 | :param dict data: POST data to be sent in the request, as key-value pairs 111 | """ 112 | 113 | def __init__(self, url: str, message, data: dict): 114 | self.url = url 115 | self.message = str(message) 116 | self.data = data 117 | 118 | def __str__(self): 119 | return f'NEEDS-POST:{self.message}' 120 | -------------------------------------------------------------------------------- /authl/flask.py: -------------------------------------------------------------------------------- 1 | """ 2 | Flask wrapper 3 | ============= 4 | 5 | :py:class:`AuthlFlask` is an easy-to-use wrapper for use with `Flask`_ . By 6 | default it gives you a login form (with optional URL tester) and a login 7 | endpoint that stores the verified identity in ``flask.session['me']``. All this 8 | behavior is configurable. 9 | 10 | .. _Flask: https://flask.palletsprojects.com/ 11 | 12 | The basic usage is very simple: 13 | 14 | .. code-block:: python 15 | 16 | import flask 17 | import authl.flask 18 | 19 | app = flask.Flask(__name__) 20 | app.secret_key = "extremely secret" 21 | 22 | authl.flask.setup(app, { 23 | # Simple IndieAuth setup 24 | 'INDIEAUTH_CLIENT_ID': authl.flask.client_id, 25 | 26 | # Minimal Fediverse setup 27 | 'FEDIVERSE_NAME': 'My website', 28 | 29 | # Send email using localhost 30 | 'EMAIL_FROM': 'me@example.com', 31 | 'SMTP_HOST': 'localhost', 32 | 'SMTP_PORT': 25 33 | }, tester_path='/test') 34 | 35 | @app.route('/') 36 | if 'me' in flask.session: 37 | return 'Hello {me}. Want to log out?'.format( 38 | me=flask.session['me'], logout=flask.url_for( 39 | 'logout', redir=flask.request.path[1:]) 40 | ) 41 | 42 | return 'You are not logged in. Want to log in?'.format( 43 | login=flask.url_for('authl.login', redir=flask.request.path[1:])) 44 | 45 | @app.route('/logout') 46 | def logout(): 47 | flask.session.clear() 48 | return flask.redirect('/') 49 | 50 | This gives you a very simple login form configured to work with IndieAuth, 51 | Fediverse, and email at the default location of ``/login``, and a logout 52 | mechanism at ``/logout``. The endpoint at ``/test`` can be used to test an 53 | identity URL for login support. 54 | 55 | """ 56 | 57 | import json 58 | import logging 59 | import os 60 | import typing 61 | import urllib.parse 62 | from typing import Optional 63 | 64 | import flask 65 | import werkzeug.exceptions as http_error 66 | 67 | from . import Authl, disposition, from_config, tokens, utils 68 | 69 | LOGGER = logging.getLogger(__name__) 70 | 71 | 72 | def setup(app: flask.Flask, config: typing.Dict[str, typing.Any], **kwargs) -> Authl: 73 | """ Simple setup function. 74 | 75 | :param flask.flask app: The Flask app to configure with Authl. 76 | 77 | :param dict config: Configuration values for the Authl instance; see 78 | :py:func:`authl.from_config` 79 | 80 | :param kwargs: Additional arguments to pass along to the 81 | :py:class:`AuthlFlask` constructor 82 | 83 | :returns: The configured :py:class:`authl.Authl` instance. Note that if you 84 | want the :py:class:`AuthlFlask` instance you should instantiate that directly. 85 | 86 | """ 87 | return AuthlFlask(app, config, **kwargs).authl 88 | 89 | 90 | def load_template(filename: str) -> str: 91 | """ Load the built-in Flask template. 92 | 93 | :param str filename: The filename of the built-in template 94 | 95 | Raises `FileNotFoundError` on no such template 96 | 97 | :returns: the contents of the template. 98 | """ 99 | return utils.read_file(os.path.join(os.path.dirname(__file__), 'flask_templates', filename)) 100 | 101 | 102 | def _nocache() -> typing.Callable: 103 | """ Cache decorator to set the maximum cache age on a response """ 104 | def decorator(func: typing.Callable) -> typing.Callable: 105 | def wrapped_func(*args, **kwargs): 106 | response = flask.make_response(func(*args, **kwargs)) 107 | response.cache_control.max_age = 0 108 | return response 109 | return wrapped_func 110 | return decorator 111 | 112 | 113 | def _redir_dest_to_path(destination: str): 114 | """ Convert a redirection destination to a path fragment """ 115 | assert destination.startswith('/'), "Redirection destinations must begin with '/'" 116 | return destination[1:] 117 | 118 | 119 | def _redir_path_to_dest(path: str): 120 | """ Convert a path fragment to a redirection destination """ 121 | assert not path.startswith('/'), "Path fragments cannot start with '/'" 122 | return '/' + path 123 | 124 | 125 | class AuthlFlask: 126 | """ Easy Authl wrapper for use with a Flask application. 127 | 128 | :param flask.Flask app: the application to attach to 129 | 130 | :param dict config: Configuration directives for Authl's handlers. See 131 | from_config for more information. 132 | 133 | :param str login_name: The endpoint name for the login handler, for 134 | flask.url_for() 135 | 136 | :param str login_path: The mount point of the login endpoint 137 | 138 | The login endpoint takes the following arguments (as specified via 139 | :py:func:`flask.url_for`): 140 | 141 | * ``me``: The URL to initiate authentication for 142 | * ``redir``: Where to redirect the user to after successful login 143 | 144 | 145 | :param str callback_name: The endpoint name for the callback handler, 146 | for flask.url_for() 147 | 148 | :param str callback_path: The mount point of the callback handler endpoints. 149 | For example, if this is set to ``/login_cb`` then your actual handler 150 | callbacks will be at ``/login_cb/{cb_id}`` for the handler's ``cb_id`` 151 | property; for example, the :py:class:`authl.handlers.email_addr.EmailAddress` 152 | handler's callback will be mounted at ``/login_cb/e``. 153 | 154 | :param str tester_name: The endpoint name for the URL tester, for 155 | flask.url_for() 156 | 157 | :param str tester_path: The mount point of the URL tester endpoint 158 | 159 | The URL tester endpoint takes a query parameter of ``url`` which is the 160 | URL to check. It returns a JSON object that describes the detected 161 | handler (if any), with the following attributes: 162 | 163 | * ``name``: the service name 164 | * ``url``: a canonicized version of the URL 165 | 166 | The URL tester endpoint will only be mounted if ``tester_path`` is 167 | specified. This will also enable a small asynchronous preview in the default 168 | login form. 169 | 170 | :param function login_render_func: The function to call to render the login 171 | page; if not specified a default will be provided. 172 | 173 | This function takes the following arguments; note that more may 174 | be added so it should also take a ``**kwargs`` for future compatibility: 175 | 176 | * ``auth``: the :py:class:`authl.Authl` object 177 | 178 | * ``login_url``: the URL to use for the login form 179 | 180 | * ``tester_url``: the URL to use for the test callback 181 | 182 | * ``redir``: The redirection destination that the login URL will 183 | redirect them to 184 | 185 | * ``id_url``: Any pre-filled value for the ID url 186 | 187 | * ``error``: Any error message that occurred 188 | 189 | If ``login_render_func`` returns a falsy value, the default login form 190 | will be used instead. This is useful for providing a conditional 191 | override, or as a rudimentary hook for analytics on the login flow or 192 | the like. 193 | 194 | :param function notify_render_func: The function to call to render the user 195 | notification page; if not specified a default will be provided. 196 | 197 | This function takes the following arguments: 198 | 199 | * ``cdata``: the client data for the handler 200 | 201 | :param function post_form_render_func: The function to call to render a 202 | necessary post-login form; if not specified a default will be provided. 203 | 204 | This function takes the following arguments: 205 | 206 | * ``message``: the notification message for the user 207 | * ``data``: the data to pass along in the POST request 208 | * ``url``: the URL to send the POST request to 209 | 210 | :param str session_auth_name: The session parameter to use for the 211 | authenticated user. Set to None if you want to use your own session 212 | management. 213 | 214 | :param bool force_https: Whether to force authentication to switch to a 215 | ``https://`` connection 216 | 217 | :param str stylesheet: the URL to use for the default page stylesheet; if 218 | not 219 | 220 | :param function on_verified: A function to call on successful login (called 221 | after setting the session value) 222 | 223 | This function receives the :py:class:`authl.disposition.Verified` object, and 224 | may return a Flask response of its own, which should ideally be a 225 | ``flask.redirect()``. This can be used to capture more information about 226 | the user (such as filling out a user profile) or to redirect certain 227 | users to an administrative screen of some sort. 228 | 229 | :param bool make_permanent: Whether a session should persist past the 230 | browser window closing 231 | 232 | :param tokens.TokenStore token_storage: Storage for token data for 233 | methods which use it. Uses the same default as :py:func:`authl.from_config`. 234 | 235 | Note that if the default is used, the ``app.secret_key`` **MUST** be set 236 | before this class is initialized. 237 | 238 | :param state_storage: The mechanism to use for transactional state 239 | storage for login methods that need it. Defaults to using the Flask 240 | user session. 241 | 242 | :param session_namespace: A namespace for Authl to keep a small amount of 243 | user session data in. Should never need to be changed. 244 | 245 | """ 246 | # pylint:disable=too-many-instance-attributes 247 | 248 | def __init__(self, 249 | app: flask.Flask, 250 | config: typing.Dict[str, typing.Any], 251 | *, 252 | login_name: str = 'authl.login', 253 | login_path: str = '/login', 254 | callback_name: str = 'authl.callback', 255 | callback_path: str = '/cb', 256 | tester_name: str = 'authl.test', 257 | tester_path: Optional[str] = None, 258 | login_render_func: Optional[typing.Callable] = None, 259 | notify_render_func: Optional[typing.Callable] = None, 260 | post_form_render_func: Optional[typing.Callable] = None, 261 | session_auth_name: typing.Optional[str] = 'me', 262 | force_https: bool = False, 263 | stylesheet: Optional[typing.Union[str, typing.Callable]] = None, 264 | on_verified: Optional[typing.Callable] = None, 265 | make_permanent: bool = True, 266 | state_storage: Optional[typing.Dict] = None, 267 | token_storage: Optional[tokens.TokenStore] = None, 268 | session_namespace='_authl', 269 | ): 270 | # pylint:disable=too-many-arguments,too-many-locals,too-many-statements 271 | 272 | if state_storage is None: 273 | state_storage = typing.cast(typing.Dict, flask.session) 274 | 275 | self.authl = from_config( 276 | config, 277 | state_storage, 278 | token_storage) 279 | 280 | self._session = state_storage 281 | self.login_name = login_name 282 | self.callback_name = callback_name 283 | self.tester_name = tester_name 284 | self._tester_path = tester_path 285 | self._login_render_func = login_render_func 286 | self._notify_render_func = notify_render_func 287 | self._post_form_render_func = post_form_render_func 288 | self._session_auth_name = session_auth_name 289 | self.force_https = force_https 290 | self._stylesheet = stylesheet 291 | self._on_verified = on_verified 292 | self.make_permanent = make_permanent 293 | self._prefill_key = session_namespace + '.prefill' 294 | 295 | for sfx in ['', '/', '/']: 296 | app.add_url_rule(login_path + sfx, login_name, 297 | self._login_endpoint, methods=('GET', 'POST')) 298 | app.add_url_rule(callback_path + '/', 299 | callback_name, 300 | self._callback_endpoint, 301 | methods=('GET', 'POST')) 302 | 303 | if tester_path: 304 | def find_service(): 305 | from flask import request 306 | 307 | url = request.args.get('url') 308 | if not url: 309 | return json.dumps(None) 310 | 311 | handler, _, canon_url = self.authl.get_handler_for_url(url) 312 | if handler: 313 | return json.dumps({'name': handler.service_name, 314 | 'url': canon_url}) 315 | 316 | return json.dumps(None) 317 | app.add_url_rule(tester_path, tester_name, find_service) 318 | 319 | @property 320 | def url_scheme(self): 321 | """ Provide the _scheme parameter to be sent along to flask.url_for """ 322 | return 'https' if self.force_https else None 323 | 324 | @_nocache() 325 | def _handle_disposition(self, disp: disposition.Disposition): 326 | if isinstance(disp, disposition.Redirect): 327 | # A simple redirection 328 | return flask.redirect(disp.url) 329 | 330 | if isinstance(disp, disposition.Verified): 331 | # The user is verified; log them in 332 | self._session.pop(self._prefill_key, None) 333 | 334 | LOGGER.info("Successful login: %s", disp.identity) 335 | if self._session_auth_name is not None: 336 | flask.session.permanent = self.make_permanent # pylint:disable=assigning-non-slot 337 | flask.session[self._session_auth_name] = disp.identity 338 | 339 | if self._on_verified: 340 | response = self._on_verified(disp) 341 | if response: 342 | return response 343 | 344 | return flask.redirect(disp.redir) 345 | 346 | if isinstance(disp, disposition.Notify): 347 | # The user needs to take some additional action 348 | return self._render_notify(disp.cdata) 349 | 350 | if isinstance(disp, disposition.Error): 351 | # The user's login failed 352 | return self.render_login_form(destination=disp.redir, error=disp.message) 353 | 354 | if isinstance(disp, disposition.NeedsPost): 355 | # A POST request is required to proceed 356 | return self._render_post_form(url=disp.url, message=disp.message, data=disp.data) 357 | 358 | # unhandled disposition 359 | raise http_error.InternalServerError(f"Unknown disposition type {str(type(disp))}") 360 | 361 | @_nocache() 362 | def _render_notify(self, cdata): 363 | if self._notify_render_func: 364 | result = self._notify_render_func(cdata=cdata) 365 | if result: 366 | return result 367 | 368 | return flask.render_template_string(load_template('notify.html'), 369 | cdata=cdata, 370 | stylesheet=self.stylesheet) 371 | 372 | @_nocache() 373 | def _render_post_form(self, url, message, data): 374 | if self._post_form_render_func: 375 | result = self._post_form_render_func(url=url, message=message, data=data) 376 | if result: 377 | return result 378 | 379 | return flask.render_template_string(load_template('post-needed.html'), 380 | url=url, 381 | message=message, 382 | data=data, 383 | stylesheet=self.stylesheet) 384 | 385 | def render_login_form(self, destination: str, error: typing.Optional[str] = None): 386 | """ 387 | Renders the login form. This might be called by the Flask app if, for 388 | example, a page requires login to be seen. 389 | 390 | :param str destination: The redirection destination, as a full path 391 | (e.g. ``'/path/to/view'``) 392 | 393 | :param str error: Any error message to display on the login form 394 | """ 395 | login_url = flask.url_for(self.login_name, 396 | redir=_redir_dest_to_path(destination or '/'), 397 | _scheme=self.url_scheme, 398 | _external=self.force_https) 399 | test_url = self._tester_path and flask.url_for(self.tester_name, 400 | _external=True) 401 | id_url = self._session.get(self._prefill_key, '') 402 | LOGGER.debug('id_url: %s', id_url) 403 | 404 | render_args: typing.Dict[str, typing.Any] = { 405 | 'login_url': login_url, 406 | 'test_url': test_url, 407 | 'auth': self.authl, 408 | 'id_url': id_url, 409 | 'error': error, 410 | 'redir': destination, 411 | } 412 | 413 | if self._login_render_func: 414 | result = self._login_render_func(**render_args) 415 | if result: 416 | return result 417 | 418 | return flask.render_template_string(load_template('login.html'), 419 | stylesheet=self.stylesheet, 420 | **render_args) 421 | 422 | def _login_endpoint(self, redir: str = ''): 423 | from flask import request 424 | 425 | if 'asset' in request.args: 426 | asset = request.args['asset'] 427 | if asset == 'css': 428 | return load_template('authl.css'), {'Content-Type': 'text/css'} 429 | raise http_error.NotFound("Unknown asset " + asset) 430 | 431 | dest = _redir_path_to_dest(redir) 432 | error = None 433 | 434 | me_url = request.form.get('me', request.args.get('me')) 435 | if me_url: 436 | # Process the login request 437 | self._session[self._prefill_key] = me_url 438 | handler, hid, id_url = self.authl.get_handler_for_url(me_url) 439 | if handler: 440 | cb_url = flask.url_for(self.callback_name, 441 | hid=hid, 442 | _external=True, 443 | _scheme=self.url_scheme) 444 | return self._handle_disposition(handler.initiate_auth( 445 | id_url, 446 | cb_url, 447 | dest)) 448 | 449 | # No handler found, so provide error message to login_form 450 | error = 'Unknown authentication method' 451 | 452 | return self.render_login_form(destination=dest, error=error) 453 | 454 | def _callback_endpoint(self, hid: str): 455 | from flask import request 456 | 457 | handler = self.authl.get_handler_by_id(hid) 458 | if not handler: 459 | return self._handle_disposition(disposition.Error("Invalid handler", '')) 460 | return self._handle_disposition( 461 | handler.check_callback(request.base_url, request.args, request.form)) 462 | 463 | @property 464 | def stylesheet(self) -> str: 465 | """ The stylesheet to use for the Flask templates """ 466 | if self._stylesheet: 467 | return utils.resolve_value(self._stylesheet) 468 | return flask.url_for(self.login_name, asset='css') 469 | 470 | 471 | def client_id(): 472 | """ A shim to generate a client ID based on the current site URL, for use 473 | with IndieAuth, Fediverse, and so on. """ 474 | from flask import request 475 | parsed = urllib.parse.urlparse(request.base_url) 476 | baseurl = f'{parsed.scheme}://{parsed.hostname}' 477 | LOGGER.debug("using client_id %s", baseurl) 478 | return baseurl 479 | -------------------------------------------------------------------------------- /authl/flask_templates/authl.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #ccf; 3 | font-family: "Helvetica Neue", sans-serif; 4 | } 5 | #login { 6 | background: white; 7 | border-radius: 1em; 8 | box-shadow: 0px 5px 5px rgba(0,0,0,0.25); 9 | 10 | margin: 3em; 11 | overflow: hidden; 12 | } 13 | #info { 14 | font-size: small; 15 | color: #333; 16 | background: #eee; 17 | padding: 1ex 1em; 18 | border-top: solid #ccc 1px; 19 | } 20 | h1 { 21 | background: #ffc; 22 | margin: 0 0 1ex; 23 | padding: 1ex 1em; 24 | box-shadow: 0px 1px 2px rgba(0,0,0,0.25); 25 | } 26 | form, #notify { 27 | display: inline-block; 28 | font-size: large; 29 | margin: 0.5em 1em 2em; 30 | } 31 | input[type="url"] { 32 | font-family: "Helvetica Neue", sans-serif; 33 | font-weight: light; 34 | font-size: large; 35 | } 36 | .description { 37 | font-style: italic; 38 | color: #555; 39 | } 40 | a:link { 41 | color: #500; 42 | } 43 | .description a:link { 44 | color: #944; 45 | } 46 | .error { 47 | color: #700; 48 | font-size: small; 49 | margin: 0; 50 | padding: 0; 51 | } 52 | 53 | #powered { 54 | font-size: x-small; 55 | background: #ddd; 56 | } 57 | #powered p { 58 | margin: 0 1em; 59 | padding: 0; 60 | } 61 | 62 | #uri-type { 63 | background: #eee; 64 | color: #555; 65 | font-size: large; 66 | text-shadow: 1px 0px 0px white, 67 | -1px 0px 0px white, 68 | 0px 1px 0px white, 69 | 0px -1px 0px white; 70 | position: relative; 71 | font-weight: bold; 72 | border-radius: 0.5ex; 73 | border: solid rgba(0,0,0,0.5) 1px; 74 | padding: 4px; 75 | animation: progress 3s linear infinite; 76 | } 77 | 78 | #uri-type.resolved { 79 | background: #cfc; 80 | color: #060; 81 | } 82 | 83 | #uri-type.error { 84 | color: #900; 85 | background-color: #ff0; 86 | text-shadow: none; 87 | } 88 | 89 | #uri-type.pending { 90 | color: black; 91 | background: repeating-linear-gradient(-45deg, white, white 1ex, #ccf 1ex, #ccf 2ex); 92 | } 93 | 94 | #uri-type.maybe { 95 | color: #307; 96 | background: #edf 97 | } 98 | 99 | #uri-type:disabled { 100 | opacity: 50%; 101 | } 102 | 103 | @keyframes progress { 104 | 0% { background-position: 0 0; } 105 | 100% { background-position: 14.14ex 0; } 106 | } 107 | 108 | .buttons { 109 | margin-bottom: 0.2em; 110 | font-size: 175%; 111 | } 112 | 113 | .buttons a { 114 | text-decoration: none; 115 | } 116 | 117 | .buttons svg, .buttons img { 118 | height: 1em; 119 | width: auto; 120 | vertical-align: middle; 121 | margin-right: 0.1em; 122 | filter: grayscale(40%); 123 | opacity: 90%; 124 | transition: filter 0.2s, opacity 0.2s; 125 | } 126 | 127 | .buttons svg:hover, .buttons img:hover { 128 | opacity: 100%; 129 | filter: none; 130 | } -------------------------------------------------------------------------------- /authl/flask_templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {%- macro login_link(handler, content, title) -%} 5 | {%- if handler.generic_url -%} 6 | 7 | {%- else -%} 8 | {%- for url,example in handler.url_schemes[:1] -%} 9 | 14 | {%- endfor -%} 15 | {%- endif %}{{content}} 16 | {%- endmacro -%} 17 | 18 | 19 | Login 20 | 21 | 22 | 23 | 24 | 25 | 115 | 116 | 117 | 118 |
119 |

Identify Yourself

120 |
121 |
122 | {%- for handler in auth.handlers %}{% if handler.logo_html -%} 123 | {%- for html,title in handler.logo_html -%} 124 | {{login_link(handler, html|safe, title)}} 125 | {%- endfor -%} 126 | {%- endif -%}{%- endfor -%} 127 |
128 | 129 | 130 | {% if error %} 131 |
{{error}}
132 | {% endif %} 133 |
134 | 135 |
136 |

This form allows you to log in using your existing identity from another website or 137 | provider. The following sources are supported:

138 |
    139 | {%- for handler in auth.handlers -%} 140 |
  • {{login_link(handler, handler.service_name)}} — {{handler.description|safe}}
  • 141 | {%- endfor -%} 142 |
143 | 144 |

You may also provide your address in WebFinger format.

146 |
147 | 148 |
149 |

Powered by Authl

150 |
151 |
152 | 153 | -------------------------------------------------------------------------------- /authl/flask_templates/notify.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Complete your login 6 | 7 | 8 | 9 | 10 |
11 |

Next Step

12 | 13 |
14 | {{cdata.message}} 15 |
16 | 17 |
18 |

Powered by Authl

19 |
20 |
21 | 22 | -------------------------------------------------------------------------------- /authl/flask_templates/post-needed.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Complete your login 6 | 7 | 8 | 9 | 14 | 15 | 16 |
17 |

Next Step

18 | 19 |
20 | {{message}} 21 | 22 |
23 | {% for name,value in data.items() %} 24 | 25 | {% endfor %} 26 | 27 |
28 |
29 | 30 |
31 |

Powered by Authl

32 |
33 |
34 | 35 | -------------------------------------------------------------------------------- /authl/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | Base handler class 4 | ================== 5 | 6 | The :py:class:`Handler` class defines the abstract interface for an 7 | authentication handler. Handlers are registered to an :py:class:`authl.Authl` 8 | instance which then selects the handler based on the provided identity. 9 | 10 | The basic flow for how a handler is selected is: 11 | 12 | #. The :py:class:`authl.Authl` instance checks to see if any handler knows how 13 | to handle the identity URL directly; if so, it returns the first match. 14 | 15 | #. The instance retrieves the URL, and hands the parse tree and response headers 16 | off to each handler to see if it's able to handle the URL based on that; if 17 | so, it returns the first match. 18 | 19 | In the case of a Webfinger address (e.g. ``@user@example.com``) it repeats this 20 | process for every profile URL provided by the Webfinger response until it finds 21 | a match. 22 | 23 | """ 24 | 25 | import typing 26 | from abc import ABC, abstractmethod 27 | 28 | from .. import disposition 29 | 30 | 31 | class Handler(ABC): 32 | """ Base class for authentication handlers """ 33 | 34 | def handles_url(self, url: str) -> typing.Optional[str]: 35 | """ 36 | If this handler can handle this URL (or something that looks like it), 37 | return something truthy, e.g. a canonicized version of the URL. 38 | Otherwise, return None. 39 | 40 | It is okay to check for an API endpoint (relative to the URL) in 41 | implementing this. However, if the content kept at the URL itself needs 42 | to be parsed to make the determination, implement that in 43 | :py:meth:`handles_page` instead. 44 | 45 | Whatever value this returns will be passed back in to initiate_auth, so 46 | if that value matters, return a reasonable URL. 47 | """ 48 | # pylint:disable=unused-argument 49 | return None 50 | 51 | def handles_page(self, url: str, headers, content, links) -> bool: 52 | """ Returns ``True``/truthy if we can handle the page based on page 53 | content 54 | 55 | :param str url: the canonicized identity URL 56 | :param dict headers: the raw headers from the page request, as a 57 | MultiDict (as provided by the `Requests`_ library) 58 | :param bs4.BeautifulSoup content: the page content, as a 59 | `BeautifulSoup4`_ parse tree 60 | :param dict links: the results of parsing the Link: headers, as a 61 | dict of rel -> dict of 'url' and 'rel', as provided by the 62 | `Requests`_ library 63 | 64 | .. _Requests: https://requests.readthedocs.io/ 65 | .. _BeautifulSoup4: https://pypi.org/project/beautifulsoup4/ 66 | """ 67 | # pylint:disable=unused-argument 68 | return False 69 | 70 | @property 71 | @abstractmethod 72 | def cb_id(self) -> str: 73 | """ The callback ID for callback registration. Must be unique across all 74 | registered handlers, and should be short and stable. 75 | """ 76 | 77 | @abstractmethod 78 | def initiate_auth(self, id_url: str, callback_uri: str, redir: str) -> disposition.Disposition: 79 | """ Initiates the authentication flow. 80 | 81 | :param str id_url: Canonicized identity URL 82 | :param str callback_uri: Callback URL for verification 83 | :param str redir: Where to redirect the user to after verification 84 | 85 | :returns: the :py:mod:`authl.disposition` to be handled by the frontend. 86 | 87 | """ 88 | 89 | @abstractmethod 90 | def check_callback(self, url: str, get: dict, data: dict) -> disposition.Disposition: 91 | """ Checks the authorization callback sent by the external provider. 92 | 93 | :param str url: the full URL of the verification request 94 | :param dict get: the GET parameters for the verification 95 | :param dict data: the POST parameters for the verification 96 | 97 | :returns: a :py:mod:`authl.disposition` object to be handled by the 98 | frontend. Any errors which get raised internally should be caught and 99 | returned as an appropriate :py:class:`authl.disposition.Error`. 100 | 101 | """ 102 | 103 | @property 104 | @abstractmethod 105 | def service_name(self) -> str: 106 | """ The human-readable service name """ 107 | 108 | @property 109 | @abstractmethod 110 | def url_schemes(self) -> typing.List[typing.Tuple[str, str]]: 111 | """ 112 | A list of supported URL schemes for the login UI to fill in 113 | with a placeholder. 114 | 115 | The list is of tuples of ``(format, default_placeholder)``, where the 116 | format string contains a ``'%'`` indicating where the placeholder goes. 117 | """ 118 | 119 | @property 120 | def generic_url(self) -> typing.Optional[str]: 121 | """ 122 | A generic URL that can be used with this handler irrespective of 123 | identity. 124 | """ 125 | return None 126 | 127 | @property 128 | @abstractmethod 129 | def description(self) -> str: 130 | """ A description of the service, in HTML format. """ 131 | 132 | @property 133 | def logo_html(self) -> typing.Optional[str]: 134 | """ A list of tuples of (html,label) for the login buttons. 135 | 136 | The HTML should be an isolated ```` element, or an ```` 137 | pointing to a publicly-usable ``https:`` URL. 138 | """ 139 | return None 140 | -------------------------------------------------------------------------------- /authl/handlers/email_addr.py: -------------------------------------------------------------------------------- 1 | """ 2 | Email handler 3 | ============= 4 | 5 | This handler emails a "magic link" to the user so that they can log in that way. 6 | It requires an SMTP server of some sort; see your hosting provider's 7 | documentation for the appropriate configuration. This should also be able to 8 | work with your regular email provider. 9 | 10 | See :py:func:`authl.from_config` for the simplest configuration mechanism. 11 | 12 | This handler registers itself with a ``cb_id`` of ``"e"``. 13 | 14 | """ 15 | 16 | import email 17 | import logging 18 | import math 19 | import time 20 | import urllib.parse 21 | from typing import Optional 22 | 23 | import expiringdict 24 | import validate_email 25 | 26 | from .. import disposition, tokens, utils 27 | from . import Handler 28 | 29 | LOGGER = logging.getLogger(__name__) 30 | 31 | DEFAULT_TEMPLATE_TEXT = """\ 32 | Hello! Someone, possibly you, asked to log in using this email address. If this 33 | was you, please visit the following link within the next {minutes} minutes: 34 | 35 | {url} 36 | 37 | If this wasn't you, you can safely disregard this message. 38 | 39 | """ 40 | 41 | 42 | class EmailAddress(Handler): 43 | """ Authenticate using a "magic link" sent via email. 44 | 45 | :param sendmail: A function that, given an :py:class:`email.message` 46 | object, sends it. It is the responsibility of this function to set 47 | the From and Subject headers before it sends. 48 | :param notify_cdata: the callback data to provide to the user for the 49 | next step instructions 50 | :param tokens.TokenStore token_store: Storage for the identity tokens 51 | :param int expires_time: how long the email link should be valid for, in 52 | seconds (default: 900) 53 | :param dict pending_storage: Storage to keep track of pending email addresses, 54 | for DDOS/abuse mitigation. Defaults to an :py:class:`ExpiringDict` that expires 55 | after ``expires_time`` 56 | :param str email_template_text: the plaintext template for the sent 57 | email, provided as a template string 58 | 59 | Email templates are formatted with the following parameters: 60 | 61 | * ``{url}``: the URL that the user should visit to complete login 62 | * ``{minutes}``: how long the URL is valid for, in minutes 63 | 64 | """ 65 | 66 | @property 67 | def service_name(self): 68 | return 'Email' 69 | 70 | @property 71 | def url_schemes(self): 72 | return [('mailto:%', 'email@example.com'), 73 | ('%', 'email@example.com')] 74 | 75 | @property 76 | def description(self): 77 | return """Uses email to log you in, by sending a "magic link" to the 78 | destination address.""" 79 | 80 | @property 81 | def cb_id(self): 82 | return 'e' 83 | 84 | @property 85 | def logo_html(self): 86 | return [(utils.read_icon('email_addr.svg'), 'Email')] 87 | 88 | def __init__(self, 89 | sendmail, 90 | notify_cdata, 91 | token_store: tokens.TokenStore, 92 | *, 93 | expires_time: Optional[int] = None, 94 | pending_storage: Optional[dict] = None, 95 | email_template_text: str = DEFAULT_TEMPLATE_TEXT, 96 | ): 97 | # pylint:disable=too-many-arguments 98 | self._sendmail = sendmail 99 | self._email_template_text = email_template_text 100 | self._cdata = notify_cdata 101 | self._token_store = token_store 102 | self._lifetime = expires_time or 900 103 | self._pending = expiringdict.ExpiringDict( 104 | max_len=1024, 105 | max_age_seconds=self._lifetime) if pending_storage is None else pending_storage 106 | 107 | def handles_url(self, url): 108 | """ 109 | Accepts any email address formatted as ``user@example.com`` or 110 | ``mailto:user@example.com``. The actual address is validated using 111 | :py:mod:`validate_email`. 112 | """ 113 | 114 | parsed = urllib.parse.urlparse(url) 115 | if parsed.scheme not in ('', 'mailto'): 116 | return None 117 | 118 | address = parsed.path.strip() 119 | 120 | if ' ' in address or '!' in address: 121 | return None 122 | 123 | if validate_email.validate_email(address): 124 | return 'mailto:' + address.lower() 125 | 126 | return None 127 | 128 | def initiate_auth(self, id_url, callback_uri, redir): 129 | parsed = urllib.parse.urlparse(id_url) 130 | if parsed.scheme != 'mailto' or not validate_email.validate_email(parsed.path): 131 | return disposition.Error("Malformed email URL", redir) 132 | dest_addr = parsed.path.lower() 133 | 134 | if dest_addr in self._pending: 135 | try: 136 | _, _, when = self._token_store.get(self._pending[dest_addr]) 137 | if time.time() <= when + self._lifetime: 138 | # There is already a pending valid token, so just remind them to 139 | # check their email again 140 | return disposition.Notify(self._cdata) 141 | except (KeyError, ValueError): 142 | pass 143 | 144 | # The token has expired, so remove the pending token 145 | self._pending.pop(dest_addr, None) 146 | 147 | token = self._token_store.put((dest_addr, redir, time.time())) 148 | self._pending[dest_addr] = token 149 | 150 | LOGGER.debug("Generated token %s", token) 151 | 152 | link_url = (callback_uri + ('&' if '?' in callback_uri else '?') + 153 | urllib.parse.urlencode({'t': token})) 154 | 155 | LOGGER.debug("Link URL %s", link_url) 156 | 157 | msg = email.message.EmailMessage() 158 | msg['To'] = dest_addr 159 | 160 | msg.set_content( 161 | self._email_template_text.format( 162 | url=link_url, minutes=int(math.ceil(self._lifetime / 60))) 163 | ) 164 | 165 | self._sendmail(msg) 166 | 167 | return disposition.Notify(self._cdata) 168 | 169 | def check_callback(self, url, get, data): 170 | if 't' in get: 171 | return disposition.NeedsPost(url, "Complete your login", {'t': get['t']}) 172 | 173 | token = data.get('t') 174 | 175 | if not token: 176 | return disposition.Error('Missing token', None) 177 | 178 | try: 179 | email_addr, redir, when = self._token_store.pop(token) 180 | except (KeyError, ValueError): 181 | return disposition.Error('Invalid token', '') 182 | 183 | self._pending.pop(email_addr, None) 184 | 185 | if time.time() > when + self._lifetime: 186 | return disposition.Error("Login timed out", redir) 187 | 188 | LOGGER.debug("addr=%s redir=%s when=%s", email_addr, redir, when) 189 | 190 | return disposition.Verified('mailto:' + email_addr, redir, {'email': email_addr}) 191 | 192 | 193 | def smtplib_connector(hostname, port, username=None, password=None, use_ssl=False): 194 | """ A utility class that generates an SMTP connection factory. 195 | 196 | :param str hostname: The SMTP server's hostname 197 | :param int port: The SMTP server's connection port 198 | :param str username: The SMTP server username 199 | :param str password: The SMTP server port 200 | :param bool use_ssl: Whether to use SSL 201 | 202 | """ 203 | 204 | def connect(): 205 | import smtplib 206 | 207 | ctor = smtplib.SMTP_SSL if use_ssl else smtplib.SMTP 208 | conn = ctor(hostname, port) 209 | if use_ssl: 210 | import ssl 211 | 212 | context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) 213 | conn.ehlo() 214 | conn.starttls(context=context) 215 | conn.ehlo() 216 | if username or password: 217 | conn.login(username, password) 218 | return conn 219 | 220 | return connect 221 | 222 | 223 | def simple_sendmail(connector, sender_address, subject): 224 | """ A simple SMTP sendmail handler. 225 | 226 | :param function connector: A factory-type function that returns an 227 | :py:class:`smtplib.SMTP`-compatible object in the connected state. 228 | Use :py:func:`authl.handlers.email_addr.smtplib_connector` for an 229 | easy-to-use general-purpose connector. 230 | :param str sender_address: The email address to use for the sender 231 | :param str subject: the subject line to attach to the message 232 | 233 | :returns" a function that, when called with an 234 | :py:class:`email.message.EmailMessage`, sets the `From` and `Subject` lines 235 | and sends the message via the provided connector. 236 | 237 | """ 238 | 239 | def sendmail(message: email.message.EmailMessage): 240 | message['From'] = sender_address 241 | message['Subject'] = subject 242 | 243 | with connector() as conn: 244 | return conn.sendmail(sender_address, message['To'], str(message)) 245 | 246 | return sendmail 247 | 248 | 249 | def from_config(config, token_store: tokens.TokenStore): 250 | """ 251 | 252 | Generate an :py:class:`EmailAddress` handler from the provided configuration 253 | dictionary. 254 | 255 | :param dict config: The configuration settings for the handler. Relevant 256 | keys: 257 | 258 | * ``EMAIL_SENDMAIL``: a function to call to send the email; if omitted, 259 | generates one using :py:func:`authl.handlers.email_addr.simple_sendmail` 260 | configured with: 261 | 262 | * ``EMAIL_FROM``: the ``From:`` address to use when sending an email 263 | 264 | * ``EMAIL_SUBJECT``: the ``Subject:`` to use for a login email 265 | 266 | * ``SMTP_HOST``: the outgoing SMTP host 267 | 268 | * ``SMTP_PORT``: the outgoing SMTP port 269 | 270 | * ``SMTP_USE_SSL``: whether to use SSL for the SMTP connection (defaults 271 | to ``False``). It is *highly recommended* to set this to `True` if 272 | your ``SMTP_HOST`` is anything other than ``localhost``. 273 | 274 | * ``SMTP_USERNAME``: the username to use with the SMTP server 275 | 276 | * ``SMTP_PASSWORD``: the password to use with the SMTP server 277 | 278 | * ``EMAIL_CHECK_MESSAGE``: The :py:class:`authl.disposition.Notify` client 279 | data. Defaults to a simple string-based message. 280 | 281 | * ``EMAIL_TEMPLATE_FILE``: A path to a text file for the email message; if 282 | not specified a default template will be used. This file must use an 283 | UTF-8 encoding. 284 | 285 | * ``EMAIL_EXPIRE_TIME``: How long a login email is valid for, in seconds 286 | (defaults to the :py:class:`EmailAddress` default value) 287 | 288 | :param tokens.TokenStore token_store: the authentication token storage 289 | mechanism; see :py:mod:`authl.tokens` for more information. 290 | 291 | """ 292 | 293 | if config.get('EMAIL_SENDMAIL'): 294 | send_func = config['EMAIL_SENDMAIL'] 295 | else: 296 | connector = smtplib_connector( 297 | hostname=config['SMTP_HOST'], 298 | port=config['SMTP_PORT'], 299 | username=config.get('SMTP_USERNAME'), 300 | password=config.get('SMTP_PASSWORD'), 301 | use_ssl=config.get('SMTP_USE_SSL'), 302 | ) 303 | send_func = simple_sendmail(connector, config['EMAIL_FROM'], config['EMAIL_SUBJECT']) 304 | 305 | check_message = config.get('EMAIL_CHECK_MESSAGE', 'Check your email for a login link.') 306 | 307 | if 'EMAIL_TEMPLATE_FILE' in config: 308 | with open(config['EMAIL_TEMPLATE_FILE'], encoding='utf-8') as file: 309 | email_template_text = file.read() 310 | else: 311 | email_template_text = DEFAULT_TEMPLATE_TEXT 312 | 313 | return EmailAddress( 314 | send_func, 315 | {'message': check_message}, 316 | token_store, 317 | expires_time=config.get('EMAIL_EXPIRE_TIME'), 318 | email_template_text=email_template_text, 319 | ) 320 | -------------------------------------------------------------------------------- /authl/handlers/fediverse.py: -------------------------------------------------------------------------------- 1 | """ 2 | Fediverse handler 3 | ================= 4 | 5 | This handler allows login via Fediverse instances; currently `Mastodon 6 | `_ and `Pleroma `_ are 7 | supported, as is anything else with basic support for the Mastodon client API. 8 | 9 | See :py:func:`authl.from_config` for the simplest configuration mechanism. 10 | 11 | This handler registers itself with a ``cb_id`` of ``"fv"``. 12 | 13 | """ 14 | 15 | import logging 16 | import re 17 | import time 18 | import typing 19 | import urllib.parse 20 | 21 | import mastodon 22 | import requests 23 | 24 | from .. import disposition, tokens, utils 25 | from . import Handler 26 | 27 | LOGGER = logging.getLogger(__name__) 28 | 29 | 30 | class Fediverse(Handler): 31 | """ Handler for Fediverse services (Mastodon, Pleroma) """ 32 | 33 | @property 34 | def service_name(self): 35 | return "Fediverse" 36 | 37 | @property 38 | def url_schemes(self): 39 | return [('https://%', 'instance/')] 40 | 41 | @property 42 | def description(self): 43 | return """Identifies you using your choice of Fediverse instance 44 | (currently supported: Mastodon, 45 | Pleroma)""" 46 | 47 | @property 48 | def cb_id(self): 49 | return 'fv' 50 | 51 | @property 52 | def logo_html(self): 53 | return [(utils.read_icon('mastodon.svg'), 'Mastodon'), 54 | (utils.read_icon('pleroma.svg'), 'Pleroma')] 55 | 56 | def __init__(self, name: str, 57 | token_store: tokens.TokenStore, 58 | timeout: typing.Optional[int] = None, 59 | homepage: typing.Optional[str] = None): 60 | """ Instantiate a Fediverse handler. 61 | 62 | :param str name: Human-readable website name 63 | :param str homepage: Homepage for the website 64 | :param token_store: Storage for session tokens 65 | :param int timeout: How long to allow a user to wait to log in, in seconds 66 | 67 | """ 68 | self._name = name 69 | self._homepage = homepage 70 | self._token_store = token_store 71 | self._timeout = timeout or 600 72 | self._http_timeout = 30 73 | 74 | @staticmethod 75 | def _get_instance(url, timeout: int) -> typing.Optional[str]: 76 | parsed = urllib.parse.urlparse(url) 77 | if not parsed.netloc: 78 | parsed = urllib.parse.urlparse('https://' + url) 79 | domain = parsed.netloc 80 | 81 | instance = 'https://' + domain 82 | 83 | try: 84 | LOGGER.debug("Trying Fediverse instance: %s", instance) 85 | request = requests.get(instance + '/api/v1/instance', timeout=timeout) 86 | if request.status_code != 200: 87 | LOGGER.debug("Instance endpoint returned error %d", request.status_code) 88 | return None 89 | 90 | info = request.json() 91 | for key in ('uri', 'version', 'urls'): 92 | if key not in info: 93 | LOGGER.debug("Instance data missing key '%s'", key) 94 | return None 95 | 96 | LOGGER.info("Found Fediverse instance: %s", instance) 97 | return instance 98 | except Exception as error: # pylint:disable=broad-except 99 | LOGGER.debug("Fediverse probe failed: %s", error) 100 | 101 | return None 102 | 103 | def handles_url(self, url): 104 | """ 105 | Checks for an ``/api/v1/instance`` endpoint to determine if this 106 | is a Mastodon-compatible instance 107 | """ 108 | LOGGER.info("Checking URL %s", url) 109 | 110 | instance = self._get_instance(url, self._http_timeout) 111 | if not instance: 112 | LOGGER.debug("Not a Fediverse instance: %s", url) 113 | return None 114 | 115 | # This seems to be a Fediverse endpoint; try to figure out the username 116 | for tmpl in ('.*/@(.*)$', '.*/user/(.*)$'): 117 | match = re.match(tmpl, url) 118 | if match: 119 | LOGGER.debug("handles_url: instance %s user %s", instance, match[1]) 120 | return instance + '/@' + match[1] 121 | 122 | return instance 123 | 124 | @staticmethod 125 | def _get_identity(instance, response, redir) -> disposition.Disposition: 126 | try: 127 | # canonicize the URL and also make sure the domain matches 128 | id_url = urllib.parse.urljoin(instance, response['url']) 129 | if urllib.parse.urlparse(id_url).netloc != urllib.parse.urlparse(instance).netloc: 130 | LOGGER.warning("Instance %s returned response of %s -> %s", 131 | instance, response['url'], id_url) 132 | return disposition.Error("Domains do not match", redir) 133 | 134 | profile = { 135 | 'name': response.get('display_name'), 136 | 'bio': response.get('source', {}).get('note'), 137 | 'avatar': response.get('avatar_static', response.get('avatar')) 138 | } 139 | 140 | # Attempt to parse useful stuff out of the fields source 141 | for field in response.get('source', {}).get('fields', []): 142 | name = field.get('name', '') 143 | value = field.get('value', '') 144 | if 'homepage' not in profile and urllib.parse.urlparse(value).scheme: 145 | profile['homepage'] = value 146 | elif 'pronoun' in name.lower(): 147 | profile['pronouns'] = value 148 | 149 | return disposition.Verified(id_url, redir, {k: v for k, v in profile.items() if v}) 150 | except KeyError: 151 | return disposition.Error('Missing user profile', redir) 152 | except (TypeError, AttributeError): 153 | return disposition.Error('Malformed user profile', redir) 154 | 155 | def initiate_auth(self, id_url, callback_uri, redir): 156 | client_id, client_secret = None, None 157 | 158 | for scope in ('profile', 'read:accounts'): 159 | scopes = [scope] 160 | try: 161 | instance = self._get_instance(id_url, self._http_timeout) 162 | client_id, client_secret = mastodon.Mastodon.create_app( 163 | api_base_url=instance, 164 | client_name=self._name, 165 | website=self._homepage, 166 | scopes=scopes, 167 | redirect_uris=callback_uri, 168 | ) 169 | break 170 | except Exception: # pylint:disable=broad-except 171 | LOGGER.info("Instance %s refused scope %s", instance, scope) 172 | 173 | if not client_id or not client_secret: 174 | return disposition.Error("Could not register client", redir) 175 | 176 | client = mastodon.Mastodon( 177 | api_base_url=instance, 178 | client_id=client_id, 179 | client_secret=client_secret 180 | ) 181 | 182 | state = self._token_store.put(( 183 | instance, 184 | client_id, 185 | client_secret, 186 | scopes, 187 | time.time(), 188 | redir 189 | )) 190 | 191 | url = client.auth_request_url( 192 | redirect_uris=callback_uri, 193 | scopes=scopes, 194 | state=state 195 | ) 196 | 197 | return disposition.Redirect(url) 198 | 199 | def check_callback(self, url, get, data): 200 | LOGGER.debug("check_callback: %s %s", url, get) 201 | try: 202 | ( 203 | instance, 204 | client_id, 205 | client_secret, 206 | scopes, 207 | when, 208 | redir 209 | ) = self._token_store.pop(get['state']) 210 | except (KeyError, ValueError): 211 | LOGGER.exception("Invalid transaction") 212 | return disposition.Error("Invalid transaction", '') 213 | 214 | if 'error' in get: 215 | return disposition.Error("Error signing into instance: " 216 | + get.get('error_description', get['error']), 217 | redir) 218 | 219 | if time.time() > when + self._timeout: 220 | return disposition.Error("Login timed out", redir) 221 | 222 | client = mastodon.Mastodon( 223 | api_base_url=instance, 224 | client_id=client_id, 225 | client_secret=client_secret 226 | ) 227 | 228 | try: 229 | client.log_in( 230 | code=get['code'], 231 | redirect_uri=url, 232 | scopes=scopes, 233 | ) 234 | except KeyError as err: 235 | return disposition.Error(f"Missing {err}", redir) 236 | except Exception as err: # pylint:disable=broad-except 237 | return disposition.Error(f"Error signing into instance: {err}", redir) 238 | 239 | result = self._get_identity(instance, client.me(), redir) 240 | 241 | # clean up after ourselves 242 | client.revoke_access_token() 243 | 244 | return result 245 | 246 | 247 | def from_config(config, token_store: tokens.TokenStore): 248 | """ Generate a Fediverse handler from the given config dictionary. 249 | 250 | :param dict config: Configuration values; relevant keys: 251 | 252 | * ``FEDIVERSE_NAME``: the name of your website (required) 253 | 254 | * ``FEDIVERSE_HOMEPAGE``: your website's homepage (recommended) 255 | 256 | * ``FEDIVERSE_TIMEOUT``: the maximum time to wait for login to complete 257 | 258 | :param tokens.TokenStore token_store: The authentication token storage 259 | """ 260 | 261 | return Fediverse(config.get('FEDIVERSE_NAME'), token_store, 262 | timeout=config.get('FEDIVERSE_TIMEOUT'), 263 | homepage=config.get('FEDIVERSE_HOMEPAGE')) 264 | -------------------------------------------------------------------------------- /authl/handlers/indieauth.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | IndieAuth handler 4 | ================= 5 | 6 | This handler allows people to log in from their own websites using the 7 | `IndieAuth `_ federated protocol. See 8 | :py:func:`authl.from_config` for the simplest configuration mechanism. 9 | 10 | Note that the client ID must match the domain name of your website. If you're 11 | using this with :py:mod:`authl.flask`, there is a function, 12 | :py:mod:`authl.flask.client_id`, which provides this at runtime with no 13 | configuration necessary. For other frameworks you will need to either configure 14 | this with your public-facing domain name, or retrieve the domain name from 15 | whatever framework you're using. Please note also that the scheme (``http`` vs. 16 | ``https``) must match. 17 | 18 | This handler registers itself with a ``cb_id`` of ``"ia"``. 19 | 20 | """ 21 | 22 | import logging 23 | import secrets 24 | import time 25 | import typing 26 | import urllib.parse 27 | from typing import Optional 28 | 29 | import expiringdict 30 | import mf2py 31 | import requests 32 | from bs4 import BeautifulSoup 33 | 34 | from .. import disposition, tokens, utils 35 | from . import Handler 36 | 37 | LOGGER = logging.getLogger(__name__) 38 | 39 | # We do this instead of functools.lru_cache so that IndieAuth.handles_page 40 | # and find_endpoint can both benefit from the same endpoint cache 41 | _ENDPOINT_CACHE = expiringdict.ExpiringDict(max_len=128, max_age_seconds=300) 42 | 43 | # And similar for retrieving user h-cards 44 | _PROFILE_CACHE = expiringdict.ExpiringDict(max_len=128, max_age_seconds=300) 45 | 46 | 47 | def find_endpoint(id_url: str, 48 | links: Optional[typing.Dict] = None, 49 | content: Optional[BeautifulSoup] = None, 50 | rel: str = "authorization_endpoint") -> typing.Tuple[typing.Optional[str], 51 | str]: 52 | """ Given an identity URL, get its IndieAuth endpoint 53 | 54 | :param str id_url: an identity URL to check 55 | :param links: a request.links object from a requests operation 56 | :param BeautifulSoup content: a BeautifulSoup parse tree of an HTML document 57 | :param str rel: the endpoint rel to retrieve 58 | 59 | :returns: a tuple of ``(endpoint_url, profile_url)`` 60 | """ 61 | endpoints, profile = find_endpoints(id_url, links, content) 62 | return endpoints.get(rel), profile 63 | 64 | 65 | def find_endpoints(id_url: str, 66 | links: Optional[typing.Dict] = None, 67 | content: Optional[BeautifulSoup] = None) -> typing.Tuple[typing.Dict[str, str], 68 | str]: 69 | """ Given an identity URL, discover its IndieWeb endpoints 70 | 71 | :param str id_url: an identity URL to check 72 | :param links: a request.links object from a requests operation 73 | :param BeautifulSoup content: a BeautifulSoup parse tree of an HTML document 74 | 75 | :returns: a tuple of ``({endpoint_name:endpoint_url}, profile_url)`` 76 | """ 77 | profile = id_url 78 | 79 | def _derive_endpoint(links, content, rel) -> typing.Optional[str]: 80 | if links and rel in links: 81 | LOGGER.debug("%s: Found %s link header: %s", id_url, rel, links[rel]['url']) 82 | return links[rel]['url'] 83 | 84 | if content: 85 | link = content.find('link', rel=rel) 86 | if link: 87 | LOGGER.debug("%s: Found %s link tag: %s", id_url, rel, link.get('href')) 88 | return urllib.parse.urljoin(id_url, link.get('href')) 89 | 90 | return None 91 | 92 | # Get the cached endpoint value, but don't immediately use it if we have 93 | # links and/or content, as it might have changed 94 | cached, profile = _ENDPOINT_CACHE.get(id_url, ({}, profile)) 95 | LOGGER.debug("Cached endpoints for %s: %s %s", id_url, cached, profile) 96 | 97 | if not cached and not (links or content): 98 | # We don't have cached endpoints, and we don't have a source to work with, 99 | # so get the source 100 | LOGGER.debug("find_endpoints: Retrieving %s", id_url) 101 | request = utils.request_url(id_url) 102 | if request is not None: 103 | links = request.links 104 | content = BeautifulSoup(request.text, 'html.parser') 105 | profile = utils.permanent_url(request) 106 | 107 | # Derive the useful IndieAuth endpoints 108 | endpoints = cached.copy() 109 | 110 | LOGGER.debug('links for %s: %s', id_url, links) 111 | for rel in ( 112 | 'authorization_endpoint', 113 | 'token_endpoint', 114 | 'ticket_endpoint', 115 | 'webmention', 116 | 'micropub', 117 | 'microsub' 118 | ): 119 | endpoint = _derive_endpoint(links, content, rel) 120 | if endpoint: 121 | endpoints[rel] = endpoint 122 | 123 | if endpoints and id_url: 124 | # we found a new value so update the cache 125 | LOGGER.debug("Caching %s -> %s", id_url, endpoints) 126 | _ENDPOINT_CACHE[id_url] = (endpoints, profile) 127 | _ENDPOINT_CACHE[profile] = (endpoints, profile) 128 | 129 | # Let's also prefill the profile cache, while we're here 130 | if content: 131 | get_profile(profile, content=content, links=links, endpoints=endpoints) 132 | 133 | return endpoints, profile 134 | 135 | 136 | def _parse_hcard(id_url, card): 137 | properties = card.get('properties', {}) 138 | 139 | def get_str(prop) -> typing.Optional[str]: 140 | for item in properties.get(prop, []): 141 | if isinstance(item, str): 142 | return item 143 | if isinstance(item, dict) and 'value' in item: 144 | # got an e-property; use the plaintext version 145 | return item['value'] 146 | return None 147 | 148 | def get_url(prop, scheme=None) -> typing.Tuple[typing.Optional[str], 149 | urllib.parse.ParseResult]: 150 | for item in properties.get(prop, []): 151 | if isinstance(item, str): 152 | url = urllib.parse.urljoin(id_url, item) 153 | parsed = urllib.parse.urlparse(url) 154 | if not scheme or parsed.scheme == scheme: 155 | return url, parsed 156 | return None, urllib.parse.urlparse('') 157 | 158 | return { 159 | 'avatar': get_url('photo')[0], 160 | 'bio': get_str('note'), 161 | 'email': urllib.parse.unquote(get_url('email', 'mailto')[1].path), 162 | 'homepage': get_url('url')[0], 163 | 'name': get_str('name'), 164 | 'pronouns': get_str('pronouns') or get_str('pronoun'), 165 | } 166 | 167 | 168 | def get_profile(id_url: str, 169 | server_profile: Optional[dict] = None, 170 | links=None, 171 | content: Optional[BeautifulSoup] = None, 172 | endpoints=None) -> dict: 173 | """ Given an identity URL, try to parse out an Authl profile 174 | 175 | :param str id_url: The profile page to parse 176 | :param dict server_profile: An IndieAuth response profile 177 | :param dict links: Profile response's links dictionary 178 | :param content: Pre-parsed page content 179 | :param dict endpoints: Pre-parsed page endpoints 180 | """ 181 | 182 | if id_url in _PROFILE_CACHE: 183 | LOGGER.debug("Reusing %s profile from cache", id_url) 184 | profile = _PROFILE_CACHE[id_url].copy() 185 | else: 186 | profile = {} 187 | 188 | if not content and id_url not in _PROFILE_CACHE: 189 | LOGGER.debug("get_profile: Retrieving %s", id_url) 190 | request = utils.request_url(id_url) 191 | if request is not None: 192 | links = request.links 193 | content = BeautifulSoup(request.text, 'html.parser') 194 | 195 | if content: 196 | profile = {} 197 | h_cards = mf2py.Parser(doc=content).to_dict(filter_by_type="h-card") 198 | LOGGER.debug("get_profile(%s): found %d h-cards", id_url, len(h_cards)) 199 | 200 | for card in h_cards: 201 | items = _parse_hcard(id_url, card) 202 | 203 | profile.update({k: v for k, v in items.items() if v and k not in profile}) 204 | 205 | # Only stash the version without the IndieAuth server profile addons, in case 206 | # the user logs in again without the profile/email scopes 207 | LOGGER.debug("Stashing %s profile", id_url) 208 | _PROFILE_CACHE[id_url] = profile.copy() 209 | 210 | if server_profile: 211 | # The IndieAuth server also provided a profile, which should supercede the h-card 212 | for in_key, out_key in (('name', 'name'), 213 | ('photo', 'avatar'), 214 | ('url', 'homepage'), 215 | ('email', 'email')): 216 | if in_key in server_profile: 217 | profile[out_key] = server_profile[in_key] 218 | 219 | if not endpoints: 220 | endpoints, _ = find_endpoints(id_url, links=links, content=content) 221 | if endpoints: 222 | profile['endpoints'] = endpoints 223 | 224 | return profile 225 | 226 | 227 | def verify_id(request_id: str, response_id: str) -> str: 228 | """ 229 | 230 | Given an ID from an identity request and its verification response, ensure 231 | that the verification response is a valid URL for the request. A response is 232 | considered valid if it declares the same authorization_endpoint. 233 | 234 | :param str request_id: The original requested identity 235 | :param str response_id: The authorized response identity 236 | 237 | :returns: the verified response ID 238 | :raises: :py:class:`ValueError` if verification failed 239 | """ 240 | 241 | # exact match is always okay 242 | if request_id == response_id: 243 | return response_id 244 | 245 | req_endpoint, _ = find_endpoint(request_id) 246 | resp_endpoint, resp_profile = find_endpoint(response_id) 247 | 248 | if not resp_endpoint: 249 | raise ValueError(f'Profile {resp_profile} missing IndieAuth endpoint') 250 | 251 | # Both the original and final profile must have the same endpoint 252 | LOGGER.debug('request endpoint=%s response endpoint=%s', 253 | req_endpoint, resp_endpoint) 254 | if req_endpoint != resp_endpoint: 255 | raise ValueError(f'Authorization endpoint mismatch for {request_id} and {response_id}') 256 | 257 | return resp_profile 258 | 259 | 260 | class IndieAuth(Handler): 261 | """ Supports login via IndieAuth. """ 262 | 263 | @property 264 | def service_name(self): 265 | return 'IndieAuth' 266 | 267 | @property 268 | def url_schemes(self): 269 | # pylint:disable=duplicate-code 270 | return [('%', 'https://domain.example.com')] 271 | 272 | @property 273 | def description(self): 274 | return """Supports login via an 275 | IndieAuth provider. """ 276 | 277 | @property 278 | def cb_id(self): 279 | return 'ia' 280 | 281 | @property 282 | def logo_html(self): 283 | return [(utils.read_icon('indieauth.svg'), 'IndieAuth')] 284 | 285 | def __init__(self, client_id: typing.Union[str, typing.Callable[..., str]], 286 | token_store: tokens.TokenStore, timeout: Optional[int] = None): 287 | """ 288 | :param client_id: The client_id to send to the remote IndieAuth 289 | provider. Can be a string or a function that returns a string. 290 | 291 | :param token_store: Storage for the tokens. 292 | 293 | ***Security note:*** :py:class:`tokens.Serializer` is not supported, 294 | as it makes this handler subject to replay attacks when used with 295 | many common IndieAuth servers, and also prevents PKCE from being 296 | effective. 297 | 298 | :param int timeout: Maximum time to wait for login to complete 299 | (default: 600) 300 | 301 | """ 302 | 303 | self._client_id = client_id 304 | self._token_store = token_store 305 | self._timeout = timeout or 600 306 | self._http_timeout = 5 307 | 308 | if isinstance(token_store, tokens.Serializer): 309 | LOGGER.error( 310 | "Cannot use tokens.Serializer with IndieAuth due to security considerations") 311 | raise ValueError("Cannot use tokens.Serializer with IndieAuth") 312 | 313 | def handles_url(self, url): 314 | """ 315 | If this page is already known to have an IndieAuth endpoint, we reuse 316 | that; otherwise this returns ``None`` so the Authl instance falls 317 | through to :py:meth:`authl.handlers.indieauth.IndieAuth.handles_page`. 318 | """ 319 | if url in _ENDPOINT_CACHE: 320 | return _ENDPOINT_CACHE[url][1] 321 | return None 322 | 323 | def handles_page(self, url, headers, content, links): 324 | """ :returns: whether an ``authorization_endpoint`` was found on the page. """ 325 | return find_endpoint(url, links, content)[0] 326 | 327 | def initiate_auth(self, id_url, callback_uri, redir): 328 | endpoint, id_url = find_endpoint(id_url) 329 | if not endpoint: 330 | return disposition.Error("Failed to get IndieAuth endpoint", redir) 331 | 332 | verifier = secrets.token_urlsafe() 333 | state = self._token_store.put( 334 | (id_url, endpoint, callback_uri, verifier, time.time(), redir)) 335 | 336 | client_id = utils.resolve_value(self._client_id) 337 | LOGGER.debug("Using client_id %s", client_id) 338 | 339 | url = endpoint + '?' + urllib.parse.urlencode({ 340 | 'redirect_uri': callback_uri, 341 | 'client_id': client_id, 342 | 'state': state, 343 | 'code_challenge': utils.pkce_challenge(verifier), 344 | 'code_challenge_method': 'S256', 345 | 'response_type': 'code', 346 | 'scope': 'profile email', 347 | 'me': id_url}) 348 | return disposition.Redirect(url) 349 | 350 | def check_callback(self, url, get, data): 351 | # pylint:disable=too-many-return-statements,too-many-locals 352 | 353 | state = get.get('state') 354 | if not state: 355 | return disposition.Error("No transaction provided", '') 356 | 357 | try: 358 | id_url, endpoint, callback_uri, verifier, when, redir = self._token_store.pop(state) 359 | except (KeyError, ValueError): 360 | return disposition.Error("Invalid token", '') 361 | 362 | if time.time() > when + self._timeout: 363 | return disposition.Error("Transaction timed out", redir) 364 | 365 | try: 366 | # Verify the auth code 367 | client_id = utils.resolve_value(self._client_id) 368 | request = requests.post(endpoint, data={ 369 | 'code': get['code'], 370 | 'client_id': client_id, 371 | 'redirect_uri': callback_uri, 372 | 'code_verifier': verifier, 373 | }, headers={ 374 | 'accept': 'application/json', 375 | 'User-Agent': f'{utils.USER_AGENT} for {client_id}', 376 | }, timeout=self._http_timeout) 377 | 378 | if request.status_code != 200: 379 | LOGGER.error("Request returned code %d: %s", request.status_code, request.text) 380 | return disposition.Error(f"Authorization endpoint returned {request.status_code}", 381 | redir) 382 | 383 | try: 384 | response = request.json() 385 | except ValueError: 386 | LOGGER.error("%s: Got invalid JSON response from %s: %s (content-type: %s)", 387 | id_url, endpoint, 388 | request.text, 389 | request.headers.get('content-type')) 390 | return disposition.Error("Got invalid response JSON", redir) 391 | 392 | response_id = verify_id(id_url, response['me']) 393 | return disposition.Verified( 394 | response_id, redir, 395 | get_profile(response_id, 396 | server_profile=response.get('profile'))) 397 | except KeyError as key: 398 | return disposition.Error("Missing " + str(key), redir) 399 | except ValueError as err: 400 | return disposition.Error(str(err), redir) 401 | 402 | 403 | def from_config(config, token_store): 404 | """ Generate an IndieAuth handler from the given config dictionary. 405 | 406 | Possible configuration values: 407 | 408 | * ``INDIEAUTH_CLIENT_ID``: the client ID (URL) of your website (required) 409 | * ``INDIEAUTH_PENDING_TTL``: timemout for a pending transction 410 | """ 411 | return IndieAuth(config['INDIEAUTH_CLIENT_ID'], 412 | token_store, 413 | timeout=config.get('INDIEAUTH_PENDING_TTL')) 414 | -------------------------------------------------------------------------------- /authl/handlers/test_handler.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test handler 3 | ============ 4 | 5 | This is a handler which always lets people log in as any URL with the fake 6 | scheme of ``test:``, with the exception of ``test:error`` which generates an 7 | error. This is only to be used for testing locally and should not be enabled in 8 | production. 9 | 10 | """ 11 | 12 | from .. import disposition 13 | from . import Handler 14 | 15 | 16 | class TestHandler(Handler): 17 | """ An Authl handler which always returns True for any URI beginning with 18 | 'test:'. Primarily for testing purposes. """ 19 | 20 | def handles_url(self, url): 21 | """ Returns ``True`` if the URL starts with ``'test:'``. """ 22 | if url.startswith('test:'): 23 | return url 24 | return None 25 | 26 | def initiate_auth(self, id_url, callback_uri, redir): 27 | """ 28 | Immediately returns a :py:class:`disposition.Verified`, unless the URL 29 | is ``'test:error'`` in which case it returns a 30 | :py:class:`disposition.Error`. 31 | """ 32 | if id_url == 'test:error': 33 | return disposition.Error("Error identity requested", redir) 34 | return disposition.Verified(id_url, redir) 35 | 36 | def check_callback(self, url, get, data): 37 | return disposition.Error("This shouldn't be possible", None) 38 | 39 | @property 40 | def cb_id(self): 41 | return 'TEST_DO_NOT_USE' 42 | 43 | @property 44 | def service_name(self): 45 | return 'Loopback' 46 | 47 | @property 48 | def url_schemes(self): 49 | return [('test:%', 'example')] 50 | 51 | @property 52 | def description(self): 53 | return """Used for testing purposes. Don't use this on a production website.""" 54 | -------------------------------------------------------------------------------- /authl/icons/email_addr.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 7 | 9 | 10 | 11 | 12 | 13 | 21 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /authl/icons/indieauth.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 8 | 9 | -------------------------------------------------------------------------------- /authl/icons/mastodon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /authl/icons/pleroma.svg: -------------------------------------------------------------------------------- 1 | 16 | 18 | 19 | 21 | image/svg+xml 22 | 24 | 25 | 26 | 27 | 28 | 30 | 50 | 53 | 59 | 65 | 71 | 77 | 83 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /authl/tokens.py: -------------------------------------------------------------------------------- 1 | """ 2 | Token storage 3 | ============= 4 | 5 | The provided implementations are intended merely as a starting point that works 6 | for the most common cases with minimal external dependencies. In general, if 7 | you're only running a service on a single endpoint, use 8 | :py:class:`DictStore`, and if you want to be able to load-balance, 9 | use :py:class:`Serializer`. However, it is quite reasonable to 10 | provide your own implementation of :py:class:`TokenStore` which is 11 | backed by a shared data store such as Redis or a database. 12 | 13 | """ 14 | 15 | import typing 16 | import uuid 17 | from abc import ABC, abstractmethod 18 | from typing import Optional 19 | 20 | import expiringdict 21 | import itsdangerous 22 | 23 | 24 | class TokenStore(ABC): 25 | """ 26 | 27 | Storage for tokens; given some data to store, returns an opaque 28 | identifier that can be used to reconstitute said token. 29 | 30 | """ 31 | @abstractmethod 32 | def put(self, value: typing.Any) -> str: 33 | """ Generates a token with the specified stored values. 34 | 35 | :param value: The token value to store 36 | 37 | :returns: The stored token's ID 38 | """ 39 | 40 | @abstractmethod 41 | def get(self, key: str, to_type=tuple) -> typing.Any: 42 | """ 43 | Retrieves the token's value; raises `KeyError` if the token does not 44 | exist or is invalid. 45 | """ 46 | 47 | @abstractmethod 48 | def remove(self, key: str): 49 | """ Removes the key from the backing store, if appropriate. Is a no-op 50 | if the key doesn't exist (or if there's no backing store). """ 51 | 52 | def pop(self, key: str, to_type=tuple) -> typing.Any: 53 | """ Retrieves the token's values, and deletes the token from the backing 54 | store. Even if the value retrieval fails, it will be removed. """ 55 | try: 56 | stored = self.get(key, to_type) 57 | return stored 58 | finally: 59 | self.remove(key) 60 | 61 | 62 | class DictStore(TokenStore): 63 | """ 64 | A token store that stores the values in a dict-like container. 65 | 66 | This is suitable for the general case of having a single persistent service 67 | running on a single endpoint. 68 | 69 | :param store: dict-like class that maps the generated key to the stored 70 | value. Defaults to an `expiringdict`_ with a size limit of 1024 and a 71 | maximum lifetime of 1 hour. This can be tuned to your needs. In 72 | particular, the lifetime never needs to be any higher than your longest 73 | allowed transaction lifetime, and the size limit generally needs to be 74 | no more than the number of concurrent logins at any given time. 75 | 76 | :param func keygen: A function to generate a non-colliding string key for 77 | the stored token. This defaults to py:func:`uuid.uuid4`. 78 | 79 | .. _expiringdict: https://pypi.org/project/expiringdict/ 80 | """ 81 | 82 | def __init__(self, store: Optional[dict] = None, 83 | keygen: typing.Callable[..., str] = lambda _: str(uuid.uuid4())): 84 | """ Initialize the store """ 85 | self._store: dict = expiringdict.ExpiringDict( 86 | max_len=1024, 87 | max_age_seconds=3600) if store is None else store 88 | self._keygen = keygen 89 | 90 | def put(self, value): 91 | key = self._keygen(value) 92 | self._store[key] = value 93 | return key 94 | 95 | def get(self, key, to_type=tuple): 96 | return to_type(self._store[key]) 97 | 98 | def remove(self, key): 99 | try: 100 | del self._store[key] 101 | except KeyError: 102 | pass 103 | 104 | 105 | class Serializer(TokenStore): 106 | """ 107 | A token store that stores the token values within the token name, using 108 | a tamper-resistant signed serializer. Use this to avoid having a backing 109 | store entirely, although the tokens can get quite large. 110 | 111 | This is suitable for situations where you are load-balancing across multiple 112 | nodes, or need tokens to persist across frequent service restarts, and don't 113 | want to be dependent on a database. Note that all running instances will 114 | need to share the same secret_key. 115 | 116 | Also note that tokens stored in this way cannot be revoked individually. 117 | 118 | Additionally, this token storage mechanism may limit the security of some 119 | of the identity providers. 120 | """ 121 | 122 | def __init__(self, secret_key): 123 | """ Initializes the token store 124 | 125 | :param str secret_key: The signing key for the serializer 126 | """ 127 | 128 | self._serializer = itsdangerous.URLSafeSerializer(secret_key) 129 | 130 | def put(self, value): 131 | return self._serializer.dumps(value) 132 | 133 | def get(self, key, to_type=tuple): 134 | try: 135 | return to_type(self._serializer.loads(key)) 136 | except itsdangerous.BadData as err: 137 | raise KeyError("Invalid token") from err 138 | 139 | def remove(self, key): 140 | pass 141 | -------------------------------------------------------------------------------- /authl/utils.py: -------------------------------------------------------------------------------- 1 | """ Utility functions """ 2 | 3 | import base64 4 | import hashlib 5 | import logging 6 | import os.path 7 | import typing 8 | import urllib.parse 9 | from typing import Optional 10 | 11 | import requests 12 | 13 | from . import __version__ 14 | 15 | LOGGER = logging.getLogger(__name__) 16 | 17 | USER_AGENT = f'Authl v{__version__.__version__}; +https://plaidweb.site/' 18 | 19 | 20 | def get_user_agent(client_id: Optional[str] = None): 21 | ''' Make a useful user-agent string for a request ''' 22 | return f'{USER_AGENT} for {client_id}' if client_id else USER_AGENT 23 | 24 | 25 | def read_file(filename): 26 | """ Given a filename, read the entire thing into a string """ 27 | with open(filename, encoding='utf-8') as file: 28 | return file.read() 29 | 30 | 31 | def read_icon(filename): 32 | """ Given a filename, read the data into a string from the icons directory """ 33 | return read_file(os.path.join(os.path.dirname(__file__), 'icons', filename)) 34 | 35 | 36 | def request_url(url: str, 37 | client_id: Optional[str] = None, 38 | timeout: int = 30) -> typing.Optional[requests.Response]: 39 | """ Requests a URL, attempting to canonicize it as it goes """ 40 | 41 | for prefix in ('', 'https://', 'http://'): 42 | attempt = prefix + url 43 | try: 44 | return requests.get(attempt, headers={ 45 | 'User-Agent': get_user_agent(client_id) 46 | }, 47 | timeout=timeout 48 | ) 49 | except requests.exceptions.MissingSchema: 50 | LOGGER.info("Missing schema on URL %s", attempt) 51 | except requests.exceptions.InvalidSchema: 52 | LOGGER.info("Unsupported schema on URL %s", attempt) 53 | return None 54 | except Exception as err: # pylint:disable=broad-except 55 | LOGGER.info("%s failed: %s", attempt, err) 56 | 57 | return None 58 | 59 | 60 | def resolve_value(val): 61 | """ if given a callable, call it; otherwise, return it """ 62 | if callable(val): 63 | return val() 64 | return val 65 | 66 | 67 | def permanent_url(response: requests.Response) -> str: 68 | """ Given a requests.Response object, determine what the permanent URL 69 | for it is from the response history """ 70 | 71 | def normalize(url): 72 | # normalize the netloc to lowercase 73 | parsed = urllib.parse.urlparse(url) 74 | return urllib.parse.urlunparse(parsed._replace(netloc=parsed.netloc.lower())) 75 | 76 | for item in response.history: 77 | if item.status_code in (301, 308): 78 | # permanent redirect means we continue on to the next URL in the 79 | # redirection change 80 | continue 81 | # Any other status code is assumed to be a temporary redirect, so this 82 | # is the last permanent URL 83 | return normalize(item.url) 84 | 85 | # Last history item was a permanent redirect, or there was no history 86 | return normalize(response.url) 87 | 88 | 89 | def pkce_challenge(verifier: str, method: str = 'S256') -> str: 90 | """ Convert a PKCE verifier string to a challenge string 91 | 92 | See RFC 7636 """ 93 | 94 | if method == 'plain': 95 | return verifier 96 | 97 | if method == 'S256': 98 | hashed = hashlib.sha256(verifier.encode()).digest() 99 | encoded = base64.urlsafe_b64encode(hashed) 100 | return encoded.decode().strip('=') 101 | 102 | raise ValueError(f'Unknown PKCE method {method}') 103 | 104 | 105 | def extract_rel(rel: str, base_url, content, links) -> typing.Set[str]: 106 | """ Given a parsed page/response, extract all of the URLs that match a particular link rel """ 107 | result: typing.Set[str] = set() 108 | 109 | if links and rel in links: 110 | LOGGER.debug("%s: Found %s link header: %s", base_url, rel, links[rel]['url']) 111 | result.add(links[rel]['url']) 112 | 113 | if content: 114 | for link in content.find_all(('link', 'a'), rel=rel): 115 | LOGGER.debug("%s: Found %s link tag: %s", base_url, rel, link.get('href')) 116 | result.add(urllib.parse.urljoin(base_url, link.get('href'))) 117 | 118 | return result 119 | -------------------------------------------------------------------------------- /authl/webfinger.py: -------------------------------------------------------------------------------- 1 | """ 2 | Webfinger utility 3 | ================= 4 | """ 5 | 6 | import html 7 | import logging 8 | import re 9 | import typing 10 | 11 | import requests 12 | 13 | from . import utils 14 | 15 | LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | def get_profiles(url: str, timeout: int = 30) -> typing.Set[str]: 19 | """ 20 | 21 | Get the potential identity URLs from a webfinger address. 22 | 23 | :param str url: The webfinger URL 24 | 25 | :returns: A :py:type:`set` of potential identity URLs 26 | 27 | """ 28 | webfinger = re.match(r'(@|acct:)([^@]+)@(.*)$', url) 29 | if not webfinger: 30 | return set() 31 | 32 | try: 33 | user, domain = webfinger.group(2, 3) 34 | LOGGER.debug("webfinger: user=%s domain=%s", user, domain) 35 | 36 | resource = html.escape(f'acct:{user}@{domain}') 37 | request = requests.get(f'https://{domain}/.well-known/webfinger?resource={resource}', 38 | headers={'User-Agent': utils.USER_AGENT}, 39 | timeout=timeout) 40 | 41 | if not 200 <= request.status_code < 300: 42 | LOGGER.info("Webfinger query %s returned status code %d", 43 | resource, request.status_code) 44 | LOGGER.debug("%s", request.text) 45 | # Service doesn't support webfinger, so just pretend it's the most 46 | # common format for a profile page 47 | return {f'https://{domain}/@{user}'} 48 | 49 | profile = request.json() 50 | LOGGER.debug("Profile: %s", repr(profile)) 51 | 52 | return {link['href'] for link in profile['links'] 53 | if link['rel'] in ('http://webfinger.net/rel/profile-page', 'profile', 'self')} 54 | except Exception: # pylint:disable=broad-except 55 | LOGGER.info("Failed to decode %s profile", resource) 56 | return set() 57 | -------------------------------------------------------------------------------- /docs/authl.rst: -------------------------------------------------------------------------------- 1 | API Documentation 2 | ================= 3 | 4 | .. automodule:: authl 5 | :members: 6 | 7 | .. automodule:: authl.disposition 8 | :members: 9 | 10 | .. automodule:: authl.tokens 11 | :members: 12 | :member-order: bysource 13 | 14 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # import sphinx_rtd_theme 2 | project = "Authl" 3 | master_doc = "index" 4 | extensions = ["sphinx.ext.autodoc"] 5 | autoclass_content = "both" 6 | autodoc_member_order = "groupwise" 7 | autodoc_inherit_docstrings = False 8 | 9 | 10 | # html_theme = 'sphinx_rtd_theme' 11 | # html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 12 | -------------------------------------------------------------------------------- /docs/flask.rst: -------------------------------------------------------------------------------- 1 | .. automodule:: authl.flask 2 | :members: 3 | 4 | -------------------------------------------------------------------------------- /docs/flow.rst: -------------------------------------------------------------------------------- 1 | Authentication Flow 2 | =================== 3 | 4 | This is a brief, framework-agnostic overview of how to use Authl. If you want to 5 | use Authl with `Flask`_, instead consider using the :py:mod:`authl.flask` 6 | wrapper. 7 | 8 | .. _Flask: https://flask.palletsprojects.com/ 9 | 10 | Notably, the example code below is not written against any specific framework 11 | and is just to be used as a rough example of how it might look. 12 | 13 | Typically you will simply use :py:func:`authl.from_config` to build an instance 14 | with your configured handlers. However, you can also instance it and your 15 | handlers directly. See the documentation for :py:class:`Authl` and 16 | :py:func:`Authl.add_handler`, as well as the documentation for 17 | :py:mod:`authl.handlers`. 18 | 19 | For the login flow, you need two parts: a login form, and a callback handler. 20 | 21 | The login form should, at the very least, have a text input field for users to 22 | enter their identity URL, and should track the final post-login redirection 23 | target. 24 | 25 | When the form is submitted, it calls 26 | :py:func:`Authl.get_handler_for_url` with the user's login URL to get the 27 | appropriate handler, and then call the handler's 28 | :py:func:`handlers.Handler.initiate_auth` function. The ``callback_uri`` 29 | argument needs to be able to map back to the handler in some way; typically you 30 | will include the handler's ``cb_id`` in the URL, either as a query parameter or 31 | as a path component. ``get_handler_for_url`` will then return a 32 | :py:class:`disposition.Disposition` object which should then direct the client 33 | in some way. Typically this will be either a :py:class:`disposition.Redirect` or 34 | a :py:class:`disposition.Notify`, but any of the disposition types are possible. 35 | 36 | The callback then must look up the associated handler and pass the request URL, 37 | the parsed ``GET`` arguments (if any), and the parsed ``POST`` arguments (if 38 | any) to the handler's :py:func:`handlers.Handler.check_callback` method. The 39 | resulting :py:class:`disposition.Disposition` object then indicates what comes 40 | next. Typically this will be either a :py:class:`disposition.Error` or a 41 | :py:class:`disposition.Verified`, but again, any disposition type is possible 42 | and must be handled accordingly. 43 | 44 | Example (pseudo-)code follows: 45 | 46 | .. code-block:: python 47 | 48 | def handle_disposition(disp): 49 | if isinstance(disp, disposition.Redirect): 50 | return redirect(disp.url) 51 | if isinstance(disp, disposition.Verified): 52 | set_user_session(username=disp.identity) 53 | return redirect(disp.redir) 54 | if isinstance(disp, disposition.Notify): 55 | return render_notification_page(message=disp.cdata) 56 | if isinstance(disp, disposition.NeedsPost): 57 | return render_post_form(message=disp.message, url=disp.url, data=disp.data) 58 | if isinstance(disp, disposition.Error): 59 | return render_login_form(error=disp.message, redir=disp.redir) 60 | raise RuntimeError("Unknown disposition type " + disp) 61 | 62 | def handle_login_form(request): 63 | # The login form should have some means of providing the post-login 64 | # redirection URL 65 | redir_url = get_redir_url(request) 66 | 67 | # Get the submitted user identity; it's a good idea to support both 68 | # GET and POST arguments for this to let people bookmark a quick 69 | # login URL if they so desire 70 | me_url = request.args.get('me', request.post.get('me')) 71 | if me_url: 72 | handler, hid, id_url = authl_instance.get_handler_for_url(me_url) 73 | if handler: 74 | # get_callback_url is implemented by the app, and produces a URL 75 | # that can map to a handler by handler ID 76 | cb_url = get_callback_url(hid) 77 | 78 | # handle_disposition is implemented by the app, and handles the 79 | # result of an authentication step 80 | return handle_disposition( 81 | handler.initiate_auth(id_url, cb_url, redir_url)) 82 | 83 | return render_login_form( 84 | error="Unknown authentication method" if me_url else None, 85 | redir=redir_url) 86 | 87 | def handle_callback(request): 88 | hid = get_hid_from_url(request.url) 89 | handler = authl_instance.get_handler_by_id(hid) 90 | if not handler: 91 | return render_login_page(error="Invalid callback") 92 | return handle_disposition(handler.check_callback(request.url, 93 | request.args, 94 | request.post)) 95 | 96 | Login form UX 97 | ------------- 98 | 99 | Authl handlers also provide a few mechanisms that allow for an improved user 100 | experience; for example, :py:func:`authl.handlers.Handler.service_name` and 101 | :py:func:`authl.handlers.Handler.url_schemes` can be used to build out form 102 | elements that provide more information about which handlers are available, and 103 | :py:func:`authl.Authl.get_handler_for_url` can be used to implement an 104 | interactive "URL tester" to tell users in real-time whether the URL they're 105 | entering is a valid identity. This functionality is all expressed in the 106 | :py:mod:`authl.flask` implementation and should absolutely be replicated in any 107 | other frontend implementation. 108 | 109 | See the `default Flask login template 110 | `_ 111 | for an example of how this might look. 112 | 113 | Asynchronous operation 114 | ---------------------- 115 | 116 | Note that many of the underlying libraries that Authl uses are blocking, so as a 117 | result, Authl as a whole will be blocking for the foreseeable future. However, 118 | if you want to use Authl asynchronously, you can wrap the functions using 119 | :py:func:`asyncio.loop.run_in_executor` or using a higher-level library such as 120 | `a_sync `_ to manage this for you. 121 | 122 | The functions you'll specifically want to wrap are: 123 | 124 | * :py:func:`authl.Authl.get_handler_for_url` 125 | * :py:func:`authl.handlers.Handler.initiate_auth` (for the returned handler) 126 | * :py:func:`authl.handlers.Handler.check_callback` (for the returned handler) 127 | 128 | For example, an async version of the above flow might look like: 129 | 130 | .. code-block:: python 131 | 132 | import asyncio 133 | 134 | async def handle_login_form(request): 135 | loop = asyncio.get_running_loop() 136 | 137 | redir_url = get_redir_url(request) 138 | me_url = request.args.get('me', request.post.get('me')) 139 | if me_url: 140 | handler, hid, id_url = await loop.run_in_executor( 141 | None, 142 | authl_instance.get_handler_for_url, me_url) 143 | if handler: 144 | bc_url = get_callback_url(hid) 145 | return handle_disposition(await loop.run_in_executor( 146 | None, handler.initiate_auth, 147 | id_url, cb_url, redir_url)) 148 | 149 | return render_login_form(redir=redir_url) 150 | 151 | async def handle_callback(request): 152 | loop = asyncio.get_running_loop() 153 | 154 | hid = get_hid_from_url(request.url) 155 | handler = authl_instance.get_handler_by_id(hid) 156 | if not handler: 157 | return render_login_page(error="Invalid callback") 158 | 159 | return handle_disposition(await loop.run_in_executor( 160 | None, handler.check_callback, 161 | request.url, request.args, request.post)) 162 | -------------------------------------------------------------------------------- /docs/handlers.rst: -------------------------------------------------------------------------------- 1 | Authentication handlers 2 | ======================= 3 | 4 | .. automodule:: authl.handlers 5 | :members: 6 | 7 | .. automodule:: authl.handlers.email_addr 8 | :members: 9 | 10 | .. automodule:: authl.handlers.fediverse 11 | :members: 12 | 13 | .. automodule:: authl.handlers.indieauth 14 | :members: 15 | 16 | .. automodule:: authl.handlers.test_handler 17 | :members: 18 | 19 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Authl 2 | ===== 3 | 4 | Authl is an authentication library that simplifies adding user authentication to 5 | your Python application. In particular, it allows the easy use of common 6 | third-party identity services such as Twitter, Fediverse, and IndieWeb 7 | implementations, as well as supporting signin via emailed "magic links." It also 8 | provides an extension API such that you can provide your own identity providers 9 | as appropriate. 10 | 11 | Installation 12 | ------------ 13 | 14 | Authl requires Python 3.8.1 or later. 15 | 16 | Install with pip:: 17 | 18 | pip install Authl 19 | 20 | Or, if you would like to work from the latest source:: 21 | 22 | git clone https://github.com/PlaidWeb/Authl.git 23 | 24 | Authl uses `Poetry `_ and ``make`` for its build and 25 | dependency management. 26 | 27 | Further reading 28 | --------------- 29 | 30 | .. toctree:: 31 | authl 32 | handlers 33 | flow 34 | flask 35 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4==4.13.3 ; python_version >= "3.9" and python_version < "4.0" \ 2 | --hash=sha256:1bd32405dacc920b42b83ba01644747ed77456a65760e285fbc47633ceddaf8b \ 3 | --hash=sha256:99045d7d3f08f91f0d656bc9b7efbae189426cd913d830294a15eefa0ea4df16 4 | blurhash==1.1.4 ; python_version >= "3.9" and python_version < "4.0" \ 5 | --hash=sha256:7611c1bc41383d2349b6129208587b5d61e8792ce953893cb49c38beeb400d1d \ 6 | --hash=sha256:da56b163e5a816e4ad07172f5639287698e09d7f3dc38d18d9726d9c1dbc4cee 7 | certifi==2025.1.31 ; python_version >= "3.9" and python_version < "4.0" \ 8 | --hash=sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651 \ 9 | --hash=sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe 10 | charset-normalizer==3.4.1 ; python_version >= "3.9" and python_version < "4.0" \ 11 | --hash=sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537 \ 12 | --hash=sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa \ 13 | --hash=sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a \ 14 | --hash=sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294 \ 15 | --hash=sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b \ 16 | --hash=sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd \ 17 | --hash=sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601 \ 18 | --hash=sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd \ 19 | --hash=sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4 \ 20 | --hash=sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d \ 21 | --hash=sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2 \ 22 | --hash=sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313 \ 23 | --hash=sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd \ 24 | --hash=sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa \ 25 | --hash=sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8 \ 26 | --hash=sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1 \ 27 | --hash=sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2 \ 28 | --hash=sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496 \ 29 | --hash=sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d \ 30 | --hash=sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b \ 31 | --hash=sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e \ 32 | --hash=sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a \ 33 | --hash=sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4 \ 34 | --hash=sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca \ 35 | --hash=sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78 \ 36 | --hash=sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408 \ 37 | --hash=sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5 \ 38 | --hash=sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3 \ 39 | --hash=sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f \ 40 | --hash=sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a \ 41 | --hash=sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765 \ 42 | --hash=sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6 \ 43 | --hash=sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146 \ 44 | --hash=sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6 \ 45 | --hash=sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9 \ 46 | --hash=sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd \ 47 | --hash=sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c \ 48 | --hash=sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f \ 49 | --hash=sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545 \ 50 | --hash=sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176 \ 51 | --hash=sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770 \ 52 | --hash=sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824 \ 53 | --hash=sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f \ 54 | --hash=sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf \ 55 | --hash=sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487 \ 56 | --hash=sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d \ 57 | --hash=sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd \ 58 | --hash=sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b \ 59 | --hash=sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534 \ 60 | --hash=sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f \ 61 | --hash=sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b \ 62 | --hash=sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9 \ 63 | --hash=sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd \ 64 | --hash=sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125 \ 65 | --hash=sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9 \ 66 | --hash=sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de \ 67 | --hash=sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11 \ 68 | --hash=sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d \ 69 | --hash=sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35 \ 70 | --hash=sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f \ 71 | --hash=sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda \ 72 | --hash=sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7 \ 73 | --hash=sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a \ 74 | --hash=sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971 \ 75 | --hash=sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8 \ 76 | --hash=sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41 \ 77 | --hash=sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d \ 78 | --hash=sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f \ 79 | --hash=sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757 \ 80 | --hash=sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a \ 81 | --hash=sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886 \ 82 | --hash=sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77 \ 83 | --hash=sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76 \ 84 | --hash=sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247 \ 85 | --hash=sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85 \ 86 | --hash=sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb \ 87 | --hash=sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7 \ 88 | --hash=sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e \ 89 | --hash=sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6 \ 90 | --hash=sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037 \ 91 | --hash=sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1 \ 92 | --hash=sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e \ 93 | --hash=sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807 \ 94 | --hash=sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407 \ 95 | --hash=sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c \ 96 | --hash=sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12 \ 97 | --hash=sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3 \ 98 | --hash=sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089 \ 99 | --hash=sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd \ 100 | --hash=sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e \ 101 | --hash=sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00 \ 102 | --hash=sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616 103 | decorator==5.1.1 ; python_version >= "3.9" and python_version < "4.0" \ 104 | --hash=sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330 \ 105 | --hash=sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186 106 | expiringdict==1.2.2 ; python_version >= "3.9" and python_version < "4.0" \ 107 | --hash=sha256:09a5d20bc361163e6432a874edd3179676e935eb81b925eccef48d409a8a45e8 \ 108 | --hash=sha256:300fb92a7e98f15b05cf9a856c1415b3bc4f2e132be07daa326da6414c23ee09 109 | html5lib==1.1 ; python_version >= "3.9" and python_version < "4.0" \ 110 | --hash=sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d \ 111 | --hash=sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f 112 | idna==3.10 ; python_version >= "3.9" and python_version < "4.0" \ 113 | --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ 114 | --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 115 | itsdangerous==2.2.0 ; python_version >= "3.9" and python_version < "4.0" \ 116 | --hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \ 117 | --hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173 118 | mastodon-py==1.8.1 ; python_version >= "3.9" and python_version < "4.0" \ 119 | --hash=sha256:22bc7e060518ef2eaa69d911cde6e4baf56bed5ea0dd407392c49051a7ac526a \ 120 | --hash=sha256:4a64cb94abadd6add73e4b8eafdb5c466048fa5f638284fd2189034104d4687e 121 | mf2py==1.1.3 ; python_version >= "3.9" and python_version < "4.0" \ 122 | --hash=sha256:4241e91ed4b644dd666d9fbd2557ed86e5bb7399c196026f7b0a1f413b33f59f \ 123 | --hash=sha256:8f9e2c147beadd56f8839644124c7d141d96e879319b9f50d02826c88766bf4d 124 | oauthlib==3.2.2 ; python_version >= "3.9" and python_version < "4.0" \ 125 | --hash=sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca \ 126 | --hash=sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918 127 | python-dateutil==2.9.0.post0 ; python_version >= "3.9" and python_version < "4.0" \ 128 | --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ 129 | --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 130 | python-magic-bin==0.4.14 ; python_version >= "3.9" and python_version < "4.0" and platform_system == "Windows" \ 131 | --hash=sha256:34a788c03adde7608028203e2dbb208f1f62225ad91518787ae26d603ae68892 \ 132 | --hash=sha256:7b1743b3dbf16601d6eedf4e7c2c9a637901b0faaf24ad4df4d4527e7d8f66a4 \ 133 | --hash=sha256:90be6206ad31071a36065a2fc169c5afb5e0355cbe6030e87641c6c62edc2b69 134 | python-magic==0.4.27 ; python_version >= "3.9" and python_version < "4.0" and platform_system != "Windows" \ 135 | --hash=sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b \ 136 | --hash=sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3 137 | requests-oauthlib==1.3.1 ; python_version >= "3.9" and python_version < "4.0" \ 138 | --hash=sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5 \ 139 | --hash=sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a 140 | requests==2.32.3 ; python_version >= "3.9" and python_version < "4.0" \ 141 | --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \ 142 | --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 143 | six==1.17.0 ; python_version >= "3.9" and python_version < "4.0" \ 144 | --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ 145 | --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 146 | soupsieve==2.6 ; python_version >= "3.9" and python_version < "4.0" \ 147 | --hash=sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb \ 148 | --hash=sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9 149 | typing-extensions==4.12.2 ; python_version >= "3.9" and python_version < "4.0" \ 150 | --hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d \ 151 | --hash=sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8 152 | urllib3==2.3.0 ; python_version >= "3.9" and python_version < "4.0" \ 153 | --hash=sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df \ 154 | --hash=sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d 155 | validate-email==1.3 ; python_version >= "3.9" and python_version < "4.0" \ 156 | --hash=sha256:784719dc5f780be319cdd185dc85dd93afebdb6ebb943811bc4c7c5f9c72aeaf 157 | webencodings==0.5.1 ; python_version >= "3.9" and python_version < "4.0" \ 158 | --hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \ 159 | --hash=sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923 160 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable=import-outside-toplevel 3 | 4 | [SIMILARITIES] 5 | ignore-imports=yes 6 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "Authl" 3 | version = "0.7.3" 4 | description = "Framework-agnostic authentication wrapper" 5 | authors = ["fluffy "] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://plaidweb.site/" 9 | repository = "https://github.com/PlaidWeb/Authl" 10 | documentation = "https://authl.readthedocs.io/" 11 | 12 | [tool.poetry.dependencies] 13 | python = ">=3.9.2,<4.0" 14 | beautifulsoup4 = "^4.13.3" 15 | expiringdict = "^1.2.2" 16 | itsdangerous = "^2.2.0" 17 | requests = "^2.32.3" 18 | requests_oauthlib = "^2.0.0" 19 | validate_email = "^1.3" 20 | mf2py = "^2.0.1" 21 | mastodon-py = "^2.0.0" 22 | 23 | [tool.poetry.group.dev.dependencies] 24 | autopep8 = "^2.3.2" 25 | flake8 = "^7.1.2" 26 | flask = "^3.1.0" 27 | isort = "^6.0.0" 28 | mypy = "^1.15.0" 29 | pylint = "^3.3.4" 30 | coverage = "^7.6.12" 31 | pytest = "^8.3.4" 32 | requests-mock = "^1.12.1" 33 | sphinx = "^7.3.7" 34 | pytest-mock = "^3.14.0" 35 | types-requests = "^2.32.0.20241016" 36 | sphinx-rtd-theme = "^2.0.0" 37 | pip-tools = "^7.4.1" 38 | poetry-plugin-export = "^1.9.0" 39 | hypercorn = "^0.17.3" 40 | 41 | [build-system] 42 | requires = ["poetry>=0.12"] 43 | build-backend = "poetry.masonry.api" 44 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | log_cli = true 3 | ;log_cli_level = DEBUG 4 | -------------------------------------------------------------------------------- /raw_assets/email icon.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PlaidWeb/Authl/9d68cb6deb0626ec9485b116018cf1f238c3416e/raw_assets/email icon.ai -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license_file = LICENSE 3 | 4 | [flake8] 5 | max-line-length = 100 6 | ignore = E722, W503, W504 7 | exclude = .git, build, __pycache__ 8 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | poetry install 4 | FLASK_DEBUG=1 FLASK_APP=test_app.py poetry run flask run "$@" 5 | 6 | -------------------------------------------------------------------------------- /test_app.py: -------------------------------------------------------------------------------- 1 | """ Basic test app for Authl, implemented using Flask. 2 | 3 | Run it locally with: 4 | 5 | poetry install --dev 6 | FLASK_APP=test poetry run flask run 7 | 8 | """ 9 | 10 | import logging 11 | 12 | import flask 13 | 14 | import authl.flask 15 | 16 | logging.basicConfig(level=logging.DEBUG) 17 | LOGGER = logging.getLogger(__name__) 18 | 19 | app = flask.Flask('authl-test') 20 | app.secret_key = "testing isn't secret" 21 | 22 | 23 | def on_login(verified): 24 | LOGGER.info("Got login: %s", verified) 25 | flask.session['profile'] = verified.profile 26 | if verified.identity == 'test:override': 27 | return "This user gets a special override" 28 | 29 | 30 | authl.flask.setup( 31 | app, 32 | { 33 | 'EMAIL_SENDMAIL': lambda message: print(message.get_payload(decode=True).decode('utf-8')), 34 | 'EMAIL_FROM': 'nobody@example.com', 35 | 'EMAIL_SUBJECT': 'Log in to authl test', 36 | 'EMAIL_CHECK_MESSAGE': 'Use the link printed to the test console', 37 | 'EMAIL_EXPIRE_TIME': 60, 38 | 39 | 'INDIEAUTH_CLIENT_ID': authl.flask.client_id, 40 | 'INDIEAUTH_PENDING_TTL': 10, 41 | 42 | 'TEST_ENABLED': True, 43 | 44 | 'FEDIVERSE_NAME': 'authl testing', 45 | 'FEDIVERSE_HOMEPAGE': 'https://github.com/PlaidWeb/Authl', 46 | }, 47 | tester_path='/check_url', 48 | on_verified=on_login 49 | ) 50 | 51 | 52 | @app.route('/logout/') 53 | @app.route('/logout/') 54 | def logout(redir=''): 55 | """ Log out from the thing """ 56 | LOGGER.info("Logging out") 57 | LOGGER.info("Redir: %s", redir) 58 | LOGGER.info("Request path: %s", flask.request.path) 59 | 60 | flask.session.clear() 61 | return flask.redirect('/' + redir) 62 | 63 | 64 | @app.route('/') 65 | @app.route('/page') 66 | @app.route('/page/') 67 | @app.route('/page/') 68 | def index(page=''): 69 | """ Just displays a very basic login form """ 70 | LOGGER.info("Session: %s", flask.session) 71 | LOGGER.info("Request path: %s", flask.request.path) 72 | 73 | if 'me' in flask.session: 74 | return flask.render_template_string( 75 | r""" 76 |

Hello {{profile.name or me}}. 77 | Want to log out?

78 | 79 | {% if profile %} 80 |

Profile data:

81 |
    82 | {% for k,v in profile.items() %} 83 |
  • {{k}}: {{v}}
  • 84 | {% endfor %} 85 |
86 | {% endif %}""", 87 | me=flask.session['me'], 88 | profile=flask.session.get('profile') 89 | ) 90 | 91 | return 'You are not logged in. Want to log in?'.format( 92 | login=flask.url_for('authl.login', redir=flask.request.path[1:])) 93 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ Common functions for the test routines """ 2 | # pylint:disable=missing-docstring 3 | 4 | import logging 5 | 6 | from authl import handlers 7 | 8 | logging.basicConfig(level=logging.DEBUG) 9 | 10 | 11 | class TestHandler(handlers.Handler): 12 | """ null test handler that does nothing """ 13 | 14 | @property 15 | def cb_id(self): 16 | return "nothing" 17 | 18 | def initiate_auth(self, id_url, callback_uri, redir): 19 | raise ValueError("not implemented") 20 | 21 | def check_callback(self, url, get, data): 22 | raise ValueError("not implemented") 23 | 24 | @property 25 | def service_name(self): 26 | return "Nothing" 27 | 28 | @property 29 | def url_schemes(self): 30 | return [] 31 | 32 | @property 33 | def description(self): 34 | return "Does nothing" 35 | -------------------------------------------------------------------------------- /tests/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | """ test functions for the handler tests """ 2 | 3 | import urllib.parse 4 | 5 | 6 | def parse_args(url): 7 | """ parse query parameters from a callback URL """ 8 | url = urllib.parse.urlparse(url) 9 | params = urllib.parse.parse_qs(url.query) 10 | return {key: val[0] for key, val in params.items()} 11 | -------------------------------------------------------------------------------- /tests/handlers/test_emailaddr.py: -------------------------------------------------------------------------------- 1 | """ Tests for email login """ 2 | # pylint:disable=missing-docstring 3 | 4 | import logging 5 | 6 | from authl import disposition, tokens 7 | from authl.handlers import email_addr 8 | 9 | from . import parse_args 10 | 11 | LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | def test_basics(): 15 | handler = email_addr.EmailAddress(None, None, tokens.DictStore()) 16 | assert handler.service_name == 'Email' 17 | assert handler.url_schemes 18 | assert 'email' in handler.description 19 | assert handler.cb_id == 'e' 20 | assert handler.logo_html[0][1] == 'Email' 21 | 22 | assert handler.handles_url('foo@bar.baz') == 'mailto:foo@bar.baz' 23 | assert handler.handles_url('mailto:foo@bar.baz') == 'mailto:foo@bar.baz' 24 | 25 | # email addresses must be well-formed 26 | assert not handler.handles_url('mailto:foobar.baz') 27 | 28 | # don't support other schemas 29 | assert not handler.handles_url('email:foo@bar.baz') 30 | assert not handler.handles_url('@foo@bar.baz') 31 | assert not handler.handles_url('https://example.com/') 32 | 33 | # handle leading/trailing spaces correctly 34 | assert handler.handles_url(' foo@bar.baz') == 'mailto:foo@bar.baz' 35 | assert handler.handles_url('mailto: foo@bar.baz') == 'mailto:foo@bar.baz' 36 | assert handler.handles_url('mailto:foo@bar.baz ') == 'mailto:foo@bar.baz' 37 | 38 | # but don't allow embedded spaces 39 | assert not handler.handles_url(' foo @bar.baz') 40 | 41 | # email address must be valid 42 | assert not handler.handles_url(' asdf[]@poiu_foo.baz!') 43 | 44 | # don't allow bang-paths 45 | assert not handler.handles_url('bang!path!is!fun!bob') 46 | assert not handler.handles_url('bang.com!path!is!fun!bob') 47 | assert not handler.handles_url('bang!path!is!fun!bob@example.com') 48 | 49 | # strip out non-email-address components 50 | assert handler.handles_url('mailto:foo@example.com?subject=pwned') == 'mailto:foo@example.com' 51 | 52 | # handle case correctly 53 | assert handler.handles_url('MailtO:Foo@Example.Com') == 'mailto:foo@example.com' 54 | 55 | 56 | def test_success(): 57 | store = {} 58 | 59 | def do_callback(message): 60 | assert message['To'] == 'user@example.com' 61 | 62 | url = message.get_payload().strip() 63 | args = parse_args(url) 64 | 65 | assert url.startswith('http://example/cb/') 66 | 67 | result = handler.check_callback(url, parse_args(url), {}) 68 | LOGGER.info('check_callback(%s,%s): %s', url, args, result) 69 | 70 | assert isinstance(result, disposition.NeedsPost) 71 | 72 | result = handler.check_callback(result.url, {}, result.data) 73 | 74 | assert isinstance(result, disposition.Verified) 75 | 76 | store['result'] = result 77 | 78 | store['is_done'] = result.identity 79 | 80 | handler = email_addr.EmailAddress(do_callback, 'some data', tokens.DictStore(store), 81 | email_template_text='{url}') 82 | 83 | result = handler.initiate_auth('mailto:user@example.com', 'http://example/cb/', '/redir') 84 | LOGGER.info('initiate_auth: %s', result) 85 | assert isinstance(result, disposition.Notify) 86 | assert result.cdata == 'some data' 87 | 88 | assert store['result'].identity == 'mailto:user@example.com' 89 | assert store['result'].redir == '/redir' 90 | 91 | 92 | def test_failures(mocker): 93 | store = {} 94 | pending = {} 95 | 96 | def accept(message): 97 | url = message.get_payload().strip() 98 | pending[message['To']] = url 99 | 100 | handler = email_addr.EmailAddress(accept, 101 | 'some data', tokens.DictStore(store), 102 | expires_time=10, 103 | email_template_text='{url}') 104 | 105 | # must be well-formed mailto: URL 106 | for malformed in ('foo@bar.baz', 'http://foo.bar/', 'mailto:blahblahblah'): 107 | assert 'Malformed' in str(handler.initiate_auth(malformed, 108 | 'http://example.cb/', 109 | '/malformed')) 110 | 111 | # check for missing or invalid tokens 112 | assert 'Missing token' in str(handler.check_callback('foo', {}, {})) 113 | assert 'Invalid token' in str(handler.check_callback('foo', {}, {'t': 'bogus'})) 114 | 115 | def initiate(addr, redir): 116 | result = handler.initiate_auth('mailto:' + addr, 'http://example/', redir) 117 | assert isinstance(result, disposition.Notify) 118 | assert result.cdata == 'some data' 119 | 120 | def check_pending(addr): 121 | url = pending[addr] 122 | result = handler.check_callback(url, parse_args(url), {}) 123 | if isinstance(result, disposition.NeedsPost): 124 | result = handler.check_callback(result.url, {}, result.data) 125 | return result 126 | 127 | # check for timeout failure 128 | mock_time = mocker.patch('time.time') 129 | mock_time.return_value = 30 130 | 131 | assert len(store) == 0 132 | initiate('timeout@example.com', '/timeout') 133 | assert len(store) == 1 134 | 135 | mock_time.return_value = 20000 136 | 137 | result = check_pending('timeout@example.com') 138 | assert isinstance(result, disposition.Error) 139 | assert 'timed out' in result.message 140 | assert result.redir == '/timeout' 141 | assert len(store) == 0 142 | 143 | # check for replay attacks 144 | assert len(store) == 0 145 | initiate('replay@example.com', '/replay') 146 | assert len(store) == 1 147 | result1 = check_pending('replay@example.com') 148 | result2 = check_pending('replay@example.com') 149 | assert len(store) == 0 150 | 151 | assert isinstance(result1, disposition.Verified) 152 | assert result1.identity == 'mailto:replay@example.com' 153 | assert result1.redir == '/replay' 154 | assert isinstance(result2, disposition.Error) 155 | assert 'Invalid token' in str(result2) 156 | 157 | 158 | def test_connector(mocker): 159 | import ssl 160 | mock_smtp_ssl = mocker.patch('smtplib.SMTP_SSL') 161 | mock_ssl = mocker.patch('ssl.SSLContext') 162 | 163 | conn = mocker.MagicMock() 164 | mock_smtp_ssl.return_value = conn 165 | 166 | connector = email_addr.smtplib_connector('localhost', 25, 167 | 'test', 'poiufojar', 168 | use_ssl=True) 169 | connector() 170 | 171 | mock_smtp_ssl.assert_called_with('localhost', 25) 172 | mock_ssl.assert_called_with(ssl.PROTOCOL_TLS_CLIENT) 173 | conn.ehlo.assert_called() 174 | conn.starttls.assert_called() 175 | conn.login.assert_called_with('test', 'poiufojar') 176 | 177 | 178 | def test_simple_sendmail(mocker): 179 | connector = mocker.MagicMock(name='connector') 180 | 181 | import email 182 | message = email.message.EmailMessage() 183 | message['To'] = 'recipient@bob.example' 184 | message.set_payload('test body') 185 | 186 | sender = email_addr.simple_sendmail(connector, 'sender@bob.example', 'test subject') 187 | 188 | sender(message) 189 | connector.assert_called_once() 190 | 191 | with connector() as conn: 192 | conn.sendmail.assert_called_with('sender@bob.example', 193 | 'recipient@bob.example', 194 | str(message)) 195 | assert message['From'] == 'sender@bob.example' 196 | assert message['Subject'] == 'test subject' 197 | 198 | 199 | def test_from_config(mocker): 200 | store = {} 201 | mock_open = mocker.patch('builtins.open', mocker.mock_open(read_data='template')) 202 | mock_smtp = mocker.patch('smtplib.SMTP') 203 | conn = mocker.MagicMock() 204 | mock_smtp.return_value = conn 205 | 206 | handler = email_addr.from_config({ 207 | 'EMAIL_FROM': 'sender@example.com', 208 | 'EMAIL_SUBJECT': 'test subject', 209 | 'EMAIL_CHECK_MESSAGE': 'check yr email', 210 | 'EMAIL_TEMPLATE_FILE': 'template.txt', 211 | 'EMAIL_EXPIRE_TIME': 37, 212 | 'SMTP_HOST': 'smtp.example.com', 213 | 'SMTP_PORT': 587, 214 | 'SMTP_USE_SSL': False, 215 | }, tokens.DictStore(store)) 216 | 217 | mock_open.assert_called_with('template.txt', encoding='utf-8') 218 | res = handler.initiate_auth('mailto:alice@bob.example', 'http://cb/', '/redir') 219 | assert res.cdata['message'] == 'check yr email' 220 | 221 | assert len(store) == 1 222 | mock_smtp.assert_called_with('smtp.example.com', 587) 223 | 224 | 225 | def test_please_wait(mocker): 226 | token_store = tokens.DictStore() 227 | pending = {} 228 | mock_send = mocker.MagicMock() 229 | handler = email_addr.EmailAddress(mock_send, "this is data", token_store, 230 | expires_time=60, 231 | pending_storage=pending) 232 | 233 | mock_time = mocker.patch('time.time') 234 | assert mock_send.call_count == 0 235 | mock_time.return_value = 10 236 | 237 | # First auth should call mock_send 238 | handler.initiate_auth('mailto:foo@bar.com', 'http://example/', 'blop') 239 | assert mock_send.call_count == 1 240 | assert 'foo@bar.com' in pending 241 | token_value = pending['foo@bar.com'] 242 | 243 | # Second auth should not 244 | handler.initiate_auth('mailto:foo@bar.com', 'http://example/', 'blop') 245 | assert mock_send.call_count == 1 246 | assert 'foo@bar.com' in pending 247 | assert token_value == pending['foo@bar.com'] 248 | 249 | # Using the link should remove the pending item 250 | handler.check_callback('http://example/', {}, {'t': pending['foo@bar.com']}) 251 | assert 'foo@bar.com' not in pending 252 | 253 | # Next auth should call mock_send again 254 | handler.initiate_auth('mailto:foo@bar.com', 'http://example/', 'blop') 255 | assert mock_send.call_count == 2 256 | assert 'foo@bar.com' in pending 257 | assert token_value != pending['foo@bar.com'] 258 | token_value = pending['foo@bar.com'] 259 | 260 | # Timing out the token should cause it to send again 261 | mock_time.return_value = 1000 262 | handler.initiate_auth('mailto:foo@bar.com', 'http://example/', 'blop') 263 | assert mock_send.call_count == 3 264 | assert 'foo@bar.com' in pending 265 | assert token_value != pending['foo@bar.com'] 266 | token_value = pending['foo@bar.com'] 267 | 268 | # And anything else that removes the token from the token_store should as well 269 | token_store.remove(pending['foo@bar.com']) 270 | handler.initiate_auth('mailto:foo@bar.com', 'http://example/', 'blop') 271 | assert mock_send.call_count == 4 272 | assert token_value != pending['foo@bar.com'] 273 | token_value = pending['foo@bar.com'] 274 | -------------------------------------------------------------------------------- /tests/handlers/test_fediverse.py: -------------------------------------------------------------------------------- 1 | """ Tests of the Fediverse handler """ 2 | # pylint:disable=missing-docstring 3 | 4 | import json 5 | import logging 6 | import urllib.parse 7 | 8 | import mastodon 9 | 10 | from authl import disposition, tokens 11 | from authl.handlers import fediverse 12 | 13 | from . import parse_args 14 | 15 | LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | def test_basics(): 19 | handler = fediverse.from_config({ 20 | 'FEDIVERSE_NAME': 'test', 21 | 'FEDIVERSE_HOMEPAGE': 'http://foo.bar/', 22 | }, tokens.DictStore()) 23 | assert handler.service_name 24 | assert handler.url_schemes 25 | assert handler.description 26 | assert handler.cb_id 27 | assert handler.logo_html 28 | 29 | 30 | def test_handles_url(requests_mock): 31 | handler = fediverse.Fediverse('test', tokens.DictStore(), homepage='http://foo.example/') 32 | 33 | requests_mock.get('https://mastodon.example/api/v1/instance', 34 | text=json.dumps({ 35 | 'uri': 'foo', 36 | 'version': '2.5.1', 37 | 'urls': 'foo.bar' 38 | })) 39 | 40 | requests_mock.get('https://not-mastodon.example/api/v1/instance', 41 | text=json.dumps({ 42 | 'moo': 'cow' 43 | })) 44 | 45 | requests_mock.get('https://also-not.example/api/v1/instance', status_code=404) 46 | 47 | assert handler.handles_url('https://mastodon.example/@fluffy') 48 | assert handler.handles_url('https://mastodon.example/') 49 | assert handler.handles_url('mastodon.example') 50 | assert not handler.handles_url('https://not-mastodon.example/@fluffy') 51 | assert not handler.handles_url('https://not-mastodon.example/') 52 | assert not handler.handles_url('https://blah.example/') 53 | assert not handler.handles_url('https://also-not.example/') 54 | 55 | 56 | def mock_auth_request_url(**args): 57 | def mock_url(redirect_uris, scopes, state): 58 | # pylint:disable=unused-argument 59 | return f"https://cb/?{urllib.parse.urlencode({'state':state, **args})}" 60 | return mock_url 61 | 62 | 63 | def test_auth_success(mocker, requests_mock): 64 | store = tokens.DictStore() 65 | handler = fediverse.Fediverse('test', store, homepage='http://foo.example/') 66 | mock_mastodon = mocker.patch('mastodon.Mastodon') 67 | mock_mastodon.create_app.return_value = ('the id', 'the secret') 68 | 69 | mock_mastodon().auth_request_url.side_effect = mock_auth_request_url(code=12345) 70 | mock_mastodon().log_in.return_value = 'some_auth_token' 71 | mock_mastodon().me.return_value = { 72 | 'url': 'https://mastodon.example/@moo', 73 | 'display_name': 'moo friend', 74 | 'avatar_static': 'https://placekitten.com/1280/1024', 75 | 'source': { 76 | 'note': 'a cow', 77 | 'fields': [ 78 | {'name': 'homepage', 'value': 'https://moo.example'}, 79 | {'name': 'my pronouns', 'value': 'moo/moo'} 80 | ] 81 | } 82 | } 83 | 84 | requests_mock.get('https://mastodon.example/api/v1/instance', 85 | text=json.dumps({ 86 | 'uri': 'foo', 87 | 'version': '2.5.1', 88 | 'urls': 'foo.bar' 89 | })) 90 | 91 | requests_mock.post('https://mastodon.example/oauth/revoke', text='ok') 92 | 93 | result = handler.initiate_auth('mastodon.example', 'https://cb', 'qwerpoiu') 94 | assert isinstance(result, disposition.Redirect) 95 | mock_mastodon().auth_request_url.assert_called_with( 96 | redirect_uris='https://cb', scopes=['profile'], 97 | state=mocker.ANY) 98 | 99 | result = handler.check_callback(result.url, parse_args(result.url), {}) 100 | assert isinstance(result, disposition.Verified) 101 | assert result.identity == 'https://mastodon.example/@moo' 102 | assert result.redir == 'qwerpoiu' 103 | assert result.profile == { # pylint:disable=no-member 104 | # https://github.com/PyCQA/pylint/issues/4693 105 | 'name': 'moo friend', 106 | 'bio': 'a cow', 107 | 'avatar': 'https://placekitten.com/1280/1024', 108 | 'homepage': 'https://moo.example', 109 | 'pronouns': 'moo/moo' 110 | } 111 | 112 | 113 | def test_auth_failures(requests_mock, mocker): 114 | # pylint:disable=too-many-statements 115 | store = tokens.DictStore({}) 116 | handler = fediverse.Fediverse('test', store, homepage='http://foo.example/') 117 | mock_mastodon = mocker.patch('mastodon.Mastodon') 118 | 119 | # nonexistent instance 120 | result = handler.initiate_auth('fail.example', 'https://cb', 'qwerpoiu') 121 | assert isinstance(result, disposition.Error) 122 | assert 'Could not register client' in result.message 123 | 124 | # not a mastodon instance 125 | requests_mock.get('https://fail.example/api/v1/instance', text="'lolwut'") 126 | result = handler.initiate_auth('fail.example', 'https://cb', 'qwerpoiu') 127 | assert isinstance(result, disposition.Error) 128 | assert 'Could not register client' in result.message 129 | 130 | # okay now it's an instance 131 | requests_mock.get('https://fail.example/api/v1/instance', 132 | text=json.dumps({ 133 | 'uri': 'foo', 134 | 'version': '2.5.1', 135 | 'urls': 'foo.bar' 136 | })) 137 | mock_mastodon.create_app.return_value = ('the id', 'the secret') 138 | 139 | # missing auth code 140 | mock_mastodon().auth_request_url.side_effect = mock_auth_request_url(foo='bar') 141 | result = handler.initiate_auth('fail.example', 'https://cb', 'qwerpoiu') 142 | assert isinstance(result, disposition.Redirect) 143 | result = handler.check_callback(result.url, parse_args(result.url), {}) 144 | assert isinstance(result, disposition.Error) 145 | assert "Missing 'code'" in result.message 146 | 147 | # Login was aborted 148 | mock_mastodon().auth_request_url.side_effect = mock_auth_request_url(code=12345, error='bloop') 149 | result = handler.initiate_auth('fail.example', 'https://cb', 'qwerpoiu') 150 | assert isinstance(result, disposition.Redirect) 151 | result = handler.check_callback(result.url, parse_args(result.url), {}) 152 | assert isinstance(result, disposition.Error) 153 | assert "Error signing into instance" in result.message 154 | 155 | mock_mastodon().auth_request_url.side_effect = mock_auth_request_url(code=12345) 156 | 157 | # login failed for some other reason 158 | mock_mastodon().log_in.side_effect = mastodon.MastodonRatelimitError("stop it") 159 | result = handler.initiate_auth('fail.example', 'https://cb', 'qwerpoiu') 160 | assert isinstance(result, disposition.Redirect) 161 | result = handler.check_callback(result.url, parse_args(result.url), {}) 162 | assert isinstance(result, disposition.Error) 163 | assert "Error signing into instance" in result.message 164 | 165 | mock_mastodon().log_in.side_effect = None 166 | mock_mastodon().log_in.return_value = 'some auth code' 167 | 168 | # login expired 169 | mock_time = mocker.patch('time.time') 170 | mock_time.return_value = 100 171 | result = handler.initiate_auth('fail.example', 'https://cb', 'qwerpoiu') 172 | assert isinstance(result, disposition.Redirect) 173 | 174 | mock_time.return_value = 86400 175 | result = handler.check_callback(result.url, parse_args(result.url), {}) 176 | assert isinstance(result, disposition.Error) 177 | assert 'Login timed out' in result.message 178 | 179 | # broken profile 180 | mock_mastodon().me.return_value = {} 181 | result = handler.initiate_auth('fail.example', 'https://cb', 'qwerpoiu') 182 | assert isinstance(result, disposition.Redirect) 183 | result = handler.check_callback(result.url, parse_args(result.url), {}) 184 | assert isinstance(result, disposition.Error) 185 | assert 'Missing user profile' in result.message 186 | 187 | mock_mastodon().me.return_value = { 188 | 'url': 'https://fail.example/@larry', 189 | 'source': ['ha ha ha', 'i break you'] 190 | } 191 | result = handler.initiate_auth('fail.example', 'https://cb', 'qwerpoiu') 192 | assert isinstance(result, disposition.Redirect) 193 | result = handler.check_callback(result.url, parse_args(result.url), {}) 194 | assert isinstance(result, disposition.Error) 195 | assert 'Malformed user profile' in result.message 196 | 197 | 198 | def test_attack_mitigations(requests_mock, mocker): 199 | store = tokens.DictStore() 200 | handler = fediverse.Fediverse('test', store, homepage='http://foo.example/') 201 | mock_mastodon = mocker.patch('mastodon.Mastodon') 202 | 203 | mock_mastodon.create_app.return_value = ('the id', 'the secret') 204 | 205 | mock_mastodon().auth_request_url.side_effect = mock_auth_request_url(code=12345) 206 | mock_mastodon().log_in.return_value = 'some_auth_token' 207 | 208 | requests_mock.get('https://mastodon.example/api/v1/instance', 209 | text=json.dumps({ 210 | 'uri': 'foo', 211 | 'version': '2.5.1', 212 | 'urls': 'foo.bar' 213 | })) 214 | 215 | # domain hijack 216 | mock_mastodon().me.return_value = { 217 | 'url': 'https://hijack.example/@moo', 218 | } 219 | result = handler.initiate_auth('mastodon.example', 'https://cb', 'qwerpoiu') 220 | assert isinstance(result, disposition.Redirect) 221 | result = handler.check_callback(result.url, parse_args(result.url), {}) 222 | assert isinstance(result, disposition.Error) 223 | assert 'Domains do not match' in result.message 224 | 225 | # attempted replay attack 226 | mock_mastodon().me.return_value = { 227 | 'url': 'https://mastodon.example/@moo', 228 | } 229 | result = handler.initiate_auth('mastodon.example', 'https://cb', 'qwerpoiu') 230 | assert isinstance(result, disposition.Redirect) 231 | args = parse_args(result.url) 232 | result = handler.check_callback(result.url, args, {}) 233 | assert isinstance(result, disposition.Verified) 234 | assert result.identity == 'https://mastodon.example/@moo' 235 | result = handler.check_callback('https://cb', args, {}) 236 | assert isinstance(result, disposition.Error) 237 | assert 'Invalid transaction' in result.message 238 | -------------------------------------------------------------------------------- /tests/handlers/test_test_handler.py: -------------------------------------------------------------------------------- 1 | """ Tests for the loopback handler """ 2 | # pylint:disable=missing-function-docstring 3 | 4 | from authl import disposition 5 | from authl.handlers.test_handler import TestHandler 6 | 7 | 8 | def test_handling(): 9 | handler = TestHandler() 10 | assert handler.handles_url('test:foo') 11 | assert handler.handles_url('test:error') 12 | assert not handler.handles_url('https://example.com') 13 | 14 | 15 | def test_success(): 16 | handler = TestHandler() 17 | 18 | positive = handler.initiate_auth('test:admin', 'https://example.com/bar', 'target') 19 | assert isinstance(positive, disposition.Verified) 20 | assert positive.identity == 'test:admin' 21 | assert positive.redir == 'target' 22 | assert positive.profile == {} 23 | 24 | 25 | def test_failure(): 26 | handler = TestHandler() 27 | 28 | negative = handler.initiate_auth('test:error', 'https://example.com/bar', 'target') 29 | assert isinstance(negative, disposition.Error) 30 | assert "Error identity" in negative.message 31 | assert negative.redir == 'target' 32 | 33 | 34 | def test_callback(): 35 | handler = TestHandler() 36 | 37 | assert isinstance(handler.check_callback('foo', {}, {}), disposition.Error) 38 | 39 | 40 | def test_misc(): 41 | handler = TestHandler() 42 | assert handler.cb_id 43 | assert handler.service_name == 'Loopback' 44 | assert handler.url_schemes 45 | assert handler.description 46 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | """ tests of the base handler """ 2 | # pylint:disable=missing-docstring 3 | 4 | from . import TestHandler 5 | 6 | 7 | def test_version(): 8 | from authl import __version__ 9 | assert __version__.__version__ 10 | 11 | 12 | def test_base_handler(): 13 | handler = TestHandler() 14 | assert handler.handles_url('foo') is None 15 | assert handler.handles_page('foo', {}, {}, {}) is False 16 | -------------------------------------------------------------------------------- /tests/test_disposition.py: -------------------------------------------------------------------------------- 1 | """ Disposition tests, such as they are """ 2 | # pylint:disable=missing-docstring 3 | 4 | from authl import disposition 5 | 6 | 7 | def test_dispositions(): 8 | assert 'foo' in str(disposition.Redirect('foo')) 9 | assert 'foo' in str(disposition.Verified('foo', None)) 10 | assert 'foo' in str(disposition.Notify('foo')) 11 | assert 'foo' in str(disposition.Error('foo', None)) 12 | assert 'foo' in str(disposition.NeedsPost('', 'foo', {})) 13 | -------------------------------------------------------------------------------- /tests/test_flask_wrapper.py: -------------------------------------------------------------------------------- 1 | """ Tests for the Flask wrapper """ 2 | # pylint:disable=missing-docstring,duplicate-code 3 | 4 | import json 5 | import logging 6 | 7 | import flask 8 | from bs4 import BeautifulSoup 9 | 10 | import authl.flask 11 | from authl import disposition 12 | 13 | from . import TestHandler 14 | 15 | LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | def test_config(): 19 | app = flask.Flask(__name__) 20 | app.secret_key = 'qwer' 21 | authl.flask.setup(app, {}, login_path='/asdf', login_name='poiu') 22 | 23 | with app.test_request_context('http://example.site'): 24 | assert (flask.url_for('poiu', me='example.com', redir='qwer') == 25 | '/asdf/qwer?me=example.com') 26 | 27 | 28 | def test_url_tester(): 29 | app = flask.Flask(__name__) 30 | app.secret_key = 'qwer' 31 | authl.flask.setup(app, {'TEST_ENABLED': True}, tester_path='/test') 32 | 33 | with app.test_request_context('http://example.site/'): 34 | test_url = flask.url_for('authl.test') 35 | 36 | with app.test_client() as client: 37 | assert json.loads(client.get(test_url).data) is None 38 | assert json.loads(client.get(test_url + '?url=nope').data) is None 39 | assert json.loads(client.get(test_url + '?url=test:foo').data) == { 40 | "name": "Loopback", 41 | "url": "test:foo" 42 | } 43 | 44 | 45 | def test_dispositions_and_hooks(mocker): 46 | 47 | class InvalidDisposition(disposition.Disposition): 48 | # pylint:disable=too-few-public-methods 49 | pass 50 | 51 | class Dispositioner(TestHandler): 52 | def handles_url(self, url): 53 | return url 54 | 55 | @property 56 | def cb_id(self): 57 | return 'hi' 58 | 59 | def initiate_auth(self, id_url, callback_uri, redir): 60 | if id_url == 'redirect': 61 | return disposition.Redirect('http://example.com/') 62 | if id_url == 'verify': 63 | return disposition.Verified('verified://', redir) 64 | if id_url == 'notify': 65 | return disposition.Notify(redir) 66 | if id_url == 'error': 67 | return disposition.Error('something', redir) 68 | if id_url == 'posty': 69 | return disposition.NeedsPost('http://foo.bar/', 'foo', {'val': 123}) 70 | if id_url == 'invalid': 71 | return InvalidDisposition() 72 | raise ValueError("nope") 73 | 74 | notify_render = mocker.Mock(return_value="notified") 75 | login_render = mocker.Mock(return_value="login form") 76 | on_verified = mocker.Mock(return_value="verified") 77 | post_render = mocker.Mock(return_value="postform") 78 | 79 | app = flask.Flask(__name__) 80 | app.secret_key = __name__ 81 | 82 | instance = authl.flask.setup(app, {}, 83 | session_auth_name=None, 84 | notify_render_func=notify_render, 85 | login_render_func=login_render, 86 | post_form_render_func=post_render, 87 | on_verified=on_verified) 88 | instance.add_handler(Dispositioner()) 89 | 90 | with app.test_request_context('http://example.site/'): 91 | login_url = flask.url_for('authl.login', _external=True) 92 | 93 | with app.test_client() as client: 94 | assert client.get(login_url + '?me=redirect').headers['Location'] == 'http://example.com/' 95 | 96 | with app.test_client() as client: 97 | assert client.get( 98 | login_url + '/blob?me=verify').data == b'verified' 99 | 100 | with app.test_client() as client: 101 | assert client.get(login_url + '/bobble?me=notify').data == b'notified' 102 | notify_render.assert_called_with(cdata='/bobble') 103 | 104 | with app.test_client() as client: 105 | assert client.get(login_url + '/chomp?me=error').data == b"login form" 106 | login_render.assert_called_with(login_url=flask.url_for('authl.login', redir='chomp'), 107 | test_url=None, 108 | auth=instance, 109 | id_url='error', 110 | error='something', 111 | redir='/chomp' 112 | ) 113 | 114 | with app.test_client() as client: 115 | assert client.get(login_url + '/chomp?me=posty').data == b"postform" 116 | post_render.assert_called_with(url="http://foo.bar/", 117 | message="foo", 118 | data={'val': 123} 119 | ) 120 | 121 | with app.test_client() as client: 122 | assert client.get(login_url + '/chomp?me=invalid').status_code == 500 123 | 124 | 125 | def test_login_rendering(): 126 | app = flask.Flask(__name__) 127 | app.secret_key = 'qwer' 128 | authl.flask.setup(app, {}, stylesheet="/what.css") 129 | with app.test_request_context('https://foo.bar/'): 130 | login_url = flask.url_for('authl.login') 131 | 132 | with app.test_client() as client: 133 | soup = BeautifulSoup(client.get(login_url).data, 'html.parser') 134 | assert soup.find('link', rel='stylesheet', href='/what.css') 135 | 136 | with app.test_client() as client: 137 | assert client.get(login_url + '?asset=css').headers['Content-Type'] == 'text/css' 138 | assert client.get(login_url + '?asset=nonsense').status_code == 404 139 | 140 | 141 | def test_default_hooks(mocker): 142 | sendmail = mocker.Mock(return_value=None) 143 | 144 | app = flask.Flask(__name__) 145 | app.secret_key = __name__ 146 | 147 | authl.flask.setup(app, { 148 | 'TEST_ENABLED': True, 149 | 'EMAIL_SENDMAIL': sendmail, 150 | 'EMAIL_CHECK_MESSAGE': 'check yr email'}) 151 | 152 | with app.test_client() as client: 153 | soup = BeautifulSoup(client.get('/login').data, 'html.parser') 154 | assert soup.find('input', type='url') 155 | 156 | with app.test_client() as client: 157 | soup = BeautifulSoup(client.get('/login?me=test:error').data, 'html.parser') 158 | assert soup.find('div', {'class': 'error'}) 159 | 160 | with app.test_client() as client: 161 | soup = BeautifulSoup(client.get('/login?me=unknown://').data, 'html.parser') 162 | error = soup.find('div', {'class': 'error'}) 163 | assert error.text.strip() == 'Unknown authentication method' 164 | 165 | with app.test_client() as client: 166 | assert client.get('/login?me=test:success') 167 | assert flask.session['me'] == 'test:success' 168 | 169 | with app.test_client() as client: 170 | soup = BeautifulSoup(client.get('/login?me=mailto:foo@bar').data, 'html.parser') 171 | sendmail.assert_called() 172 | message = soup.find('div', {'id': 'notify'}) 173 | assert message.text.strip() == 'check yr email' 174 | 175 | 176 | def test_callbacks(): 177 | class CallbackHandler(TestHandler): 178 | @property 179 | def cb_id(self): 180 | return 'foo' 181 | 182 | def check_callback(self, url, get, data): 183 | LOGGER.info('url=%s get=%s data=%s', url, get, data) 184 | if 'me' in get: 185 | return disposition.Verified('get://' + get['me'], None) 186 | if 'me' in data: 187 | return disposition.Verified('data://' + data['me'], None) 188 | return disposition.Error('nope', None) 189 | 190 | app = flask.Flask(__name__) 191 | app.secret_key = __name__ 192 | instance = authl.flask.setup(app, {}) 193 | instance.add_handler(CallbackHandler()) 194 | 195 | with app.test_client() as client: 196 | assert client.get('/cb/foo?me=yumyan') 197 | assert flask.session['me'] == 'get://yumyan' 198 | with app.test_client() as client: 199 | assert client.post('/cb/foo', data={'me': 'hammerpaw'}) 200 | assert flask.session['me'] == 'data://hammerpaw' 201 | with app.test_client() as client: 202 | soup = BeautifulSoup(client.get('/cb/foo').data, 'html.parser') 203 | error = soup.find('div', {'class': 'error'}) 204 | assert error.text.strip() == 'nope' 205 | with app.test_client() as client: 206 | soup = BeautifulSoup(client.get('/cb/bar').data, 'html.parser') 207 | error = soup.find('div', {'class': 'error'}) 208 | assert error.text.strip() == 'Invalid handler' 209 | 210 | 211 | def test_client_id(): 212 | app = flask.Flask(__name__) 213 | app.secret_key = 'qwer' 214 | authl.flask.setup(app, {}) 215 | with app.test_request_context('https://foo.bar/baz/'): 216 | assert authl.flask.client_id() == 'https://foo.bar' 217 | 218 | 219 | def test_app_render_hook(): 220 | app = flask.Flask(__name__) 221 | app.secret_key = 'qwer' 222 | aflask = authl.flask.AuthlFlask(app, {'TEST_ENABLED': True}, 223 | login_render_func=lambda **_: ('please login', 401)) 224 | 225 | @app.route('/') 226 | def index(): # pylint:disable=unused-variable 227 | if 'me' not in flask.session: 228 | return aflask.render_login_form('/') 229 | return f'hello {flask.session["me"]}' 230 | 231 | with app.test_client() as client: 232 | response = client.get('/') 233 | assert response.status_code == 401 234 | assert response.data == b'please login' 235 | response = client.get('/login') 236 | assert response.status_code == 401 237 | assert response.data == b'please login' 238 | response = client.get('/login/?me=test:poiu') 239 | assert flask.session['me'] == 'test:poiu' 240 | assert response.headers['Location'] == '/' 241 | response = client.get('/') 242 | assert response.data == b'hello test:poiu' 243 | 244 | 245 | def test_generic_login(): 246 | app = flask.Flask(__name__) 247 | app.secret_key = 'qwer' 248 | aflask = authl.flask.AuthlFlask(app, {}) 249 | 250 | @app.route('/') 251 | def index(): # pylint:disable=unused-variable 252 | if 'me' not in flask.session: 253 | return aflask.render_login_form('/') 254 | return f'hello {flask.session["me"]}' 255 | 256 | class GenericHandler(TestHandler): 257 | @property 258 | def cb_id(self): 259 | return 'foo' 260 | 261 | def handles_url(self, url): 262 | return url if 'login.example' in url else None 263 | 264 | @property 265 | def generic_url(self): 266 | return 'https://login.example/larry' 267 | 268 | @property 269 | def service_name(self): 270 | return "generic" 271 | 272 | @property 273 | def url_schemes(self): 274 | return [('https://login.example/%', 'example')] 275 | 276 | @property 277 | def logo_html(self): 278 | return [('foo', 'generic_logo')] 279 | 280 | def initiate_auth(self, id_url, callback_uri, redir): 281 | return disposition.Verified(id_url + '/auth', redir) 282 | 283 | aflask.authl.add_handler(GenericHandler()) 284 | 285 | with app.test_client() as client: 286 | response = client.get('/') 287 | soup = BeautifulSoup(response.data, 'html.parser') 288 | link = soup.find('a', title='generic_logo') 289 | response = client.get(link['href']) 290 | assert flask.session['me'] == 'https://login.example/larry/auth' 291 | assert response.headers['Location'] == '/' 292 | response = client.get('/') 293 | assert response.data == b'hello https://login.example/larry/auth' 294 | 295 | 296 | def test_session_override(): 297 | app = flask.Flask(__name__) 298 | app.secret_key = 'poiu' 299 | 300 | stash = {} 301 | 302 | def on_verified(disp): 303 | stash['v'] = disp 304 | 305 | authl.flask.setup(app, 306 | {'TEST_ENABLED': True}, 307 | session_auth_name=None, 308 | on_verified=on_verified) 309 | 310 | with app.test_client() as client: 311 | client.get('/login/?me=test:poiu') 312 | assert 'me' not in flask.session 313 | assert isinstance(stash['v'], disposition.Verified) 314 | assert stash['v'].identity == 'test:poiu' 315 | 316 | 317 | def test_post_form_render(): 318 | app = flask.Flask(__name__) 319 | app.secret_key = 'qwer' 320 | 321 | def no_login(*args, **kwargs): 322 | raise ValueError(f"Got spurious login func. args={args} kwargs={kwargs}") 323 | 324 | aflask = authl.flask.AuthlFlask(app, {}, login_render_func=no_login) 325 | 326 | class PostProxyHandler(TestHandler): 327 | @property 328 | def cb_id(self): 329 | return 'posty' 330 | 331 | def check_callback(self, url, get, data): 332 | return disposition.NeedsPost('fake-url', 'This is a message', {'proxied': get['bogus']}) 333 | 334 | aflask.authl.add_handler(PostProxyHandler()) 335 | 336 | with app.test_request_context('https://foo.bar/'): 337 | cb_url = flask.url_for('authl.callback', hid='posty', bogus='fancy') 338 | 339 | with app.test_client() as client: 340 | response = client.get(cb_url) 341 | soup = BeautifulSoup(response.data, 'html.parser') 342 | assert soup.find('form', method='POST', action='fake-url') 343 | assert 'This is a message' in soup.find('div', id='notify').text 344 | assert soup.find('input', {'type': 'hidden', 'name': 'proxied', 'value': 'fancy'}) 345 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | """ main instance tests """ 2 | 3 | import pytest 4 | 5 | import authl 6 | from authl import Authl, tokens 7 | 8 | from . import TestHandler 9 | 10 | 11 | class UrlHandler(TestHandler): 12 | """ a handler that just handles a specific URL """ 13 | 14 | def __init__(self, url, cid): 15 | self.url = url 16 | self.cid = cid 17 | 18 | @property 19 | def cb_id(self): 20 | return self.cid 21 | 22 | def handles_url(self, url): 23 | return url if url == self.url else None 24 | 25 | 26 | class LinkHandler(TestHandler): 27 | """ a handler that just handles a page with a particular link rel """ 28 | 29 | def __init__(self, rel, cid): 30 | self.rel = rel 31 | self.cid = cid 32 | 33 | @property 34 | def cb_id(self): 35 | return self.cid 36 | 37 | def handles_page(self, url, headers, content, links): 38 | return self.rel in links or content.find('link', rel=self.rel) 39 | 40 | 41 | def test_register_handler(): 42 | """ Test that both registration paths result in the same, correct result """ 43 | handler = TestHandler() 44 | 45 | instance_1 = Authl([handler]) 46 | assert list(instance_1.handlers) == [handler] 47 | 48 | instance_2 = Authl() 49 | instance_2.add_handler(handler) 50 | 51 | assert list(instance_1.handlers) == list(instance_2.handlers) 52 | 53 | with pytest.raises(ValueError): 54 | instance_2.add_handler(handler) 55 | 56 | 57 | def test_get_handler_for_url(requests_mock): 58 | """ Test that URL rules map correctly """ 59 | handler_1 = UrlHandler('test://foo', 'a') 60 | handler_2 = UrlHandler('test://bar', 'b') 61 | handler_3 = LinkHandler('moo', 'c') 62 | instance = Authl([handler_1, handler_2, handler_3]) 63 | 64 | requests_mock.get('http://moo/link', text='') 65 | requests_mock.get('http://moo/header', headers={'Link': '; rel="moo"'}) 66 | requests_mock.get('http://moo/redir', status_code=301, 67 | headers={'Location': 'http://moo/header'}) 68 | requests_mock.get('http://foo.bar', text="nothing here") 69 | 70 | assert instance.get_handler_for_url('test://foo') == (handler_1, 'a', 'test://foo') 71 | assert instance.get_handler_for_url('test://bar') == (handler_2, 'b', 'test://bar') 72 | assert instance.get_handler_for_url('test://baz') == (None, '', '') 73 | 74 | assert instance.get_handler_for_url(' test://foo ') == (handler_1, 'a', 'test://foo') 75 | 76 | assert instance.get_handler_for_url('http://moo/link') == \ 77 | (handler_3, 'c', 'http://moo/link') 78 | assert instance.get_handler_for_url('http://moo/header') == \ 79 | (handler_3, 'c', 'http://moo/header') 80 | assert instance.get_handler_for_url('http://moo/redir') == \ 81 | (handler_3, 'c', 'http://moo/header') 82 | 83 | assert instance.get_handler_for_url('http://foo.bar') == (None, '', '') 84 | 85 | assert instance.get_handler_for_url('') == (None, '', '') 86 | 87 | 88 | def test_from_config(mocker): 89 | """ Ensure the main from_config function calls the appropriate proxied ones """ 90 | test_config = { 91 | 'EMAIL_FROM': 'hello', 92 | 'FEDIVERSE_NAME': 'hello', 93 | 'INDIEAUTH_CLIENT_ID': 'hello', 94 | 'TEST_ENABLED': True 95 | } 96 | 97 | mocks = {} 98 | 99 | handler_modules = (('email_addr', tokens.DictStore), 100 | ('fediverse', tokens.DictStore), 101 | ('indieauth', tokens.DictStore), 102 | ) 103 | 104 | for name, _ in handler_modules: 105 | mocks[name] = mocker.patch(f'authl.handlers.{name}.from_config') 106 | 107 | mock_test_handler = mocker.patch('authl.handlers.test_handler.TestHandler') 108 | 109 | authl.from_config(test_config) 110 | 111 | for name, storage_type in handler_modules: 112 | mocks[name].assert_called_once() 113 | config, storage = mocks[name].call_args[0] 114 | assert config == test_config 115 | assert isinstance(storage, storage_type) 116 | 117 | mock_test_handler.assert_called_once() 118 | assert mock_test_handler.call_args == (()) 119 | 120 | 121 | def test_redir_url(requests_mock): 122 | """ Ensure that redirected profile pages match URL rules for the redirect """ 123 | requests_mock.get('http://foo', status_code=301, headers={'Location': 'http://bar'}) 124 | requests_mock.get('http://bar', text='blah') 125 | handler = UrlHandler('http://bar', 'foo') 126 | instance = Authl([handler]) 127 | 128 | assert instance.get_handler_for_url('http://foo') == \ 129 | (handler, 'foo', 'http://bar') 130 | 131 | 132 | def test_webfinger_profiles(mocker): 133 | """ test handles_url on a webfinger profile """ 134 | handler_1 = UrlHandler('test://foo', 'a') 135 | handler_2 = UrlHandler('test://bar', 'b') 136 | instance = Authl([handler_1, handler_2]) 137 | 138 | wgp = mocker.patch('authl.webfinger.get_profiles') 139 | wgp.side_effect = lambda url: {'test://cat', 140 | 'test://bar'} if url == 'fake webfinger address' else {} 141 | 142 | assert instance.get_handler_for_url('fake webfinger address') == (handler_2, 'b', 'test://bar') 143 | 144 | 145 | def test_scheme_fallback(requests_mock): 146 | """ Ensure that unknown and missing schemes fall back correctly """ 147 | requests_mock.get('https://foo/bar', text='blah') 148 | requests_mock.get('http://foo/bar', text='blah') 149 | 150 | handler_https = UrlHandler('https://foo/bar', 'secure') 151 | handler_http = UrlHandler('http://foo/bar', 'insecure') 152 | instance = Authl([handler_https, handler_http]) 153 | 154 | assert instance.get_handler_for_url( 155 | 'https://foo/bar') == (handler_https, 'secure', 'https://foo/bar') 156 | assert instance.get_handler_for_url( 157 | 'http://foo/bar') == (handler_http, 'insecure', 'http://foo/bar') 158 | assert instance.get_handler_for_url('foo/bar') == (handler_https, 'secure', 'https://foo/bar') 159 | assert instance.get_handler_for_url('example://foo') == (None, '', '') 160 | 161 | 162 | def test_relme_auth(requests_mock): 163 | """ test RelMeAuth profile support """ 164 | handler_1 = UrlHandler('test://foo', 'a') 165 | handler_2 = UrlHandler('https://social.example/bob', 'b') 166 | instance = Authl([handler_1, handler_2]) 167 | 168 | requests_mock.get('https://foo-link.example/', text='') 169 | requests_mock.get('https://foo-a.example/', text='') 170 | requests_mock.get('https://header.example/', headers={'Link': '; rel="me"'}) 171 | 172 | requests_mock.get('https://social.example/relative-link', text='') 173 | requests_mock.get('https://social.example/relative-a', text='') 174 | requests_mock.get('https://multiple.example/', text=''' 175 | 176 | 177 | 178 | ''') 179 | 180 | for url in ('https://foo-link.example/', 181 | 'https://foo-a.example', 182 | 'https://header.example'): 183 | assert instance.get_handler_for_url(url) == (handler_1, 'a', 'test://foo') 184 | 185 | for url in ('https://social.example/relative-link', 186 | 'https://social.example/relative-a', 187 | 'https://multiple.example/'): 188 | assert instance.get_handler_for_url(url) == (handler_2, 'b', 'https://social.example/bob') 189 | -------------------------------------------------------------------------------- /tests/test_tokens.py: -------------------------------------------------------------------------------- 1 | """ Tests of the TokenStore implementations """ 2 | # pylint:disable=missing-docstring 3 | 4 | 5 | import pytest 6 | 7 | from authl import tokens 8 | 9 | 10 | def test_dictstore(): 11 | store = tokens.DictStore({}) 12 | 13 | # different values should have different keys 14 | token = store.put((1, 2, 3)) 15 | token2 = store.put((1, 2, 3)) 16 | assert token != token2 17 | assert isinstance(token, str) 18 | assert isinstance(token2, str) 19 | 20 | # getting repeatedly should succeed 21 | assert store.get(token) == (1, 2, 3) 22 | assert store.get(token) == (1, 2, 3) 23 | assert store.get(token) == (1, 2, 3) 24 | 25 | # popping should remove it 26 | assert store.pop(token) == (1, 2, 3) 27 | with pytest.raises(KeyError): 28 | store.get(token) 29 | with pytest.raises(KeyError): 30 | store.pop(token) 31 | 32 | # removal should also remove it 33 | store.remove(token2) 34 | with pytest.raises(KeyError): 35 | store.get(token2) 36 | with pytest.raises(KeyError): 37 | store.pop(token2) 38 | 39 | # getting nonexistent should fail 40 | with pytest.raises(KeyError): 41 | store.get('bogus') 42 | 43 | # removal should always work even if the token doesn't exist 44 | store.remove(token) 45 | store.remove(token2) 46 | store.remove('bogus') 47 | 48 | 49 | def test_serializer(): 50 | store = tokens.Serializer(__name__) 51 | 52 | # different values should get the same key 53 | token = store.put((1, 2, 3)) 54 | token2 = store.put((1, 2, 3)) 55 | assert token == token2 56 | assert isinstance(token, str) 57 | assert isinstance(token2, str) 58 | 59 | # getting repeatedly should succeed 60 | assert store.get(token) == (1, 2, 3) 61 | assert store.get(token) == (1, 2, 3) 62 | assert store.get(token) == (1, 2, 3) 63 | 64 | # popping won't remove it 65 | assert store.pop(token) == (1, 2, 3) 66 | assert store.get(token) == (1, 2, 3) 67 | 68 | # removal also won't remove it 69 | store.remove(token2) 70 | store.remove(token2) 71 | store.remove(token2) 72 | assert store.get(token2) == (1, 2, 3) 73 | 74 | # getting nonexistent should fail 75 | with pytest.raises(KeyError): 76 | store.get('bogus') 77 | 78 | # removal should always work even if the token doesn't exist 79 | store.remove(token) 80 | store.remove(token2) 81 | store.remove('bogus') 82 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """ Tests for the various utility functions """ 2 | # pylint:disable=missing-docstring,missing-timeout 3 | 4 | 5 | import pytest 6 | import requests 7 | 8 | from authl import utils 9 | 10 | 11 | def test_request_url(requests_mock): 12 | requests_mock.get('http://example.com/', text='insecure') 13 | 14 | assert utils.request_url('example.com').text == 'insecure' 15 | 16 | requests_mock.get('https://example.com/', text='secure') 17 | 18 | assert utils.request_url('example.com').text == 'secure' 19 | assert utils.request_url('https://example.com').text == 'secure' 20 | assert utils.request_url('http://example.com').text == 'insecure' 21 | 22 | assert utils.request_url('http://nonexistent') is None 23 | assert utils.request_url('invalid://protocol') is None 24 | 25 | requests_mock.get('https://has.links/', headers={'Link': '; rel="bar"'}) 26 | assert utils.request_url('has.links').links['bar']['url'] == 'https://foo' 27 | 28 | 29 | def test_resolve_value(): 30 | def moo(): 31 | return 5 32 | assert utils.resolve_value(moo) == 5 33 | assert utils.resolve_value(10) == 10 34 | 35 | 36 | def test_permanent_url(requests_mock): 37 | requests_mock.get('http://make-secure.example', status_code=301, 38 | headers={'Location': 'https://make-secure.example'}) 39 | requests_mock.get('https://make-secure.example', status_code=302, 40 | headers={'Location': 'https://make-secure.example/final'}) 41 | requests_mock.get('https://make-secure.example/final', text="you made it!") 42 | 43 | # this redirects permanent to https, which redirects temporary to /final 44 | req = requests.get('http://make-secure.example') 45 | assert utils.permanent_url(req) == 'https://make-secure.example' 46 | 47 | # direct request to /final should remain /final 48 | req = requests.get('https://make-secure.example/final') 49 | assert utils.permanent_url(req) == 'https://make-secure.example/final' 50 | 51 | # correct case folding 52 | req = requests.get('Https://Make-SecuRE.Example/final') 53 | assert utils.permanent_url(req) == 'https://make-secure.example/final' 54 | 55 | # ensure 308 redirect works too 56 | requests_mock.get('http://perm-308.example', status_code=308, 57 | headers={'Location': 'https://make-secure.example/308'}) 58 | requests_mock.get('https://make-secure.example/308', status_code=401) 59 | 60 | req = requests.get('http://perm-308.example') 61 | assert utils.permanent_url(req) == 'https://make-secure.example/308' 62 | 63 | # make sure that it's the last pre-temporary redirect that counts 64 | requests_mock.get('https://one/', status_code=301, 65 | headers={'Location': 'https://two/'}) 66 | requests_mock.get('https://two/', status_code=302, 67 | headers={'Location': 'https://three/'}) 68 | requests_mock.get('https://three/', status_code=301, 69 | headers={'Location': 'https://four/'}) 70 | requests_mock.get('https://four/', text="done") 71 | 72 | req = requests.get('https://one/') 73 | assert req.url == 'https://four/' 74 | assert utils.permanent_url(req) == 'https://two/' 75 | assert req.text == 'done' 76 | 77 | req = requests.get('https://two/') 78 | assert req.url == 'https://four/' 79 | assert utils.permanent_url(req) == 'https://two/' 80 | assert req.text == 'done' 81 | 82 | req = requests.get('https://three/') 83 | assert req.url == 'https://four/' 84 | assert utils.permanent_url(req) == 'https://four/' 85 | assert req.text == 'done' 86 | 87 | 88 | def test_pkce_challenge(): 89 | assert utils.pkce_challenge('asdf', 'plain') == 'asdf' 90 | assert utils.pkce_challenge('foo', 'S256') == 'LCa0a2j_xo_5m0U8HTBBNBNCLXBkg7-g-YpeiGJm564' 91 | with pytest.raises(Exception): 92 | utils.pkce_challenge('moo', 'plap') 93 | -------------------------------------------------------------------------------- /tests/test_webfinger.py: -------------------------------------------------------------------------------- 1 | """ Tests for the webfinger mechanism """ 2 | # pylint:disable=missing-docstring 3 | 4 | 5 | from authl import webfinger 6 | 7 | 8 | def test_not_address(requests_mock): 9 | assert webfinger.get_profiles("http://example.com") == set() 10 | assert webfinger.get_profiles("foo@bar.baz") == set() 11 | assert webfinger.get_profiles("@quux") == set() 12 | 13 | assert not requests_mock.called 14 | 15 | 16 | def test_no_resource(requests_mock): 17 | requests_mock.get('https://example.com/.well-known/webfinger?resource=acct:404@example.com', 18 | status_code=404) 19 | assert webfinger.get_profiles("@404@example.com") == {"https://example.com/@404"} 20 | 21 | 22 | def test_resource(requests_mock): 23 | requests_mock.get('https://example.com/.well-known/webfinger?resource=acct:profile@example.com', 24 | json={ 25 | "links": [{ 26 | "rel": "profile", 27 | "href": "https://profile.example.com/u/moo" 28 | }, { 29 | "rel": "self", 30 | "href": "https://profile.example.com/u/moo" 31 | }, { 32 | "rel": "self", 33 | "href": "https://self.example.com/u/moo" 34 | }, { 35 | "rel": "posts", 36 | "href": "https://example.com/u/moo/posts" 37 | }] 38 | }) 39 | assert webfinger.get_profiles( 40 | "@profile@example.com") == {'https://profile.example.com/u/moo', 41 | 'https://self.example.com/u/moo'} 42 | assert webfinger.get_profiles( 43 | "acct:profile@example.com") == {'https://profile.example.com/u/moo', 44 | 'https://self.example.com/u/moo'} 45 | 46 | requests_mock.get('https://example.com/.well-known/webfinger?resource=acct:empty@example.com', 47 | json={ 48 | "links": [{ 49 | "rel": "nothing", 50 | "href": "https://profile.example.com/u/moo" 51 | }, { 52 | "rel": "still-nothing", 53 | "href": "https://self.example.com/u/moo" 54 | }, { 55 | "rel": "posts", 56 | "href": "https://example.com/u/moo/posts" 57 | }] 58 | }) 59 | 60 | assert webfinger.get_profiles("@empty@example.com") == set() 61 | 62 | 63 | def test_invalid(requests_mock): 64 | requests_mock.get('https://example.com/.well-known/webfinger?resource=acct:invalid@example.com', 65 | text="""This is not valid JSON""") 66 | assert webfinger.get_profiles('@invalid@example.com') == set() 67 | --------------------------------------------------------------------------------