diff --git a/docs/user_authentication/third_party_token_management.md b/docs/user_authentication/third_party_token_management.md new file mode 100644 index 0000000000..0bb4fcfe09 --- /dev/null +++ b/docs/user_authentication/third_party_token_management.md @@ -0,0 +1,104 @@ + +```sequence {theme: 'simple'} +title Third-party Token Management + +participant Pbench-Server +participant Browser +participant Identity-Broker +participant Identity-Provider + +autonumber 1 +activate Browser #red + +rbox over Browser: Dashboard +Browser->Pbench-Server: GET Pbench client ID + +Pbench-Server->Browser: 200 Response + +note right of Pbench-Server:{identity_broker_auth_URI: \nclient_id: \nclient_secret: # optional\n} + +note over Browser:User clicks login +abox over Browser: Dashboard instructs the browser to \nload identity broker authentication page \nurl that was supplied by the Pbench-server. + +deactivate Browser + +Browser->Identity-Broker:GET identity broker auth URI\n(Authentication Request) + +note right of Browser:GET request:\n\n?client_id=\n&response_type=code\n&redirect_uri=\n&scope=openid + +Identity-Broker->Browser: 200 Response + +activate Browser #blue +rbox over Browser: Identity-Broker +note over Browser:User selects an identity provider from the list + +abox over Browser:Identity broker instructs the browser to \nload identity provider authentication page + +deactivate Browser + +Browser->Identity-Provider:GET identity provider auth page +note right of Browser:GET request:\n\n?client_id=\n&response_type=code\n&redirect_uri=\n&scope=openid + +Identity-Provider->Browser:303 Response\n(Redirect to identity provider auth page) + +Browser->Identity-Provider:GET request auth Page +Browser<-Identity-Provider:200 Response + +activate Browser #green +rbox over Browser: Identity-Provider + +note over Browser:User challenge credentials and consent + +abox over Browser:Identity provider instructs the browser to \nsend the request and load the response + +deactivate Browser + +Browser->Identity-Provider: GET/POST authentication request + +Identity-Provider->Browser: 302/303 Response +note left of Identity-Provider:Redirect Location:\n\n?code=\n&state= + +Identity-Broker<-Browser:GET Redirect location (identity broker URI) + +note over Identity-Broker:Identity federation\na. Checks the validity of response from the identity provider\nb. Imports and creates user identity from the token\nc. Links the user identity with the identity provider + +Identity-Broker->Browser:302 Authentication Response\n(Redirect back to Pbench dashboard) + +note left of Identity-Broker:Redirect Location:\n\n?code=\n&state= + +Browser->Pbench-Server: GET Pbench-dashboard redirect location + +Pbench-Server->Browser: 200 Response + +activate Browser #red +rbox over Browser: Dashboard + +Browser->Identity-Broker:POST Request to token endpoint + +note right of Browser:POST request:\npost \npayload:\n{code: \nclient_id: \nredirect_uri: \n} + +Identity-Broker->Browser: 200 Token Response + +note left of Identity-Broker:token response:\n{\n access_token: ,\n expires_in: ,\n refresh_expires_in: ,\n refresh_token: ,\n token_type: "Bearer",\n id_token: \n session_state: ,\n scope: \n} + +==Authorization setup complete; the steps below may be repeated to issue a series of requests== + +Browser->Pbench-Server: POST /api/v1/ request (Bearer: Pbench access token) + +note over Pbench-Server:Validation and identity extraction\nfrom the Pbench token + +alt Authenticated user is authorized for resource +Pbench-Server->Browser: 200 /api/v1/ response +else Authenticated user is not authorized for resource +Pbench-Server->Browser:403 /api/v1/ response +else Authorization token expired or invalid +Pbench-Server->Browser:401 /api/v1/ response +end + +space +deactivate Browser +destroysilent Pbench-Server +destroysilent Browser +destroysilent Identity-Broker +destroysilent Identity-Provider +``` \ No newline at end of file diff --git a/lib/pbench/server/api/__init__.py b/lib/pbench/server/api/__init__.py index 91dce574bf..a562e393a1 100644 --- a/lib/pbench/server/api/__init__.py +++ b/lib/pbench/server/api/__init__.py @@ -14,7 +14,6 @@ from pbench.common.exceptions import BadConfig, ConfigFileNotSpecified from pbench.common.logger import get_pbench_logger from pbench.server import PbenchServerConfig -from pbench.server.api.auth import Auth from pbench.server.api.resources.datasets_daterange import DatasetsDateRange from pbench.server.api.resources.datasets_inventory import DatasetsInventory from pbench.server.api.resources.datasets_list import DatasetsList @@ -39,6 +38,7 @@ from pbench.server.api.resources.server_configuration import ServerConfiguration from pbench.server.api.resources.upload_api import Upload from pbench.server.api.resources.users_api import Login, Logout, RegisterUser, UserAPI +from pbench.server.auth.auth import Auth from pbench.server.database import init_db from pbench.server.database.database import Database diff --git a/lib/pbench/server/api/resources/__init__.py b/lib/pbench/server/api/resources/__init__.py index 4c87dd2886..ffa9ddd1c3 100644 --- a/lib/pbench/server/api/resources/__init__.py +++ b/lib/pbench/server/api/resources/__init__.py @@ -13,7 +13,7 @@ from sqlalchemy.orm.query import Query from pbench.server import JSON, JSONOBJECT, JSONVALUE, PbenchServerConfig -from pbench.server.api.auth import Auth +from pbench.server.auth.auth import Auth from pbench.server.database.models.datasets import ( Dataset, DatasetNotFound, diff --git a/lib/pbench/server/api/resources/datasets_metadata.py b/lib/pbench/server/api/resources/datasets_metadata.py index 89f9425eb8..e00fbe653f 100644 --- a/lib/pbench/server/api/resources/datasets_metadata.py +++ b/lib/pbench/server/api/resources/datasets_metadata.py @@ -6,7 +6,6 @@ from flask.wrappers import Request, Response from pbench.server import PbenchServerConfig -from pbench.server.api.auth import Auth from pbench.server.api.resources import ( API_AUTHORIZATION, API_METHOD, @@ -20,6 +19,7 @@ ParamType, Schema, ) +from pbench.server.auth.auth import Auth from pbench.server.database.models.datasets import ( Metadata, MetadataBadValue, diff --git a/lib/pbench/server/api/resources/query_apis/__init__.py b/lib/pbench/server/api/resources/query_apis/__init__.py index 47738a1785..aa032514f4 100644 --- a/lib/pbench/server/api/resources/query_apis/__init__.py +++ b/lib/pbench/server/api/resources/query_apis/__init__.py @@ -15,7 +15,6 @@ import requests from pbench.server import JSON, PbenchServerConfig -from pbench.server.api.auth import Auth from pbench.server.api.resources import ( API_AUTHORIZATION, API_METHOD, @@ -27,6 +26,7 @@ SchemaError, UnauthorizedAccess, ) +from pbench.server.auth.auth import Auth from pbench.server.database.models.datasets import Dataset from pbench.server.database.models.template import Template from pbench.server.database.models.users import User diff --git a/lib/pbench/server/api/resources/upload_api.py b/lib/pbench/server/api/resources/upload_api.py index a300bbced2..b396354d15 100644 --- a/lib/pbench/server/api/resources/upload_api.py +++ b/lib/pbench/server/api/resources/upload_api.py @@ -9,7 +9,7 @@ import humanize from pbench.common.utils import Cleanup, validate_hostname -from pbench.server.api.auth import Auth +from pbench.server.auth.auth import Auth from pbench.server.database.models.datasets import ( Dataset, DatasetDuplicate, diff --git a/lib/pbench/server/api/resources/users_api.py b/lib/pbench/server/api/resources/users_api.py index a3f7ca1589..8d566090c7 100644 --- a/lib/pbench/server/api/resources/users_api.py +++ b/lib/pbench/server/api/resources/users_api.py @@ -1,3 +1,4 @@ +from datetime import timedelta from http import HTTPStatus from typing import NamedTuple @@ -8,7 +9,7 @@ import jwt from sqlalchemy.exc import IntegrityError, SQLAlchemyError -from pbench.server.api.auth import Auth +from pbench.server.auth.auth import Auth from pbench.server.database.models.active_tokens import ActiveTokens from pbench.server.database.models.server_config import ServerConfig from pbench.server.database.models.users import User @@ -150,7 +151,8 @@ def __init__(self, config, logger, auth): def post(self): """ Post request for logging in user. - The user is allowed to re-login multiple times and each time a new valid auth token will be provided + The user is allowed to re-login multiple times and each time a new + valid auth token will be returned. This requires a JSON data with required user metadata fields { @@ -210,7 +212,8 @@ def post(self): try: auth_token = self.auth.encode_auth_token( - self.token_expire_duration, user.id + time_delta=timedelta(minutes=int(self.token_expire_duration)), + user_id=user.id, ) except ( jwt.InvalidIssuer, diff --git a/lib/pbench/server/auth/__init__.py b/lib/pbench/server/auth/__init__.py new file mode 100644 index 0000000000..e87122a32e --- /dev/null +++ b/lib/pbench/server/auth/__init__.py @@ -0,0 +1,294 @@ +from http import HTTPStatus +import logging +from typing import Dict, List, Optional +from urllib.parse import urljoin + +import jwt +import requests +from requests.structures import CaseInsensitiveDict + +from pbench.server import JSON + + +class OpenIDClientError(Exception): + def __init__(self, http_status: int, message: str = None): + self.http_status = http_status + self.message = message if message else HTTPStatus(http_status).phrase + + def __repr__(self) -> str: + return f"Oidc error {self.http_status} : {str(self)}" + + def __str__(self) -> str: + return self.message + + +class OpenIDCAuthenticationError(OpenIDClientError): + pass + + +class OpenIDClient: + """ + OpenID Connect client object. + """ + + USERINFO_ENDPOINT: Optional[str] = None + TOKENINFO_ENDPOINT: Optional[str] = None + JWKS_URI: Optional[str] = None + + def __init__( + self, + server_url: str, + client_id: str, + logger: logging.Logger, + realm_name: str = "", + client_secret_key: Optional[str] = None, + verify: bool = True, + headers: Optional[Dict[str, str]] = None, + ): + """ + Args: + server_url: OpenID Connect server auth url + client_id: client id + realm_name: realm name + client_secret_key: client secret key + verify: True if require valid SSL + headers: dict of custom header to pass to each HTML request + """ + self.server_url = server_url + self.client_id = client_id + self.client_secret_key = client_secret_key + self.realm_name = realm_name + self.logger = logger + self.headers = CaseInsensitiveDict([] if headers is None else headers) + self.verify = verify + self.connection = requests.session() + self.set_well_known_endpoints() + + def __repr__(self): + return ( + f"OpenIDClient(server_url={self.server_url}, " + f"client_id={self.client_id}, realm_name={self.realm_name}, " + f"headers={self.headers})" + ) + + def add_header_param(self, key: str, value: str): + """ + Add a single parameter inside the header. + Args: + key: Header parameters key. + value: Value to be added for the key. + """ + self.headers[key] = value + + def del_header_params(self, key: str): + """ + Remove a specific header parameter. + Args: + Key to delete from the headers. + """ + del self.headers[key] + + def set_well_known_endpoints(self): + """ + Sets the well-known configuration endpoints for the current OIDC + client. This includes common useful endpoints for decoding tokens, + getting user information etc. + ref: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig + """ + well_known_endpoint = "/.well-known/openid-configuration" + well_known_uri = f"{self.server_url}{self.realm_name}{well_known_endpoint}" + endpoints_json = self._get(well_known_uri).json() + try: + OpenIDClient.USERINFO_ENDPOINT = endpoints_json.get("userinfo_endpoint") + OpenIDClient.TOKENINFO_ENDPOINT = endpoints_json.get( + "introspection_endpoint" + ) + OpenIDClient.JWKS_ENDPOINT = endpoints_json.get("jwks_uri") + except KeyError as e: + self.logger.exception( + "Missing endpoint {!r} at {}; Endpoints found: {}", + str(e), + well_known_uri, + endpoints_json, + ) + raise + + def get_oidc_public_key(self, token: str): + """ + Returns the OIDC client public key that can be used for decoding + tokens offline. + Args: + token: Third party token to extract the signing key + Returns: + OIDC client public key + """ + jwks_client = jwt.PyJWKClient(self.JWKS_URI) + pubkey = jwks_client.get_signing_key_from_jwt(token).key + return pubkey + + def token_introspect_online(self, token: str, token_info_uri: str) -> JSON: + """ + The introspection endpoint is used to retrieve the active state of a + token. + It can only be invoked by confidential clients. + The introspected JWT token contains the claims specified in + https://tools.ietf.org/html/rfc7662 + Note: this is not supposed to be used in production, instead rely on + offline token validation because of security concerns mentioned in + https://www.rfc-editor.org/rfc/rfc7662.html#section-4 + Args: + token: token value to introspect + token_info_uri: token introspection uri + Returns: + Extracted token information + { + "aud": , + "email_verified": , + "expires_in": , + "access_type": "offline", + "exp": , + "azp": , + "scope": , # "openid email profile" + "email": , + "sub": + } + """ + payload = { + "client_id": self.client_id, + "client_secret": self.client_secret_key, + "token": token, + } + return self._post(token_info_uri, data=payload).json() + + def token_introspect_offline( + self, + token: str, + key: str, + audience: str = "account", + algorithms: List[str] = ["RS256"], + **kwargs, + ) -> JSON: + """ + Utility method to decode access/Id tokens using the public key provided + by the identity provider. + The introspected JWT token contains the claims specified in + https://tools.ietf.org/html/rfc7662 + + Please refer https://www.rfc-editor.org/rfc/rfc7662.html#section-4 for + requirements on doing the token introspection offline. + Args: + token: token value to introspect + key: client public key + audience: jwt token audience/client, who this token was intended for + algorithms: Algorithm with which this JWT token was encoded + Returns: + Extracted token information + { + "aud": , + "email_verified": , + "expires_in": , + "access_type": "offline", + "exp": , + "azp": , + "scope": , # "openid email profile" + "email": , + "sub": + } + """ + return jwt.decode( + token, key, algorithms=algorithms, audience=audience, **kwargs + ) + + def get_userinfo(self, token: str = None) -> JSON: + """ + The userinfo endpoint returns standard claims about the authenticated + user, and is protected by a bearer token. + http://openid.net/specs/openid-connect-core-1_0.html#UserInfo + Args: + token: Valid token to extract the userinfo + Returns: + Userinfo payload + { + "family_name": , + "sub": , + "picture": , + "locale": , + "email_verified": , + "given_name": , + "email": , + "hd": , + "name": + } + """ + + if token: + self.add_header_param("Authorization", f"Bearer {token}") + + return self._get(self.USERINFO_ENDPOINT).json() + + def _method( + self, method: str, path: str, data: Dict, **kwargs + ) -> requests.Response: + """ + Common frontend for the HTTP operations on OIDC client. + Args: + method: The API HTTP method + path: Path for the request. + data: Json data to send with the request in case of the POST + kwargs: Additional keyword args + Returns: + Response from the request. + """ + + try: + response = self.connection.request( + method, + urljoin(self.server_url, path), + params=kwargs, + data=data, + headers=self.headers, + verify=self.verify, + ) + response.raise_for_status() + return response + except requests.exceptions.HTTPError: + raise OpenIDCAuthenticationError(response.status_code) + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e: + self.logger.exception("Could not connect to the OIDC client {!r}", self) + raise OpenIDClientError( + HTTPStatus.BAD_GATEWAY, + f"Failure to connect to the OpenID client {e}", + ) + except Exception as exc: + self.logger.exception( + "{} request failed for OIDC client {!r}", method, self + ) + raise OpenIDClientError( + HTTPStatus.INTERNAL_SERVER_ERROR, + f"Failure to complete the {method} request from the OpenID client {exc}", + ) + + def _get(self, path: str, **kwargs) -> requests.Response: + """ + GET wrapper to handle an authenticated GET operation on the Resource at + a given path. + Args: + path: Path for the request. + kwargs: Additional keyword args + Returns: + Response from the request. + """ + return self._method("GET", path, None, **kwargs) + + def _post(self, path: str, data: Dict, **kwargs) -> requests.Response: + """ + POST wrapper to handle an authenticated POST operation on the Resource at + a given path + Args: + path: Path for the request. + data: JSON request body + kwargs: Additional keyword args + Returns: + Response from the request. + """ + return self._method("POST", path, data, **kwargs) diff --git a/lib/pbench/server/api/auth.py b/lib/pbench/server/auth/auth.py similarity index 59% rename from lib/pbench/server/api/auth.py rename to lib/pbench/server/auth/auth.py index f24ab54363..d96573b39d 100644 --- a/lib/pbench/server/api/auth.py +++ b/lib/pbench/server/auth/auth.py @@ -6,6 +6,7 @@ from flask_httpauth import HTTPTokenAuth import jwt +from pbench.server.auth import OpenIDClient, OpenIDClientError from pbench.server.database.models.active_tokens import ActiveTokens from pbench.server.database.models.users import User @@ -18,15 +19,19 @@ def set_logger(logger): # Logger gets set at the time of auth module initialization Auth.logger = logger - def encode_auth_token(self, token_expire_duration, user_id): + def encode_auth_token(self, time_delta: datetime.timedelta, user_id: int) -> str: """ Generates the Auth Token - :return: jwt token string + Args: + time_delta: Token lifetime + user_id: Authorized user's internal ID + Returns: + JWT token string """ current_utc = datetime.datetime.utcnow() payload = { "iat": current_utc, - "exp": current_utc + datetime.timedelta(minutes=int(token_expire_duration)), + "exp": current_utc + time_delta, "sub": user_id, } @@ -101,3 +106,53 @@ def verify_auth(auth_token): e, ) return None + + @staticmethod + def verify_third_party_token(auth_token: str, oidc_client: OpenIDClient) -> bool: + """ + Verify a token provided to the Pbench server which was obtained from a + third party identity provider. + Args: + auth_token: Token to authenticate + oidc_client: OIDC client to call to authenticate the token + Returns: + True if the verification succeeds else False + """ + identity_provider_pubkey = oidc_client.get_oidc_public_key(auth_token) + try: + oidc_client.token_introspect_offline( + token=auth_token, + key=identity_provider_pubkey, + audience=oidc_client.client_id, + options={ + "verify_signature": True, + "verify_aud": True, + "verify_exp": True, + }, + ) + return True + except ( + jwt.ExpiredSignatureError, + jwt.InvalidTokenError, + jwt.InvalidAudienceError, + ): + Auth.logger.error("OIDC token verification failed") + return False + except Exception: + Auth.logger.exception( + "Unexpected exception occurred while verifying the auth token {}", + auth_token, + ) + + if not oidc_client.TOKENINFO_ENDPOINT: + Auth.logger.warning("Can not perform OIDC online token verification") + return False + + try: + token_payload = oidc_client.token_introspect_online( + token=auth_token, token_info_uri=oidc_client.TOKENINFO_ENDPOINT + ) + except OpenIDClientError: + return False + + return "aud" in token_payload and oidc_client.client_id in token_payload["aud"] diff --git a/lib/pbench/test/unit/server/auth/__init__.py b/lib/pbench/test/unit/server/auth/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/pbench/test/unit/server/auth/conftest.py b/lib/pbench/test/unit/server/auth/conftest.py new file mode 100644 index 0000000000..c0e34b958f --- /dev/null +++ b/lib/pbench/test/unit/server/auth/conftest.py @@ -0,0 +1,39 @@ +import jwt +import pytest + +from pbench.common.logger import get_pbench_logger +from pbench.server.auth import OpenIDClient + + +def mock_set_oidc_auth_endpoints(oidc_client): + oidc_client.USERINFO_ENDPOINT = "https://oidc_userinfo_endpoint.example.com" + oidc_client.JWKS_ENDPOINT = "https://oidc_jwks_endpoint.example.com" + oidc_client.TOKENINFO_ENDPOINT = "https://oidc_token_introspection.example.com" + + +@pytest.fixture +def keycloak_oidc(server_config, monkeypatch): + logger = get_pbench_logger("TEST", server_config) + monkeypatch.setattr( + OpenIDClient, "set_well_known_endpoints", mock_set_oidc_auth_endpoints + ) + oidc = OpenIDClient( + server_url=server_config.get("keycloak", "server_url"), + realm_name="public_test_realm", + client_id="test_client", + logger=logger, + ) + return oidc + + +@pytest.fixture +def keycloak_mock_token(server_config): + payload = { + "iat": 1659476706, + "exp": 1685396687, + "sub": "12345", + "aud": "test_client", + } + + # Get jwt key + return jwt.encode(payload, key="some_secret", algorithm="HS256") diff --git a/lib/pbench/test/unit/server/auth/test_auth.py b/lib/pbench/test/unit/server/auth/test_auth.py new file mode 100644 index 0000000000..672331b3f3 --- /dev/null +++ b/lib/pbench/test/unit/server/auth/test_auth.py @@ -0,0 +1,36 @@ +from jwt.exceptions import InvalidAudienceError +import pytest + + +class TestUserTokenManagement: + USERNAME = "test" + PASSWORD = "test123" + REALM_NAME = "public_test_realm" + + def test_token_introspect_offline(self, keycloak_oidc, keycloak_mock_token): + options = {"verify_signature": True, "verify_aud": True, "verify_exp": True} + response = keycloak_oidc.token_introspect_offline( + token=keycloak_mock_token, + key="some_secret", + algorithms=["HS256"], + audience="test_client", + options=options, + ) + assert response == { + "iat": 1659476706, + "exp": 1685396687, + "sub": "12345", + "aud": "test_client", + } + + def test_token_introspect_wrong_aud_claim(self, keycloak_oidc, keycloak_mock_token): + options = {"verify_signature": True, "verify_aud": True, "verify_exp": True} + with pytest.raises(InvalidAudienceError) as e: + keycloak_oidc.token_introspect_offline( + token=keycloak_mock_token, + key="some_secret", + algorithms=["HS256"], + audience="wrong_client", + options=options, + ) + assert str(e.value) == "Invalid audience" diff --git a/lib/pbench/test/unit/server/conftest.py b/lib/pbench/test/unit/server/conftest.py index 062716d34b..9555bf0661 100644 --- a/lib/pbench/test/unit/server/conftest.py +++ b/lib/pbench/test/unit/server/conftest.py @@ -17,7 +17,7 @@ from pbench.server import PbenchServerConfig from pbench.server.api import create_app, get_server_config -from pbench.server.api.auth import Auth +from pbench.server.auth.auth import Auth from pbench.server.database.database import Database from pbench.server.database.models.datasets import Dataset, Metadata, States from pbench.server.database.models.template import Template @@ -52,6 +52,8 @@ [Indexing] index_prefix = unit-test +[keycloak] +server_url = http://pbench.example.com/ ########################################################################### # The rest will come from the default config file. @@ -183,13 +185,13 @@ def register_user( ) -def login_user(client, server_config, username, password): +def login_user(client, server_config, username, password, token_expiry=None): """ Helper function to generate a user authentication token """ return client.post( f"{server_config.rest_uri}/login", - json={"username": username, "password": password}, + json={"username": username, "password": password, "token_expiry": token_expiry}, ) diff --git a/lib/pbench/test/unit/server/query_apis/test_query_builder.py b/lib/pbench/test/unit/server/query_apis/test_query_builder.py index 1074b16444..9b2bdf387f 100644 --- a/lib/pbench/test/unit/server/query_apis/test_query_builder.py +++ b/lib/pbench/test/unit/server/query_apis/test_query_builder.py @@ -3,9 +3,9 @@ import pytest from pbench.server import JSON -from pbench.server.api.auth import Auth from pbench.server.api.resources import API_METHOD, API_OPERATION, ApiSchema from pbench.server.api.resources.query_apis import ElasticBase +from pbench.server.auth.auth import Auth from pbench.server.database.models.users import User ADMIN_ID = "6" # This needs to match the current_user_admin fixture diff --git a/lib/pbench/test/unit/server/test_authorization.py b/lib/pbench/test/unit/server/test_authorization.py index 5e55370c1f..4933785290 100644 --- a/lib/pbench/test/unit/server/test_authorization.py +++ b/lib/pbench/test/unit/server/test_authorization.py @@ -2,7 +2,6 @@ import pytest -from pbench.server.api.auth import Auth from pbench.server.api.resources import ( API_AUTHORIZATION, API_METHOD, @@ -12,6 +11,7 @@ ApiSchema, UnauthorizedAccess, ) +from pbench.server.auth.auth import Auth from pbench.server.database.models.users import User