| #!/usr/bin/env vpython3 |
| # Copyright 2016 The LUCI Authors. All rights reserved. |
| # Use of this source code is governed under the Apache License, Version 2.0 |
| # that can be found in the LICENSE file. |
| |
| import contextlib |
| import json |
| import socket |
| import time |
| |
| # Mutates sys.path. |
| import test_env |
| |
| # third_party/ |
| from depot_tools import auto_stub |
| import requests |
| |
| from libs import luci_context |
| from utils import authenticators |
| from utils import auth_server |
| from utils import net |
| from utils import oauth |
| |
| import net_utils |
| |
| |
| def call_rpc(account_id, scopes=None, audience=None): |
| ctx = luci_context.read('local_auth') |
| |
| if audience is None: |
| assert scopes |
| method = 'GetOAuthToken' |
| body = { |
| 'account_id': account_id, |
| 'scopes': scopes, |
| 'secret': ctx['secret'], |
| } |
| else: |
| assert scopes is None |
| method = 'GetIDToken' |
| body = { |
| 'account_id': account_id, |
| 'audience': audience, |
| 'secret': ctx['secret'], |
| } |
| |
| r = requests.post( |
| url='http://127.0.0.1:%d/rpc/LuciLocalAuthService.%s' % ( |
| ctx['rpc_port'], method), |
| data=json.dumps(body), |
| headers={'Content-Type': 'application/json'}) |
| |
| return r.json() |
| |
| |
| @contextlib.contextmanager |
| def local_auth_server(token_cb, default_account_id, **overrides): |
| class MockedProvider: |
| def generate_access_token(self, account_id, scopes): |
| return token_cb(account_id, scopes=scopes) |
| def generate_id_token(self, account_id, audience): |
| return token_cb(account_id, audience=audience) |
| |
| acc = lambda aid: auth_server.Account(id=aid, email=aid+'@example.com') |
| |
| s = auth_server.LocalAuthServer() |
| try: |
| local_auth = s.start( |
| token_provider=MockedProvider(), |
| accounts=(acc('acc_1'), acc('acc_2'), acc('acc_3')), |
| default_account_id=default_account_id) |
| local_auth.update(overrides) |
| with luci_context.write(local_auth=local_auth): |
| yield |
| finally: |
| s.stop() |
| |
| |
| class LocalAuthServerTest(auto_stub.TestCase): |
| epoch = 12345678 |
| |
| def setUp(self): |
| super(LocalAuthServerTest, self).setUp() |
| self.mock_time(0) |
| |
| def mock_time(self, delta): |
| self.mock(time, 'time', lambda: self.epoch + delta) |
| |
| def test_accounts_in_ctx(self): |
| def token_gen(_account_id, **_kwargs): |
| self.fail('must not be called') |
| with local_auth_server(token_gen, 'acc_1'): |
| ctx = luci_context.read('local_auth') |
| ctx.pop('rpc_port') |
| ctx.pop('secret') |
| self.assertEqual({ |
| 'accounts': [ |
| {'email': 'acc_1@example.com', 'id': 'acc_1'}, |
| {'email': 'acc_2@example.com', 'id': 'acc_2'}, |
| {'email': 'acc_3@example.com', 'id': 'acc_3'}, |
| ], |
| 'default_account_id': 'acc_1', |
| }, ctx) |
| |
| def test_access_tokens(self): |
| calls = [] |
| def token_gen(account_id, scopes=None, audience=None): |
| assert audience is None |
| calls.append((account_id, scopes)) |
| return auth_server.AccessToken('tok_%s' % account_id, time.time() + 300) |
| |
| with local_auth_server(token_gen, 'acc_1'): |
| # Grab initial token. |
| resp = call_rpc('acc_1', scopes=['B', 'B', 'A', 'C']) |
| self.assertEqual({ |
| 'access_token': 'tok_acc_1', |
| 'expiry': self.epoch + 300 |
| }, resp) |
| self.assertEqual([('acc_1', ('A', 'B', 'C'))], calls) |
| del calls[:] |
| |
| # Reuses cached token until it is close to expiration. |
| self.mock_time(60) |
| resp = call_rpc('acc_1', scopes=['B', 'A', 'C']) |
| self.assertEqual({ |
| 'access_token': 'tok_acc_1', |
| 'expiry': self.epoch + 300 |
| }, resp) |
| self.assertFalse(calls) |
| |
| # Asking for different account gives another token. |
| resp = call_rpc('acc_2', scopes=['B', 'B', 'A', 'C']) |
| self.assertEqual({ |
| 'access_token': 'tok_acc_2', |
| 'expiry': self.epoch + 360 |
| }, resp) |
| self.assertEqual([('acc_2', ('A', 'B', 'C'))], calls) |
| del calls[:] |
| |
| # First token has expired. Generated new one. |
| self.mock_time(300) |
| resp = call_rpc('acc_1', scopes=['A', 'B', 'C']) |
| self.assertEqual({ |
| 'access_token': 'tok_acc_1', |
| 'expiry': self.epoch + 600 |
| }, resp) |
| self.assertEqual([('acc_1', ('A', 'B', 'C'))], calls) |
| |
| def test_id_tokens(self): |
| calls = [] |
| def token_gen(account_id, scopes=None, audience=None): |
| assert scopes is None |
| calls.append((account_id, audience)) |
| return auth_server.AccessToken('tok_%s' % account_id, time.time() + 300) |
| |
| with local_auth_server(token_gen, 'acc_1'): |
| # Grab initial token. |
| resp = call_rpc('acc_1', audience='some-audience') |
| self.assertEqual({ |
| 'id_token': 'tok_acc_1', |
| 'expiry': self.epoch + 300 |
| }, resp) |
| self.assertEqual([('acc_1', 'some-audience')], calls) |
| del calls[:] |
| |
| # Reuses cached token until it is close to expiration. |
| self.mock_time(60) |
| resp = call_rpc('acc_1', audience='some-audience') |
| self.assertEqual({ |
| 'id_token': 'tok_acc_1', |
| 'expiry': self.epoch + 300 |
| }, resp) |
| self.assertFalse(calls) |
| |
| # Asking for different audience gives another token. |
| resp = call_rpc('acc_1', audience='another-audience') |
| self.assertEqual({ |
| 'id_token': 'tok_acc_1', |
| 'expiry': self.epoch + 360 |
| }, resp) |
| self.assertEqual([('acc_1', 'another-audience')], calls) |
| del calls[:] |
| |
| # Asking for different account gives another token. |
| resp = call_rpc('acc_2', audience='some-audience') |
| self.assertEqual({ |
| 'id_token': 'tok_acc_2', |
| 'expiry': self.epoch + 360 |
| }, resp) |
| self.assertEqual([('acc_2', 'some-audience')], calls) |
| del calls[:] |
| |
| # First token has expired. Generated new one. |
| self.mock_time(300) |
| resp = call_rpc('acc_1', audience='some-audience') |
| self.assertEqual({ |
| 'id_token': 'tok_acc_1', |
| 'expiry': self.epoch + 600 |
| }, resp) |
| self.assertEqual([('acc_1', 'some-audience')], calls) |
| |
| def test_handles_token_errors(self): |
| calls = [] |
| def token_gen(_account_id, **_kwargs): |
| calls.append(1) |
| raise auth_server.TokenError(123, 'error message') |
| |
| with local_auth_server(token_gen, 'acc_1'): |
| self.assertEqual({ |
| 'error_code': 123, |
| 'error_message': 'error message' |
| }, call_rpc('acc_1', scopes=['B', 'B', 'A', 'C'])) |
| self.assertEqual(1, len(calls)) |
| |
| # Errors are cached. Same error is returned. |
| self.assertEqual({ |
| 'error_code': 123, |
| 'error_message': 'error message' |
| }, call_rpc('acc_1', scopes=['B', 'B', 'A', 'C'])) |
| self.assertEqual(1, len(calls)) |
| |
| def test_http_level_errors(self): |
| def token_gen(_account_id, **_kwargs): |
| self.fail('must not be called') |
| |
| with local_auth_server(token_gen, 'acc_1'): |
| # Wrong URL. |
| ctx = luci_context.read('local_auth') |
| r = requests.post( |
| url='http://127.0.0.1:%d/blah/LuciLocalAuthService.GetOAuthToken' % |
| ctx['rpc_port'], |
| data=json.dumps({ |
| 'account_id': 'acc_1', |
| 'scopes': ['A', 'B', 'C'], |
| 'secret': ctx['secret'], |
| }), |
| headers={'Content-Type': 'application/json'}) |
| self.assertEqual(404, r.status_code) |
| |
| # Wrong HTTP method. |
| r = requests.get( |
| url='http://127.0.0.1:%d/rpc/LuciLocalAuthService.GetOAuthToken' % |
| ctx['rpc_port'], |
| data=json.dumps({ |
| 'account_id': 'acc_1', |
| 'scopes': ['A', 'B', 'C'], |
| 'secret': ctx['secret'], |
| }), |
| headers={'Content-Type': 'application/json'}) |
| self.assertEqual(501, r.status_code) |
| |
| # Wrong content type. |
| r = requests.post( |
| url='http://127.0.0.1:%d/rpc/LuciLocalAuthService.GetOAuthToken' % |
| ctx['rpc_port'], |
| data=json.dumps({ |
| 'account_id': 'acc_1', |
| 'scopes': ['A', 'B', 'C'], |
| 'secret': ctx['secret'], |
| }), |
| headers={'Content-Type': 'application/xml'}) |
| self.assertEqual(400, r.status_code) |
| |
| # Bad JSON. |
| r = requests.post( |
| url='http://127.0.0.1:%d/rpc/LuciLocalAuthService.GetOAuthToken' % |
| ctx['rpc_port'], |
| data='not a json', |
| headers={'Content-Type': 'application/json'}) |
| self.assertEqual(400, r.status_code) |
| |
| def test_validation(self): |
| def token_gen(_account_id, **_kwargs): |
| self.fail('must not be called') |
| |
| with local_auth_server(token_gen, 'acc_1'): |
| ctx = luci_context.read('local_auth') |
| |
| def must_fail(method, body, err, code): |
| for m in ['GetOAuthToken', 'GetIDToken'] if method == '*' else [method]: |
| r = requests.post( |
| url='http://127.0.0.1:%d/rpc/LuciLocalAuthService.%s' % ( |
| ctx['rpc_port'], m), |
| data=json.dumps(body), |
| headers={'Content-Type': 'application/json'}) |
| self.assertEqual(code, r.status_code) |
| self.assertIn(err, r.text) |
| |
| cases = [ |
| # account_id |
| ( |
| '*', |
| {}, |
| '"account_id" is required', |
| 400, |
| ), |
| ( |
| '*', |
| {'account_id': 123}, |
| '"account_id" must be a string', |
| 400, |
| ), |
| |
| # secret |
| ( |
| '*', |
| {'account_id': 'acc_1', 'scopes': ['a']}, |
| '"secret" is required', |
| 400, |
| ), |
| ( |
| '*', |
| {'account_id': 'acc_1', 'scopes': ['a'], 'secret': 123}, |
| '"secret" must be a string', |
| 400, |
| ), |
| ( |
| '*', |
| {'account_id': 'acc_1', 'scopes': ['a'], 'secret': 'abc'}, |
| 'Invalid "secret"', |
| 403, |
| ), |
| |
| # The account is known. |
| ( |
| '*', |
| {'account_id': 'zzz', 'scopes': ['a'], 'secret': ctx['secret']}, |
| 'Unrecognized account ID', |
| 404, |
| ), |
| |
| # scopes |
| ( |
| 'GetOAuthToken', |
| {'account_id': 'acc_1', 'secret': ctx['secret']}, |
| '"scopes" is required', |
| 400, |
| ), |
| ( |
| 'GetOAuthToken', |
| {'account_id': 'acc_1', 'secret': ctx['secret'], 'scopes': []}, |
| '"scopes" is required', |
| 400, |
| ), |
| ( |
| 'GetOAuthToken', |
| {'account_id': 'acc_1', 'secret': ctx['secret'], 'scopes': 'abc'}, |
| '"scopes" must be a list of strings', |
| 400, |
| ), |
| ( |
| 'GetOAuthToken', |
| {'account_id': 'acc_1', 'secret': ctx['secret'], 'scopes': [1]}, |
| '"scopes" must be a list of strings', |
| 400, |
| ), |
| |
| # audience |
| ( |
| 'GetIDToken', |
| {'account_id': 'acc_1', 'secret': ctx['secret']}, |
| '"audience" is required', |
| 400, |
| ), |
| ( |
| 'GetIDToken', |
| {'account_id': 'acc_1', 'secret': ctx['secret'], 'audience': ''}, |
| '"audience" is required', |
| 400, |
| ), |
| ( |
| 'GetIDToken', |
| {'account_id': 'acc_1', 'secret': ctx['secret'], 'audience': 123}, |
| '"audience" must be a string', |
| 400, |
| ), |
| ] |
| for method, body, err, code in cases: |
| must_fail(method, body, err, code) |
| |
| |
| class LocalAuthHttpServiceTest(auto_stub.TestCase): |
| """Tests for LocalAuthServer and LuciContextAuthenticator.""" |
| epoch = 12345678 |
| |
| def setUp(self): |
| super(LocalAuthHttpServiceTest, self).setUp() |
| self.mock_time(0) |
| |
| def mock_time(self, delta): |
| self.mock(time, 'time', lambda: self.epoch + delta) |
| |
| @staticmethod |
| def mocked_http_service( |
| url='http://example.com', |
| perform_request=None): |
| |
| class MockedRequestEngine: |
| def perform_request(self, request): |
| return perform_request(request) if perform_request else None |
| |
| return net.HttpService( |
| url, |
| authenticator=authenticators.LuciContextAuthenticator(), |
| engine=MockedRequestEngine()) |
| |
| def test_works(self): |
| service_url = 'http://example.com' |
| request_url = '/some_request' |
| response = b'True' |
| token = 'notasecret' |
| |
| def token_gen(account_id, scopes=None, audience=None): |
| self.assertEqual('acc_1', account_id) |
| self.assertEqual(1, len(scopes)) |
| self.assertEqual(oauth.OAUTH_SCOPES, scopes[0]) |
| self.assertIsNone(audience) |
| return auth_server.AccessToken(token, time.time() + 300) |
| |
| def handle_request(request): |
| self.assertTrue( |
| request.get_full_url().startswith(service_url + request_url)) |
| self.assertEqual(b'', request.body) |
| self.assertEqual('Bearer %s' % token, request.headers['Authorization']) |
| return net_utils.make_fake_response(response, request.get_full_url()) |
| |
| with local_auth_server(token_gen, 'acc_1'): |
| service = self.mocked_http_service(perform_request=handle_request) |
| self.assertEqual(service.request(request_url, data={}).read(), response) |
| |
| def test_bad_secret(self): |
| service_url = 'http://example.com' |
| request_url = '/some_request' |
| response = b'False' |
| |
| def token_gen(_account_id, **_kwargs): |
| self.fail('must not be called') |
| |
| def handle_request(request): |
| self.assertTrue( |
| request.get_full_url().startswith(service_url + request_url)) |
| self.assertEqual(b'', request.body) |
| self.assertIsNone(request.headers.get('Authorization')) |
| return net_utils.make_fake_response(response, request.get_full_url()) |
| |
| with local_auth_server(token_gen, 'acc_1', secret='invalid'): |
| service = self.mocked_http_service(perform_request=handle_request) |
| self.assertEqual(service.request(request_url, data={}).read(), response) |
| |
| def test_bad_port(self): |
| request_url = '/some_request' |
| |
| def token_gen(_account_id, **_kwargs): |
| self.fail('must not be called') |
| |
| def handle_request(_request): |
| self.fail('must not be called') |
| |
| # This little dance should pick an unused port, bind it and then close it, |
| # trusting that the OS will not reallocate it between now and when the http |
| # client attempts to use it as a local_auth service. This is better than |
| # picking a static port number, as there's at least some guarantee that the |
| # port WASN'T in use before this test ran. |
| sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
| sock.bind(('localhost', 0)) |
| port = sock.getsockname()[1] |
| sock.close() |
| with local_auth_server(token_gen, 'acc_1', rpc_port=port): |
| service = self.mocked_http_service(perform_request=handle_request) |
| with self.assertRaises(ConnectionRefusedError): |
| self.assertRaises(service.request(request_url, data={}).read()) |
| |
| def test_expired_token(self): |
| service_url = 'http://example.com' |
| request_url = '/some_request' |
| response = b'False' |
| token = 'notasecret' |
| |
| def token_gen(account_id, scopes=None, audience=None): |
| self.assertEqual('acc_1', account_id) |
| self.assertEqual(1, len(scopes)) |
| self.assertEqual(oauth.OAUTH_SCOPES, scopes[0]) |
| self.assertIsNone(audience) |
| return auth_server.AccessToken(token, time.time()) |
| |
| def handle_request(request): |
| self.assertTrue( |
| request.get_full_url().startswith(service_url + request_url)) |
| self.assertEqual(b'', request.body) |
| self.assertIsNone(request.headers.get('Authorization')) |
| return net_utils.make_fake_response(response, request.get_full_url()) |
| |
| with local_auth_server(token_gen, 'acc_1'): |
| service = self.mocked_http_service(perform_request=handle_request) |
| self.assertEqual(service.request(request_url, data={}).read(), response) |
| |
| |
| if __name__ == '__main__': |
| # Terminate HTTP server in tests 50x faster. Impacts performance though so |
| # do it only in tests. |
| auth_server._HTTPServer.poll_interval = 0.01 |
| test_env.main() |