Have external login always make an API request to get the authorization URL
This makes the OIDC lookup lazy, ensuring that the rest of the registry and app continues working even if one OIDC provider goes down.
This commit is contained in:
parent
fda203e4d7
commit
a9791ea419
9 changed files with 128 additions and 49 deletions
|
@ -11,7 +11,9 @@ from peewee import IntegrityError
|
||||||
|
|
||||||
import features
|
import features
|
||||||
|
|
||||||
from app import app, billing as stripe, authentication, avatar, user_analytics, all_queues
|
from app import (app, billing as stripe, authentication, avatar, user_analytics, all_queues,
|
||||||
|
oauth_login)
|
||||||
|
|
||||||
from auth import scopes
|
from auth import scopes
|
||||||
from auth.auth_context import get_authenticated_user
|
from auth.auth_context import get_authenticated_user
|
||||||
from auth.permissions import (AdministerOrganizationPermission, CreateRepositoryPermission,
|
from auth.permissions import (AdministerOrganizationPermission, CreateRepositoryPermission,
|
||||||
|
@ -24,11 +26,12 @@ from endpoints.api import (ApiResource, nickname, resource, validate_json_reques
|
||||||
query_param, require_scope, format_date, show_if,
|
query_param, require_scope, format_date, show_if,
|
||||||
require_fresh_login, path_param, define_json_response,
|
require_fresh_login, path_param, define_json_response,
|
||||||
RepositoryParamResource, page_support)
|
RepositoryParamResource, page_support)
|
||||||
from endpoints.exception import NotFound, InvalidToken
|
from endpoints.exception import NotFound, InvalidToken, InvalidRequest, DownstreamIssue
|
||||||
from endpoints.api.subscribe import subscribe
|
from endpoints.api.subscribe import subscribe
|
||||||
from endpoints.common import common_login
|
from endpoints.common import common_login
|
||||||
from endpoints.csrf import generate_csrf_token, OAUTH_CSRF_TOKEN_NAME
|
from endpoints.csrf import generate_csrf_token, OAUTH_CSRF_TOKEN_NAME
|
||||||
from endpoints.decorators import anon_allowed
|
from endpoints.decorators import anon_allowed
|
||||||
|
from oauth.oidc import DiscoveryFailureException
|
||||||
from util.useremails import (send_confirmation_email, send_recovery_email, send_change_email,
|
from util.useremails import (send_confirmation_email, send_recovery_email, send_change_email,
|
||||||
send_password_changed, send_org_recovery_email)
|
send_password_changed, send_org_recovery_email)
|
||||||
from util.names import parse_single_urn
|
from util.names import parse_single_urn
|
||||||
|
@ -692,14 +695,50 @@ class Signout(ApiResource):
|
||||||
return {'success': True}
|
return {'success': True}
|
||||||
|
|
||||||
|
|
||||||
@resource('/v1/externaltoken')
|
@resource('/v1/externallogin/<service_id>')
|
||||||
@internal_only
|
@internal_only
|
||||||
class GenerateExternalToken(ApiResource):
|
class ExternalLoginInformation(ApiResource):
|
||||||
""" Resource for generating a token for external login. """
|
""" Resource for both setting a token for external login and returning its authorization
|
||||||
@nickname('generateExternalLoginToken')
|
url.
|
||||||
def post(self):
|
"""
|
||||||
""" Generates a CSRF token explicitly for OIDC/OAuth-associated login. """
|
schemas = {
|
||||||
return {'token': generate_csrf_token(OAUTH_CSRF_TOKEN_NAME)}
|
'GetLogin': {
|
||||||
|
'type': 'object',
|
||||||
|
'description': 'Information required to an retrieve external login URL.',
|
||||||
|
'required': [
|
||||||
|
'kind',
|
||||||
|
],
|
||||||
|
'properties': {
|
||||||
|
'kind': {
|
||||||
|
'type': 'string',
|
||||||
|
'description': 'The kind of URL',
|
||||||
|
'enum': ['login', 'attach'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@nickname('retrieveExternalLoginAuthorizationUrl')
|
||||||
|
@anon_allowed
|
||||||
|
@validate_json_request('GetLogin')
|
||||||
|
def post(self, service_id):
|
||||||
|
""" Generates the auth URL and CSRF token explicitly for OIDC/OAuth-associated login. """
|
||||||
|
login_service = oauth_login.get_service(service_id)
|
||||||
|
if login_service is None:
|
||||||
|
raise InvalidRequest()
|
||||||
|
|
||||||
|
csrf_token = generate_csrf_token(OAUTH_CSRF_TOKEN_NAME)
|
||||||
|
kind = request.get_json()['kind']
|
||||||
|
redirect_suffix = '/attach' if kind == 'attach' else ''
|
||||||
|
|
||||||
|
try:
|
||||||
|
login_scopes = login_service.get_login_scopes()
|
||||||
|
auth_url = login_service.get_auth_url(app.config, redirect_suffix, csrf_token, login_scopes)
|
||||||
|
return {'auth_url': auth_url}
|
||||||
|
except DiscoveryFailureException as dfe:
|
||||||
|
logger.exception('Could not discovery OAuth endpoint information')
|
||||||
|
raise DownstreamIssue(dfe.message)
|
||||||
|
|
||||||
|
|
||||||
@resource('/v1/detachexternal/<service_id>')
|
@resource('/v1/detachexternal/<service_id>')
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
import logging
|
import logging
|
||||||
|
import urllib
|
||||||
|
|
||||||
|
from util import get_app_url
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -10,7 +13,6 @@ class OAuthGetUserInfoException(Exception):
|
||||||
""" Exception raised if a call to get user information fails. """
|
""" Exception raised if a call to get user information fails. """
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class OAuthService(object):
|
class OAuthService(object):
|
||||||
""" A base class for defining an external service, exposed via OAuth. """
|
""" A base class for defining an external service, exposed via OAuth. """
|
||||||
def __init__(self, config, key_name):
|
def __init__(self, config, key_name):
|
||||||
|
@ -38,6 +40,10 @@ class OAuthService(object):
|
||||||
""" Performs validation of the client ID and secret, raising an exception on failure. """
|
""" Performs validation of the client ID and secret, raising an exception on failure. """
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def authorize_endpoint(self):
|
||||||
|
""" Endpoint for authorization. """
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
def requires_form_encoding(self):
|
def requires_form_encoding(self):
|
||||||
""" Returns True if form encoding is necessary for the exchange_code_for_token call. """
|
""" Returns True if form encoding is necessary for the exchange_code_for_token call. """
|
||||||
return False
|
return False
|
||||||
|
@ -48,6 +54,20 @@ class OAuthService(object):
|
||||||
def client_secret(self):
|
def client_secret(self):
|
||||||
return self.config.get('CLIENT_SECRET')
|
return self.config.get('CLIENT_SECRET')
|
||||||
|
|
||||||
|
def get_auth_url(self, app_config, redirect_suffix, csrf_token, scopes):
|
||||||
|
""" Retrieves the authorization URL for this login service. """
|
||||||
|
redirect_uri = '%s/oauth2/%s/callback%s' % (get_app_url(app_config), self.service_id(),
|
||||||
|
redirect_suffix)
|
||||||
|
params = {
|
||||||
|
'client_id': self.client_id(),
|
||||||
|
'redirect_uri': redirect_uri,
|
||||||
|
'scope': ' '.join(scopes),
|
||||||
|
'state': csrf_token,
|
||||||
|
}
|
||||||
|
|
||||||
|
authorize_url = '%s%s' % (self.authorize_endpoint(), urllib.urlencode(params))
|
||||||
|
return authorize_url
|
||||||
|
|
||||||
def get_redirect_uri(self, app_config, redirect_suffix=''):
|
def get_redirect_uri(self, app_config, redirect_suffix=''):
|
||||||
return '%s://%s/oauth2/%s/callback%s' % (app_config['PREFERRED_URL_SCHEME'],
|
return '%s://%s/oauth2/%s/callback%s' % (app_config['PREFERRED_URL_SCHEME'],
|
||||||
app_config['SERVER_HOSTNAME'],
|
app_config['SERVER_HOSTNAME'],
|
||||||
|
|
|
@ -22,3 +22,10 @@ class OAuthLoginManager(object):
|
||||||
self.services.append(custom_service)
|
self.services.append(custom_service)
|
||||||
else:
|
else:
|
||||||
self.services.append(OIDCLoginService(config, key))
|
self.services.append(OIDCLoginService(config, key))
|
||||||
|
|
||||||
|
def get_service(self, service_id):
|
||||||
|
for service in self.services:
|
||||||
|
if service.service_id() == service_id:
|
||||||
|
return service
|
||||||
|
|
||||||
|
return None
|
|
@ -14,7 +14,6 @@ from jwkest.jwk import KEYS
|
||||||
from oauth.base import OAuthService, OAuthExchangeCodeException, OAuthGetUserInfoException
|
from oauth.base import OAuthService, OAuthExchangeCodeException, OAuthGetUserInfoException
|
||||||
from oauth.login import OAuthLoginException
|
from oauth.login import OAuthLoginException
|
||||||
from util.security.jwtutil import decode, InvalidTokenError
|
from util.security.jwtutil import decode, InvalidTokenError
|
||||||
from util import get_app_url
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -28,7 +27,6 @@ class DiscoveryFailureException(Exception):
|
||||||
""" Exception raised when OIDC discovery fails. """
|
""" Exception raised when OIDC discovery fails. """
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PublicKeyLoadException(Exception):
|
class PublicKeyLoadException(Exception):
|
||||||
""" Exception raised if loading the OIDC public key fails. """
|
""" Exception raised if loading the OIDC public key fails. """
|
||||||
pass
|
pass
|
||||||
|
@ -75,14 +73,7 @@ class OIDCLoginService(OAuthService):
|
||||||
|
|
||||||
def validate_client_id_and_secret(self, http_client, app_config):
|
def validate_client_id_and_secret(self, http_client, app_config):
|
||||||
# TODO: find a way to verify client secret too.
|
# TODO: find a way to verify client secret too.
|
||||||
redirect_url = '%s/oauth2/%s/callback' % (get_app_url(app_config), self.service_id())
|
check_auth_url = http_client.get(self.get_auth_url())
|
||||||
scopes_string = ' '.join(self.get_login_scopes())
|
|
||||||
authorize_url = '%sclient_id=%s&redirect_uri=%s&scope=%s' % (self.authorize_endpoint(),
|
|
||||||
self.client_id(),
|
|
||||||
redirect_url,
|
|
||||||
scopes_string)
|
|
||||||
|
|
||||||
check_auth_url = http_client.get(authorize_url)
|
|
||||||
if check_auth_url.status_code // 100 != 2:
|
if check_auth_url.status_code // 100 != 2:
|
||||||
raise Exception('Got non-200 status code for authorization endpoint')
|
raise Exception('Got non-200 status code for authorization endpoint')
|
||||||
|
|
||||||
|
|
|
@ -20,9 +20,7 @@ angular.module('quay').directive('externalLoginButton', function () {
|
||||||
|
|
||||||
$scope.startSignin = function() {
|
$scope.startSignin = function() {
|
||||||
$scope.signInStarted({'service': $scope.provider});
|
$scope.signInStarted({'service': $scope.provider});
|
||||||
ApiService.generateExternalLoginToken().then(function(data) {
|
ExternalLoginService.getLoginUrl($scope.provider, $scope.action || 'login', function(url) {
|
||||||
var url = ExternalLoginService.getLoginUrl($scope.provider, $scope.action || 'login');
|
|
||||||
url = url + '&state=' + encodeURIComponent(data['token']);
|
|
||||||
|
|
||||||
// Save the redirect URL in a cookie so that we can redirect back after the service returns to us.
|
// Save the redirect URL in a cookie so that we can redirect back after the service returns to us.
|
||||||
var redirectURL = $scope.redirectUrl || window.location.toString();
|
var redirectURL = $scope.redirectUrl || window.location.toString();
|
||||||
|
@ -34,7 +32,7 @@ angular.module('quay').directive('externalLoginButton', function () {
|
||||||
$timeout(function() {
|
$timeout(function() {
|
||||||
document.location = url;
|
document.location = url;
|
||||||
}, 250);
|
}, 250);
|
||||||
}, ApiService.errorDisplay('Could not perform sign in'));
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -16,7 +16,9 @@ angular.module('quay').directive('headerBar', function () {
|
||||||
PlanService, ApiService, NotificationService, Config, Features,
|
PlanService, ApiService, NotificationService, Config, Features,
|
||||||
DocumentationService, ExternalLoginService) {
|
DocumentationService, ExternalLoginService) {
|
||||||
|
|
||||||
$scope.externalSigninUrl = ExternalLoginService.getSingleSigninUrl();
|
ExternalLoginService.getSingleSigninUrl(function(url) {
|
||||||
|
$scope.externalSigninUrl = url;
|
||||||
|
});
|
||||||
|
|
||||||
var hotkeysAdded = false;
|
var hotkeysAdded = false;
|
||||||
var userUpdated = function(cUser) {
|
var userUpdated = function(cUser) {
|
||||||
|
|
|
@ -11,9 +11,10 @@
|
||||||
function SignInCtrl($scope, $location, ExternalLoginService, Features) {
|
function SignInCtrl($scope, $location, ExternalLoginService, Features) {
|
||||||
$scope.redirectUrl = '/';
|
$scope.redirectUrl = '/';
|
||||||
|
|
||||||
var singleUrl = ExternalLoginService.getSingleSigninUrl();
|
ExternalLoginService.getSingleSigninUrl(function(singleUrl) {
|
||||||
if (singleUrl) {
|
if (singleUrl) {
|
||||||
document.location = singleUrl;
|
document.location = singleUrl;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -1,40 +1,43 @@
|
||||||
/**
|
/**
|
||||||
* Service which exposes the supported external logins.
|
* Service which exposes the supported external logins.
|
||||||
*/
|
*/
|
||||||
angular.module('quay').factory('ExternalLoginService', ['Features', 'Config',
|
angular.module('quay').factory('ExternalLoginService', ['Features', 'Config', 'ApiService',
|
||||||
function(Features, Config) {
|
function(Features, Config, ApiService) {
|
||||||
var externalLoginService = {};
|
var externalLoginService = {};
|
||||||
|
|
||||||
externalLoginService.EXTERNAL_LOGINS = window.__external_login;
|
externalLoginService.EXTERNAL_LOGINS = window.__external_login;
|
||||||
|
|
||||||
externalLoginService.getLoginUrl = function(loginService, action) {
|
externalLoginService.getLoginUrl = function(loginService, action, callback) {
|
||||||
var loginUrl = loginService['config']['AUTHORIZE_ENDPOINT'];
|
var errorDisplay = ApiService.errorDisplay('Could not load external login service ' +
|
||||||
var clientId = loginService['config']['CLIENT_ID'];
|
'information. Please contact your service ' +
|
||||||
|
'administrator.')
|
||||||
|
|
||||||
var scope = loginService.scopes.join(' ');
|
var params = {
|
||||||
var redirectUri = Config.getUrl('/oauth2/' + loginService['id'] + '/callback');
|
'service_id': loginService['id']
|
||||||
|
};
|
||||||
|
|
||||||
if (action == 'attach') {
|
var data = {
|
||||||
redirectUri += '/attach';
|
'kind': action
|
||||||
}
|
};
|
||||||
|
|
||||||
var url = loginUrl + 'client_id=' + clientId + '&scope=' + scope + '&redirect_uri=' +
|
ApiService.retrieveExternalLoginAuthorizationUrl(data, params).then(function(resp) {
|
||||||
redirectUri;
|
callback(resp['auth_url']);
|
||||||
return url;
|
}, errorDisplay);
|
||||||
};
|
};
|
||||||
|
|
||||||
externalLoginService.hasSingleSignin = function() {
|
externalLoginService.hasSingleSignin = function() {
|
||||||
return externalLoginService.EXTERNAL_LOGINS.length == 1 && !Features.DIRECT_LOGIN;
|
return externalLoginService.EXTERNAL_LOGINS.length == 1 && !Features.DIRECT_LOGIN;
|
||||||
};
|
};
|
||||||
|
|
||||||
externalLoginService.getSingleSigninUrl = function() {
|
externalLoginService.getSingleSigninUrl = function(callback) {
|
||||||
// If there is a single external login service and direct login is disabled,
|
if (!externalLoginService.hasSingleSignin()) {
|
||||||
// then redirect to the external login directly.
|
callback(null);
|
||||||
if (externalLoginService.hasSingleSignin()) {
|
return;
|
||||||
return externalLoginService.getLoginUrl(externalLoginService.EXTERNAL_LOGINS[0]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
// If there is a single external login service and direct login is disabled,
|
||||||
|
// then redirect to the external login directly.
|
||||||
|
externalLoginService.getLoginUrl(externalLoginService.EXTERNAL_LOGINS[0], 'login', callback);
|
||||||
};
|
};
|
||||||
|
|
||||||
return externalLoginService;
|
return externalLoginService;
|
||||||
|
|
|
@ -28,7 +28,7 @@ from endpoints.api.repositorynotification import RepositoryNotification, Reposit
|
||||||
from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Recovery, Signout,
|
from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Recovery, Signout,
|
||||||
Signin, User, UserAuthorizationList, UserAuthorization, UserNotification,
|
Signin, User, UserAuthorizationList, UserAuthorization, UserNotification,
|
||||||
VerifyUser, DetachExternal, StarredRepositoryList, StarredRepository,
|
VerifyUser, DetachExternal, StarredRepositoryList, StarredRepository,
|
||||||
ClientKey)
|
ClientKey, ExternalLoginInformation)
|
||||||
from endpoints.api.repotoken import RepositoryToken, RepositoryTokenList
|
from endpoints.api.repotoken import RepositoryToken, RepositoryTokenList
|
||||||
from endpoints.api.prototype import PermissionPrototype, PermissionPrototypeList
|
from endpoints.api.prototype import PermissionPrototype, PermissionPrototypeList
|
||||||
from endpoints.api.logs import UserLogs, OrgLogs, RepositoryLogs
|
from endpoints.api.logs import UserLogs, OrgLogs, RepositoryLogs
|
||||||
|
@ -504,6 +504,24 @@ class TestSignin(ApiTestCase):
|
||||||
self._run_test('POST', 403, 'devtable', {u'username': 'E9RY', u'password': 'LQ0N'})
|
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):
|
class TestDetachExternal(ApiTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
ApiTestCase.setUp(self)
|
ApiTestCase.setUp(self)
|
||||||
|
|
Reference in a new issue