Merge pull request #2695 from coreos-inc/oidc-internal-auth
OIDC internal auth support
This commit is contained in:
commit
3bef21253d
29 changed files with 341 additions and 38 deletions
|
@ -2,8 +2,8 @@ import sys
|
||||||
import os
|
import os
|
||||||
sys.path.append(os.path.join(os.path.dirname(__file__), "../"))
|
sys.path.append(os.path.join(os.path.dirname(__file__), "../"))
|
||||||
|
|
||||||
from util.log import logfile_path
|
|
||||||
from Crypto import Random
|
from Crypto import Random
|
||||||
|
from util.log import logfile_path
|
||||||
|
|
||||||
|
|
||||||
logconfig = logfile_path(debug=True)
|
logconfig = logfile_path(debug=True)
|
||||||
|
|
|
@ -2,8 +2,8 @@ import sys
|
||||||
import os
|
import os
|
||||||
sys.path.append(os.path.join(os.path.dirname(__file__), "../"))
|
sys.path.append(os.path.join(os.path.dirname(__file__), "../"))
|
||||||
|
|
||||||
from util.log import logfile_path
|
|
||||||
from Crypto import Random
|
from Crypto import Random
|
||||||
|
from util.log import logfile_path
|
||||||
|
|
||||||
|
|
||||||
logconfig = logfile_path(debug=False)
|
logconfig = logfile_path(debug=False)
|
||||||
|
|
|
@ -2,8 +2,8 @@ import sys
|
||||||
import os
|
import os
|
||||||
sys.path.append(os.path.join(os.path.dirname(__file__), "../"))
|
sys.path.append(os.path.join(os.path.dirname(__file__), "../"))
|
||||||
|
|
||||||
from util.log import logfile_path
|
|
||||||
from Crypto import Random
|
from Crypto import Random
|
||||||
|
from util.log import logfile_path
|
||||||
|
|
||||||
|
|
||||||
logconfig = logfile_path(debug=False)
|
logconfig = logfile_path(debug=False)
|
||||||
|
|
|
@ -2,9 +2,8 @@ import sys
|
||||||
import os
|
import os
|
||||||
sys.path.append(os.path.join(os.path.dirname(__file__), "../"))
|
sys.path.append(os.path.join(os.path.dirname(__file__), "../"))
|
||||||
|
|
||||||
from util.log import logfile_path
|
|
||||||
from Crypto import Random
|
from Crypto import Random
|
||||||
|
from util.log import logfile_path
|
||||||
|
|
||||||
logconfig = logfile_path(debug=False)
|
logconfig = logfile_path(debug=False)
|
||||||
|
|
||||||
|
|
|
@ -2,8 +2,8 @@ import sys
|
||||||
import os
|
import os
|
||||||
sys.path.append(os.path.join(os.path.dirname(__file__), "../"))
|
sys.path.append(os.path.join(os.path.dirname(__file__), "../"))
|
||||||
|
|
||||||
from util.log import logfile_path
|
|
||||||
from Crypto import Random
|
from Crypto import Random
|
||||||
|
from util.log import logfile_path
|
||||||
|
|
||||||
|
|
||||||
logconfig = logfile_path(debug=False)
|
logconfig = logfile_path(debug=False)
|
||||||
|
|
|
@ -23,7 +23,7 @@ CLIENT_WHITELIST = ['SERVER_HOSTNAME', 'PREFERRED_URL_SCHEME', 'MIXPANEL_KEY',
|
||||||
'CONTACT_INFO', 'AVATAR_KIND', 'LOCAL_OAUTH_HANDLER', 'DOCUMENTATION_LOCATION',
|
'CONTACT_INFO', 'AVATAR_KIND', 'LOCAL_OAUTH_HANDLER', 'DOCUMENTATION_LOCATION',
|
||||||
'DOCUMENTATION_METADATA', 'SETUP_COMPLETE', 'DEBUG', 'MARKETO_MUNCHKIN_ID',
|
'DOCUMENTATION_METADATA', 'SETUP_COMPLETE', 'DEBUG', 'MARKETO_MUNCHKIN_ID',
|
||||||
'STATIC_SITE_BUCKET', 'RECAPTCHA_SITE_KEY', 'CHANNEL_COLORS',
|
'STATIC_SITE_BUCKET', 'RECAPTCHA_SITE_KEY', 'CHANNEL_COLORS',
|
||||||
'TAG_EXPIRATION_OPTIONS']
|
'TAG_EXPIRATION_OPTIONS', 'INTERNAL_OIDC_SERVICE_ID']
|
||||||
|
|
||||||
|
|
||||||
def frontend_visible_config(config_dict):
|
def frontend_visible_config(config_dict):
|
||||||
|
|
|
@ -10,6 +10,7 @@ from data.users.database import DatabaseUsers
|
||||||
from data.users.externalldap import LDAPUsers
|
from data.users.externalldap import LDAPUsers
|
||||||
from data.users.externaljwt import ExternalJWTAuthN
|
from data.users.externaljwt import ExternalJWTAuthN
|
||||||
from data.users.keystone import get_keystone_users
|
from data.users.keystone import get_keystone_users
|
||||||
|
from data.users.oidc import OIDCInternalAuth
|
||||||
from util.security.aes import AESCipher
|
from util.security.aes import AESCipher
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -24,6 +25,12 @@ def get_federated_service_name(authentication_type):
|
||||||
if authentication_type == 'Keystone':
|
if authentication_type == 'Keystone':
|
||||||
return 'keystone'
|
return 'keystone'
|
||||||
|
|
||||||
|
if authentication_type == 'OIDC':
|
||||||
|
return None
|
||||||
|
|
||||||
|
if authentication_type == 'Database':
|
||||||
|
return None
|
||||||
|
|
||||||
raise Exception('Unknown auth type: %s' % authentication_type)
|
raise Exception('Unknown auth type: %s' % authentication_type)
|
||||||
|
|
||||||
|
|
||||||
|
@ -77,6 +84,13 @@ def get_users_handler(config, _, override_config_dir):
|
||||||
keystone_admin_password, keystone_admin_tenant, timeout,
|
keystone_admin_password, keystone_admin_tenant, timeout,
|
||||||
requires_email=features.MAILING)
|
requires_email=features.MAILING)
|
||||||
|
|
||||||
|
if authentication_type == 'OIDC':
|
||||||
|
if features.DIRECT_LOGIN:
|
||||||
|
raise Exception('Direct login feature must be disabled to use OIDC internal auth')
|
||||||
|
|
||||||
|
login_service = config.get('INTERNAL_OIDC_SERVICE_ID')
|
||||||
|
return OIDCInternalAuth(config, login_service, requires_email=features.MAILING)
|
||||||
|
|
||||||
raise RuntimeError('Unknown authentication type: %s' % authentication_type)
|
raise RuntimeError('Unknown authentication type: %s' % authentication_type)
|
||||||
|
|
||||||
class UserAuthentication(object):
|
class UserAuthentication(object):
|
||||||
|
@ -160,6 +174,11 @@ class UserAuthentication(object):
|
||||||
"""
|
"""
|
||||||
return self.state.federated_service
|
return self.state.federated_service
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_encrypted_credentials(self):
|
||||||
|
""" Returns whether this auth system supports using encrypted credentials. """
|
||||||
|
return self.state.supports_encrypted_credentials
|
||||||
|
|
||||||
def query_users(self, query, limit=20):
|
def query_users(self, query, limit=20):
|
||||||
""" Performs a lookup against the user system for the specified query. The returned tuple
|
""" Performs a lookup against the user system for the specified query. The returned tuple
|
||||||
will be of the form (results, federated_login_id, err_msg). If the method is unsupported,
|
will be of the form (results, federated_login_id, err_msg). If the method is unsupported,
|
||||||
|
|
|
@ -9,6 +9,10 @@ class DatabaseUsers(object):
|
||||||
""" Always assumed to be working. If the DB is broken, other checks will handle it. """
|
""" Always assumed to be working. If the DB is broken, other checks will handle it. """
|
||||||
return (True, None)
|
return (True, None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_encrypted_credentials(self):
|
||||||
|
return True
|
||||||
|
|
||||||
def verify_credentials(self, username_or_email, password):
|
def verify_credentials(self, username_or_email, password):
|
||||||
""" Simply delegate to the model implementation. """
|
""" Simply delegate to the model implementation. """
|
||||||
result = model.user.verify_user(username_or_email, password)
|
result = model.user.verify_user(username_or_email, password)
|
||||||
|
|
|
@ -24,6 +24,10 @@ class FederatedUsers(object):
|
||||||
def federated_service(self):
|
def federated_service(self):
|
||||||
return self._federated_service
|
return self._federated_service
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_encrypted_credentials(self):
|
||||||
|
return True
|
||||||
|
|
||||||
def get_user(self, username_or_email):
|
def get_user(self, username_or_email):
|
||||||
""" Retrieves the user with the given username or email, returning a tuple containing
|
""" Retrieves the user with the given username or email, returning a tuple containing
|
||||||
a UserInformation (if success) and the error message (on failure).
|
a UserInformation (if success) and the error message (on failure).
|
||||||
|
|
83
data/users/oidc.py
Normal file
83
data/users/oidc.py
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from data import model
|
||||||
|
from oauth.loginmanager import OAuthLoginManager
|
||||||
|
from oauth.oidc import PublicKeyLoadException
|
||||||
|
from util.security.jwtutil import InvalidTokenError
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownServiceException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCInternalAuth(object):
|
||||||
|
""" Handles authentication by delegating authentication to a signed OIDC JWT produced by the
|
||||||
|
configured OIDC service.
|
||||||
|
"""
|
||||||
|
def __init__(self, config, login_service_id, requires_email):
|
||||||
|
login_manager = OAuthLoginManager(config)
|
||||||
|
|
||||||
|
self.login_service_id = login_service_id
|
||||||
|
self.login_service = login_manager.get_service(login_service_id)
|
||||||
|
if self.login_service is None:
|
||||||
|
raise UnknownServiceException('Unknown OIDC login service %s' % login_service_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def federated_service(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_encrypted_credentials(self):
|
||||||
|
# Since the "password" is already a signed JWT.
|
||||||
|
return False
|
||||||
|
|
||||||
|
def verify_credentials(self, username_or_email, id_token):
|
||||||
|
# Parse the ID token.
|
||||||
|
try:
|
||||||
|
payload = self.login_service.decode_user_jwt(id_token)
|
||||||
|
except InvalidTokenError as ite:
|
||||||
|
logger.exception('Got invalid token error on OIDC decode: %s. Token: %s', ite.message, id_token)
|
||||||
|
return (None, 'Could not validate OIDC token')
|
||||||
|
except PublicKeyLoadException as pke:
|
||||||
|
logger.exception('Could not load public key during OIDC decode: %s. Token: %s', pke.message, id_token)
|
||||||
|
return (None, 'Could not validate OIDC token')
|
||||||
|
|
||||||
|
# Find the user ID.
|
||||||
|
user_id = payload['sub']
|
||||||
|
|
||||||
|
# Lookup the federated login and user record with that matching ID and service.
|
||||||
|
user_found = model.user.verify_federated_login(self.login_service_id, user_id)
|
||||||
|
if user_found is None:
|
||||||
|
return (None, 'User does not exist')
|
||||||
|
|
||||||
|
if not user_found.enabled:
|
||||||
|
return (None, 'User account is disabled. Please contact your administrator.')
|
||||||
|
|
||||||
|
return (user_found, None)
|
||||||
|
|
||||||
|
def verify_and_link_user(self, username_or_email, password):
|
||||||
|
return self.verify_credentials(username_or_email, password)
|
||||||
|
|
||||||
|
def confirm_existing_user(self, username, password):
|
||||||
|
return self.verify_credentials(username, password)
|
||||||
|
|
||||||
|
def link_user(self, username_or_email):
|
||||||
|
return (None, 'Unsupported for this authentication system')
|
||||||
|
|
||||||
|
def get_and_link_federated_user_info(self, user_info):
|
||||||
|
return (None, 'Unsupported for this authentication system')
|
||||||
|
|
||||||
|
def query_users(self, query, limit):
|
||||||
|
return (None, '', '')
|
||||||
|
|
||||||
|
def check_group_lookup_args(self, group_lookup_args):
|
||||||
|
return (False, 'Not supported')
|
||||||
|
|
||||||
|
def iterate_group_members(self, group_lookup_args, page_size=None, disable_pagination=False):
|
||||||
|
return (None, 'Not supported')
|
||||||
|
|
||||||
|
def service_metadata(self):
|
||||||
|
return {}
|
38
data/users/test/test_oidc.py
Normal file
38
data/users/test/test_oidc.py
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from httmock import HTTMock
|
||||||
|
|
||||||
|
from data import model
|
||||||
|
from data.users.oidc import OIDCInternalAuth
|
||||||
|
from oauth.test.test_oidc import *
|
||||||
|
from test.fixtures import *
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('username, expect_success', [
|
||||||
|
('devtable', True),
|
||||||
|
('disabled', False)
|
||||||
|
])
|
||||||
|
def test_oidc_login(username, expect_success, app_config, id_token, jwks_handler,
|
||||||
|
discovery_handler, app):
|
||||||
|
internal_auth = OIDCInternalAuth(app_config, 'someoidc', False)
|
||||||
|
with HTTMock(jwks_handler, discovery_handler):
|
||||||
|
# Try an invalid token.
|
||||||
|
(user, err) = internal_auth.verify_credentials('someusername', 'invalidtoken')
|
||||||
|
assert err is not None
|
||||||
|
assert user is None
|
||||||
|
|
||||||
|
# Try a valid token for an unlinked user.
|
||||||
|
(user, err) = internal_auth.verify_credentials('someusername', id_token)
|
||||||
|
assert err is not None
|
||||||
|
assert user is None
|
||||||
|
|
||||||
|
# Link the user to the service.
|
||||||
|
model.user.attach_federated_login(model.user.get_user(username), 'someoidc', 'cooluser')
|
||||||
|
|
||||||
|
# Try a valid token for a linked user.
|
||||||
|
(user, err) = internal_auth.verify_credentials('someusername', id_token)
|
||||||
|
if expect_success:
|
||||||
|
assert err is None
|
||||||
|
assert user.username == username
|
||||||
|
else:
|
||||||
|
assert err is not None
|
||||||
|
assert user is None
|
|
@ -217,9 +217,10 @@ class SuperUserConfig(ApiResource):
|
||||||
# Write the configuration changes to the config override file.
|
# Write the configuration changes to the config override file.
|
||||||
config_provider.save_config(config_object)
|
config_provider.save_config(config_object)
|
||||||
|
|
||||||
# If the authentication system is not the database, link the superuser account to the
|
# If the authentication system is federated, link the superuser account to the
|
||||||
# the authentication system chosen.
|
# the authentication system chosen.
|
||||||
if config_object.get('AUTHENTICATION_TYPE', 'Database') != 'Database':
|
service_name = get_federated_service_name(config_object['AUTHENTICATION_TYPE'])
|
||||||
|
if service_name is not None:
|
||||||
current_user = get_authenticated_user()
|
current_user = get_authenticated_user()
|
||||||
if current_user is None:
|
if current_user is None:
|
||||||
abort(401)
|
abort(401)
|
||||||
|
|
|
@ -529,6 +529,9 @@ class ClientKey(ApiResource):
|
||||||
@validate_json_request('GenerateClientKey')
|
@validate_json_request('GenerateClientKey')
|
||||||
def post(self):
|
def post(self):
|
||||||
""" Return's the user's private client key. """
|
""" Return's the user's private client key. """
|
||||||
|
if not authentication.supports_encrypted_credentials:
|
||||||
|
raise NotFound()
|
||||||
|
|
||||||
username = get_authenticated_user().username
|
username = get_authenticated_user().username
|
||||||
password = request.get_json()['password']
|
password = request.get_json()['password']
|
||||||
(result, error_message) = authentication.confirm_existing_user(username, password)
|
(result, error_message) = authentication.confirm_existing_user(username, password)
|
||||||
|
@ -744,7 +747,7 @@ class ExternalLoginInformation(ApiResource):
|
||||||
'kind': {
|
'kind': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'description': 'The kind of URL',
|
'description': 'The kind of URL',
|
||||||
'enum': ['login', 'attach'],
|
'enum': ['login', 'attach', 'cli'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -762,7 +765,7 @@ class ExternalLoginInformation(ApiResource):
|
||||||
|
|
||||||
csrf_token = generate_csrf_token(OAUTH_CSRF_TOKEN_NAME)
|
csrf_token = generate_csrf_token(OAUTH_CSRF_TOKEN_NAME)
|
||||||
kind = request.get_json()['kind']
|
kind = request.get_json()['kind']
|
||||||
redirect_suffix = '/attach' if kind == 'attach' else ''
|
redirect_suffix = '' if kind == 'login' else '/' + kind
|
||||||
|
|
||||||
try:
|
try:
|
||||||
login_scopes = login_service.get_login_scopes()
|
login_scopes = login_service.get_login_scopes()
|
||||||
|
|
|
@ -252,6 +252,24 @@ def _register_service(login_service):
|
||||||
auth_url = login_service.get_auth_url(app.config, '', csrf_token, login_scopes)
|
auth_url = login_service.get_auth_url(app.config, '', csrf_token, login_scopes)
|
||||||
return redirect(auth_url)
|
return redirect(auth_url)
|
||||||
|
|
||||||
|
@require_session_login
|
||||||
|
@oauthlogin_csrf_protect
|
||||||
|
def cli_token_func():
|
||||||
|
# Check for a callback error.
|
||||||
|
error = request.args.get('error', None)
|
||||||
|
if error:
|
||||||
|
return _render_ologin_error(login_service.service_name(), error)
|
||||||
|
|
||||||
|
# Exchange the OAuth code for the ID token.
|
||||||
|
code = request.args.get('code')
|
||||||
|
try:
|
||||||
|
idtoken, _ = login_service.exchange_code_for_tokens(app.config, client, code, '/cli')
|
||||||
|
except OAuthLoginException as ole:
|
||||||
|
return _render_ologin_error(login_service.service_name(), ole.message)
|
||||||
|
|
||||||
|
user_obj = get_authenticated_user()
|
||||||
|
return redirect(url_for('web.user_view', path=user_obj.username, tab='settings',
|
||||||
|
idtoken=idtoken))
|
||||||
|
|
||||||
oauthlogin.add_url_rule('/%s/callback/captcha' % login_service.service_id(),
|
oauthlogin.add_url_rule('/%s/callback/captcha' % login_service.service_id(),
|
||||||
'%s_oauth_captcha' % login_service.service_id(),
|
'%s_oauth_captcha' % login_service.service_id(),
|
||||||
|
@ -268,6 +286,11 @@ def _register_service(login_service):
|
||||||
attach_func,
|
attach_func,
|
||||||
methods=['GET'])
|
methods=['GET'])
|
||||||
|
|
||||||
|
oauthlogin.add_url_rule('/%s/callback/cli' % login_service.service_id(),
|
||||||
|
'%s_oauth_cli' % login_service.service_id(),
|
||||||
|
cli_token_func,
|
||||||
|
methods=['GET'])
|
||||||
|
|
||||||
# Register the routes for each of the login services.
|
# Register the routes for each of the login services.
|
||||||
for current_service in oauth_login.services:
|
for current_service in oauth_login.services:
|
||||||
_register_service(current_service)
|
_register_service(current_service)
|
||||||
|
|
|
@ -267,6 +267,7 @@ def initialize_database():
|
||||||
LoginService.create(name='jwtauthn')
|
LoginService.create(name='jwtauthn')
|
||||||
LoginService.create(name='keystone')
|
LoginService.create(name='keystone')
|
||||||
LoginService.create(name='dex')
|
LoginService.create(name='dex')
|
||||||
|
LoginService.create(name='oidc')
|
||||||
|
|
||||||
BuildTriggerService.create(name='github')
|
BuildTriggerService.create(name='github')
|
||||||
BuildTriggerService.create(name='custom-git')
|
BuildTriggerService.create(name='custom-git')
|
||||||
|
|
|
@ -97,7 +97,6 @@ class OAuthService(object):
|
||||||
|
|
||||||
def get_user_info(self, http_client, token):
|
def get_user_info(self, http_client, token):
|
||||||
token_param = {
|
token_param = {
|
||||||
'access_token': token,
|
|
||||||
'alt': 'json',
|
'alt': 'json',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
from oauth.services.github import GithubOAuthService
|
from oauth.services.github import GithubOAuthService
|
||||||
from oauth.services.google import GoogleOAuthService
|
from oauth.services.google import GoogleOAuthService
|
||||||
from oauth.oidc import OIDCLoginService
|
from oauth.oidc import OIDCLoginService
|
||||||
from data.users import UserAuthentication
|
|
||||||
|
|
||||||
CUSTOM_LOGIN_SERVICES = {
|
CUSTOM_LOGIN_SERVICES = {
|
||||||
'GITHUB_LOGIN_CONFIG': GithubOAuthService,
|
'GITHUB_LOGIN_CONFIG': GithubOAuthService,
|
||||||
|
|
|
@ -39,7 +39,7 @@ class OIDCLoginService(OAuthService):
|
||||||
|
|
||||||
self._public_key_cache = TTLCache(1, PUBLIC_KEY_CACHE_TTL, missing=self._load_public_key)
|
self._public_key_cache = TTLCache(1, PUBLIC_KEY_CACHE_TTL, missing=self._load_public_key)
|
||||||
self._id = key_name[0:key_name.find('_')].lower()
|
self._id = key_name[0:key_name.find('_')].lower()
|
||||||
self._http_client = client or config['HTTPCLIENT']
|
self._http_client = client or config.get('HTTPCLIENT')
|
||||||
self._mailing = config.get('FEATURE_MAILING', False)
|
self._mailing = config.get('FEATURE_MAILING', False)
|
||||||
|
|
||||||
def service_id(self):
|
def service_id(self):
|
||||||
|
@ -89,7 +89,7 @@ class OIDCLoginService(OAuthService):
|
||||||
'OIDC': True,
|
'OIDC': True,
|
||||||
}
|
}
|
||||||
|
|
||||||
def exchange_code_for_login(self, app_config, http_client, code, redirect_suffix):
|
def exchange_code_for_tokens(self, app_config, http_client, code, redirect_suffix):
|
||||||
# Exchange the code for the access token and id_token
|
# Exchange the code for the access token and id_token
|
||||||
try:
|
try:
|
||||||
json_data = self.exchange_code(app_config, http_client, code,
|
json_data = self.exchange_code(app_config, http_client, code,
|
||||||
|
@ -109,9 +109,16 @@ class OIDCLoginService(OAuthService):
|
||||||
logger.debug('Missing id_token in response: %s', json_data)
|
logger.debug('Missing id_token in response: %s', json_data)
|
||||||
raise OAuthLoginException('Missing `id_token` in OIDC response')
|
raise OAuthLoginException('Missing `id_token` in OIDC response')
|
||||||
|
|
||||||
|
return id_token, access_token
|
||||||
|
|
||||||
|
def exchange_code_for_login(self, app_config, http_client, code, redirect_suffix):
|
||||||
|
# Exchange the code for the access token and id_token
|
||||||
|
id_token, access_token = self.exchange_code_for_tokens(app_config, http_client, code,
|
||||||
|
redirect_suffix)
|
||||||
|
|
||||||
# Decode the id_token.
|
# Decode the id_token.
|
||||||
try:
|
try:
|
||||||
decoded_id_token = self._decode_user_jwt(id_token)
|
decoded_id_token = self.decode_user_jwt(id_token)
|
||||||
except InvalidTokenError as ite:
|
except InvalidTokenError as ite:
|
||||||
logger.exception('Got invalid token error on OIDC decode: %s', ite.message)
|
logger.exception('Got invalid token error on OIDC decode: %s', ite.message)
|
||||||
raise OAuthLoginException('Could not decode OIDC token')
|
raise OAuthLoginException('Could not decode OIDC token')
|
||||||
|
@ -181,7 +188,7 @@ class OIDCLoginService(OAuthService):
|
||||||
logger.exception('Could not parse OIDC discovery for url: %s', discovery_url)
|
logger.exception('Could not parse OIDC discovery for url: %s', discovery_url)
|
||||||
raise DiscoveryFailureException("Could not parse OIDC discovery information")
|
raise DiscoveryFailureException("Could not parse OIDC discovery information")
|
||||||
|
|
||||||
def _decode_user_jwt(self, token):
|
def decode_user_jwt(self, token):
|
||||||
""" Decodes the given JWT under the given provider and returns it. Raises an InvalidTokenError
|
""" Decodes the given JWT under the given provider and returns it. Raises an InvalidTokenError
|
||||||
exception on an invalid token or a PublicKeyLoadException if the public key could not be
|
exception on an invalid token or a PublicKeyLoadException if the public key could not be
|
||||||
loaded for decoding.
|
loaded for decoding.
|
||||||
|
|
0
oauth/test/__init__.py
Normal file
0
oauth/test/__init__.py
Normal file
|
@ -60,7 +60,7 @@ def app_config(http_client, mailing_feature):
|
||||||
'SERVER_HOSTNAME': 'localhost',
|
'SERVER_HOSTNAME': 'localhost',
|
||||||
'FEATURE_MAILING': mailing_feature,
|
'FEATURE_MAILING': mailing_feature,
|
||||||
|
|
||||||
'SOMEOIDC_TEST_SERVICE': {
|
'SOMEOIDC_LOGIN_CONFIG': {
|
||||||
'CLIENT_ID': 'foo',
|
'CLIENT_ID': 'foo',
|
||||||
'CLIENT_SECRET': 'bar',
|
'CLIENT_SECRET': 'bar',
|
||||||
'SERVICE_NAME': 'Some Cool Service',
|
'SERVICE_NAME': 'Some Cool Service',
|
||||||
|
@ -74,7 +74,7 @@ def app_config(http_client, mailing_feature):
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def oidc_service(app_config):
|
def oidc_service(app_config):
|
||||||
return OIDCLoginService(app_config, 'SOMEOIDC_TEST_SERVICE')
|
return OIDCLoginService(app_config, 'SOMEOIDC_LOGIN_CONFIG')
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def discovery_content(userinfo_supported):
|
def discovery_content(userinfo_supported):
|
||||||
|
|
|
@ -622,13 +622,14 @@
|
||||||
<div class="co-panel-body">
|
<div class="co-panel-body">
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<p>
|
<p>
|
||||||
Authentication for the registry can be handled by either the registry itself, LDAP or external JWT endpoint.
|
Authentication for the registry can be handled by either the registry itself, LDAP, Keystone, OIDC or external JWT endpoint.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Additional <strong>external</strong> authentication providers (such as GitHub) can be used in addition for <strong>login into the UI</strong>.
|
Additional <strong>external</strong> authentication providers (such as GitHub) can be used in addition for <strong>login into the UI</strong>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div ng-if="config.AUTHENTICATION_TYPE != 'OIDC'">
|
||||||
<div class="co-alert co-alert-warning" ng-if="config.AUTHENTICATION_TYPE != 'Database' && !config.FEATURE_REQUIRE_ENCRYPTED_BASIC_AUTH">
|
<div class="co-alert co-alert-warning" ng-if="config.AUTHENTICATION_TYPE != 'Database' && !config.FEATURE_REQUIRE_ENCRYPTED_BASIC_AUTH">
|
||||||
It is <strong>highly recommended</strong> to require encrypted client passwords. External passwords used in the Docker client will be stored in <strong>plaintext</strong>!
|
It is <strong>highly recommended</strong> to require encrypted client passwords. External passwords used in the Docker client will be stored in <strong>plaintext</strong>!
|
||||||
<a ng-click="config.FEATURE_REQUIRE_ENCRYPTED_BASIC_AUTH = true">Enable this requirement now</a>.
|
<a ng-click="config.FEATURE_REQUIRE_ENCRYPTED_BASIC_AUTH = true">Enable this requirement now</a>.
|
||||||
|
@ -638,6 +639,7 @@
|
||||||
Note: The "Require Encrypted Client Passwords" feature is currently enabled which will
|
Note: The "Require Encrypted Client Passwords" feature is currently enabled which will
|
||||||
prevent passwords from being saved as plaintext by the Docker client.
|
prevent passwords from being saved as plaintext by the Docker client.
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table class="config-table" style="margin-bottom: 20px;">
|
<table class="config-table" style="margin-bottom: 20px;">
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -648,6 +650,7 @@
|
||||||
<option value="LDAP">LDAP</option>
|
<option value="LDAP">LDAP</option>
|
||||||
<option value="Keystone">Keystone (OpenStack Identity)</option>
|
<option value="Keystone">Keystone (OpenStack Identity)</option>
|
||||||
<option value="JWT">JWT Custom Authentication</option>
|
<option value="JWT">JWT Custom Authentication</option>
|
||||||
|
<option value="OIDC">OIDC Token Authentication</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -687,6 +690,21 @@
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<!-- OIDC Token Authentication -->
|
||||||
|
<table class="config-table" ng-if="config.AUTHENTICATION_TYPE == 'OIDC'">
|
||||||
|
<tr>
|
||||||
|
<td>OIDC Provider:</td>
|
||||||
|
<td>
|
||||||
|
<select class="form-control" ng-model="config.INTERNAL_OIDC_SERVICE_ID" ng-if="getOIDCProviders(config).length">
|
||||||
|
<option value="{{ getOIDCProviderId(provider) }}" ng-repeat="provider in getOIDCProviders(config)">{{ config[provider]['SERVICE_NAME'] || getOIDCProviderId(provider) }}</option>
|
||||||
|
</select>
|
||||||
|
<div class="co-alert co-alert-danger" ng-if="!getOIDCProviders(config).length">
|
||||||
|
An OIDC provider must be configured to use this authentication system
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
<!-- Keystone Authentication -->
|
<!-- Keystone Authentication -->
|
||||||
<table class="config-table" ng-if="config.AUTHENTICATION_TYPE == 'Keystone'">
|
<table class="config-table" ng-if="config.AUTHENTICATION_TYPE == 'Keystone'">
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -1073,7 +1091,7 @@
|
||||||
<span style="display: inline-block; margin-left: 10px">(<a href="javascript:void(0)" ng-click="removeOIDCProvider(provider)">Delete</a>)</span>
|
<span style="display: inline-block; margin-left: 10px">(<a href="javascript:void(0)" ng-click="removeOIDCProvider(provider)">Delete</a>)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="co-panel-body">
|
<div class="co-panel-body">
|
||||||
<div class="co-alert co-alert-warning" ng-if="config.AUTHENTICATION_TYPE != 'Database' && !(config[provider].LOGIN_BINDING_FIELD)">
|
<div class="co-alert co-alert-warning" ng-if="config.AUTHENTICATION_TYPE && config.AUTHENTICATION_TYPE != 'Database' && config.AUTHENTICATION_TYPE != 'OIDC' && !(config[provider].LOGIN_BINDING_FIELD)">
|
||||||
Warning: This OIDC provider is not bound to your <strong>{{ config.AUTHENTICATION_TYPE }}</strong> authentication. Logging in via this provider will create a <strong><span class="registry-name"></span>-only user</strong>, which is not the recommended approach. It is <strong>highly</strong> recommended to choose a "Binding Field" below.
|
Warning: This OIDC provider is not bound to your <strong>{{ config.AUTHENTICATION_TYPE }}</strong> authentication. Logging in via this provider will create a <strong><span class="registry-name"></span>-only user</strong>, which is not the recommended approach. It is <strong>highly</strong> recommended to choose a "Binding Field" below.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -1134,7 +1152,7 @@
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr ng-if="config.AUTHENTICATION_TYPE != 'Database'">
|
<tr ng-if="config.AUTHENTICATION_TYPE != 'Database' && config.AUTHENTICATION_TYPE != 'OIDC'">
|
||||||
<td>Binding Field:</td>
|
<td>Binding Field:</td>
|
||||||
<td>
|
<td>
|
||||||
<select class="form-control" ng-model="config[provider].LOGIN_BINDING_FIELD">
|
<select class="form-control" ng-model="config[provider].LOGIN_BINDING_FIELD">
|
||||||
|
@ -1292,7 +1310,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="co-panel-body">
|
<div class="co-panel-body">
|
||||||
<div class="description">
|
<div class="description">
|
||||||
If enabled, users can submit Dockerfiles to be built and pushed by the Enterprise Registry.
|
If enabled, users can submit Dockerfiles to be built and pushed by <span class="registry-name"></span>.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="config-bool-field" binding="config.FEATURE_BUILD_SUPPORT">
|
<div class="config-bool-field" binding="config.FEATURE_BUILD_SUPPORT">
|
||||||
|
|
|
@ -43,6 +43,10 @@ angular.module("core-config-setup", ['angularFileUpload'])
|
||||||
return config.AUTHENTICATION_TYPE == 'Keystone';
|
return config.AUTHENTICATION_TYPE == 'Keystone';
|
||||||
}, 'password': true},
|
}, 'password': true},
|
||||||
|
|
||||||
|
{'id': 'oidc-auth', 'title': 'OIDC Authentication', 'condition': function(config) {
|
||||||
|
return config.AUTHENTICATION_TYPE == 'OIDC';
|
||||||
|
}},
|
||||||
|
|
||||||
{'id': 'signer', 'title': 'ACI Signing', 'condition': function(config) {
|
{'id': 'signer', 'title': 'ACI Signing', 'condition': function(config) {
|
||||||
return config.FEATURE_ACI_CONVERSION;
|
return config.FEATURE_ACI_CONVERSION;
|
||||||
}},
|
}},
|
||||||
|
@ -203,7 +207,7 @@ angular.module("core-config-setup", ['angularFileUpload'])
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return key.substr(0, index);
|
return key.substr(0, index).toLowerCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.getOIDCProviders = function(config) {
|
$scope.getOIDCProviders = function(config) {
|
||||||
|
@ -687,6 +691,12 @@ angular.module("core-config-setup", ['angularFileUpload'])
|
||||||
$scope.configform.$setValidity('storageConfig', valid);
|
$scope.configform.$setValidity('storageConfig', valid);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$scope.$watch('config.INTERNAL_OIDC_SERVICE_ID', function(service_id) {
|
||||||
|
if (service_id) {
|
||||||
|
$scope.config['FEATURE_DIRECT_LOGIN'] = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$scope.$watch('config.FEATURE_STORAGE_REPLICATION', function() {
|
$scope.$watch('config.FEATURE_STORAGE_REPLICATION', function() {
|
||||||
refreshStorageConfig();
|
refreshStorageConfig();
|
||||||
});
|
});
|
||||||
|
|
|
@ -21,7 +21,6 @@ angular.module('quay').directive('externalLoginButton', function () {
|
||||||
$scope.startSignin = function() {
|
$scope.startSignin = function() {
|
||||||
$scope.signInStarted({'service': $scope.provider});
|
$scope.signInStarted({'service': $scope.provider});
|
||||||
ExternalLoginService.getLoginUrl($scope.provider, $scope.action || 'login', function(url) {
|
ExternalLoginService.getLoginUrl($scope.provider, $scope.action || 'login', function(url) {
|
||||||
|
|
||||||
// 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();
|
||||||
CookieService.putPermanent('quay.redirectAfterLoad', redirectURL);
|
CookieService.putPermanent('quay.redirectAfterLoad', redirectURL);
|
||||||
|
|
|
@ -237,11 +237,10 @@ import * as URI from 'urijs';
|
||||||
|
|
||||||
$scope.serializeDbUri = function(fields) {
|
$scope.serializeDbUri = function(fields) {
|
||||||
if (!fields['server']) { return ''; }
|
if (!fields['server']) { return ''; }
|
||||||
|
if (!fields['database']) { return ''; }
|
||||||
|
|
||||||
var uri = URI();
|
var uri = URI();
|
||||||
try {
|
try {
|
||||||
if (!fields['server']) { return ''; }
|
|
||||||
if (!fields['database']) { return ''; }
|
|
||||||
uri = uri && uri.host(fields['server']);
|
uri = uri && uri.host(fields['server']);
|
||||||
uri = uri && uri.protocol(fields['kind']);
|
uri = uri && uri.protocol(fields['kind']);
|
||||||
uri = uri && uri.username(fields['username']);
|
uri = uri && uri.username(fields['username']);
|
||||||
|
|
|
@ -13,6 +13,8 @@
|
||||||
function UserViewCtrl($scope, $routeParams, $timeout, ApiService, UserService, UIService, AvatarService, Config, ExternalLoginService) {
|
function UserViewCtrl($scope, $routeParams, $timeout, ApiService, UserService, UIService, AvatarService, Config, ExternalLoginService) {
|
||||||
var username = $routeParams.username;
|
var username = $routeParams.username;
|
||||||
|
|
||||||
|
$scope.Config = Config;
|
||||||
|
|
||||||
$scope.showAppsCounter = 0;
|
$scope.showAppsCounter = 0;
|
||||||
$scope.showRobotsCounter = 0;
|
$scope.showRobotsCounter = 0;
|
||||||
$scope.showBillingCounter = 0;
|
$scope.showBillingCounter = 0;
|
||||||
|
@ -25,7 +27,27 @@
|
||||||
$scope.hasSingleSignin = ExternalLoginService.hasSingleSignin();
|
$scope.hasSingleSignin = ExternalLoginService.hasSingleSignin();
|
||||||
$scope.context = {};
|
$scope.context = {};
|
||||||
|
|
||||||
UserService.updateUserIn($scope);
|
$scope.oidcLoginProvider = null;
|
||||||
|
|
||||||
|
if (Config['INTERNAL_OIDC_SERVICE_ID']) {
|
||||||
|
ExternalLoginService.EXTERNAL_LOGINS.forEach(function(provider) {
|
||||||
|
if (provider.id == Config['INTERNAL_OIDC_SERVICE_ID']) {
|
||||||
|
$scope.oidcLoginProvider = provider;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
UserService.updateUserIn($scope, function(user) {
|
||||||
|
if (user && user.username) {
|
||||||
|
if ($scope.oidcLoginProvider && $routeParams['idtoken']) {
|
||||||
|
$scope.context.idTokenCredentials = {
|
||||||
|
'username': UserService.getCLIUsername(),
|
||||||
|
'password': $routeParams['idtoken'],
|
||||||
|
'namespace': UserService.currentUser().username
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
var loadRepositories = function() {
|
var loadRepositories = function() {
|
||||||
var options = {
|
var options = {
|
||||||
|
|
|
@ -70,8 +70,25 @@
|
||||||
|
|
||||||
<!-- Settings -->
|
<!-- Settings -->
|
||||||
<cor-tab-pane id="settings">
|
<cor-tab-pane id="settings">
|
||||||
|
<!-- OIDC Token -->
|
||||||
|
<div class="settings-section" ng-if="Config.AUTHENTICATION_TYPE == 'OIDC'">
|
||||||
|
<h3>Docker CLI Token</h3>
|
||||||
|
<div>
|
||||||
|
A generated token is <strong>required</strong> to login via the Docker CLI.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="co-list-table" style="margin-top: 10px;">
|
||||||
|
<tr>
|
||||||
|
<td>CLI Token:</td>
|
||||||
|
<td>
|
||||||
|
<span class="external-login-button" is-link="true" action="cli" provider="oidcLoginProvider"></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Encrypted Password -->
|
<!-- Encrypted Password -->
|
||||||
<div class="settings-section">
|
<div class="settings-section" ng-if="Config.AUTHENTICATION_TYPE != 'OIDC'">
|
||||||
<h3>Docker CLI Password</h3>
|
<h3>Docker CLI Password</h3>
|
||||||
<div ng-if="!Features.REQUIRE_ENCRYPTED_BASIC_AUTH">
|
<div ng-if="!Features.REQUIRE_ENCRYPTED_BASIC_AUTH">
|
||||||
The Docker CLI stores passwords entered on the command line in <strong>plaintext</strong>. It is therefore highly recommended to generate an an encrypted version of your password to use for <code>docker login</code>.
|
The Docker CLI stores passwords entered on the command line in <strong>plaintext</strong>. It is therefore highly recommended to generate an an encrypted version of your password to use for <code>docker login</code>.
|
||||||
|
@ -216,4 +233,7 @@
|
||||||
|
|
||||||
<!-- Credentials for encrypted passwords -->
|
<!-- Credentials for encrypted passwords -->
|
||||||
<div class="credentials-dialog" credentials="context.encryptedPasswordCredentials" secret-title="Encrypted Password" entity-title="encrypted password" entity-icon="fa-key">
|
<div class="credentials-dialog" credentials="context.encryptedPasswordCredentials" secret-title="Encrypted Password" entity-title="encrypted password" entity-icon="fa-key">
|
||||||
|
|
||||||
|
<!-- Credentials for ID token -->
|
||||||
|
<div class="credentials-dialog" credentials="context.idTokenCredentials" secret-title="CLI Token" entity-title="Docker CLI token" entity-icon="fa-key">
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -22,6 +22,7 @@ from util.config.validators.validate_oidc import OIDCLoginValidator
|
||||||
from util.config.validators.validate_timemachine import TimeMachineValidator
|
from util.config.validators.validate_timemachine import TimeMachineValidator
|
||||||
from util.config.validators.validate_access import AccessSettingsValidator
|
from util.config.validators.validate_access import AccessSettingsValidator
|
||||||
from util.config.validators.validate_actionlog_archiving import ActionLogArchivingValidator
|
from util.config.validators.validate_actionlog_archiving import ActionLogArchivingValidator
|
||||||
|
from util.config.validators.validate_oidcauth import OIDCAuthValidator
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -59,6 +60,7 @@ VALIDATORS = {
|
||||||
TimeMachineValidator.name: TimeMachineValidator.validate,
|
TimeMachineValidator.name: TimeMachineValidator.validate,
|
||||||
AccessSettingsValidator.name: AccessSettingsValidator.validate,
|
AccessSettingsValidator.name: AccessSettingsValidator.validate,
|
||||||
ActionLogArchivingValidator.name: ActionLogArchivingValidator.validate,
|
ActionLogArchivingValidator.name: ActionLogArchivingValidator.validate,
|
||||||
|
OIDCAuthValidator.name: OIDCAuthValidator.validate,
|
||||||
}
|
}
|
||||||
|
|
||||||
def validate_service_for_config(service, config, password=None):
|
def validate_service_for_config(service, config, password=None):
|
||||||
|
|
32
util/config/validators/test/test_validate_oidcauth.py
Normal file
32
util/config/validators/test/test_validate_oidcauth.py
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from util.config.validators import ConfigValidationException
|
||||||
|
from util.config.validators.validate_oidcauth import OIDCAuthValidator
|
||||||
|
|
||||||
|
from test.fixtures import *
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('unvalidated_config', [
|
||||||
|
({'AUTHENTICATION_TYPE': 'OIDC'}),
|
||||||
|
({'AUTHENTICATION_TYPE': 'OIDC', 'INTERNAL_OIDC_SERVICE_ID': 'someservice'}),
|
||||||
|
])
|
||||||
|
def test_validate_invalid_oidc_auth_config(unvalidated_config, app):
|
||||||
|
validator = OIDCAuthValidator()
|
||||||
|
|
||||||
|
with pytest.raises(ConfigValidationException):
|
||||||
|
validator.validate(unvalidated_config, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_oidc_auth(app):
|
||||||
|
config = {
|
||||||
|
'AUTHENTICATION_TYPE': 'OIDC',
|
||||||
|
'INTERNAL_OIDC_SERVICE_ID': 'someservice',
|
||||||
|
'SOMESERVICE_LOGIN_CONFIG': {
|
||||||
|
'CLIENT_ID': 'foo',
|
||||||
|
'CLIENT_SECRET': 'bar',
|
||||||
|
'OIDC_SERVER': 'http://someserver',
|
||||||
|
},
|
||||||
|
'HTTPCLIENT': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
validator = OIDCAuthValidator()
|
||||||
|
validator.validate(config, None, None)
|
21
util/config/validators/validate_oidcauth.py
Normal file
21
util/config/validators/validate_oidcauth.py
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
from app import app
|
||||||
|
from data.users.oidc import OIDCInternalAuth, UnknownServiceException
|
||||||
|
from util.config.validators import BaseValidator, ConfigValidationException
|
||||||
|
|
||||||
|
class OIDCAuthValidator(BaseValidator):
|
||||||
|
name = "oidc-auth"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate(cls, config, user, user_password):
|
||||||
|
if config.get('AUTHENTICATION_TYPE', 'Database') != 'OIDC':
|
||||||
|
return
|
||||||
|
|
||||||
|
login_service_id = config.get('INTERNAL_OIDC_SERVICE_ID')
|
||||||
|
if not login_service_id:
|
||||||
|
raise ConfigValidationException('Missing OIDC provider')
|
||||||
|
|
||||||
|
# By instantiating the auth engine, it will check if the provider exists and works.
|
||||||
|
try:
|
||||||
|
OIDCInternalAuth(config, login_service_id, False)
|
||||||
|
except UnknownServiceException as use:
|
||||||
|
raise ConfigValidationException(use.message)
|
Reference in a new issue