Skip to content

Commit 39ec788

Browse files
Merge branch 'dev' into staging
2 parents 4c884eb + 95ad33b commit 39ec788

File tree

6 files changed

+144
-52
lines changed

6 files changed

+144
-52
lines changed

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# image: tapis/authenticator
2-
FROM tapis/flaskbase:1.7.0
2+
FROM tapis/flaskbase:1.8.1
33

44
COPY requirements.txt /home/tapis/requirements.txt
55
RUN pip install -r /home/tapis/requirements.txt

Makefile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@
66
# CONSIDER USING VIM (sigh!) WHICH ACTUALLY HAS GOOD MAKEFILE EDITING SUPPORT.
77

88

9+
10+
# To run tests ensure service_password and token_url in .env
11+
# make clean
12+
# make build
13+
# make init_dbs
14+
# make migrate.upgrade
15+
# make test
16+
917
# it is required that the operator export API_NAME=<name_of_the_api> before using this makefile/
1018
# default to authenticator
1119
API_NAME ?=authenticator

service/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
logger.debug("creating the authenticator tapis service client...")
2020
t = get_service_tapis_client(tenants=auth_tenants,
2121
# todo -- change back once tokens api update is in prod
22-
resource_set='dev'
22+
# resource_set='dev'
2323
)
2424

2525

service/api.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
ProfilesResource, ProfileResource, StaticFilesResource, LoginResource, SetTenantResource, LogoutResource, \
1010
WebappTokenGen, WebappTokenAndRedirect, TenantConfigResource, UserInfoResource, OAuth2ProviderExtCallback, \
1111
OAuthMetadataResource, MFAResource, DeviceFlowResource, DeviceCodeResource, V2TokenResource, \
12-
RevokeTokensResource, SetIdentityProvider, WebappLogout, OIDCjwksResource, OIDCTokensResource#, OIDCUserInfoResource, OIDCMetadataResource
12+
RevokeTokensResource, SetIdentityProvider, WebappLogout, OIDCjwksResource, OIDCTokensResource, OIDCUserInfoResource #, OIDCMetadataResource
1313
from service.ldap import populate_test_ldap
1414
from service.models import db, app, initialize_tenant_configs
1515

@@ -79,6 +79,7 @@ def authnz_for_authenticator():
7979
#api.add_resource(OIDCMetadataResource, '/v3/oauth2/.well-known/openid-configuration')
8080
api.add_resource(OIDCjwksResource, '/v3/oauth2/jwks')
8181
api.add_resource(OIDCTokensResource, '/v3/oauth2/tokens/oidc')
82+
api.add_resource(OIDCUserInfoResource, '/v3/oauth2/userinfo/oidc')
8283

8384
# Auth server resources
8485
api.add_resource(AuthorizeResource, '/v3/oauth2/authorize')

service/auth.py

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ def authentication():
6060
raise common_errors.ResourceError("The endpoint and HTTP method combination "
6161
"are not available from this service.")
6262

