Skip to content

Commit c65f42b

Browse files
committed
add capability to login with username and password
1 parent e3e1ce9 commit c65f42b

File tree

3 files changed

+315
-13
lines changed

3 files changed

+315
-13
lines changed

dataspace_sdk/auth.py

Lines changed: 239 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Authentication module for DataSpace SDK."""
22

3+
import time
34
from typing import Any, Dict, Optional
45

56
import requests
@@ -10,19 +11,219 @@
1011
class AuthClient:
1112
"""Handles authentication with DataSpace API."""
1213

13-
def __init__(self, base_url: str):
14+
def __init__(
15+
self,
16+
base_url: str,
17+
keycloak_url: Optional[str] = None,
18+
keycloak_realm: Optional[str] = None,
19+
keycloak_client_id: Optional[str] = None,
20+
keycloak_client_secret: Optional[str] = None,
21+
):
1422
"""
1523
Initialize the authentication client.
1624
1725
Args:
1826
base_url: Base URL of the DataSpace API
27+
keycloak_url: Keycloak server URL (e.g., "https://opub-kc.civicdatalab.in")
28+
keycloak_realm: Keycloak realm name (e.g., "DataSpace")
29+
keycloak_client_id: Keycloak client ID (e.g., "dataspace")
30+
keycloak_client_secret: Optional client secret for confidential clients
1931
"""
2032
self.base_url = base_url.rstrip("/")
33+
self.keycloak_url = keycloak_url.rstrip("/") if keycloak_url else None
34+
self.keycloak_realm = keycloak_realm
35+
self.keycloak_client_id = keycloak_client_id
36+
self.keycloak_client_secret = keycloak_client_secret
37+
38+
# Session state
2139
self.access_token: Optional[str] = None
2240
self.refresh_token: Optional[str] = None
41+
self.keycloak_access_token: Optional[str] = None
42+
self.keycloak_refresh_token: Optional[str] = None
43+
self.token_expires_at: Optional[float] = None
2344
self.user_info: Optional[Dict] = None
2445

25-
def login_with_keycloak(self, keycloak_token: str) -> Dict[str, Any]:
46+
# Stored credentials for auto-relogin
47+
self._username: Optional[str] = None
48+
self._password: Optional[str] = None
49+
50+
def login(self, username: str, password: str) -> Dict[str, Any]:
51+
"""
52+
Login using username and password via Keycloak.
53+
54+
Args:
55+
username: User's username or email
56+
password: User's password
57+
58+
Returns:
59+
Dictionary containing user info and tokens
60+
61+
Raises:
62+
DataSpaceAuthError: If authentication fails
63+
"""
64+
if not all([self.keycloak_url, self.keycloak_realm, self.keycloak_client_id]):
65+
raise DataSpaceAuthError(
66+
"Keycloak configuration missing. Please provide keycloak_url, "
67+
"keycloak_realm, and keycloak_client_id when initializing the client."
68+
)
69+
70+
# Store credentials for auto-relogin
71+
self._username = username
72+
self._password = password
73+
74+
# Get Keycloak token
75+
keycloak_token = self._get_keycloak_token(username, password)
76+
77+
# Login to DataSpace backend
78+
return self._login_with_keycloak_token(keycloak_token)
79+
80+
def _get_keycloak_token(self, username: str, password: str) -> str:
81+
"""
82+
Get Keycloak access token using username and password.
83+
84+
Args:
85+
username: User's username or email
86+
password: User's password
87+
88+
Returns:
89+
Keycloak access token
90+
91+
Raises:
92+
DataSpaceAuthError: If authentication fails
93+
"""
94+
token_url = (
95+
f"{self.keycloak_url}/auth/realms/{self.keycloak_realm}/"
96+
f"protocol/openid-connect/token"
97+
)
98+
99+
data = {
100+
"grant_type": "password",
101+
"client_id": self.keycloak_client_id,
102+
"username": username,
103+
"password": password,
104+
}
105+
106+
if self.keycloak_client_secret:
107+
data["client_secret"] = self.keycloak_client_secret
108+
109+
try:
110+
response = requests.post(
111+
token_url,
112+
data=data,
113+
headers={"Content-Type": "application/x-www-form-urlencoded"},
114+
)
115+
116+
if response.status_code == 200:
117+
token_data = response.json()
118+
self.keycloak_access_token = token_data.get("access_token")
119+
self.keycloak_refresh_token = token_data.get("refresh_token")
120+
121+
# Calculate token expiration time
122+
expires_in = token_data.get("expires_in", 300)
123+
self.token_expires_at = time.time() + expires_in
124+
125+
if not self.keycloak_access_token:
126+
raise DataSpaceAuthError("No access token in Keycloak response")
127+
128+
return self.keycloak_access_token
129+
else:
130+
error_data = response.json()
131+
error_msg = error_data.get(
132+
"error_description",
133+
error_data.get("error", "Keycloak authentication failed"),
134+
)
135+
raise DataSpaceAuthError(
136+
f"Keycloak login failed: {error_msg}",
137+
status_code=response.status_code,
138+
response=error_data,
139+
)
140+
except requests.RequestException as e:
141+
raise DataSpaceAuthError(f"Network error during Keycloak authentication: {str(e)}")
142+
143+
def _refresh_keycloak_token(self) -> str:
144+
"""
145+
Refresh Keycloak access token using refresh token.
146+
147+
Returns:
148+
New Keycloak access token
149+
150+
Raises:
151+
DataSpaceAuthError: If token refresh fails
152+
"""
153+
if not self.keycloak_refresh_token:
154+
# If no refresh token, try to relogin with stored credentials
155+
if self._username and self._password:
156+
return self._get_keycloak_token(self._username, self._password)
157+
raise DataSpaceAuthError("No refresh token or credentials available")
158+
159+
token_url = (
160+
f"{self.keycloak_url}/auth/realms/{self.keycloak_realm}/"
161+
f"protocol/openid-connect/token"
162+
)
163+
164+
data = {
165+
"grant_type": "refresh_token",
166+
"client_id": self.keycloak_client_id,
167+
"refresh_token": self.keycloak_refresh_token,
168+
}
169+
170+
if self.keycloak_client_secret:
171+
data["client_secret"] = self.keycloak_client_secret
172+
173+
try:
174+
response = requests.post(
175+
token_url,
176+
data=data,
177+
headers={"Content-Type": "application/x-www-form-urlencoded"},
178+
)
179+
180+
if response.status_code == 200:
181+
token_data = response.json()
182+
self.keycloak_access_token = token_data.get("access_token")
183+
self.keycloak_refresh_token = token_data.get("refresh_token")
184+
185+
expires_in = token_data.get("expires_in", 300)
186+
self.token_expires_at = time.time() + expires_in
187+
188+
if not self.keycloak_access_token:
189+
raise DataSpaceAuthError("No access token in refresh response")
190+
191+
return self.keycloak_access_token
192+
else:
193+
# Refresh failed, try to relogin with stored credentials
194+
if self._username and self._password:
195+
return self._get_keycloak_token(self._username, self._password)
196+
raise DataSpaceAuthError("Keycloak token refresh failed")
197+
except requests.RequestException as e:
198+
# Network error, try to relogin with stored credentials
199+
if self._username and self._password:
200+
return self._get_keycloak_token(self._username, self._password)
201+
raise DataSpaceAuthError(f"Network error during token refresh: {str(e)}")
202+
203+
def _ensure_valid_keycloak_token(self) -> str:
204+
"""
205+
Ensure we have a valid Keycloak token, refreshing if necessary.
206+
207+
Returns:
208+
Valid Keycloak access token
209+
210+
Raises:
211+
DataSpaceAuthError: If unable to get valid token
212+
"""
213+
# Check if token is expired or about to expire (within 30 seconds)
214+
if (
215+
not self.keycloak_access_token
216+
or not self.token_expires_at
217+
or time.time() >= (self.token_expires_at - 30)
218+
):
219+
# Token expired or about to expire, refresh it
220+
if self.keycloak_refresh_token or (self._username and self._password):
221+
return self._refresh_keycloak_token()
222+
raise DataSpaceAuthError("No valid token or credentials available")
223+
224+
return self.keycloak_access_token
225+
226+
def _login_with_keycloak_token(self, keycloak_token: str) -> Dict[str, Any]:
26227
"""
27228
Login using a Keycloak token.
28229
@@ -140,3 +341,39 @@ def _get_auth_headers(self) -> Dict[str, str]:
140341
def is_authenticated(self) -> bool:
141342
"""Check if the client is authenticated."""
142343
return self.access_token is not None
344+
345+
def ensure_authenticated(self) -> None:
346+
"""
347+
Ensure the client is authenticated, attempting auto-relogin if needed.
348+
349+
Raises:
350+
DataSpaceAuthError: If unable to authenticate
351+
"""
352+
if not self.is_authenticated():
353+
# Try to relogin with stored credentials
354+
if self._username and self._password:
355+
self.login(self._username, self._password)
356+
else:
357+
raise DataSpaceAuthError("Not authenticated. Please call login() first.")
358+
359+
def get_valid_token(self) -> str:
360+
"""
361+
Get a valid access token, refreshing if necessary.
362+
363+
Returns:
364+
Valid access token
365+
366+
Raises:
367+
DataSpaceAuthError: If unable to get valid token
368+
"""
369+
# First ensure we have a valid Keycloak token
370+
if self.keycloak_url and self.keycloak_realm:
371+
keycloak_token = self._ensure_valid_keycloak_token()
372+
# Re-login to backend with fresh Keycloak token if needed
373+
if not self.access_token:
374+
self._login_with_keycloak_token(keycloak_token)
375+
376+
if not self.access_token:
377+
raise DataSpaceAuthError("No access token available")
378+
379+
return self.access_token

