Skip to content

Commit 0c92e77

Browse files
committed
Add OIDC user to the functional test (#3235)
* Use OIDC user for the functional test Functional tests should move to using OIDC tokens by making an Admin REST API request to the OIDC server (Keycloak) directly as the server won't be providing Register/Login capabilities on its endpoints. - Create a new scope in the Keycloak to add OIDC client name in aud claim (required for the authentication) - Functional test user registration happens directly with the OIDC server using the Admin token - Functional test user makes a REST API call to get the OIDC token PBENCH-1070
1 parent 65e7d36 commit 0c92e77

File tree

15 files changed

+310
-305
lines changed

15 files changed

+310
-305
lines changed

lib/pbench/client/__init__.py

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
import requests
77
from requests.structures import CaseInsensitiveDict
88

9-
from pbench.client.types import Dataset, JSONMap, JSONOBJECT
9+
from pbench.client.oidc_admin import OIDCAdmin
10+
from pbench.client.types import Dataset, JSONOBJECT
1011

1112

1213
class PbenchClientError(Exception):
@@ -83,6 +84,7 @@ def __init__(self, host: str):
8384
self.auth_token: Optional[str] = None
8485
self.session: Optional[requests.Session] = None
8586
self.endpoints: Optional[JSONOBJECT] = None
87+
self.oidc_admin: Optional[OIDCAdmin] = None
8688

8789
def _headers(
8890
self, user_headers: Optional[dict[str, str]] = None
@@ -299,8 +301,13 @@ def delete(
299301
return response
300302

301303
def connect(self, headers: Optional[dict[str, str]] = None) -> None:
302-
"""Connect to the Pbench Server host using the endpoints API to be sure
303-
that it responds, and cache the endpoints response payload.
304+
"""Performs some pre-requisite actions to make server client usable.
305+
306+
1. Connect to the Pbench Server host using the endpoints API to be
307+
sure that it responds, and cache the endpoints response payload.
308+
309+
2. Create an OIDCAdmin object that a server client can use to
310+
perform privileged actions on an OIDC server.
304311
305312
This also allows the client to add default HTTP headers to the session
306313
which will be used for all operations unless overridden for specific
@@ -318,31 +325,24 @@ def connect(self, headers: Optional[dict[str, str]] = None) -> None:
318325
self.endpoints = response.json()
319326
assert self.endpoints
320327

321-
def login(self, user: str, password: str) -> JSONMap:
322-
"""Login to a specified username with the password, and store the
323-
resulting authentication token.
328+
# Create an OIDCAdmin object and confirm the connection was successful
329+
self.oidc_admin = OIDCAdmin(server_url=self.endpoints["openid"]["server"])
330+
331+
def login(self, user: str, password: str):
332+
"""Log into the OIDC server using the specified username and password,
333+
and store the resulting authentication token.
324334
325335
Args:
326336
user: Account username
327337
password: Account password
328-
329-
Returns:
330-
The login response
331-
"""
332-
response = self.post(API.LOGIN, json={"username": user, "password": password})
333-
response.raise_for_status()
334-
json = response.json()
335-
self.username = json["username"]
336-
self.auth_token = json["auth_token"]
337-
return JSONMap(json)
338-
339-
def logout(self) -> None:
340-
"""Logout the currently authenticated user and remove the
341-
authentication token.
342338
"""
343-
self.post(API.LOGOUT)
344-
self.username = None
345-
self.auth_token = None
339+
response = self.oidc_admin.user_login(
340+
client_id=self.endpoints["openid"]["client"],
341+
username=user,
342+
password=password,
343+
)
344+
self.username = user
345+
self.auth_token = response["access_token"]
346346

347347
def upload(self, tarball: Path, **kwargs) -> requests.Response:
348348
"""Upload a tarball to the server.

lib/pbench/client/oidc_admin.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
from http import HTTPStatus
2+
import os
3+
4+
import requests
5+
6+
from pbench.server.auth import Connection
7+
8+
9+
class OIDCAdmin(Connection):
10+
OIDC_REALM = os.getenv("OIDC_REALM", "pbench-server")
11+
ADMIN_USERNAME = os.getenv("OIDC_ADMIN_USERNAME", "admin")
12+
ADMIN_PASSWORD = os.getenv("OIDC_ADMIN_PASSWORD", "admin")
13+
14+
def __init__(self, server_url: str):
15+
super().__init__(server_url, verify=False)
16+
17+
def get_admin_token(self) -> dict:
18+
"""pbench-server realm admin user login.
19+
20+
Returns:
21+
access_token json payload
22+
23+
{ 'access_token': <access_token>,
24+
'expires_in': 60, 'refresh_expires_in': 1800,
25+
'refresh_token': <refresh_token>,
26+
'token_type': 'Bearer',
27+
'not-before-policy': 0,
28+
'session_state': '8f558797-50e7-496d-bb45-3b5ac9fdcddb',
29+
'scope': 'profile email'}
30+
31+
"""
32+
url_path = "/realms/master/protocol/openid-connect/token"
33+
data = {
34+
"grant_type": "password",
35+
"client_id": "admin-cli",
36+
"username": self.ADMIN_USERNAME,
37+
"password": self.ADMIN_PASSWORD,
38+
}
39+
return self.post(path=url_path, data=data).json()
40+
41+
def create_new_user(
42+
self,
43+
username: str,
44+
email: str,
45+
password: str,
46+
first_name: str = "",
47+
last_name: str = "",
48+
) -> requests.Response:
49+
"""Creates a new user under the OIDC_REALM.
50+
51+
Note: This involves a REST API call to the
52+
OIDC server to create a new user.
53+
54+
Args:
55+
username: username to register,
56+
email: user email address,
57+
password: user password,
58+
first_name: Optional first name of the user,
59+
last_name: Optional first name of the user,
60+
61+
Returns:
62+
Response from the request.
63+
"""
64+
admin_token = self.get_admin_token().get("access_token")
65+
url_path = f"/admin/realms/{self.OIDC_REALM}/users"
66+
headers = {"Authorization": f"Bearer {admin_token}"}
67+
data = {
68+
"username": username,
69+
"email": email,
70+
"emailVerified": True,
71+
"enabled": True,
72+
"firstName": first_name,
73+
"lastName": last_name,
74+
"credentials": [
75+
{"type": "password", "value": password, "temporary": False}
76+
],
77+
}
78+
response = self.post(path=url_path, json=data, headers=headers)
79+
return response
80+
81+
def user_login(self, client_id: str, username: str, password: str) -> dict:
82+
"""pbench-server realm user login on a specified client.
83+
84+
Args:
85+
client_id: client_name to use in the request
86+
username: username of the user logging in
87+
password: OIDC password
88+
89+
Returns:
90+
access_token json payload
91+
92+
{ 'access_token': <access_token>,
93+
'expires_in': 60, 'refresh_expires_in': 1800,
94+
'refresh_token': <refresh_token>,
95+
'token_type': 'Bearer',
96+
'not-before-policy': 0,
97+
'session_state': '8f558797-50e7-496d-bb45-3b5ac9fdcddb',
98+
'scope': 'profile email'}
99+
100+
"""
101+
url_path = f"/realms/{self.OIDC_REALM}/protocol/openid-connect/token"
102+
data = {
103+
"client_id": client_id,
104+
"grant_type": "password",
105+
"scope": "profile email",
106+
"username": username,
107+
"password": password,
108+
}
109+
return self.post(path=url_path, data=data).json()
110+
111+
def get_user(self, username: str, token: str) -> dict:
112+
"""Get the OIDC user representation dict.
113+
114+
Args:
115+
username: username to query
116+
token: access_token string to validate
117+
118+
Returns:
119+
User dict representation
120+
121+
{'id': '37117992-a3de-43f7-b844-e6ee178e9965',
122+
'createdTimestamp': 1675981768951,
123+
'username': 'admin',
124+
'enabled': True,
125+
'totp': False,
126+
'emailVerified': False,
127+
'disableableCredentialTypes': [],
128+
'requiredActions': [],
129+
'notBefore': 0,
130+
'access': {'manageGroupMembership': True, 'view': True, 'mapRoles': True, 'impersonate': True, 'manage': True}
131+
...
132+
}
133+
"""
134+
response = self.get(
135+
f"admin/realms/{self.OIDC_REALM}/users",
136+
headers={"Authorization": f"Bearer {token}"},
137+
username=username,
138+
)
139+
if response.status_code == HTTPStatus.OK:
140+
return response.json()[0]
141+
return {}

lib/pbench/server/api/resources/__init__.py

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1326,13 +1326,6 @@ def _check_authorization(self, mode: ApiAuthorization):
13261326
user_id = mode.user
13271327
role = mode.role
13281328
authorized_user: User = Auth.token_auth.current_user()
1329-
username = "none"
1330-
if user_id:
1331-
user = User.query(id=user_id)
1332-
if user:
1333-
username = user.username
1334-
else:
1335-
current_app.logger.error("User ID {} not found", user_id)
13361329

13371330
# The ADMIN authorization doesn't involve a target resource owner or
13381331
# access, so take care of that first as a special case. If there is
@@ -1355,10 +1348,9 @@ def _check_authorization(self, mode: ApiAuthorization):
13551348
access = mode.access
13561349

13571350
current_app.logger.debug(
1358-
"Authorizing {} access for {} to user {} ({}) with access {} using {}",
1351+
"Authorizing {} access for {} to user (user id: {}) with access {} using {}",
13591352
role,
13601353
authorized_user,
1361-
username,
13621354
user_id,
13631355
mode.access,
13641356
mode.type,
@@ -1387,12 +1379,12 @@ def _check_authorization(self, mode: ApiAuthorization):
13871379
# An unauthenticated user is never allowed to access private
13881380
# data nor to perform an potential mutation of data: REJECT
13891381
current_app.logger.warning(
1390-
"Attempt to {} user {} data without login", role, username
1382+
"Attempt to {} user {} data without login", role, user_id
13911383
)
13921384
raise UnauthorizedAccess(
13931385
authorized_user,
13941386
role,
1395-
username,
1387+
user_id,
13961388
access,
13971389
HTTPStatus.UNAUTHORIZED,
13981390
)
@@ -1405,7 +1397,7 @@ def _check_authorization(self, mode: ApiAuthorization):
14051397
role,
14061398
)
14071399
raise UnauthorizedAccess(
1408-
authorized_user, role, username, access, HTTPStatus.FORBIDDEN
1400+
authorized_user, role, user_id, access, HTTPStatus.FORBIDDEN
14091401
)
14101402
elif (
14111403
user_id
@@ -1419,10 +1411,10 @@ def _check_authorization(self, mode: ApiAuthorization):
14191411
"Unauthorized attempt by {} to {} user {} data",
14201412
authorized_user,
14211413
role,
1422-
username,
1414+
user_id,
14231415
)
14241416
raise UnauthorizedAccess(
1425-
authorized_user, role, username, access, HTTPStatus.FORBIDDEN
1417+
authorized_user, role, user_id, access, HTTPStatus.FORBIDDEN
14261418
)
14271419
else:
14281420
# We have determined that there is an authenticated user with

0 commit comments

Comments
 (0)