63+
6364
# the metadata endpoint is publicly available
6465
if '/v3/oauth2/.well-known/' in request.url_rule.rule:
6566
logger.debug(".well-known endpoint; request is allowed to be made unauthenticated.")
@@ -82,7 +83,7 @@ def authentication():
8283
# first, make sure this request is for a tenant served by this authenticator
8384
if g.request_tenant_id not in conf.tenants:
8485
raise common_errors.PermissionsError(f"The request is for a tenant ({g.request_tenant_id}) that is not "
85-
f"served by this authenticator.")
86+
f"served by this authenticator.")
8687
# we only want to honor tokens from THIS authenticator; i.e., not some other authenticator. therefore, we need
8788
# to check that the tenant_id associated with the token (g.tenant_id) is the same as THIS authenticator's tenant
8889
# id;
@@ -91,28 +92,28 @@ def authentication():
9192
f"and tenant was {conf.service_tenant_id}")
9293
return True
9394
logger.debug(f"request token does not represent THIS authenticator: token username: {g.username};"
94-
f" request tenant: {g.tenant_id}. Now checking for tenant admin...")
95+
f" request tenant: {g.tenant_id}. Now checking for tenant admin...")
9596
# all other service accounts are not allowed to update authenticator
9697
if g.account_type == 'service':
9798
raise common_errors.PermissionsError("Not authorized -- service accounts are not allowed to access the"
98-
"authenticator admin endpoints.")
99+
"authenticator admin endpoints.")
99100
# sanity check -- the request tenant id should be the same as the token tenant id in the remaining cases because
100101
# they are all user tokens
101102
if not g.request_tenant_id == g.tenant_id:
102103
logger.error(f"program error -- g.request_tenant_id: {g.request_tenant_id} not equal to "
103-
f"g.tenant_id: {g.tenant_id} even though account type was user!")
104+
f"g.tenant_id: {g.tenant_id} even though account type was user!")
104105
raise common_errors.ServiceConfigError(f"Unexpected program error checking permissions. The tenant id of"
105-
f"the request ({g.request_tenant_id}) did not match the tenant id "
106-
f"of the access token ({g.tenant_id}). Please contact server "
107-
f"administrators.")
106+
f"the request ({g.request_tenant_id}) did not match the tenant id "
107+
f"of the access token ({g.tenant_id}). Please contact server "
108+
f"administrators.")
108109
# check SK for tenant admin --
109110
try:
110111
rsp = t.sk.isAdmin(tenant=g.tenant_id, user=g.username)
111112
except Exception as e:
112113
logger.error(f"Got exception trying to check tenant admin role for tenant: {g.tenant_id} "
113-
f"and user: {g.username}; exception: {e}")
114+
f"and user: {g.username}; exception: {e}")
114115
raise common_errors.PermissionsError("Could not check tenant admin role with SK; this role is required for "
115-
"accessing the authenticator admin endpoints.")
116+
"accessing the authenticator admin endpoints.")
116117
try:
117118
if rsp.isAuthorized:
118119
logger.info(f"user {g.username} had tenant admin role for tenant {g.tenant_id}; allowing request.")
@@ -121,14 +122,14 @@ def authentication():
121122
logger.info(f"user {g.username} DID NOT have tenant admin role for tenant {g.tenant_id}; "
122123
f"NOT allowing request.")
123124
raise common_errors.PermissionsError("Permission denied -- Tenant admin role required for accessing "
124-
"the authenticator admin endpoints.")
125+
"the authenticator admin endpoints.")
125126
except Exception as e:
126127
logger.error(f"got exception trying to check isAuthorized property from isAdmin() call to SK."
127-
f"username: {g.username}; tenant: {g.tenant_id}; rsp: {rsp}; e: {e}")
128+
f"username: {g.username}; tenant: {g.tenant_id}; rsp: {rsp}; e: {e}")
128129
logger.info(f"user {g.username} DID NOT have tenant admin role for tenant {g.tenant_id}; "
129130
f"NOT allowing request.")
130131
raise common_errors.PermissionsError("Permission denied -- Tenant admin role required for accessing the "
131-
"authenticator admin endpoints.")
132+
"authenticator admin endpoints.")
132133