dataspace_sdk/client.py

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,24 +33,67 @@ class DataSpaceClient:
3333
>>> org_usecases = client.usecases.get_organization_usecases("org-uuid")
3434
"""
3535

36-
def __init__(self, base_url: str):
36+
def __init__(
37+
self,
38+
base_url: str,
39+
keycloak_url: Optional[str] = None,
40+
keycloak_realm: Optional[str] = None,
41+
keycloak_client_id: Optional[str] = None,
42+
keycloak_client_secret: Optional[str] = None,
43+
):
3744
"""
3845
Initialize the DataSpace client.
3946
4047
Args:
4148
base_url: Base URL of the DataSpace API (e.g., "https://api.dataspace.example.com")
49+
keycloak_url: Keycloak server URL (e.g., "https://opub-kc.civicdatalab.in")
50+
keycloak_realm: Keycloak realm name (e.g., "DataSpace")
51+
keycloak_client_id: Keycloak client ID (e.g., "dataspace")
52+
keycloak_client_secret: Optional client secret for confidential clients
4253
"""
4354
self.base_url = base_url.rstrip("/")
44-
self._auth = AuthClient(self.base_url)
55+
self._auth = AuthClient(
56+
self.base_url,
57+
keycloak_url=keycloak_url,
58+
keycloak_realm=keycloak_realm,
59+
keycloak_client_id=keycloak_client_id,
60+
keycloak_client_secret=keycloak_client_secret,
61+
)
4562

4663
# Initialize resource clients
4764
self.datasets = DatasetClient(self.base_url, self._auth)
4865
self.aimodels = AIModelClient(self.base_url, self._auth)
4966
self.usecases = UseCaseClient(self.base_url, self._auth)
5067

51-
def login(self, keycloak_token: str) -> dict:
68+
def login(self, username: str, password: str) -> dict:
5269
"""
53-
Login using a Keycloak token.
70+
Login using username and password.
71+
72+
Args:
73+
username: User's username or email
74+
password: User's password
75+
76+
Returns:
77+
Dictionary containing user info and tokens
78+
79+
Raises:
80+
DataSpaceAuthError: If authentication fails
81+
82+
Example:
83+
>>> client = DataSpaceClient(
84+
... base_url="https://api.dataspace.example.com",
85+
... keycloak_url="https://opub-kc.civicdatalab.in",
86+
... keycloak_realm="DataSpace",
87+
... keycloak_client_id="dataspace"
88+
... )
89+
>>> user_info = client.login(username="[email protected]", password="secret")
90+
>>> print(user_info["user"]["username"])
91+
"""
92+
return self._auth.login(username, password)
93+
94+
def login_with_token(self, keycloak_token: str) -> dict:
95+
"""
96+
Login using a pre-obtained Keycloak token.
5497
5598
Args:
5699
keycloak_token: Valid Keycloak access token
@@ -63,10 +106,10 @@ def login(self, keycloak_token: str) -> dict:
63106
64107
Example:
65108
>>> client = DataSpaceClient(base_url="https://api.dataspace.example.com")
66-
>>> user_info = client.login(keycloak_token="your_token")
109+
>>> user_info = client.login_with_token(keycloak_token="your_token")
67110
>>> print(user_info["user"]["username"])
68111
"""
69-
return self._auth.login_with_keycloak(keycloak_token)
112+
return self._auth._login_with_keycloak_token(keycloak_token)
70113

71114
def refresh_token(self) -> str:
72115
"""

0 commit comments

Comments
 (0)