diff --git a/lib/pbench/client/oidc_admin.py b/lib/pbench/client/oidc_admin.py index 6d60110848..523dc14cc2 100644 --- a/lib/pbench/client/oidc_admin.py +++ b/lib/pbench/client/oidc_admin.py @@ -1,115 +1,43 @@ +from http import HTTPStatus import json -from typing import Dict, Optional, Union -from urllib.parse import urljoin import requests -from requests.structures import CaseInsensitiveDict +from pbench.server.auth import Connection -class OIDCAdmin: - def __init__( - self, - server_url: str, - headers: Optional[Dict[str, str]] = None, - verify: bool = False, - ): - self.server_url = server_url - self.headers = CaseInsensitiveDict({} if headers is None else headers) - self.verify = verify - self._connection = requests.Session() - - def _method( - self, - method: str, - path: str, - data: Union[Dict, str, None], - headers: Optional[Dict] = None, - raise_error: bool = True, - **kwargs, - ) -> requests.Response: - """Common frontend for the HTTP operations on OIDC client connection. - Args: - method : The API HTTP method - path : Path for the request. - data : Json data to send with the request in case of the POST - headers : Optional headers to send with request - kwargs : Additional keyword args +class OIDCAdmin(Connection): + OIDC_REALM = "pbench-server" + OIDC_CLIENT = "pbench-dashboard" + ADMIN_USERNAME = "admin" + ADMIN_PASSWORD = "123" - Returns: - Response from the request. - """ - final_headers = self.headers.copy() - if headers is not None: - final_headers.update(headers) - url = urljoin(self.server_url, path) - kwargs = dict( - params=kwargs, - data=data, - headers=final_headers, - verify=self.verify, - ) - response = self._connection.request(method, url, **kwargs) - if raise_error: - response.raise_for_status() - return response - - def get( - self, path: str, headers: Optional[Dict] = None, **kwargs - ) -> requests.Response: - """GET wrapper to handle an authenticated GET operation on the Resource - at a given path. + def __init__(self, server_url: str): + super().__init__(server_url, verify=False) - Args: - path : Path for the request - headers : Additional headers to add to the request - kwargs : Additional keyword args to be added as URL parameters + def get_admin_token(self) -> dict: + """pbench-server realm admin user login. Returns: - Response from the request. - """ - return self._method("GET", path, None, headers=headers, **kwargs) - - def post( - self, - path: str, - data: Union[Dict, str], - headers: Optional[Dict] = None, - **kwargs, - ) -> requests.Response: - """POST wrapper to handle an authenticated POST operation on the - Resource at a given path. + access_token json payload - Args: - path : Path for the request - data : JSON request body - headers : Additional headers to add to the request - kwargs : Additional keyword args to be added as URL parameters + { 'access_token': , + 'expires_in': 60, 'refresh_expires_in': 1800, + 'refresh_token': , + 'token_type': 'Bearer', + 'not-before-policy': 0, + 'session_state': '8f558797-50e7-496d-bb45-3b5ac9fdcddb', + 'scope': 'profile email'} - Returns: - Response from the request. """ - return self._method("POST", path, data, headers=headers, **kwargs) - - def get_admin_token(self) -> requests.Response: - url_path = "/realms/master/protocol/openid-connect/token" + url_path = f"/realms/master/protocol/openid-connect/token" data = { "grant_type": "password", "client_id": "admin-cli", "username": "admin", "password": "admin", } - return self.post(path=url_path, data=data) - - def get_client_secret(self, client_id: str) -> requests.Response: - admin_token = self.get_admin_token().json().get("access_token") - url_path = f"admin/realms/pbench-server/clients?clientId={client_id}" - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {admin_token}", - } - response = self.get(path=url_path, headers=headers) - return response.json()[0]["secret"] + return self.post(path=url_path, data=data).json() def create_new_user( self, @@ -119,8 +47,21 @@ def create_new_user( first_name: str = "", last_name: str = "", ) -> requests.Response: - admin_token = self.get_admin_token().json().get("access_token") - url_path = "/admin/realms/pbench-server/users" + """Creates a new user under the OIDC_REALM and assign OIDC_CLIENT_ROLE + to the new user. + + Args: + username: username to register, + email: user email address, + password: user password, + first_name: Optional first name of the user, + last_name: Optional first name of the user, + + Returns: + Response from the request. + """ + admin_token = self.get_admin_token().get("access_token") + url_path = f"/admin/realms/{self.OIDC_REALM}/users" headers = { "Content-Type": "application/json", "Authorization": f"Bearer {admin_token}", @@ -139,23 +80,69 @@ def create_new_user( response = self.post(path=url_path, data=json.dumps(data), headers=headers) return response - def user_login( - self, client_id: str, username: str, password: str, client_secret: str = None - ) -> requests.Response: - url_path = "/realms/pbench-server/protocol/openid-connect/token" + def user_login(self, client_id: str, username: str, password: str) -> dict: + """pbench-server realm user login on a specified client. + + Args: + client_id: client_name to use in the request + username: username of the user logging in + password: OIDC password + + Returns: + access_token json payload + + { 'access_token': , + 'expires_in': 60, 'refresh_expires_in': 1800, + 'refresh_token': , + 'token_type': 'Bearer', + 'not-before-policy': 0, + 'session_state': '8f558797-50e7-496d-bb45-3b5ac9fdcddb', + 'scope': 'profile email'} + + """ + url_path = f"/realms/{self.OIDC_REALM}/protocol/openid-connect/token" headers = {"Content-Type": "application/x-www-form-urlencoded"} - client_secret = ( - client_secret - if client_secret - else self.get_client_secret(client_id=client_id) - ) data = { "client_id": client_id, "grant_type": "password", - "client_secret": client_secret, "scope": "profile email", "username": username, "password": password, } response = self.post(path=url_path, data=data, headers=headers) - return response + return response.json() + + def get_user(self, username: str, token: str) -> dict: + """Get the OIDC user representation dict. + + Args: + username: username to query + token: access_token string to validate + + Returns: + User dict representation + + {'id': '37117992-a3de-43f7-b844-e6ee178e9965', + 'createdTimestamp': 1675981768951, + 'username': 'admin', + 'enabled': True, + 'totp': False, + 'emailVerified': False, + 'disableableCredentialTypes': [], + 'requiredActions': [], + 'notBefore': 0, + 'access': {'manageGroupMembership': True, 'view': True, 'mapRoles': True, 'impersonate': True, 'manage': True} + ... + } + """ + response = self.get( + f"admin/realms/{self.OIDC_REALM}/users?username={username}", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + }, + verify=False, + ) + if response.status_code == HTTPStatus.OK: + return response.json()[0] + return {} diff --git a/lib/pbench/server/auth/__init__.py b/lib/pbench/server/auth/__init__.py index 1e7acf0fe0..14549106d6 100644 --- a/lib/pbench/server/auth/__init__.py +++ b/lib/pbench/server/auth/__init__.py @@ -50,7 +50,7 @@ def _method( self, method: str, path: str, - data: Union[Dict, None], + data: Union[Dict, str, None], headers: Optional[Dict] = None, **kwargs, ) -> requests.Response: @@ -126,7 +126,11 @@ def get( return self._method("GET", path, None, headers=headers, **kwargs) def post( - self, path: str, data: Dict, headers: Optional[Dict] = None, **kwargs + self, + path: str, + data: Union[Dict, str], + headers: Optional[Dict] = None, + **kwargs, ) -> requests.Response: """POST wrapper to handle an authenticated POST operation on the Resource at a given path. @@ -404,7 +408,7 @@ def token_introspect(self, token: str) -> JSON: token, self._pem_public_key, algorithms=[self._TOKEN_ALG], - audience=["account", self.client_id], + audience=[self.client_id], options={ "verify_signature": True, "verify_aud": True, diff --git a/lib/pbench/test/functional/server/conftest.py b/lib/pbench/test/functional/server/conftest.py index 01affaaaf3..848bf661c8 100644 --- a/lib/pbench/test/functional/server/conftest.py +++ b/lib/pbench/test/functional/server/conftest.py @@ -32,8 +32,8 @@ def oidc_admin(server_client: PbenchServerClient): Used by Pbench Server functional tests to get admin access on OIDC server. """ - oidc_endpoints = server_client.endpoints["openid-connect"] - oidc_server = OIDCAdmin(server_url=oidc_endpoints["issuer"]) + oidc_endpoints = server_client.endpoints["openid"] + oidc_server = OIDCAdmin(server_url=oidc_endpoints["server"]) return oidc_server @@ -51,7 +51,7 @@ def register_test_user(oidc_admin: OIDCAdmin): # To allow testing outside our transient CI containers, allow the tester # user to already exist. assert ( - response.ok or response.status_code == HTTPStatus.FORBIDDEN + response.ok or response.status_code == HTTPStatus.CONFLICT ), f"Register failed with {response.json()}" @@ -60,11 +60,11 @@ def login_user( server_client: PbenchServerClient, oidc_admin: OIDCAdmin, register_test_user ): """Log in the test user and return the authentication token""" - oidc_endpoints = server_client.endpoints["openid-connect"] + oidc_endpoints = server_client.endpoints["openid"] response = oidc_admin.user_login( client_id=oidc_endpoints["client"], username="tester", password="123456" ) - auth_token = response.json()["access_token"] + auth_token = response["access_token"] assert auth_token json = {"username": "tester", "auth_token": auth_token} server_client.username = "tester" diff --git a/server/lib/config/pbench-server-default.cfg b/server/lib/config/pbench-server-default.cfg index d966887665..26b6750d62 100644 --- a/server/lib/config/pbench-server-default.cfg +++ b/server/lib/config/pbench-server-default.cfg @@ -143,7 +143,7 @@ wait_timeout = 120 realm = pbench-server # Client entity name requesting OIDC to authenticate a user. -client = pbench-server-client +client = pbench-dashboard # Client secret if the above client is not public. #secret = diff --git a/server/pbenchinacan/etc/pbench-server/pbench-server.cfg b/server/pbenchinacan/etc/pbench-server/pbench-server.cfg index 0b643a37e3..34e8a32b39 100644 --- a/server/pbenchinacan/etc/pbench-server/pbench-server.cfg +++ b/server/pbenchinacan/etc/pbench-server/pbench-server.cfg @@ -30,7 +30,7 @@ secret-key = "pbench-in-a-can secret shhh" [openid-connect] server_url = http://localhost:8090 -secret = +secret = keycloak_secret ########################################################################### # The rest will come from the default config file. diff --git a/server/pbenchinacan/load_keycloak.sh b/server/pbenchinacan/load_keycloak.sh index 56dfba4e6b..16c284ea16 100755 --- a/server/pbenchinacan/load_keycloak.sh +++ b/server/pbenchinacan/load_keycloak.sh @@ -65,10 +65,45 @@ else echo "Created ${REALM} realm" fi +# Create a client scope with custom mapper that a functional test +# user can include in the OIDC token request over a REST call. +# This will instruct Keycloak to include the CLIENT_ID (pbench-dashboard) +# when someone request a token Over a rest API using a CLIENT_ID. +# Having CLIENT_ID in the aud claim of the token is essential for the token +# to be validated. +curl -si -f -X POST "${KEYCLOAK_HOST_PORT}/admin/realms/${REALM}/client-scopes" \ + -H "Authorization: Bearer ${ADMIN_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "pbench", + "description": "", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "gui.order": "", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "name": "pbench-mapper", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "pbench-dashboard", + "id.token.claim": "false", + "access.token.claim": "true" + } + } + ] + }' + + CLIENT_CONF=$(curl -si -f -X POST "${KEYCLOAK_HOST_PORT}/admin/realms/${REALM}/clients" \ -H "Authorization: Bearer ${ADMIN_TOKEN}" \ -H "Content-Type: application/json" \ - -d '{"clientId": "'${CLIENT}'", "publicClient": true, "directAccessGrantsEnabled": true, "enabled": true, "redirectUris": ["'${KEYCLOAK_REDIRECT_URI}'"]}') + -d '{"clientId": "'${CLIENT}'", "publicClient": true, "defaultClientScopes": ["pbench", "openid", "profile", "email"], "directAccessGrantsEnabled": true, "serviceAccountsEnabled": true, "enabled": true, "redirectUris": ["'${KEYCLOAK_REDIRECT_URI}'"]}') CLIENT_ID=$(grep -o -e 'http://[^[:space:]]*' <<< ${CLIENT_CONF} | sed -e 's|.*/||') if [[ -z "${CLIENT_ID}" ]]; then @@ -124,7 +159,7 @@ else echo "Assigned an 'ADMIN' client role to the user 'admin' created above" fi -# Verify that the user id has a role assigned to it +# Verify that the user id has a role 'ADMIN' assigned to it USER_ROLES=$(curl -s "${KEYCLOAK_HOST_PORT}/admin/realms/${REALM}/users/${USER_ID}/role-mappings/clients/${CLIENT_ID}" \ -H "Authorization: Bearer ${ADMIN_TOKEN}" \ -H "Content-Type: application/json" | jq -r '.[].name')