Merge pull request #2300 from coreos-inc/openid-connect
OpenID Connect support and OAuth login refactoring
This commit is contained in:
commit
01ec22b362
36 changed files with 1623 additions and 983 deletions
|
@ -28,7 +28,7 @@ from endpoints.api.repositorynotification import RepositoryNotification, Reposit
|
|||
from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Recovery, Signout,
|
||||
Signin, User, UserAuthorizationList, UserAuthorization, UserNotification,
|
||||
VerifyUser, DetachExternal, StarredRepositoryList, StarredRepository,
|
||||
ClientKey)
|
||||
ClientKey, ExternalLoginInformation)
|
||||
from endpoints.api.repotoken import RepositoryToken, RepositoryTokenList
|
||||
from endpoints.api.prototype import PermissionPrototype, PermissionPrototypeList
|
||||
from endpoints.api.logs import UserLogs, OrgLogs, RepositoryLogs
|
||||
|
@ -505,10 +505,28 @@ class TestSignin(ApiTestCase):
|
|||
self._run_test('POST', 403, 'devtable', {u'username': 'E9RY', u'password': 'LQ0N'})
|
||||
|
||||
|
||||
class TestExternalLoginInformation(ApiTestCase):
|
||||
def setUp(self):
|
||||
ApiTestCase.setUp(self)
|
||||
self._set_url(ExternalLoginInformation, service_id='someservice')
|
||||
|
||||
def test_post_anonymous(self):
|
||||
self._run_test('POST', 400, None, {})
|
||||
|
||||
def test_post_freshuser(self):
|
||||
self._run_test('POST', 400, 'freshuser', {})
|
||||
|
||||
def test_post_reader(self):
|
||||
self._run_test('POST', 400, 'reader', {})
|
||||
|
||||
def test_post_devtable(self):
|
||||
self._run_test('POST', 400, 'devtable', {})
|
||||
|
||||
|
||||
class TestDetachExternal(ApiTestCase):
|
||||
def setUp(self):
|
||||
ApiTestCase.setUp(self)
|
||||
self._set_url(DetachExternal, servicename='someservice')
|
||||
self._set_url(DetachExternal, service_id='someservice')
|
||||
|
||||
def test_post_anonymous(self):
|
||||
self._run_test('POST', 401, None, {})
|
||||
|
|
|
@ -8,7 +8,6 @@ import base64
|
|||
from urllib import urlencode
|
||||
from urlparse import urlparse, urlunparse, parse_qs
|
||||
from datetime import datetime, timedelta
|
||||
from httmock import urlmatch, HTTMock
|
||||
|
||||
import jwt
|
||||
|
||||
|
@ -25,16 +24,9 @@ from endpoints.api.user import Signin
|
|||
from endpoints.keyserver import jwk_with_kid
|
||||
from endpoints.csrf import OAUTH_CSRF_TOKEN_NAME
|
||||
from endpoints.web import web as web_bp
|
||||
from endpoints.oauthlogin import oauthlogin as oauthlogin_bp
|
||||
from initdb import setup_database_for_testing, finished_database_for_testing
|
||||
from test.helpers import assert_action_logged
|
||||
|
||||
try:
|
||||
app.register_blueprint(oauthlogin_bp, url_prefix='/oauth')
|
||||
except ValueError:
|
||||
# This blueprint was already registered
|
||||
pass
|
||||
|
||||
try:
|
||||
app.register_blueprint(web_bp, url_prefix='')
|
||||
except ValueError:
|
||||
|
@ -129,140 +121,6 @@ class EndpointTestCase(unittest.TestCase):
|
|||
self.assertEquals(rv.status_code, 200)
|
||||
|
||||
|
||||
class OAuthLoginTestCase(EndpointTestCase):
|
||||
def invoke_oauth_tests(self, callback_endpoint, attach_endpoint, service_name, service_ident,
|
||||
new_username):
|
||||
# Test callback.
|
||||
created = self.invoke_oauth_test(callback_endpoint, service_name, service_ident, new_username)
|
||||
|
||||
# Delete the created user.
|
||||
model.user.delete_user(created, [])
|
||||
|
||||
# Test attach.
|
||||
self.login('devtable', 'password')
|
||||
self.invoke_oauth_test(attach_endpoint, service_name, service_ident, 'devtable')
|
||||
|
||||
def invoke_oauth_test(self, endpoint_name, service_name, service_ident, username):
|
||||
# No CSRF.
|
||||
self.getResponse('oauthlogin.' + endpoint_name, expected_code=403)
|
||||
|
||||
# Invalid CSRF.
|
||||
self.getResponse('oauthlogin.' + endpoint_name, state='somestate', expected_code=403)
|
||||
|
||||
# Valid CSRF, invalid code.
|
||||
self.getResponse('oauthlogin.' + endpoint_name, state='someoauthtoken',
|
||||
code='invalidcode', expected_code=400)
|
||||
|
||||
# Valid CSRF, valid code.
|
||||
self.getResponse('oauthlogin.' + endpoint_name, state='someoauthtoken',
|
||||
code='somecode', expected_code=302)
|
||||
|
||||
# Ensure the user was added/modified.
|
||||
found_user = model.user.get_user(username)
|
||||
self.assertIsNotNone(found_user)
|
||||
|
||||
federated_login = model.user.lookup_federated_login(found_user, service_name)
|
||||
self.assertIsNotNone(federated_login)
|
||||
self.assertEquals(federated_login.service_ident, service_ident)
|
||||
return found_user
|
||||
|
||||
def test_google_oauth(self):
|
||||
@urlmatch(netloc=r'accounts.google.com', path='/o/oauth2/token')
|
||||
def account_handler(_, request):
|
||||
if request.body.find("code=somecode") > 0:
|
||||
content = {'access_token': 'someaccesstoken'}
|
||||
return py_json.dumps(content)
|
||||
else:
|
||||
return {'status_code': 400, 'content': '{"message": "Invalid code"}'}
|
||||
|
||||
@urlmatch(netloc=r'www.googleapis.com', path='/oauth2/v1/userinfo')
|
||||
def user_handler(_, __):
|
||||
content = {
|
||||
'id': 'someid',
|
||||
'email': 'someemail@example.com',
|
||||
'verified_email': True,
|
||||
}
|
||||
return py_json.dumps(content)
|
||||
|
||||
with HTTMock(account_handler, user_handler):
|
||||
self.invoke_oauth_tests('google_oauth_callback', 'google_oauth_attach', 'google',
|
||||
'someid', 'someemail')
|
||||
|
||||
def test_github_oauth(self):
|
||||
@urlmatch(netloc=r'github.com', path='/login/oauth/access_token')
|
||||
def account_handler(url, _):
|
||||
if url.query.find("code=somecode") > 0:
|
||||
content = {'access_token': 'someaccesstoken'}
|
||||
return py_json.dumps(content)
|
||||
else:
|
||||
return {'status_code': 400, 'content': '{"message": "Invalid code"}'}
|
||||
|
||||
@urlmatch(netloc=r'github.com', path='/api/v3/user')
|
||||
def user_handler(_, __):
|
||||
content = {
|
||||
'id': 'someid',
|
||||
'login': 'someusername'
|
||||
}
|
||||
return py_json.dumps(content)
|
||||
|
||||
@urlmatch(netloc=r'github.com', path='/api/v3/user/emails')
|
||||
def email_handler(_, __):
|
||||
content = [{
|
||||
'email': 'someemail@example.com',
|
||||
'verified': True,
|
||||
'primary': True,
|
||||
}]
|
||||
return py_json.dumps(content)
|
||||
|
||||
with HTTMock(account_handler, email_handler, user_handler):
|
||||
self.invoke_oauth_tests('github_oauth_callback', 'github_oauth_attach', 'github',
|
||||
'someid', 'someusername')
|
||||
|
||||
def test_dex_oauth(self):
|
||||
# TODO(jschorr): Add tests for invalid and expired keys.
|
||||
|
||||
# Generate a public/private key pair for the OIDC transaction.
|
||||
private_key = RSA.generate(2048)
|
||||
jwk = RSAKey(key=private_key.publickey()).serialize()
|
||||
token = jwt.encode({
|
||||
'iss': 'https://oidcserver/',
|
||||
'aud': 'someclientid',
|
||||
'sub': 'someid',
|
||||
'exp': int(time.time()) + 60,
|
||||
'iat': int(time.time()),
|
||||
'nbf': int(time.time()),
|
||||
'email': 'someemail@example.com',
|
||||
'email_verified': True,
|
||||
}, private_key.exportKey('PEM'), 'RS256')
|
||||
|
||||
@urlmatch(netloc=r'oidcserver', path='/.well-known/openid-configuration')
|
||||
def wellknown_handler(url, _):
|
||||
return py_json.dumps({
|
||||
'authorization_endpoint': 'http://oidcserver/auth',
|
||||
'token_endpoint': 'http://oidcserver/token',
|
||||
'jwks_uri': 'http://oidcserver/keys',
|
||||
})
|
||||
|
||||
@urlmatch(netloc=r'oidcserver', path='/token')
|
||||
def account_handler(url, request):
|
||||
if request.body.find("code=somecode") > 0:
|
||||
return py_json.dumps({
|
||||
'access_token': token,
|
||||
})
|
||||
else:
|
||||
return {'status_code': 400, 'content': '{"message": "Invalid code"}'}
|
||||
|
||||
@urlmatch(netloc=r'oidcserver', path='/keys')
|
||||
def keys_handler(_, __):
|
||||
return py_json.dumps({
|
||||
"keys": [jwk],
|
||||
})
|
||||
|
||||
with HTTMock(wellknown_handler, account_handler, keys_handler):
|
||||
self.invoke_oauth_tests('dex_oauth_callback', 'dex_oauth_attach', 'dex',
|
||||
'someid', 'someemail')
|
||||
|
||||
|
||||
class WebEndpointTestCase(EndpointTestCase):
|
||||
def test_index(self):
|
||||
self.getResponse('web.index')
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import unittest
|
||||
|
||||
from util.config.oauth import GithubOAuthConfig
|
||||
from oauth.services.github import GithubOAuthService
|
||||
|
||||
class TestGithub(unittest.TestCase):
|
||||
def test_basic_enterprise_config(self):
|
||||
|
@ -12,7 +12,7 @@ class TestGithub(unittest.TestCase):
|
|||
}
|
||||
}
|
||||
|
||||
github_trigger = GithubOAuthConfig(config, 'GITHUB_TRIGGER_CONFIG')
|
||||
github_trigger = GithubOAuthService(config, 'GITHUB_TRIGGER_CONFIG')
|
||||
self.assertTrue(github_trigger.is_enterprise())
|
||||
self.assertEquals('https://github.somedomain.com/login/oauth/authorize?', github_trigger.authorize_endpoint())
|
||||
self.assertEquals('https://github.somedomain.com/login/oauth/access_token', github_trigger.token_endpoint())
|
||||
|
@ -33,7 +33,7 @@ class TestGithub(unittest.TestCase):
|
|||
}
|
||||
}
|
||||
|
||||
github_trigger = GithubOAuthConfig(config, 'GITHUB_TRIGGER_CONFIG')
|
||||
github_trigger = GithubOAuthService(config, 'GITHUB_TRIGGER_CONFIG')
|
||||
self.assertTrue(github_trigger.is_enterprise())
|
||||
self.assertEquals('https://github.somedomain.com/login/oauth/authorize?', github_trigger.authorize_endpoint())
|
||||
self.assertEquals('https://github.somedomain.com/login/oauth/access_token', github_trigger.token_endpoint())
|
||||
|
|
175
test/test_oauth_login.py
Normal file
175
test/test_oauth_login.py
Normal file
|
@ -0,0 +1,175 @@
|
|||
import json as py_json
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import jwt
|
||||
|
||||
from Crypto.PublicKey import RSA
|
||||
from httmock import urlmatch, HTTMock
|
||||
from jwkest.jwk import RSAKey
|
||||
|
||||
from app import app
|
||||
from data import model
|
||||
from endpoints.oauthlogin import oauthlogin as oauthlogin_bp
|
||||
from test.test_endpoints import EndpointTestCase
|
||||
|
||||
try:
|
||||
app.register_blueprint(oauthlogin_bp, url_prefix='/oauth2')
|
||||
except ValueError:
|
||||
# This blueprint was already registered
|
||||
pass
|
||||
|
||||
class OAuthLoginTestCase(EndpointTestCase):
|
||||
def invoke_oauth_tests(self, callback_endpoint, attach_endpoint, service_name, service_ident,
|
||||
new_username):
|
||||
# Test callback.
|
||||
created = self.invoke_oauth_test(callback_endpoint, service_name, service_ident, new_username)
|
||||
|
||||
# Delete the created user.
|
||||
model.user.delete_user(created, [])
|
||||
|
||||
# Test attach.
|
||||
self.login('devtable', 'password')
|
||||
self.invoke_oauth_test(attach_endpoint, service_name, service_ident, 'devtable')
|
||||
|
||||
def invoke_oauth_test(self, endpoint_name, service_name, service_ident, username):
|
||||
# No CSRF.
|
||||
self.getResponse('oauthlogin.' + endpoint_name, expected_code=403)
|
||||
|
||||
# Invalid CSRF.
|
||||
self.getResponse('oauthlogin.' + endpoint_name, state='somestate', expected_code=403)
|
||||
|
||||
# Valid CSRF, invalid code.
|
||||
self.getResponse('oauthlogin.' + endpoint_name, state='someoauthtoken',
|
||||
code='invalidcode', expected_code=400)
|
||||
|
||||
# Valid CSRF, valid code.
|
||||
self.getResponse('oauthlogin.' + endpoint_name, state='someoauthtoken',
|
||||
code='somecode', expected_code=302)
|
||||
|
||||
# Ensure the user was added/modified.
|
||||
found_user = model.user.get_user(username)
|
||||
self.assertIsNotNone(found_user)
|
||||
|
||||
federated_login = model.user.lookup_federated_login(found_user, service_name)
|
||||
self.assertIsNotNone(federated_login)
|
||||
self.assertEquals(federated_login.service_ident, service_ident)
|
||||
return found_user
|
||||
|
||||
def test_google_oauth(self):
|
||||
@urlmatch(netloc=r'accounts.google.com', path='/o/oauth2/token')
|
||||
def account_handler(_, request):
|
||||
if request.body.find("code=somecode") > 0:
|
||||
content = {'access_token': 'someaccesstoken'}
|
||||
return py_json.dumps(content)
|
||||
else:
|
||||
return {'status_code': 400, 'content': '{"message": "Invalid code"}'}
|
||||
|
||||
@urlmatch(netloc=r'www.googleapis.com', path='/oauth2/v1/userinfo')
|
||||
def user_handler(_, __):
|
||||
content = {
|
||||
'id': 'someid',
|
||||
'email': 'someemail@example.com',
|
||||
'verified_email': True,
|
||||
}
|
||||
return py_json.dumps(content)
|
||||
|
||||
with HTTMock(account_handler, user_handler):
|
||||
self.invoke_oauth_tests('google_oauth_callback', 'google_oauth_attach', 'google',
|
||||
'someid', 'someemail')
|
||||
|
||||
def test_github_oauth(self):
|
||||
@urlmatch(netloc=r'github.com', path='/login/oauth/access_token')
|
||||
def account_handler(url, _):
|
||||
if url.query.find("code=somecode") > 0:
|
||||
content = {'access_token': 'someaccesstoken'}
|
||||
return py_json.dumps(content)
|
||||
else:
|
||||
return {'status_code': 400, 'content': '{"message": "Invalid code"}'}
|
||||
|
||||
@urlmatch(netloc=r'github.com', path='/api/v3/user')
|
||||
def user_handler(_, __):
|
||||
content = {
|
||||
'id': 'someid',
|
||||
'login': 'someusername'
|
||||
}
|
||||
return py_json.dumps(content)
|
||||
|
||||
@urlmatch(netloc=r'github.com', path='/api/v3/user/emails')
|
||||
def email_handler(_, __):
|
||||
content = [{
|
||||
'email': 'someemail@example.com',
|
||||
'verified': True,
|
||||
'primary': True,
|
||||
}]
|
||||
return py_json.dumps(content)
|
||||
|
||||
with HTTMock(account_handler, email_handler, user_handler):
|
||||
self.invoke_oauth_tests('github_oauth_callback', 'github_oauth_attach', 'github',
|
||||
'someid', 'someusername')
|
||||
|
||||
def test_oidc_auth(self):
|
||||
private_key = RSA.generate(2048)
|
||||
generatedjwk = RSAKey(key=private_key.publickey()).serialize()
|
||||
kid = 'somekey'
|
||||
private_pem = private_key.exportKey('PEM')
|
||||
|
||||
token_data = {
|
||||
'iss': app.config['TESTOIDC_LOGIN_CONFIG']['OIDC_SERVER'],
|
||||
'aud': app.config['TESTOIDC_LOGIN_CONFIG']['CLIENT_ID'],
|
||||
'nbf': int(time.time()),
|
||||
'iat': int(time.time()),
|
||||
'exp': int(time.time() + 600),
|
||||
'sub': 'cooluser',
|
||||
}
|
||||
|
||||
token_headers = {
|
||||
'kid': kid,
|
||||
}
|
||||
|
||||
id_token = jwt.encode(token_data, private_pem, 'RS256', headers=token_headers)
|
||||
|
||||
@urlmatch(netloc=r'fakeoidc', path='/token')
|
||||
def token_handler(_, request):
|
||||
if request.body.find("code=somecode") >= 0:
|
||||
content = {'access_token': 'someaccesstoken', 'id_token': id_token}
|
||||
return py_json.dumps(content)
|
||||
else:
|
||||
return {'status_code': 400, 'content': '{"message": "Invalid code"}'}
|
||||
|
||||
@urlmatch(netloc=r'fakeoidc', path='/user')
|
||||
def user_handler(_, __):
|
||||
content = {
|
||||
'sub': 'cooluser',
|
||||
'preferred_username': 'someusername',
|
||||
'email': 'someemail@example.com',
|
||||
'email_verified': True,
|
||||
}
|
||||
return py_json.dumps(content)
|
||||
|
||||
@urlmatch(netloc=r'fakeoidc', path='/jwks')
|
||||
def jwks_handler(_, __):
|
||||
jwk = generatedjwk.copy()
|
||||
jwk.update({'kid': kid})
|
||||
|
||||
content = {'keys': [jwk]}
|
||||
return py_json.dumps(content)
|
||||
|
||||
@urlmatch(netloc=r'fakeoidc', path='.+openid.+')
|
||||
def discovery_handler(_, __):
|
||||
content = {
|
||||
'scopes_supported': ['profile'],
|
||||
'authorization_endpoint': 'http://fakeoidc/authorize',
|
||||
'token_endpoint': 'http://fakeoidc/token',
|
||||
'userinfo_endpoint': 'http://fakeoidc/userinfo',
|
||||
'jwks_uri': 'http://fakeoidc/jwks',
|
||||
}
|
||||
return py_json.dumps(content)
|
||||
|
||||
with HTTMock(discovery_handler, jwks_handler, token_handler, user_handler):
|
||||
self.invoke_oauth_tests('testoidc_oauth_callback', 'testoidc_oauth_attach', 'testoidc',
|
||||
'cooluser', 'someusername')
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -76,13 +76,17 @@ class TestConfig(DefaultConfig):
|
|||
|
||||
PROMETHEUS_AGGREGATOR_URL = None
|
||||
|
||||
GITHUB_LOGIN_CONFIG = {}
|
||||
GOOGLE_LOGIN_CONFIG = {}
|
||||
|
||||
FEATURE_GITHUB_LOGIN = True
|
||||
FEATURE_GOOGLE_LOGIN = True
|
||||
FEATURE_DEX_LOGIN = True
|
||||
|
||||
DEX_LOGIN_CONFIG = {
|
||||
'CLIENT_ID': 'someclientid',
|
||||
'OIDC_SERVER': 'https://oidcserver/',
|
||||
TESTOIDC_LOGIN_CONFIG = {
|
||||
'CLIENT_ID': 'foo',
|
||||
'CLIENT_SECRET': 'bar',
|
||||
'OIDC_SERVER': 'http://fakeoidc',
|
||||
'DEBUGGING': True,
|
||||
}
|
||||
|
||||
RECAPTCHA_SITE_KEY = 'somekey'
|
||||
|
|
Reference in a new issue