133134
# no credentials required on the authorize, login and oa2 extension pages
134135
if '/v3/oauth2/authorize' in request.url_rule.rule or '/v3/oauth2/login' in request.url_rule.rule \
@@ -142,9 +143,51 @@ def authentication():
142143
except AttributeError:
143144
raise common_errors.BaseTapisError("Unable to resolve tenant_id for request.")
144145
# make sure this request is for a tenant served by this authenticator
146+
if g.request_tenant_id not in conf.tenants:
147+
raise common_errors.PermissionsError(f"The request is for a tenant ({g.request_tenant_id}) that is not "
148+
f"served by this authenticator.")
149+
return True
150+
151+
# token should come from `Authorization: Bearer $token` header. rather than x-tapis-token
152+
# this endpoint takes both, converts Authorization to x-tapis-token for simplicity
153+
if '/v3/oauth2/userinfo/oidc' in request.url_rule.rule:
154+
logger.debug(f"top of /v3/oauth2/userinfo/oidc auth: request.headers: {request.headers}")
155+
156+
auth_token = request.headers.get('Authorization')
157+
if auth_token and auth_token.startswith('Bearer ') and not request.headers.get('X-Tapis-Token'):
158+
try:
159+
# overwrite the headers via wsgi environ. request.headers itself is read-only
160+
tapis_token = auth_token.replace('Bearer ', '')
161+
logger.debug(f"found auth header; setting environ X-Tapis-Token to {tapis_token}")
162+
# modify the WSGI environment directly
163+
# wsgi requires headers be uppercase, no dashes, and prefixed with 'HTTP_'
164+
request.environ['HTTP_X_TAPIS_TOKEN'] = tapis_token
165+
except Exception as e:
166+
logger.error(f"found auth header, but failed to parse it; exception: {e}")
167+
168+
# debug logs
169+
try:
170+
headers = request.headers
171+
logger.debug(f"before auth.authentication(). request.headers: {headers.keys()}")
172+
except Exception as e:
173+
pass
174+
175+
# tokens might have aud, if jwt.decode in tapisservice doesn't specify expected aud you'll
176+
# get invalid aud. Either we can somehow pop aud or specify to jwt.decode(options={'verify_aud': False})
177+
# Instead of verify = false we can also specify a list of valid auds. Pop aud would require
178+
# re-encoding+signing key. We don't have private tenant key in auth though. Ignoring for now, only
179+
# bookstack looks for this when running their auth.
180+
# resolve_tenant_id_for_request decode needs aud to expect - https://github.com/jpadilla/pyjwt/blob/master/docs/usage.rst#audience-claim-aud
181+
# Edit, expected_aud now exists. Bookstack asks for aud == client_id. For now we'll just allow any aud, especially as this is one endpoint.
182+
183+
auth.authentication(expected_aud=["*"])
184+
# always resolve the request tenant id based on the URL:
185+
auth.resolve_tenant_id_for_request()
186+
# make sure this request is for a tenant served by this authenticator
145187
if g.request_tenant_id not in conf.tenants:
146188
raise common_errors.PermissionsError(f"The request is for a tenant ({g.request_tenant_id}) that is not "
147189
f"served by this authenticator.")
190+
logger.debug(f"End of v3/oauth2/userinfo/oidc auth: final request_tenant_id: {g.request_tenant_id}")
148191
return True
149192

150193
# the profiles endpoints always use standard Tapis Token auth -
@@ -156,7 +199,7 @@ def authentication():
156199
# make sure this request is for a tenant served by this authenticator
157200
if g.request_tenant_id not in conf.tenants:
158201
raise common_errors.PermissionsError(f"The request is for a tenant ({g.request_tenant_id}) that is not "
159-
f"served by this authenticator.")
202+
f"served by this authenticator.")
160203
return True
161204

162205
# the clients endpoints need to accept both standard Tapis Token auth and basic auth,
@@ -244,7 +287,7 @@ def authentication():
244287
# make sure this request is for a tenant served by this authenticator
245288
if g.request_tenant_id not in conf.tenants:
246289
raise common_errors.PermissionsError(f"The request is for a tenant ({g.request_tenant_id}) that is not "
247-
f"served by this authenticator.")
290+
f"served by this authenticator.")
248291
return True
249292

250293
# Special v3->v2 token generation endpoint.

service/controllers.py

Lines changed: 75 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from openapi_core import openapi_request_validator
1111
from openapi_core.contrib.flask import FlaskOpenAPIRequest
1212
from jwcrypto import jwk
13+
import jwt
1314
import sqlalchemy
1415
import secrets
1516
import random
@@ -203,19 +204,74 @@ def get(self):
203204
return resp
204205

205206

