def test_login_timeout(mocker, requests_mock): store = {} handler = indieauth.IndieAuth('http://client/', tokens.DictStore(store), 10) mock_time = mocker.patch('time.time') mock_time.return_value = 238742 requests_mock.get( 'http://example.user/', text='hello', headers={'Link': '<http://endpoint/>; rel="authorization_endpoint"'}) assert len(store) == 0 response = handler.initiate_auth('http://example.user', 'http://client/cb', '/dest') assert isinstance(response, disposition.Redirect) assert len(store) == 1 mock_time.return_value += 100 data = {'state': parse_args(response.url)['state'], 'code': 'bogus'} response = handler.check_callback('http://client/cb', data, {}) assert isinstance(response, disposition.Error) assert 'timed out' in response.message assert len(store) == 0
def test_handles_url(requests_mock): handler = fediverse.Fediverse('test', tokens.DictStore(), homepage='http://foo.example/') requests_mock.get('https://mastodon.example/api/v1/instance', text=json.dumps({ 'uri': 'foo', 'version': '2.5.1', 'urls': 'foo.bar' })) requests_mock.get('https://not-mastodon.example/api/v1/instance', text=json.dumps({'moo': 'cow'})) requests_mock.get('https://also-not.example/api/v1/instance', status_code=404) assert handler.handles_url('https://mastodon.example/@fluffy') assert handler.handles_url('https://mastodon.example/') assert handler.handles_url('mastodon.example') assert not handler.handles_url('https://not-mastodon.example/@fluffy') assert not handler.handles_url('https://not-mastodon.example/') assert not handler.handles_url('https://blah.example/') assert not handler.handles_url('https://also-not.example/')
def test_success(): store = {} def do_callback(message): assert message['To'] == '*****@*****.**' url = message.get_payload().strip() args = parse_args(url) assert url.startswith('http://example/cb/') result = handler.check_callback(url, parse_args(url), {}) LOGGER.info('check_callback(%s,%s): %s', url, args, result) assert isinstance(result, disposition.Verified) store['result'] = result store['is_done'] = result.identity handler = email_addr.EmailAddress(do_callback, 'some data', tokens.DictStore(store), email_template_text='{url}') result = handler.initiate_auth('mailto:[email protected]', 'http://example/cb/', '/redir') LOGGER.info('initiate_auth: %s', result) assert isinstance(result, disposition.Notify) assert result.cdata == 'some data' assert store['result'].identity == 'mailto:[email protected]' assert store['result'].redir == '/redir'
def test_from_config(mocker): store = {} mock_open = mocker.patch('builtins.open', mocker.mock_open(read_data='template')) mock_smtp = mocker.patch('smtplib.SMTP') conn = mocker.MagicMock() mock_smtp.return_value = conn handler = email_addr.from_config( { 'EMAIL_FROM': '*****@*****.**', 'EMAIL_SUBJECT': 'test subject', 'EMAIL_CHECK_MESSAGE': 'check yr email', 'EMAIL_TEMPLATE_FILE': 'template.txt', 'EMAIL_EXPIRE_TIME': 37, 'SMTP_HOST': 'smtp.example.com', 'SMTP_PORT': 587, 'SMTP_USE_SSL': False, }, tokens.DictStore(store)) mock_open.assert_called_with('template.txt', encoding='utf-8') res = handler.initiate_auth('mailto:[email protected]', 'http://cb/', '/redir') assert res.cdata['message'] == 'check yr email' assert len(store) == 1 mock_smtp.assert_called_with('smtp.example.com', 587)
def test_basics(): handler = fediverse.from_config( { 'FEDIVERSE_NAME': 'test', 'MASTODON_HOMEPAGE': 'http://foo.bar/', }, tokens.DictStore()) assert handler.service_name assert handler.url_schemes assert handler.description assert handler.cb_id assert handler.logo_html
def test_auth_success(mocker, requests_mock): store = tokens.DictStore() handler = fediverse.Fediverse('test', store, homepage='http://foo.example/') mock_mastodon = mocker.patch('mastodon.Mastodon') mock_mastodon.create_app.return_value = ('the id', 'the secret') mock_mastodon().auth_request_url.return_value = 'https://cb?code=12345' mock_mastodon().log_in.return_value = 'some_auth_token' mock_mastodon().me.return_value = { 'url': 'https://mastodon.example/@moo', 'display_name': 'moo friend', 'avatar_static': 'https://placekitten.com/1280/1024', 'source': { 'note': 'a cow', 'fields': [{ 'name': 'homepage', 'value': 'https://moo.example' }, { 'name': 'my pronouns', 'value': 'moo/moo' }] } } requests_mock.get('https://mastodon.example/api/v1/instance', text=json.dumps({ 'uri': 'foo', 'version': '2.5.1', 'urls': 'foo.bar' })) requests_mock.post('https://mastodon.example/oauth/revoke', text='ok') result = handler.initiate_auth('mastodon.example', 'https://cb', 'qwerpoiu') assert isinstance(result, disposition.Redirect) mock_mastodon().auth_request_url.assert_called_with( redirect_uris='https://cb', scopes=['read:accounts']) result = handler.check_callback(result.url, parse_args(result.url), {}) assert isinstance(result, disposition.Verified) assert result.identity == 'https://mastodon.example/@moo' assert result.redir == 'qwerpoiu' assert result.profile == { # pylint:disable=no-member # https://github.com/PyCQA/pylint/issues/4693 'name': 'moo friend', 'bio': 'a cow', 'avatar': 'https://placekitten.com/1280/1024', 'homepage': 'https://moo.example', 'pronouns': 'moo/moo' }
def test_please_wait(mocker): token_store = tokens.DictStore() pending = {} mock_send = mocker.MagicMock() handler = email_addr.EmailAddress(mock_send, "this is data", token_store, expires_time=60, pending_storage=pending) mock_time = mocker.patch('time.time') assert mock_send.call_count == 0 mock_time.return_value = 10 # First auth should call mock_send handler.initiate_auth('mailto:[email protected]', 'http://example/', 'blop') assert mock_send.call_count == 1 assert '*****@*****.**' in pending token_value = pending['*****@*****.**'] # Second auth should not handler.initiate_auth('mailto:[email protected]', 'http://example/', 'blop') assert mock_send.call_count == 1 assert '*****@*****.**' in pending assert token_value == pending['*****@*****.**'] # Using the link should remove the pending item handler.check_callback('http://example/', {'t': pending['*****@*****.**']}, {}) assert '*****@*****.**' not in pending # Next auth should call mock_send again handler.initiate_auth('mailto:[email protected]', 'http://example/', 'blop') assert mock_send.call_count == 2 assert '*****@*****.**' in pending assert token_value != pending['*****@*****.**'] token_value = pending['*****@*****.**'] # Timing out the token should cause it to send again mock_time.return_value = 1000 handler.initiate_auth('mailto:[email protected]', 'http://example/', 'blop') assert mock_send.call_count == 3 assert '*****@*****.**' in pending assert token_value != pending['*****@*****.**'] token_value = pending['*****@*****.**'] # And anything else that removes the token from the token_store should as well token_store.remove(pending['*****@*****.**']) handler.initiate_auth('mailto:[email protected]', 'http://example/', 'blop') assert mock_send.call_count == 4 assert token_value != pending['*****@*****.**'] token_value = pending['*****@*****.**']
def test_attack_mitigations(requests_mock, mocker): store = tokens.DictStore() handler = fediverse.Fediverse('test', store, homepage='http://foo.example/') mock_mastodon = mocker.patch('mastodon.Mastodon') mock_mastodon.create_app.return_value = ('the id', 'the secret') mock_mastodon().auth_request_url.return_value = 'https://cb?code=12345' mock_mastodon().log_in.return_value = 'some_auth_token' requests_mock.get('https://mastodon.example/api/v1/instance', text=json.dumps({ 'uri': 'foo', 'version': '2.5.1', 'urls': 'foo.bar' })) # domain hijack mock_mastodon().me.return_value = { 'url': 'https://hijack.example/@moo', } result = handler.initiate_auth('mastodon.example', 'https://cb', 'qwerpoiu') assert isinstance(result, disposition.Redirect) result = handler.check_callback(result.url, parse_args(result.url), {}) assert isinstance(result, disposition.Error) assert 'Domains do not match' in result.message # attempted replay attack mock_mastodon().me.return_value = { 'url': 'https://mastodon.example/@moo', } result = handler.initiate_auth('mastodon.example', 'https://cb', 'qwerpoiu') assert isinstance(result, disposition.Redirect) args = parse_args(result.url) result = handler.check_callback(result.url, args, {}) assert isinstance(result, disposition.Verified) assert result.identity == 'https://mastodon.example/@moo' result = handler.check_callback('https://cb', args, {}) assert isinstance(result, disposition.Error) assert 'Invalid transaction' in result.message
def test_basics(): handler = email_addr.EmailAddress(None, None, tokens.DictStore()) assert handler.service_name == 'Email' assert handler.url_schemes assert 'email' in handler.description assert handler.cb_id == 'e' assert handler.logo_html[0][1] == 'Email' assert handler.handles_url('*****@*****.**') == 'mailto:[email protected]' assert handler.handles_url('mailto:[email protected]') == 'mailto:[email protected]' # email addresses must be well-formed assert not handler.handles_url('mailto:foobar.baz') # don't support other schemas assert not handler.handles_url('email:[email protected]') assert not handler.handles_url('@[email protected]') assert not handler.handles_url('https://example.com/') # handle leading/trailing spaces correctly assert handler.handles_url(' [email protected]') == 'mailto:[email protected]' assert handler.handles_url('mailto: [email protected]') == 'mailto:[email protected]' assert handler.handles_url('mailto:[email protected] ') == 'mailto:[email protected]' # but don't allow embedded spaces assert not handler.handles_url(' foo @bar.baz') # email address must be valid assert not handler.handles_url(' asdf[]@poiu_foo.baz!') # don't allow bang-paths assert not handler.handles_url('bang!path!is!fun!bob') assert not handler.handles_url('bang.com!path!is!fun!bob') assert not handler.handles_url('[email protected]') # strip out non-email-address components assert handler.handles_url( 'mailto:[email protected]?subject=pwned') == 'mailto:[email protected]' # handle case correctly assert handler.handles_url( 'MailtO:[email protected]') == 'mailto:[email protected]'
def test_dictstore(): store = tokens.DictStore({}) # different values should have different keys token = store.put((1, 2, 3)) token2 = store.put((1, 2, 3)) assert token != token2 assert isinstance(token, str) assert isinstance(token2, str) # getting repeatedly should succeed assert store.get(token) == (1, 2, 3) assert store.get(token) == (1, 2, 3) assert store.get(token) == (1, 2, 3) # popping should remove it assert store.pop(token) == (1, 2, 3) with pytest.raises(KeyError): store.get(token) with pytest.raises(KeyError): store.pop(token) # removal should also remove it store.remove(token2) with pytest.raises(KeyError): store.get(token2) with pytest.raises(KeyError): store.pop(token2) # getting nonexistent should fail with pytest.raises(KeyError): store.get('bogus') # removal should always work even if the token doesn't exist store.remove(token) store.remove(token2) store.remove('bogus')
def test_handler_failures(requests_mock): store = {} handler = indieauth.IndieAuth('http://client/', tokens.DictStore(store), 10) # Attempt to auth against page with no endpoint requests_mock.get('http://no-endpoint/', text='hello') response = handler.initiate_auth('http://no-endpoint/', 'http://cb/', 'bogus') assert isinstance(response, disposition.Error) assert 'endpoint' in response.message assert len(store) == 0 # Attempt to inject a transaction-less callback response response = handler.check_callback('http://no-transaction', {}, {}) assert isinstance(response, disposition.Error) assert 'No transaction' in response.message assert len(store) == 0 # Attempt to inject a forged callback response response = handler.check_callback('http://bogus-transaction', {'state': 'bogus'}, {}) assert isinstance(response, disposition.Error) assert 'Invalid token' in response.message assert len(store) == 0 # Get a valid state token requests_mock.get( 'http://example.user/', text='hello', headers={'Link': '<http://endpoint/>; rel="authorization_endpoint"'}) response = handler.initiate_auth('http://example.user', 'http://client/cb', '/dest') assert isinstance(response, disposition.Redirect) data = {'state': parse_args(response.url)['state']} assert len(store) == 1 # no code assigned assert "Missing 'code'" in handler.check_callback('http://client/cb', data, {}).message assert len(store) == 0 def check_failure(message): assert len(store) == 0 response = handler.initiate_auth('http://example.user', 'http://client/cb', '/dest') assert isinstance(response, disposition.Redirect) assert len(store) == 1 data = {'state': parse_args(response.url)['state'], 'code': 'bogus'} response = handler.check_callback('http://client/cb', data, {}) assert isinstance(response, disposition.Error) assert message in response.message assert len(store) == 0 # callback returns error requests_mock.post('http://endpoint/', status_code=400) check_failure('returned 400') # callback returns broken JSON requests_mock.post('http://endpoint/', text='invalid json') check_failure('invalid response JSON') # callback returns a page with no endpoint requests_mock.post('http://endpoint/', json={'me': 'http://empty.user'}) requests_mock.get('http://empty.user', text='hello') check_failure('missing IndieAuth endpoint') # callback returns a page with a different endpoint requests_mock.post('http://endpoint/', json={'me': 'http://different.user'}) requests_mock.get( 'http://different.user', headers={ 'Link': '<http://otherendpoint/>; rel="authorization_endpoint"' }) check_failure('Authorization endpoint mismatch')
def test_handler_success(requests_mock): store = {} handler = indieauth.IndieAuth('http://client/', tokens.DictStore(store)) assert handler.service_name == 'IndieAuth' assert handler.url_schemes assert 'IndieAuth' in handler.description assert handler.cb_id assert handler.logo_html[0][1] == 'IndieAuth' # profile page at http://example.user/ which redirects to https://example.user/bob endpoint = { 'Link': '<https://auth.example/endpoint>; rel="authorization_endpoint' } requests_mock.get('http://example.user/', headers=endpoint) requests_mock.get('https://example.user/bob', headers=endpoint) injected = requests.get('http://example.user/') # it should not handle the URL on its own assert not handler.handles_url('http://example.user/') assert handler.handles_page('http://example.user/', injected.headers, BeautifulSoup(injected.text, 'html.parser'), injected.links) # and now the URL should be cached assert handler.handles_url('http://example.user/') disp = handler.initiate_auth('http://example.user/', 'http://client/cb', '/dest') assert isinstance(disp, disposition.Redirect) assert disp.url.startswith('https://auth.example/endpoint') # fake the user dialog on the IndieAuth endpoint user_get = parse_args(disp.url) assert user_get['redirect_uri'].startswith('http://client/cb') assert 'client_id' in user_get assert 'state' in user_get assert user_get['state'] in store assert user_get['response_type'] == 'code' assert 'me' in user_get challenge = user_get['code_challenge'] assert user_get['code_challenge_method'] == 'S256' # fake the verification response def verify_callback(request, _): import urllib.parse args = urllib.parse.parse_qs(request.text) assert args['code'] == ['asdf'] assert args['client_id'] == ['http://client/'] assert 'redirect_uri' in args verifier = args['code_verifier'][0] assert utils.pkce_challenge(verifier) == challenge return json.dumps({ 'me': 'https://example.user/bob', 'profile': { 'email': '*****@*****.**', 'url': 'https://bob.example.user/' } }) requests_mock.post('https://auth.example/endpoint', text=verify_callback) LOGGER.debug("state=%s", user_get['state']) response = handler.check_callback(user_get['redirect_uri'], { 'state': user_get['state'], 'code': 'asdf', }, {}) LOGGER.debug("verification response: %s", response) assert isinstance(response, disposition.Verified) assert response.identity == 'https://example.user/bob' assert response.redir == '/dest' for key, val in { # provided by profile scope 'homepage': 'https://bob.example.user/', 'email': '*****@*****.**', }.items(): assert response.profile[key] == val # trying to replay the same transaction should fail response = handler.check_callback(user_get['redirect_uri'], { 'state': user_get['state'], 'code': 'asdf', }, {}) assert isinstance(response, disposition.Error)
def test_auth_failures(requests_mock, mocker): # pylint:disable=too-many-statements store = tokens.DictStore({}) handler = fediverse.Fediverse('test', store, homepage='http://foo.example/') mock_mastodon = mocker.patch('mastodon.Mastodon') # nonexistent instance result = handler.initiate_auth('fail.example', 'https://cb', 'qwerpoiu') assert isinstance(result, disposition.Error) assert 'Failed to register client' in result.message # not a mastodon instance requests_mock.get('https://fail.example/api/v1/instance', text="'lolwut'") result = handler.initiate_auth('fail.example', 'https://cb', 'qwerpoiu') assert isinstance(result, disposition.Error) assert 'Failed to register client' in result.message # okay now it's an instance requests_mock.get('https://fail.example/api/v1/instance', text=json.dumps({ 'uri': 'foo', 'version': '2.5.1', 'urls': 'foo.bar' })) mock_mastodon.create_app.return_value = ('the id', 'the secret') # missing auth code mock_mastodon().auth_request_url.return_value = 'https://cb?foo=bar' result = handler.initiate_auth('fail.example', 'https://cb', 'qwerpoiu') assert isinstance(result, disposition.Redirect) result = handler.check_callback(result.url, parse_args(result.url), {}) assert isinstance(result, disposition.Error) assert "Missing 'code'" in result.message # Login was aborted mock_mastodon( ).auth_request_url.return_value = 'https://cb?code=12345&error=nope' result = handler.initiate_auth('fail.example', 'https://cb', 'qwerpoiu') assert isinstance(result, disposition.Redirect) result = handler.check_callback(result.url, parse_args(result.url), {}) assert isinstance(result, disposition.Error) assert "Error signing into instance" in result.message mock_mastodon().auth_request_url.return_value = 'https://cb?code=yep' # login failed for some other reason mock_mastodon().log_in.side_effect = mastodon.MastodonRatelimitError( "stop it") result = handler.initiate_auth('fail.example', 'https://cb', 'qwerpoiu') assert isinstance(result, disposition.Redirect) result = handler.check_callback(result.url, parse_args(result.url), {}) assert isinstance(result, disposition.Error) assert "Error signing into instance" in result.message mock_mastodon().log_in.side_effect = None mock_mastodon().log_in.return_value = 'some auth code' # login expired mock_time = mocker.patch('time.time') mock_time.return_value = 100 result = handler.initiate_auth('fail.example', 'https://cb', 'qwerpoiu') assert isinstance(result, disposition.Redirect) mock_time.return_value = 86400 result = handler.check_callback(result.url, parse_args(result.url), {}) assert isinstance(result, disposition.Error) assert 'Login timed out' in result.message # broken profile mock_mastodon().me.return_value = {} result = handler.initiate_auth('fail.example', 'https://cb', 'qwerpoiu') assert isinstance(result, disposition.Redirect) result = handler.check_callback(result.url, parse_args(result.url), {}) assert isinstance(result, disposition.Error) assert 'Missing user profile' in result.message mock_mastodon().me.return_value = { 'url': 'https://fail.example/@larry', 'source': ['ha ha ha', 'i break you'] } result = handler.initiate_auth('fail.example', 'https://cb', 'qwerpoiu') assert isinstance(result, disposition.Redirect) result = handler.check_callback(result.url, parse_args(result.url), {}) assert isinstance(result, disposition.Error) assert 'Malformed user profile' in result.message
def test_failures(mocker): store = {} pending = {} def accept(message): url = message.get_payload().strip() pending[message['To']] = url handler = email_addr.EmailAddress(accept, 'some data', tokens.DictStore(store), 10, email_template_text='{url}') # must be well-formed mailto: URL for malformed in ('*****@*****.**', 'http://foo.bar/', 'mailto:blahblahblah'): assert 'Malformed' in str( handler.initiate_auth(malformed, 'http://example.cb/', '/malformed')) # check for missing or invalid tokens assert 'Missing token' in str(handler.check_callback('foo', {}, {})) assert 'Invalid token' in str( handler.check_callback('foo', {'t': 'bogus'}, {})) def initiate(addr, redir): result = handler.initiate_auth('mailto:' + addr, 'http://example/', redir) assert isinstance(result, disposition.Notify) assert result.cdata == 'some data' def check_pending(addr): url = pending[addr] return handler.check_callback(url, parse_args(url), {}) # check for timeout failure mock_time = mocker.patch('time.time') mock_time.return_value = 30 assert len(store) == 0 initiate('*****@*****.**', '/timeout') assert len(store) == 1 mock_time.return_value = 20000 result = check_pending('*****@*****.**') assert isinstance(result, disposition.Error) assert 'timed out' in result.message assert result.redir == '/timeout' assert len(store) == 0 # check for replay attacks assert len(store) == 0 initiate('*****@*****.**', '/replay') assert len(store) == 1 result1 = check_pending('*****@*****.**') result2 = check_pending('*****@*****.**') assert len(store) == 0 assert isinstance(result1, disposition.Verified) assert result1.identity == 'mailto:[email protected]' assert result1.redir == '/replay' assert isinstance(result2, disposition.Error) assert 'Invalid token' in str(result2)