blob: 1f42817c87a210a988dcd259b8f45cbaea79209c [file] [edit]
#!/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()