207+
def _handle_userinfo_request(request, oidc=False):
208+
tenant_id = g.request_tenant_id
209+
if oidc:
210+
logger.debug(f'top of GET /v3/oauth2/userinfo/oidc - tenant_id: {tenant_id}')
211+
else:
212+
logger.debug(f'top of GET /v3/oauth2/userinfo - tenant_id: {tenant_id}')
213+
# note that the user info endpoint is more limited for custom oauth idp extensions in general because the
214+
# custom OAuth server may not provide a profile endpoint.
215+
custom_oa2_extension_type = tenant_configs_cache.get_custom_oa2_extension_type(tenant_id=tenant_id)
216+
## token should maybe already have:
217+
# jti iss sub exp tapis/tenant_id tapis/token_type
218+
# tapis/delegation tapis/delegation_sub tapis/username
219+
# tapis/account_type tapis/client_id tapis/grant_type
220+
221+
if custom_oa2_extension_type and not custom_oa2_extension_type == 'ldap':
222+
logger.debug(f"Using custom auth for userinfo; custom_oa2_extension_type: {custom_oa2_extension_type}")
223+
logger.debug(f"g.token_claims - {g.token_claims}")
224+
result = {"username": g.username}
225+
return utils.ok(result=result, msg="User profile retrieved successfully - custom auth extension provider")
226+
227+
userinfo = get_tenant_user(tenant_id=tenant_id, username=g.username)
228+
229+
## Rubin Science place needs
230+
# rubin scope with info via data_rights
231+
# adding data rights for specific users for rubin - test
232+
logger.debug(f"userinfo: {userinfo.serialize}")
233+
try:
234+
username = userinfo.get('username')
235+
except:
236+
username = "TALKTODEV"
237+
if oidc:
238+
logger.debug(f"inside of oidc userinfo; username: {username}")
239+
## This code still doesn't matter, was attempting some debugging for rubin place
240+
# Kevin got Gafaelfawr to look in the "correct field" to map groups
241+
if username and username in ["cgarcia", "mpackard", "kprice", "jstubbs"]:
242+
data_rights = get_user_data_rights(username)
243+
if data_rights:
244+
userinfo["data_rights"] = " ".join(data_rights)
245+
246+
# return token + userinfo as return for bookstack OIDC userinfo call.
247+
# bookstack at leasts needs sub claim.
248+
try:
249+
token_dict = jwt.decode(g.x_tapis_token, options={"verify_signature": False})
250+
newinfo = userinfo.serialize
251+
newinfo.update(token_dict)
252+
except Exception as e:
253+
logger.debug(f"Error creating userinfo+token object: {e}, token: {g.x_tapis_token}")
254+
raise errors.ResourceError("Error with token and userinfo objects.")
255+
return jsonify(newinfo)
256+
257+
return utils.ok(result=userinfo.serialize, msg="User profile retrieved successfully.")
258+
259+
260+
def get_user_data_rights(username):
261+
# Implement logic to retrieve the list of data releases the user has access to
262+
# This function should return a list of strings representing data releases
263+
return ["release1", "release2", "lsst-sqre", "admin:jupyterlab", "admin", "jupyterlab", "square", "tacc-spherex"]
264+
265+
206266
class UserInfoResource(Resource):
207267
def get(self):
208-
logger.debug(f'top of GET /v3/oauth2/userinfo')
209-
tenant_id = g.request_tenant_id
210-
# note that the user info endpoint is more limited for custom oauth idp extensions in general because the
211-
# custom OAuth server may not provider a profile endpoint.
212-
custom_oa2_extension_type = tenant_configs_cache.get_custom_oa2_extension_type(tenant_id=tenant_id)
213-
if custom_oa2_extension_type and not custom_oa2_extension_type == 'ldap':
214-
result = {"username": g.username}
215-
return utils.ok(result=result, msg="User profile retrieved successfully.")
268+
return _handle_userinfo_request(request, oidc=False)
269+
270+
271+
class OIDCUserInfoResource(Resource):
272+
def get(self):
273+
return _handle_userinfo_request(request, oidc=True)
216274

217-
user = get_tenant_user(tenant_id=tenant_id, username=g.username)
218-
return utils.ok(result=user.serialize, msg="User profile retrieved successfully.")
219275

220276

