Skip to content

Commit

Permalink
ui: ui and initial scaffolding for OIDC auth (PROJQUAY-6298) (quay#2646)
Browse files Browse the repository at this point in the history
* added base class for OIDC auth + UI

* adding read-only teams page + display sync config + option to remove team sync

* setting page in read only mode fix

* ui tests

* adding validation for group name input

* fixes based on review + fixing test suite

* add backend tests for externalOIDC

* minor fixes
  • Loading branch information
Sunandadadi authored Feb 20, 2024
1 parent e825647 commit 4cb0a57
Show file tree
Hide file tree
Showing 22 changed files with 1,284 additions and 131 deletions.
21 changes: 21 additions & 0 deletions data/users/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from data.users.database import DatabaseUsers
from data.users.externaljwt import ExternalJWTAuthN
from data.users.externalldap import LDAPUsers
from data.users.externaloidc import OIDCUsers
from data.users.federated import FederatedUsers
from data.users.keystone import get_keystone_users
from util.config.superusermanager import ConfigUserManager
Expand All @@ -35,6 +36,9 @@ def get_federated_service_name(authentication_type):
if authentication_type == "Database":
return None

if authentication_type == "OIDC":
return "oidc"

raise Exception("Unknown auth type: %s" % authentication_type)


Expand Down Expand Up @@ -135,6 +139,23 @@ def get_users_handler(config, _, override_config_dir):

return AppTokenInternalAuth()

if authentication_type == "OIDC":
client_id = config.get("CLIENT_ID")
client_secret = config.get("CLIENT_SECRET")
oidc_server = config.get("OIDC_SERVER")
service_name = config.get("SERVICE_NAME")
login_scopes = config.get("LOGIN_SCOPES")
preferred_group_claim_name = config.get("PREFERRED_GROUP_CLAIM_NAME")

return OIDCUsers(
client_id,
client_secret,
oidc_server,
service_name,
login_scopes,
preferred_group_claim_name,
)

raise RuntimeError("Unknown authentication type: %s" % authentication_type)


Expand Down
52 changes: 52 additions & 0 deletions data/users/externaloidc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from data.users.federated import FederatedUsers


class OIDCUsers(FederatedUsers):
def __init__(
self,
client_id,
client_secret,
oidc_server,
service_name,
login_scopes,
preferred_group_claim_name,
requires_email=True,
):
super(OIDCUsers, self).__init__("oidc", requires_email)
self._client_id = client_id
self._client_secret = client_secret
self._oidc_server = oidc_server
self._service_name = service_name
self._login_scopes = login_scopes
self._preferred_group_claim_name = preferred_group_claim_name
self._requires_email = requires_email

def is_superuser(self, username: str):
"""
Initiated from FederatedUserManager.is_superuser(), falls back to ConfigUserManager.is_superuser()
"""
return None

def verify_credentials(self, username_or_email, password):
"""
Verify the credentials with OIDC: To Implement
"""
pass

def check_group_lookup_args(self, group_lookup_args, disable_pagination=False):
"""
No way to verify if the group is valid, so assuming the group is valid
"""
return (True, None)

def get_user(self, username_or_email):
"""
No way to look up a username or email in OIDC so returning None
"""
return (None, "Currently user lookup is not supported with OIDC")

def query_users(self, query, limit):
"""
No way to query users so returning empty list
"""
return ([], self.federated_service, None)
202 changes: 191 additions & 11 deletions endpoints/oauth/test/test_login.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
from test.analytics import analytics
from test.fixtures import *
from test.test_ldap import mock_ldap

import pytest
from mock import patch

from data import database, model
from data.users import DatabaseUsers, get_users_handler
from data.users import DatabaseUsers, UserAuthentication, get_users_handler
from endpoints.oauth.login import _conduct_oauth_login
from oauth.oidc import OIDCLoginService
from oauth.services.github import GithubOAuthService
from test.analytics import analytics
from test.fixtures import *
from test.test_ldap import mock_ldap


@pytest.fixture(params=[None, "username", "email"])
Expand All @@ -20,18 +20,45 @@ def login_service(request, app):
return GithubOAuthService(config, "GITHUB")


@pytest.fixture(params=["Database", "LDAP"])
@pytest.fixture()
def oidc_login_service(app):
config = {
"REGISTRY_TITLE_SHORT": "quay-test",
"TESTING": True,
"OIDC_LOGIN_CONFIG": {
"CLIENT_ID": "foo",
"CLIENT_SECRET": "bar",
"SERVICE_NAME": "Test Service",
"OIDC_SERVER": "http://server-hostname/realms/server-realm/",
"DEBUGGING": True,
},
}

return OIDCLoginService(config, "OIDC_LOGIN_CONFIG")


@pytest.fixture(params=["Database", "LDAP", "OIDC"])
def auth_system(request):
return _get_users_handler(request.param)


def _get_users_handler(auth_type):
config = {}
config["AUTHENTICATION_TYPE"] = auth_type
config["LDAP_BASE_DN"] = ["dc=quay", "dc=io"]
config["LDAP_ADMIN_DN"] = "uid=testy,ou=employees,dc=quay,dc=io"
config["LDAP_ADMIN_PASSWD"] = "password"
config["LDAP_USER_RDN"] = ["ou=employees"]
if auth_type == "LDAP":
config["AUTHENTICATION_TYPE"] = auth_type
config["LDAP_BASE_DN"] = ["dc=quay", "dc=io"]
config["LDAP_ADMIN_DN"] = "uid=testy,ou=employees,dc=quay,dc=io"
config["LDAP_ADMIN_PASSWD"] = "password"
config["LDAP_USER_RDN"] = ["ou=employees"]

if auth_type == "OIDC":
config["AUTHENTICATION_TYPE"] = auth_type
config["CLIENT_ID"] = "foo"
config["CLIENT_SECRET"] = "bar"
config["OIDC_SERVER"] = "http://server-hostname/realms/server-realm/"
config["SERVICE_NAME"] = "Test Service"
config["LOGIN_SCOPES"] = ["openid", "roles"]
config["PREFERRED_GROUP_CLAIM_NAME"] = "groups"

return get_users_handler(config, None, None)

Expand Down Expand Up @@ -268,3 +295,156 @@ def test_existing_account_in_ldap(app):
result.user_obj, internal_auth.federated_service
)
assert internal_login is not None


