Accept access token for api auth

This commit is contained in:
nuwang
2023-11-06 19:16:12 +05:30
parent d1aa6d1893
commit 9d3d45a0e6
8 changed files with 156 additions and 48 deletions
+34
View File
@@ -60,6 +60,7 @@ class CustosAuthnzConfiguration:
redirect_uri: str
ca_bundle: Optional[str]
pkce_support: bool
accepted_audiences: List[str]
extra_params: Optional[dict]
authorization_endpoint: Optional[str]
token_endpoint: Optional[str]
@@ -68,6 +69,8 @@ class CustosAuthnzConfiguration:
iam_client_secret: Optional[str]
userinfo_endpoint: Optional[str]
credential_url: Optional[str]
issuer: Optional[str]
jwks_uri: Optional[str]
class OIDCAuthnzBase(IdentityProvider):
@@ -84,6 +87,7 @@ class OIDCAuthnzBase(IdentityProvider):
redirect_uri=oidc_backend_config["redirect_uri"],
ca_bundle=oidc_backend_config.get("ca_bundle", None),
pkce_support=oidc_backend_config.get("pkce_support", False),
accepted_audiences=oidc_backend_config.get("accepted_audiences", []),
extra_params={},
authorization_endpoint=None,
token_endpoint=None,
@@ -92,6 +96,8 @@ class OIDCAuthnzBase(IdentityProvider):
iam_client_secret=None,
userinfo_endpoint=None,
credential_url=None,
issuer=None,
jwks_uri=None,
)
def _decode_token_no_signature(self, token):
@@ -457,6 +463,12 @@ class OIDCAuthnzBase(IdentityProvider):
self.config.token_endpoint = well_known_oidc_config["token_endpoint"]
self.config.userinfo_endpoint = well_known_oidc_config["userinfo_endpoint"]
self.config.end_session_endpoint = well_known_oidc_config.get("end_session_endpoint")
self.config.issuer = well_known_oidc_config.get("issuer")
self.config.jwks_uri = well_known_oidc_config.get("jwks_uri")
if self.config.jwks_uri:
self.jwks_client = jwt.PyJWKClient(self.config.jwks_uri, cache_jwk_set=True, lifespan=360)
else:
self.jwks_client = None
def _get_verify_param(self):
"""Return 'ca_bundle' if 'verify_ssl' is true and 'ca_bundle' is configured."""
@@ -587,3 +599,25 @@ class OIDCAuthnzBaseCustos(OIDCAuthnzBase):
params = {"client_id": self.config.client_id}
self._load_config(headers, params)
def match_access_token_to_user(self, sa_session, access_token):
signing_key = self.jwks_client.get_signing_key_from_jwt(access_token)
decoded_jwt = jwt.decode(
access_token,
signing_key.key,
algorithms=["RS256"],
issuer=self.config["issuer"],
audience=self.config["accepted_audiences"],
options={
"verify_signature": True,
"verify_exp": True,
"verify_nbf": True,
"verify_iat": True,
"verify_aud": self.config["accepted_audiences"] is not None,
"verify_iss": True,
},
)
# jwt verified, we can now fetch the user
user_id = decoded_jwt["sub"]
custos_authnz_token = self._get_custos_authnz_token(sa_session, user_id, self.config["provider"])
return custos_authnz_token.user
+27
View File
@@ -404,6 +404,33 @@ class AuthnzManager:
log.exception(msg)
return False, msg, (None, None)
def find_user_by_access_token_in_provider(self, sa_session, provider, access_token):
try:
success, message, backend = self._get_authnz_backend(provider)
if success is False:
msg = f"An error occurred when obtaining user by token with provider `{provider}`: {message}"
log.error(msg)
return None
user = backend.match_access_token_to_user(sa_session, access_token)
if user:
log.debug(f"Found user: {user} via `{provider}` identity provider")
return user
return None
except Exception as e:
msg = f"An error occurred when finding user by token: {e}"
log.error(msg)
return None
def find_user_by_access_token(self, sa_session, access_token):
# decoded_token = jwt.decode(access_token, options={"verify_signature": False})
# issuer = decoded_token["iss"]
# audience = decoded_token["aud"]
for provider in self.oidc_backends_config:
user = self.find_user_by_access_token_in_provider(sa_session, provider, access_token)
if user:
return user
return None
def logout(self, provider, trans, post_user_logout_href=None):
"""
Log the user out of the identity provider.
+7
View File
@@ -295,6 +295,13 @@ class UserManager(base.ModelManager, deletable.PurgableManagerMixin):
raise exceptions.AuthenticationFailed("Provided API key has expired.")
return provided_key.user
def by_oidc_access_token(self, access_token: str):
if hasattr(self.app, "authnz_manager") and self.app.authnz_manager:
user = self.app.authnz_manager.find_user_by_access_token(self.app.model.session, access_token) # type: ignore[attr-defined]
return user
else:
return None
def check_bootstrap_admin_api_key(self, api_key):
bootstrap_admin_api_key = getattr(self.app.config, "bootstrap_admin_api_key", None)
if not bootstrap_admin_api_key:
+10
View File
@@ -532,6 +532,8 @@ class GalaxyWebTransaction(base.DefaultWebTransaction, context.ProvidesHistoryCo
"""
Authenticate for the API via key or session (if available).
"""
oidc_access_token = self.request.headers.get("Authorization", None)
oidc_token_supplied = self.environ.get("is_api_request", False) and oidc_access_token and "Bearer " in oidc_access_token
api_key = self.request.params.get("key", None) or self.request.headers.get("x-api-key", None)
secure_id = self.get_cookie(name=session_cookie)
api_key_supplied = self.environ.get("is_api_request", False) and api_key
@@ -554,6 +556,14 @@ class GalaxyWebTransaction(base.DefaultWebTransaction, context.ProvidesHistoryCo
)
self.user = None
self.galaxy_session = None
elif oidc_token_supplied:
# Sessionless API transaction with oidc token, we just need to associate a user.
oidc_access_token = oidc_access_token.replace("Bearer ", "")
try:
user = self.user_manager.by_oidc_access_token(oidc_access_token)
except AuthenticationFailed as e:
return str(e)
self.set_user(user)
else:
# Anonymous API interaction -- anything but @expose_api_anonymous will fail past here.
self.user = None
+8 -2
View File
@@ -36,6 +36,7 @@ from fastapi.security import (
APIKeyCookie,
APIKeyHeader,
APIKeyQuery,
HTTPBearer
)
from fastapi_utils.cbv import cbv
from fastapi_utils.inferring_router import InferringRouter
@@ -80,6 +81,7 @@ from galaxy.work.context import (
api_key_query = APIKeyQuery(name="key", auto_error=False)
api_key_header = APIKeyHeader(name="x-api-key", auto_error=False)
api_key_cookie = APIKeyCookie(name="galaxysession", auto_error=False)
api_bearer_token = HTTPBearer()
def get_app() -> StructuredApp:
@@ -139,6 +141,7 @@ def get_api_user(
user_manager: UserManager = depends(UserManager),
key: str = Security(api_key_query),
x_api_key: str = Security(api_key_header),
bearer_token: str = Security(api_bearer_token),
run_as: Optional[DecodedDatabaseIdField] = Header(
default=None,
title="Run as User",
@@ -149,9 +152,12 @@ def get_api_user(
),
) -> Optional[User]:
api_key = key or x_api_key
if not api_key:
if api_key:
user = user_manager.by_api_key(api_key=api_key)
elif bearer_token:
user = user_manager.by_oidc_access_token(access_token=bearer_token.credentials)
else:
return None
user = user_manager.by_api_key(api_key=api_key)
if run_as:
if user_manager.user_can_do_run_as(user):
return user_manager.by_id(run_as)
+20 -18
View File
@@ -1,20 +1,22 @@
-----BEGIN CERTIFICATE-----
MIIDVzCCAj+gAwIBAgIUCYRtnqxm/HMxGPpE6VpTmPacZMQwDQYJKoZIhvcNAQEL
BQAwOzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxFzAVBgNVBAoM
DkdhbGF4eSBQcm9qZWN0MB4XDTIzMDkxMDE5MjAwOFoXDTMzMDkwNzE5MjAwOFow
OzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxFzAVBgNVBAoMDkdh
bGF4eSBQcm9qZWN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwF+F
iXMW2emcYCvSkiZCh00RDVCcKUfB/i7Q+Rxl0y0HG6sTJWN8ELf4SVR7Zaq3b30w
5Yx6tmXY0lfzHkfjRXG/WdAjPDeN8j54RK74UAOM72KFySm/CxX2w5peNyBHA4BX
mHKGagIgWH4VUCeJ0z7GkI0ESrdlwOix8Qi8n/l0hjmmS2NOvM1dq1ehNVfOtB4K
DF1AANffiCEz2OkAIWwczwVZrgHg7ILvga+g4GnX6SEioEHMCfyk7E6ASbLb2mDj
iaIVT1PpwtRLgL+yhC7QRWK66IuurcRAJsLL/isIeZGtF0xfvP5nfiWC07lzYior
J3M1I14I7LTpEwmEIwIDAQABo1MwUTAdBgNVHQ4EFgQUbAYXkXuWm8xrOWmsbN5w
rQoaLG4wHwYDVR0jBBgwFoAUbAYXkXuWm8xrOWmsbN5wrQoaLG4wDwYDVR0TAQH/
BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAhvHjMe20W7TO299iI1fEhob4Ihyr
tm/4hPPBLC9HSiKokKYvgOU0dK2dh/X7ax9N+ouuDmWYCLCP7AcaWS+HHDVyCv+0
j8YNVTaqoD/u9qnvx9/2Zv1TqGaLhw9UPmro0MnUUOMoQRYQKaH3fatidCMQJt2j
rzvyrkOuicOr1lRh8Hj1+pdKzar4rmskz9SLkoKRiXjsAUrC1JvQxVxORzf4CoD/
eR+ZXeglmAZu8AbwFd/y5OPaHuqsXXHpzg8oJffymOo8L3jp3fZjHFhzUBIHZX2u
ZVmpT0COKu69yQ1EoHmKpVFCinYVqbfOsu7s4qWOIt8qpoHPdaElzMD/2w==
MIIDkzCCAnugAwIBAgIUQFOsGXws9PtauKu5YhjKynETHVowDQYJKoZIhvcNAQEL
BQAwWTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDESMBAGA1UEAwwJbG9jYWxob3N0MB4X
DTIzMTAxNTIwMzYyNloXDTMzMTAxMjIwMzYyNlowWTELMAkGA1UEBhMCQVUxEzAR
BgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5
IEx0ZDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
MIIBCgKCAQEA1rp3CEgQRHmpgK4NFwqxfnhfwJMJeH9rFlSQ1XjN1JPCoICSwNDO
g1NWoPGFIImRnZ34X9CLsLeZdlcE/RaThdzwLbwbExl0mKP+rDSLP5/90K3utVD8
DWfNSWnCaeXcnhaosl5z8ZhpI5xWN20ajCj3VnrPEbhVDAChxlZvVh+a6uvsEMP0
SHOcy0OKe0g2I+x1Yw5J62ApC21pDMuFrjyualkpFEx+b7JY9lNhDjSPW8Iu4wRC
7TK0q3MRyyw+er0P7lPobw/khfVTTFmDrc1V8ui+271Bu8L5EvlcOADGM47jgt5j
Y3Q77gy7fVKDwFSS6Y2jpQRBgocUq9qN1QIDAQABo1MwUTAdBgNVHQ4EFgQUHlYS
w56dunNy35FN3D1JG3ZaZRAwHwYDVR0jBBgwFoAUHlYSw56dunNy35FN3D1JG3Za
ZRAwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAIoEdTs1B1h9Q
WOO+zK5kcd6pbOJnfqvZh44TvQs0DnezENQb4y1lR0USPjgyWSF1VHxP1yrm3H64
tx5E7ZeVMO/Uq2x2x9qgEjJfk9y88+RzYmZyR3PvmtTPaf7ftokGi9eZSsj3khP3
8YqKDpty5siA+Wd5HkAE/z1xoBAzxQ+D9JNA8miiCkmSMUWuTHXbH8nYaS7a4rWx
YvJJXxdAv7JsoLvSVgMgaiee+MtKhMPxDBJoCeuk8nGt8HXZh615JqYSkNDrfzwQ
C74INkovM6emUW9ehQ6sOK5ROprM7fXlnrH4Klc9BGRgJu5Lsj0VkrBfN2LQ03D2
77yHVwzrgQ==
-----END CERTIFICATE-----
+26 -26
View File
@@ -1,28 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDAX4WJcxbZ6Zxg
K9KSJkKHTRENUJwpR8H+LtD5HGXTLQcbqxMlY3wQt/hJVHtlqrdvfTDljHq2ZdjS
V/MeR+NFcb9Z0CM8N43yPnhErvhQA4zvYoXJKb8LFfbDml43IEcDgFeYcoZqAiBY
fhVQJ4nTPsaQjQRKt2XA6LHxCLyf+XSGOaZLY068zV2rV6E1V860HgoMXUAA19+I
ITPY6QAhbBzPBVmuAeDsgu+Br6DgadfpISKgQcwJ/KTsToBJstvaYOOJohVPU+nC
1EuAv7KELtBFYrroi66txEAmwsv+Kwh5ka0XTF+8/md+JYLTuXNiKisnczUjXgjs
tOkTCYQjAgMBAAECggEAR4Jah8z11hwuSluP408AlP3zVEE+Q2QOuzdY6/j/6t+G
kGTn8ZKJGEfirhOO6SxsEq/1QKcXKH00e4BA+eUcGQDe8fpaVrSJ+d355AIsgMXJ
GmVDuRLhFrRb7bsQwaooa6dGKH+N8pMGxg5cStbft1xTAIHC8ik5VFvLZX9raoVw
DyfLPg3CtWQID9Jbb4lODqHpjSP5J5v+IskCbbk1sihelEoyFQxka8y1UYglSB7E
ntz5j83OqH7qFes/J/g3K8eNvhd3RQZCozzCwjaUUNHpAeTOEmQqdu9joVvQu1pp
/iQcwqCb0TSzezUssCRGJNHCVzb0UwBgqCH4stt+8QKBgQD/NZSj0ofY8GA1XMcK
s1R9dc/iz6FTeFOz4/36xoTSWoLUFVUpP+1XVVzjV98j65D+CtC6RDBso3tf5Jeo
JB0u4Jy86iyVVN8VMgxDLmNsNSPWOdQCfa6ASQRjnlSwa99L+fZ3ITjz71fDR+0E
RKMEzRAu9CYS7X6dy2/6ccRP0QKBgQDA+Bo7VE3HMeSQkT8hJWZpEmFseJ8AdM+/
2+dwQRFvyBO25hlaaedF3fv52D5S3hiM40tHxSyRmd0qJBQAF5gXVMAtsDD/SZru
maAQXGR0gorGuWkBkyKHDpZmYnK4TWAE5Tikp2638Zrtik6gSZtJVaBqz9oj1Z56
8elxUxSlswKBgA6lU4dnQJaJjOvjDCUoD3Q3u5tnkPmKUTtknSG0HNE5koJ7SuzY
F1XIt1DwrQSRsztxAXN/EExbwWvKYY2rrOUQzM3mnnJfcYH04lFEqz39erYDkZAn
CEmiZxcUcgXB2wmwKZ3CkVMmbCHv0cFJ8xUGc6ROLJUvffGuSBnWR47RAoGBAJr0
4IOyD/8cVpGswUgJs3I+m1MyjWQdaVKE8RXYensL7KDbp5JOJrSZLKWV+34p8Qb5
4tLQSBjnuI0FibYFPFHH8cb+jGoEHlDptnpM5zIUPgKUkvj7wSc5FJdFCSBsqeVk
eJboCOXXMvT7MmZOlU9PXlFHdcF+4hOSPUaOU683AoGAdj+MsVxRgnCEmUCl05EJ
kd5H8bBFB/vq+Rn3erJFXKLM3vpjBV3Yi2RGC06YGe4L1+krrMIrnEFno3G5yi4Z
UWuakpvEDV+esu9s/EcZxv4WtHkQQ+2g3CGqy5BqDefe5YOJrQaCVuwIga1WIV0q
JNY3E9bDmpvvDBw+SEzcJug=
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDWuncISBBEeamA
rg0XCrF+eF/Akwl4f2sWVJDVeM3Uk8KggJLA0M6DU1ag8YUgiZGdnfhf0Iuwt5l2
VwT9FpOF3PAtvBsTGXSYo/6sNIs/n/3Qre61UPwNZ81JacJp5dyeFqiyXnPxmGkj
nFY3bRqMKPdWes8RuFUMAKHGVm9WH5rq6+wQw/RIc5zLQ4p7SDYj7HVjDknrYCkL
bWkMy4WuPK5qWSkUTH5vslj2U2EONI9bwi7jBELtMrSrcxHLLD56vQ/uU+hvD+SF
9VNMWYOtzVXy6L7bvUG7wvkS+Vw4AMYzjuOC3mNjdDvuDLt9UoPAVJLpjaOlBEGC
hxSr2o3VAgMBAAECggEAAVbB4RtEyGmqE9WvtexIygrgP68UKxw/tgwl8QtjASwc
HsenmXV4bC2ix7ey9E573FBhWQS9hIcRsBxtglIzN22JCGyLDmOlUNRWqdQUC6CW
i+yht5+Rb1g/2af6qdsXa7buBshpRjyW1hjvq9SpHO4wv3uCsgapbjzxV5PmQIlt
H59GPNcMJTnBLktPfLQ3fNZRjOiE/aFvGoe1YyFNbl5RADzq5YkmuUSFhkBbyNz4
wOzhfPr8cnthStFY17GGKmu9nueFoiQhmaiUIIIgeHuSg+0tMZa/PrGsnbfdhoKZ
ifvv5Fyi44COEpzcDjfQqQlHAMRPzWnJNdI4phRzoQKBgQDZDJYAt+Eq8sU6ZLVG
W47k6WouDro9ndf7ZLd0j39KwQ2GAxyHvL8JNmphVa2T8yrvG6QRTrUJ/t2WpXYl
sOfTwPXZh4udMLA+ia0fBHB1X7c2grPlQILLhcvqP5ZUAT3+iOg2j17Aa91B+36g
w/OEwgBWfIt4rW8BBfFfjpXtZQKBgQD9Q0KhqoFs2YZk4Dq5tgl5YMFPBKJUbmED
B4cRGDqPtI7R9kGDTQ06boLZ1W72iX4eroPKhosYVIJ0ILRUvdApbc+ZVi5iRp7Q
lR3fkHdGwJznPH4SLgl6XU3O0JRTSeELDGT5dgJSM2hY7kHLSyZVIsrhsfIxwxVr
sLa0hd6PsQKBgCRmjvWZ4QJh6p/LafddvY44cx08TRGuWh2IG5hJxJBV5h22gd9l
0SgLXzXpt42bd3TYTuC0MXp2qtTr9O+HRqnlr5WGvOxk0Pn7/vO3u+CDZ+eVbfQh
qI3XZ4ZmmaCHAM2iSkd0LfDZZib9tZPiqQF3w7S9eGqJs5cZVwyujghdAoGBAM75
g9/JD4KcdtfD5FpXKYD3kKN6HwuvNTsbkMdorJs6rSlr/fhHJRNmsytA4TE8BkUe
y/fcYppcnkw3WgiTIyZoZqtiof+QxUOjScmmL8Qzr0bOyh0jYH2O+QKWtVEn5HF6
DJ9OUPFKr/FVrZFtdgNHrtQp78u5Ka1busTnTo7RAoGAQFdl7sIDPaO0OIXJEuk9
89OBMPQbuMdyQ63FFl8SADCBLuqZLxxNQArVdelPPCVCgi6sL3w23GrGG0LibfrM
Ts8FQ5cXXFwa+qvYjskazBsiDAJ5LjHWXFQ9ZAg1dpYwToTalaXKer4TBgxMTnte
e05IvlRad2fy9xnaBHoQ0wY=
-----END PRIVATE KEY-----
+24 -2
View File
@@ -18,12 +18,13 @@ KEYCLOAK_ADMIN_USERNAME = "admin"
KEYCLOAK_ADMIN_PASSWORD = "admin"
KEYCLOAK_TEST_USERNAME = "gxyuser"
KEYCLOAK_TEST_PASSWORD = "gxypass"
KEYCLOAK_URL = "https://localhost:8443/realms/gxyrealm"
OIDC_BACKEND_CONFIG_TEMPLATE = """<?xml version="1.0"?>
OIDC_BACKEND_CONFIG_TEMPLATE = f"""<?xml version="1.0"?>
<OIDC>
<provider name="keycloak">
<url>https://localhost:8443/realms/gxyrealm</url>
<url>{KEYCLOAK_URL}</url>
<client_id>gxyclient</client_id>
<client_secret>gxytestclientsecret</client_secret>
<redirect_uri>$galaxy_url/authnz/keycloak/callback</redirect_uri>
@@ -125,6 +126,8 @@ class AbstractTestCases:
if "OAUTHLIB_INSECURE_TRANSPORT" in os.environ:
cls.saved_oauthlib_insecure_transport = os.environ["OAUTHLIB_INSECURE_TRANSPORT"]
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "true"
os.environ["REQUESTS_CA_BUNDLE"] = os.path.dirname(__file__) + "/keycloak-server.crt.pem"
os.environ["SSL_CERT_FILE"] = os.path.dirname(__file__) + "/keycloak-server.crt.pem"
@classmethod
def restoreOauthlibHttps(cls):
@@ -189,3 +192,22 @@ class TestGalaxyOIDCLoginIntegration(AbstractTestCases.BaseKeycloakIntegrationTe
# make sure we can no longer request the user
response = session.get(self._api_url("users/current"))
self._assert_status_code_is(response, 400)
def test_auth_by_access_token(self):
# login at least once
self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD)
access_token = self.get_keycloak_access_token()
response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"})
self._assert_status_code_is(response, 200)
assert response.json()["email"] == "gxyuser@galaxy.org"
def get_keycloak_access_token(self, username=KEYCLOAK_TEST_USERNAME, password=KEYCLOAK_TEST_PASSWORD):
data = {
"client_id": "gxyclient",
"client_secret": "gxytestclientsecret",
"grant_type": "password",
"username": username,
"password": password
}
response = requests.post(f"{KEYCLOAK_URL}/protocol/openid-connect/token", data=data, verify=False)
return response.json()["access_token"]