221277
class ProfileResource(Resource):
@@ -358,29 +414,6 @@ def put(self):
358414
# OIDC endpoints
359415
# ---------------------------------
360416

361-
# class OIDCMetadataResource(Resource):
362-
# """
363-
# Provides the OIDC .well-known endpoint.
364-
# """
365-
# def get(self):
366-
# logger.info("top of GET /v3/oauth2/.well-known/openid-configuration")
367-
# tenant_id = g.request_tenant_id
368-
# config = tenant_configs_cache.get_config(tenant_id)
369-
# allowable_grant_types = json.loads(config.allowable_grant_types)
370-
# tenant = t.tenant_cache.get_tenant_config(tenant_id=tenant_id)
371-
# base_url = tenant.base_url
372-
# json_response = {
373-
# 'issuer': f'{base_url}/v3/tokens',
374-
# 'authorization_endpoint': f'{base_url}/v3/oauth2/authorize',
375-
# 'token_endpoint': f'{base_url}/v3/oauth2/tokens/oidc?oidc=true',
376-
# 'jwks_uri': f'{base_url}/v3/oauth2/jwks',
377-
# 'registration_endpoint': f'{base_url}/v3/oauth2/clients',
378-
# 'grant_types_supported': allowable_grant_types,
379-
# 'userinfo_endpoint': f'{base_url}/v3/oauth2/userinfo/oidc',
380-
# }
381-
# return json_response #utils.ok(result=metadata, msg='OAuth OIDC metadata retrieved successfully.')
382-
383-
384417
class OIDCjwksResource(Resource):
385418
"""
386419
Provides the OIDC jwks endpoint.
@@ -397,11 +430,17 @@ def get(self):
397430
pem_key = tenant.public_key
398431
key = jwk.JWK.from_pem(pem_key.encode('utf-8'))
399432
jwk_json = key.export(as_dict=True)
433+
# check for required values:
434+
if 'alg' not in jwk_json.keys():
435+
jwk_json['alg'] = 'RS256'
436+
if 'typ' not in jwk_json.keys():
437+
jwk_json['typ'] = 'JWT'
438+
# NOTE 2025.3.28 kprice -- these values can be hard coded since they are also hard coded in tokens. If these values ever change in tokens we'll need to update this block.
400439

401440
json_response = {
402441
'keys': [jwk_json]
403442
}
404-
return json_response #utils.ok(result=metadata, msg='OAuth OIDC metadata retrieved successfully.')
443+
return jsonify(json_response) #utils.ok(result=metadata, msg='OAuth OIDC metadata retrieved successfully.')
405444

406445

407446
# ---------------------------------
@@ -459,7 +498,6 @@ def check_client(use_session=False):
459498
logout()
460499
raise errors.ResourceError("Required query parameter client_id missing.")
461500
# make sure the client exists and the redirect_uri matches
462-
logger.debug(f"checking for client with id: {client_id} in tenant {tenant_id}")
463501
client = Client.query.filter_by(tenant_id=tenant_id, client_id=client_id).first()
464502
if not client:
465503
logout()
@@ -1405,6 +1443,7 @@ def _handle_tokens_request(request, oidc=False):
14051443
client = Client.query.filter_by(tenant_id=tenant_id, client_id=client_id, client_key=client_key).first()
14061444
if not client:
14071445
# todo -- remove session
1446+
logger.debug(f'Client with id {client_id} and key {client_key} not found on tenant {tenant_id}.')
14081447
raise errors.ResourceError(msg=f'Invalid client credentials: {client_id}, {client_key}. '
14091448
f'session: {session}')
14101449

@@ -1511,6 +1550,7 @@ def _handle_tokens_request(request, oidc=False):
15111550
content['claims']['tapis/idp_id'] = idp_id
15121551
if oidc:
15131552
if client_id:
1553+
# bookstack for example requires aud to match client id
15141554
content['claims']['aud'] = client_id
15151555
content['claims']['iat'] = int(time.time())
15161556
content['claims']['extravar'] = username

0 commit comments

Comments
 (0)