1
1
"""
2
2
OpenID connect IdP client
3
3
"""
4
- import json
5
4
import urllib .parse
6
5
from typing import List , Optional
7
6
import requests
@@ -31,6 +30,7 @@ class ArxivOidcIdpClient:
31
30
_login_redirect_url : str
32
31
_logout_redirect_url : str
33
32
jwt_verify_options : dict
33
+ _ssl_cert_verify : bool
34
34
35
35
def __init__ (self , redirect_uri : str ,
36
36
server_url : str = "https://openid.arxiv.org" ,
@@ -41,26 +41,36 @@ def __init__(self, redirect_uri: str,
41
41
login_redirect_url : str | None = None ,
42
42
logout_redirect_url : str | None = None ,
43
43
logger : logging .Logger | None = None ,
44
+ ssl_verify : bool = True ,
44
45
):
45
46
"""
46
47
Make Tapir user data from pass-data
47
48
48
49
Parameters
49
50
----------
50
- redirect_uri: Callback URL - typically FOO/callback which is POSTED when the IdP
51
- authentication succeeds.
52
- server_url: IdP's URL
53
- realm: OpenID's realm - for arXiv users, it should be "arxiv"
54
- client_id: Registered client ID. OAuth2 client/callback are registered on IdP and need to
55
- match
56
- scope: List of OAuth2 scopes - Apparently, keycloak (v20?) dropped the "openid" scope.
57
- Trying to include "openid" results in no such scope error if you don't set up "openid" scope in the realm.
58
- You need to have the "openid" scope for id_token, or else logout does not work.
59
- IOW, you need to create "openid" scope for the realm if it does not exist.
60
- client_secret: Registered client secret
61
- login_redirect_url: redircet URL after log in
62
- logout_redirect_url: redircet URL after log out
63
- logger: Python logging logger instance
51
+ redirect_uri : str
52
+ str: Callback URL - typically FOO/callback which is POSTED when the IdP authentication succeeds.
53
+ server_url : str
54
+ str: IdP's URL
55
+ realm : str
56
+ OpenID's realm - for arXiv users, it should be "arxiv"
57
+ client_id : str
58
+ Registered client ID. OAuth2 client/callback are registered on IdP and need to match
59
+ scope : [str]
60
+ List of OAuth2 scopes - Apparently, keycloak (v20?) dropped the "openid" scope.
61
+ Trying to include "openid" results in no such scope error if you don't set up "openid" scope in the realm.
62
+ You need to have the "openid" scope for id_token, or else logout does not work.
63
+ IOW, you need to create "openid" scope for the realm if it does not exist.
64
+ client_secret : str
65
+ Registered client secret
66
+ login_redirect_url: str
67
+ redircet URL after log in
68
+ logout_redirect_url : str
69
+ redircet URL after log out
70
+ logger : logging.Logger
71
+ Python logging logger instance
72
+ ssl_verify: bool
73
+ Verify SSL certificate - DO NOT TURN THIS OFF UNLESS YOU ARE WORKING ON LOCAL HOST
64
74
"""
65
75
self .server_url = server_url
66
76
self .realm = realm
@@ -80,14 +90,17 @@ def __init__(self, redirect_uri: str,
80
90
"verify_iss" : True ,
81
91
"verify_aud" : False , # audience is "account" when it comes from olde tapir but not so for Keycloak.
82
92
}
93
+ self ._ssl_cert_verify = ssl_verify
83
94
pass
84
95
85
96
@property
86
97
def oidc (self ) -> str :
98
+ """OIDC URL"""
87
99
return f'{ self .server_url } /realms/{ self .realm } /protocol/openid-connect'
88
100
89
101
@property
90
- def auth_url (self ) -> str :
102
+ def authn_url (self ) -> str :
103
+ """Authentication URL"""
91
104
return self .oidc + '/auth'
92
105
93
106
@property
@@ -115,7 +128,7 @@ def logout_url(self, user: ArxivUserClaims, redirect_url: str | None = None) ->
115
128
@property
116
129
def login_url (self ) -> str :
117
130
scope = "&scope=" + "%20" .join (self .scope ) if self .scope else ""
118
- url = f'{ self .auth_url } ?client_id={ self .client_id } &redirect_uri={ self .redirect_uri } &response_type=code{ scope } '
131
+ url = f'{ self .authn_url } ?client_id={ self .client_id } &redirect_uri={ self .redirect_uri } &response_type=code{ scope } '
119
132
self ._logger .debug (f'login_url: { url } ' )
120
133
return url
121
134
@@ -125,7 +138,7 @@ def server_certs(self) -> dict:
125
138
# I'm having some 2nd thought about caching this. Fresh cert every time is probably needed
126
139
# if not self._server_certs:
127
140
# This adds one extra fetch but it avoids weird expired certs situation
128
- certs_response = requests .get (self .certs_url )
141
+ certs_response = requests .get (self .certs_url , verify = self . _ssl_cert_verify )
129
142
self ._server_certs = certs_response .json ()
130
143
return self ._server_certs
131
144
@@ -145,7 +158,13 @@ def acquire_idp_token(self, code: str) -> Optional[dict]:
145
158
146
159
Parameters
147
160
----------
148
- code: When IdP calls back, it comes with the authentication code as a query parameter.
161
+ code : str
162
+ When IdP calls back, it comes with the authentication code as a query parameter.
163
+
164
+ Returns
165
+ -------
166
+ dict | None
167
+ IDP token when this is a success
149
168
"""
150
169
auth = None
151
170
if self .client_secret :
@@ -169,7 +188,8 @@ def acquire_idp_token(self, code: str) -> Optional[dict]:
169
188
'redirect_uri' : self .redirect_uri ,
170
189
'client_id' : self .client_id ,
171
190
},
172
- auth = auth
191
+ auth = auth ,
192
+ verify = self ._ssl_cert_verify ,
173
193
)
174
194
if token_response .status_code != 200 :
175
195
self ._logger .warning (f'idp %s' , token_response .status_code )
@@ -187,12 +207,13 @@ def validate_access_token(self, access_token: str) -> dict | None:
187
207
188
208
Parameters
189
209
----------
190
- access_token: This is the access token in the IdP's token that you get from the code
210
+ access_token : str
211
+ This is the access token in the IdP's token that you get from the code
191
212
192
- Return
193
- ------
194
- None -> Invalid access token
195
- dict -> The content of idp token as dict
213
+ Returns
214
+ -------
215
+ None | dict
216
+ None -> Invalid access token, dict -> The content of idp token as dict
196
217
"""
197
218
198
219
try :
@@ -220,9 +241,14 @@ def validate_access_token(self, access_token: str) -> dict | None:
220
241
except jwt .ExpiredSignatureError :
221
242
self ._logger .error ("IdP signature cert is expired." )
222
243
return None
244
+
223
245
except jwt .InvalidTokenError :
224
246
self ._logger .error ("jwt.InvalidTokenError: Token is invalid." , exc_info = True )
225
247
return None
248
+
249
+ except jwt .ImmatureSignatureError :
250
+ self ._logger .error ("jwt.ImmatureSignatureError: Token is invalid." , exc_info = True )
251
+ return None
226
252
# not reached
227
253
228
254
def to_arxiv_user_claims (self ,
@@ -236,13 +262,13 @@ def to_arxiv_user_claims(self,
236
262
237
263
Parameters
238
264
----------
239
- idp_token: This is the IdP token which contains access, refresh, id tokens, etc.
265
+ idp_token : dict | None
266
+ This is the IdP token which contains access, refresh, id tokens, etc.
240
267
241
- kc_cliams: This is the contents of (unpacked) access token. IdP signs it with the private
268
+ kc_cliams : dict | None
269
+ This is the contents of (unpacked) access token. IdP signs it with the private
242
270
key, and the value is verified using the published public cert.
243
271
244
- NOTE: So this means there are two copies of access token. Unfortunate, but unpacking
245
- every time can be costly.
246
272
"""
247
273
if idp_token is None :
248
274
idp_token = {}
@@ -261,12 +287,13 @@ def from_code_to_user_claims(self, code: str) -> ArxivUserClaims | None:
261
287
262
288
Parameters
263
289
----------
264
- code: The code you get in the /callback
290
+ code : str
291
+ The code you get in the /callback
265
292
266
293
Returns
267
294
-------
268
- ArxivUserClaims: User's IdP claims
269
- None: Something is wrong
295
+ ArxivUserClaims | None
296
+ User's IdP claims or None when something is wrong
270
297
271
298
Note
272
299
----
@@ -293,7 +320,13 @@ def logout_user(self, user: ArxivUserClaims) -> bool:
293
320
294
321
Parameters
295
322
----------
296
- user: ArxivUserClaims
323
+ user : ArxivUserClaims
324
+ user claims
325
+
326
+ Returns
327
+ -------
328
+ bool
329
+ Logout success / failure
297
330
"""
298
331
try :
299
332
header = {
@@ -314,7 +347,7 @@ def logout_user(self, user: ArxivUserClaims) -> bool:
314
347
log_extra = {'header' : header , 'body' : data }
315
348
self ._logger .debug ('Logout request %s' , url , extra = log_extra )
316
349
try :
317
- response = requests .post (url , headers = header , data = data , timeout = 30 )
350
+ response = requests .post (url , headers = header , data = data , timeout = 30 , verify = self . _ssl_cert_verify )
318
351
if response .status_code == 200 :
319
352
# If Keycloak is misconfigured, this does not log out.
320
353
# Turn front channel logout off in the logout settings of the client."
@@ -342,13 +375,18 @@ def logout_user(self, user: ArxivUserClaims) -> bool:
342
375
return False
343
376
344
377
345
- def refresh_access_token (self , refresh_token : str ) -> ArxivUserClaims :
378
+ def refresh_access_token (self , refresh_token : str ) -> Optional [ ArxivUserClaims ] :
346
379
"""With the refresh token, get a new access token
347
380
348
381
Parameters
349
382
----------
350
383
refresh_token: str
351
- refresh token which is given by OIDC
384
+ refresh token which is given by OIDC
385
+
386
+ Returns
387
+ -------
388
+ ArxivUserClaims | None
389
+ New (refreshed) user claims when success. None - the refresh token is invalid/expired.
352
390
"""
353
391
354
392
headers = {
@@ -378,6 +416,7 @@ def refresh_access_token(self, refresh_token: str) -> ArxivUserClaims:
378
416
},
379
417
auth = auth ,
380
418
headers = headers ,
419
+ verify = self ._ssl_cert_verify
381
420
)
382
421
if token_response .status_code != 200 :
383
422
self ._logger .warning (f'idp %s' , token_response .status_code )
0 commit comments