@pytest.mark.parametrize(
"binding_field, lid, lusername, lemail, additional_login_info, expected_error",
[
# No binding field + newly seen user -> New unlinked user
(None, "someid", "someunknownuser", "someemail@example.com", None, None),
# No binding field + newly seen user with additional login info -> New unlinked user
(
None,
"someid",
"someunknownuser",
"someemail@example.com",
{"group": "information", "is": "here"},
None,
),
# sub binding field + unknown sub -> Error.
(
"sub",
"someid",
"someuser",
"foo@bar.com",
None,
"sub someid not found in backing auth system",
),
# username binding field + unknown username -> Error.
(
"username",
"someid",
"someunknownuser",
"foo@bar.com",
None,
"username someunknownuser not found in backing auth system",
),
# email binding field + unknown email address -> Error.
(
"email",
"someid",
"someuser",
"someemail@example.com",
None,
"email someemail@example.com not found in backing auth system",
),
# No binding field + newly seen user -> New unlinked user.
(None, "someid", "someuser", "foo@bar.com", None, None),
# username binding field + valid username -> fully bound user.
(
"username",
"someid",
"someuser",
"foo@bar.com",
None,
"username someuser not found in backing auth system",
),
# sub binding field + valid sub -> fully bound user.
(
"sub",
"someuser",
"someusername",
"foo@bar.com",
None,
"sub someuser not found in backing auth system",
),
# email binding field + valid email -> fully bound user.
(
"email",
"someid",
"someuser",
"foo@bar.com",
None,
"email foo@bar.com not found in backing auth system",
),
],
)
def test_new_account_via_oidc(
binding_field, lid, lusername, lemail, additional_login_info, expected_error, app
):
existing_user_count = database.User.select().count()

config = {"GITHUB": {}}
if binding_field is not None:
config["GITHUB"]["LOGIN_BINDING_FIELD"] = binding_field

external_auth = GithubOAuthService(config, "GITHUB")
internal_auth = _get_users_handler("OIDC")

result = _conduct_oauth_login(
app.config,
analytics,
internal_auth,
external_auth,
lid,
lusername,
lemail,
additional_login_info,
)
assert result.error_message == expected_error

current_user_count = database.User.select().count()
if expected_error is None:
assert current_user_count == existing_user_count + 1
assert result.user_obj is not None

# Check the service bindings.
external_login = model.user.lookup_federated_login(
result.user_obj, external_auth.service_id()
)
assert external_login is not None

internal_login = model.user.lookup_federated_login(
result.user_obj, internal_auth.federated_service
)
if binding_field is not None:
assert internal_login is not None
else:
assert internal_login is None
else:
# Ensure that no additional users were created.
assert current_user_count == existing_user_count


def test_existing_account_in_oidc(app, oidc_login_service):
# Add an existing federated user bound to the OIDC account associated with `someuser`.
bound_user = model.user.create_federated_user(
"someuser", "foo@bar.com", oidc_login_service.service_id(), "someuser", False
)
existing_user_count = database.User.select().count()

# Conduct OAuth login with the same lid and bound field.
result = _conduct_oauth_login(
app.config,
analytics,
UserAuthentication,
oidc_login_service,
"someuser",
bound_user.username,
bound_user.email,
)
assert result.error_message is None

# Ensure that the same user was returned, and that it is now bound to the Github account
# as well.
assert result.user_obj.id == bound_user.id

# Ensure that no additional users were created.
current_user_count = database.User.select().count()
assert current_user_count == existing_user_count

# Check the service bindings.
external_login = model.user.lookup_federated_login(
result.user_obj, oidc_login_service.service_id()
)
assert external_login is not None
7 changes: 5 additions & 2 deletions static/js/pages/team-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,11 @@
case 'jwtauthn':
return 'External JWT Auth';

case 'oidc':
return 'OIDC';

default:
return synced.service;
return service;
}
};

Expand Down Expand Up @@ -288,4 +291,4 @@
// Load the organization.
loadOrganization();
}
})();
})();
Loading

0 comments on commit 4cb0a57

Please sign in to comment.