diff --git a/.secrets.baseline b/.secrets.baseline index c8aad5d34..8c97f29fb 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -225,7 +225,7 @@ "filename": "fence/utils.py", "hashed_secret": "8318df9ecda039deac9868adf1944a29a95c7114", "is_verified": false, - "line_number": 128 + "line_number": 129 } ], "migrations/versions/a04a70296688_non_unique_client_name.py": [ @@ -268,7 +268,7 @@ "filename": "tests/conftest.py", "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", "is_verified": false, - "line_number": 1559 + "line_number": 1561 }, { "type": "Base64 High Entropy String", diff --git a/bin/fence_create.py b/bin/fence_create.py index 7adaedd82..2c2d6a718 100755 --- a/bin/fence_create.py +++ b/bin/fence_create.py @@ -329,6 +329,9 @@ def parse_arguments(): help='scopes to include in the token (e.g. "user" or "data")', ) token_create.add_argument("--exp", help="time in seconds until token expiration") + token_create.add_argument( + "--client_id", help="Client Id, required to generate refresh token" + ) force_link_google = subparsers.add_parser("force-link-google") force_link_google.add_argument( @@ -581,6 +584,7 @@ def main(): username=args.username, scopes=args.scopes, expires_in=args.exp, + client_id=args.client_id, ) token_type = str(args.type).strip().lower() if token_type == "access_token" or token_type == "access": diff --git a/fence/__init__.py b/fence/__init__.py index 2d44e33f2..fc0fe97cf 100755 --- a/fence/__init__.py +++ b/fence/__init__.py @@ -5,7 +5,7 @@ import flask from flask_cors import CORS from sqlalchemy.orm import scoped_session -from flask import _app_ctx_stack, current_app +from flask import current_app from werkzeug.local import LocalProxy from authutils.oauth2.client import OAuthClient @@ -364,7 +364,6 @@ def app_config( _setup_audit_service_client(app) _setup_data_endpoint_and_boto(app) _load_keys(app, root_dir) - _set_authlib_cfgs(app) app.prometheus_counters = {} if config["ENABLE_PROMETHEUS_METRICS"]: @@ -406,24 +405,6 @@ def _load_keys(app, root_dir): } -def _set_authlib_cfgs(app): - # authlib OIDC settings - # key will need to be added - settings = {"OAUTH2_JWT_KEY": keys.default_private_key(app)} - app.config.update(settings) - config.update(settings) - - # only add the following if not already provided - config.setdefault("OAUTH2_JWT_ENABLED", True) - config.setdefault("OAUTH2_JWT_ALG", "RS256") - config.setdefault("OAUTH2_JWT_ISS", app.config["BASE_URL"]) - config.setdefault("OAUTH2_PROVIDER_ERROR_URI", "/api/oauth2/errors") - app.config.setdefault("OAUTH2_JWT_ENABLED", True) - app.config.setdefault("OAUTH2_JWT_ALG", "RS256") - app.config.setdefault("OAUTH2_JWT_ISS", app.config["BASE_URL"]) - app.config.setdefault("OAUTH2_PROVIDER_ERROR_URI", "/api/oauth2/errors") - - def _setup_oidc_clients(app): configured_idps = config.get("OPENID_CONNECT", {}) @@ -481,7 +462,10 @@ def _setup_oidc_clients(app): logger=logger, ) elif idp == "fence": - app.fence_client = OAuthClient(**settings) + # https://docs.authlib.org/en/latest/client/flask.html#flask-client + app.fence_client = OAuthClient(app) + # https://docs.authlib.org/en/latest/client/frameworks.html + app.fence_client.register(**settings) else: # generic OIDC implementation client = Oauth2ClientBase( settings=settings, diff --git a/fence/blueprints/login/fence_login.py b/fence/blueprints/login/fence_login.py index 7efd49520..13b4de8c8 100644 --- a/fence/blueprints/login/fence_login.py +++ b/fence/blueprints/login/fence_login.py @@ -30,19 +30,22 @@ def __init__(self): def get(self): """Handle ``GET /login/fence``.""" - oauth2_redirect_uri = flask.current_app.fence_client.client_kwargs.get( - "redirect_uri" - ) + + # OAuth class can have mutliple clients + client = flask.current_app.fence_client._clients[ + flask.current_app.config["OPENID_CONNECT"]["fence"]["name"] + ] + + oauth2_redirect_uri = client.client_kwargs.get("redirect_uri") + redirect_url = flask.request.args.get("redirect") if redirect_url: validate_redirect(redirect_url) flask.session["redirect"] = redirect_url - ( - authorization_url, - state, - ) = flask.current_app.fence_client.generate_authorize_redirect( - oauth2_redirect_uri, prompt="login" - ) + + rv = client.create_authorization_url(oauth2_redirect_uri, prompt="login") + + authorization_url = rv["url"] # add idp parameter to the authorization URL if "idp" in flask.request.args: @@ -57,7 +60,7 @@ def get(self): flask.session["shib_idp"] = shib_idp authorization_url = add_params_to_uri(authorization_url, params) - flask.session["state"] = state + flask.session["state"] = rv["state"] return flask.redirect(authorization_url) @@ -88,16 +91,19 @@ def get(self): " login page for the original application to continue." ) # Get the token response and log in the user. - redirect_uri = flask.current_app.fence_client._get_session().redirect_uri - tokens = flask.current_app.fence_client.fetch_access_token( - redirect_uri, **flask.request.args.to_dict() + client_name = config["OPENID_CONNECT"]["fence"].get("name", "fence") + client = flask.current_app.fence_client._clients[client_name] + oauth2_redirect_uri = client.client_kwargs.get("redirect_uri") + + tokens = client.fetch_access_token( + oauth2_redirect_uri, **flask.request.args.to_dict() ) try: # For multi-Fence setup with two Fences >=5.0.0 id_token_claims = validate_jwt( tokens["id_token"], - aud=self.client.client_id, + aud=client.client_id, scope={"openid"}, purpose="id", attempt_refresh=True, diff --git a/fence/blueprints/login/utils.py b/fence/blueprints/login/utils.py index 4b189977e..3dfca2eae 100644 --- a/fence/blueprints/login/utils.py +++ b/fence/blueprints/login/utils.py @@ -21,7 +21,10 @@ def allowed_login_redirects(): with flask.current_app.db.session as session: clients = session.query(Client).all() for client in clients: - allowed.extend(client.redirect_uris) + if isinstance(client.redirect_uris, list): + allowed.extend(client.redirect_uris) + elif isinstance(client.redirect_uris, str): + allowed.append(client.redirect_uris) return {domain(url) for url in allowed} diff --git a/fence/blueprints/oauth2.py b/fence/blueprints/oauth2.py index d79f106aa..0d1428cf5 100644 --- a/fence/blueprints/oauth2.py +++ b/fence/blueprints/oauth2.py @@ -32,9 +32,14 @@ from fence.utils import clear_cookies from fence.user import get_current_user from fence.config import config - +from authlib.oauth2.rfc6749.errors import ( + InvalidScopeError, +) +from fence.utils import validate_scopes +from cdislogging import get_logger blueprint = flask.Blueprint("oauth2", __name__) +logger = get_logger(__name__) @blueprint.route("/authorize", methods=["GET", "POST"]) @@ -114,7 +119,7 @@ def authorize(*args, **kwargs): return flask.redirect(login_url) try: - grant = server.validate_consent_request(end_user=user) + grant = server.get_consent_grant(end_user=user) except OAuth2Error as e: raise Unauthorized("Failed to authorize: {}".format(str(e))) @@ -122,6 +127,13 @@ def authorize(*args, **kwargs): with flask.current_app.db.session as session: client = session.query(Client).filter_by(client_id=client_id).first() + # Need to do scope check here now due to our design of putting allowed_scope on client + # Authlib now put allowed scope on OIDC server side which doesn't work with our design without modification to the lib + # Doing the scope check here because both client and grant is available here + # Either Get or Post request + request_scopes = flask.request.args.get("scope") or flask.request.form.get("scope") + validate_scopes(request_scopes, client) + # TODO: any way to get from grant? confirm = flask.request.form.get("confirm") or flask.request.args.get("confirm") if client.auto_approve: diff --git a/fence/config-default.yaml b/fence/config-default.yaml index da2bc1bfb..a73ebb976 100755 --- a/fence/config-default.yaml +++ b/fence/config-default.yaml @@ -138,6 +138,9 @@ OPENID_CONNECT: # If this fence instance is a client of another fence, fill this cfg out. # REMOVE if not needed fence: + # Custom name to display for consent screens. If not provided, will use `fence`. + # If the other fence is using NIH Login, you should make name: `NIH Login` + name: '' # this api_base_url should be the root url for the OTHER fence # something like: https://example.com api_base_url: '' @@ -155,9 +158,6 @@ OPENID_CONNECT: authorize_url: '{{api_base_url}}/oauth2/authorize' access_token_url: '{{api_base_url}}/oauth2/token' refresh_token_url: '{{api_base_url}}/oauth2/token' - # Custom name to display for consent screens. If not provided, will use `fence`. - # If the other fence is using NIH Login, you should make name: `NIH Login` - name: '' # if mock is true, will fake a successful login response for login # WARNING: DO NOT ENABLE IN PRODUCTION (for testing purposes only) mock: false @@ -386,16 +386,9 @@ ENABLED_IDENTITY_PROVIDERS: {} # ////////////////////////////////////////////////////////////////////////////////////// -# LIBRARY CONFIGURATION (authlib & flask) +# LIBRARY CONFIGURATION (flask) # - Already contains reasonable defaults # ////////////////////////////////////////////////////////////////////////////////////// -# authlib-specific configs for OIDC flow and JWTs -# NOTE: the OAUTH2_JWT_KEY cfg gets set automatically by fence if keys are setup -# correctly -OAUTH2_JWT_ALG: 'RS256' -OAUTH2_JWT_ENABLED: true -OAUTH2_JWT_ISS: '{{BASE_URL}}' -OAUTH2_PROVIDER_ERROR_URI: '/api/oauth2/errors' # used for flask, "path mounted under by the application / web server" # since we deploy as microservices, fence is typically under {{base}}/user @@ -691,7 +684,7 @@ GS_BUCKETS: {} # bucket3: # region: 'us-east-1' -# When using the Cleversafe storageclient, whether or not to send verify=true +# When using the Cleversafe storageclient, whether or not to send verify=true # for requests VERIFY_CLEVERSAFE_CERT: true diff --git a/fence/models.py b/fence/models.py index 9122e874c..779d27519 100644 --- a/fence/models.py +++ b/fence/models.py @@ -9,7 +9,13 @@ from enum import Enum -from authlib.flask.oauth2.sqla import OAuth2AuthorizationCodeMixin, OAuth2ClientMixin +from authlib.integrations.sqla_oauth2 import ( + OAuth2AuthorizationCodeMixin, + OAuth2ClientMixin, +) + +import time +import json import bcrypt from datetime import datetime, timedelta import flask @@ -150,7 +156,7 @@ def get_client_expires_at(expires_in, grant_types): # `timestamp()` already converts to UTC expires_at = (datetime.now() + timedelta(days=expires_in)).timestamp() - if "client_credentials" in grant_types.split("\n"): + if "client_credentials" in grant_types: if not expires_in or expires_in <= 0 or expires_in > 366: logger.warning( "Credentials with the 'client_credentials' grant which will be used externally are required to expire within 12 months. Use the `--expires-in` parameter to add an expiration." @@ -188,9 +194,9 @@ class Client(Base, OAuth2ClientMixin): __tablename__ = "client" - client_id = Column(String(40), primary_key=True) + client_id = Column(String(48), primary_key=True, index=True) # this is hashed secret - client_secret = Column(String(60), unique=True, index=True, nullable=True) + client_secret = Column(String(120), unique=True, index=True, nullable=True) # human readable name name = Column(String(40), nullable=False) @@ -211,46 +217,51 @@ class Client(Base, OAuth2ClientMixin): # public or confidential is_confidential = Column(Boolean, default=True) - # NOTE: DEPRECATED - # Client now uses `redirect_uri` column, from authlib client model - _redirect_uris = Column(Text) - - _allowed_scopes = Column(Text, nullable=False, default="") + expires_at = Column(Integer, nullable=False, default=0) + # Deprecated, keeping these around in case it is needed later _default_scopes = Column(Text) _scopes = ["compute", "storage", "user"] - expires_at = Column(Integer, nullable=False, default=0) + def __init__(self, client_id, expires_in=0, **kwargs): - # note that authlib adds a response_type column which is not used here + # New Json object for Authlib Oauth client + if "_client_metadata" in kwargs: + client_metadata = json.loads(kwargs.pop("_client_metadata")) + else: + client_metadata = {} - def __init__(self, client_id, expires_in=0, **kwargs): - """ - NOTE that for authlib, the client must have an attribute ``redirect_uri`` which - is a newline-delimited list of valid redirect URIs. - """ if "allowed_scopes" in kwargs: allowed_scopes = kwargs.pop("allowed_scopes") if isinstance(allowed_scopes, list): - kwargs["_allowed_scopes"] = " ".join(allowed_scopes) + client_metadata["scope"] = " ".join(allowed_scopes) else: - kwargs["_allowed_scopes"] = allowed_scopes + client_metadata["scope"] = allowed_scopes + + # redirect uri is now part of authlibs client_metadata if "redirect_uris" in kwargs: redirect_uris = kwargs.pop("redirect_uris") if isinstance(redirect_uris, list): - kwargs["redirect_uri"] = "\n".join(redirect_uris) + # redirect_uris is now part of the metadata json object + client_metadata["redirect_uris"] = redirect_uris + elif redirect_uris: + client_metadata["redirect_uris"] = [redirect_uris] else: - kwargs["redirect_uri"] = redirect_uris + client_metadata["redirect_uris"] = [] + # default grant types to allow for auth code flow and resfreshing grant_types = kwargs.pop("grant_types", None) or [ GrantType.code.value, GrantType.refresh.value, ] + # grant types is now part of authlibs client_metadata if isinstance(grant_types, list): - kwargs["grant_type"] = "\n".join(grant_types) + client_metadata["grant_types"] = grant_types + elif grant_types: + # assume it's already in correct format and make it a list + client_metadata["grant_types"] = [grant_types] else: - # assume it's already in correct format - kwargs["grant_type"] = grant_types + client_metadata["grant_types"] = [] supported_grant_types = [ "authorization_code", @@ -260,28 +271,50 @@ def __init__(self, client_id, expires_in=0, **kwargs): ] assert all( grant_type in supported_grant_types - for grant_type in kwargs["grant_type"].split("\n") - ), f"Grant types '{kwargs['grant_type']}' are not in supported types {supported_grant_types}" + for grant_type in client_metadata["grant_types"] + ), f"Grant types '{client_metadata['grant_types']}' are not in supported types {supported_grant_types}" - if "authorization_code" in kwargs["grant_type"].split("\n"): + if "authorization_code" in client_metadata["grant_types"]: assert kwargs.get("user") or kwargs.get( "user_id" ), "A username is required for the 'authorization_code' grant" - assert kwargs.get( - "redirect_uri" + assert client_metadata.get( + "redirect_uris" ), "Redirect URL(s) are required for the 'authorization_code' grant" - expires_at = get_client_expires_at( - expires_in=expires_in, grant_types=kwargs["grant_type"] - ) - if expires_at: - kwargs["expires_at"] = expires_at + # response_types is now part of authlib's client_metadata + response_types = kwargs.pop("response_types", None) + if isinstance(response_types, list): + client_metadata["response_types"] = "\n".join(response_types) + elif response_types: + # assume it's already in correct format + client_metadata["response_types"] = [response_types] + else: + client_metadata["response_types"] = [] + + if "token_endpoint_auth_method" in kwargs: + client_metadata["token_endpoint_auth_method"] = kwargs.pop( + "token_endpoint_auth_method" + ) + + # Do this if expires_in is specified or expires_at is not supplied + if expires_in != 0 or ("expires_at" not in kwargs): + expires_at = get_client_expires_at( + expires_in=expires_in, grant_types=client_metadata["grant_types"] + ) + if expires_at: + kwargs["expires_at"] = expires_at + + if "client_id_issued_at" not in kwargs or kwargs["client_id_issued_at"] is None: + kwargs["client_id_issued_at"] = int(time.time()) + + kwargs["_client_metadata"] = json.dumps(client_metadata) super(Client, self).__init__(client_id=client_id, **kwargs) @property def allowed_scopes(self): - return self._allowed_scopes.split(" ") + return self.scope.split(" ") @property def client_type(self): @@ -295,16 +328,6 @@ def client_type(self): return "public" return "confidential" - @property - def default_redirect_uri(self): - return self.redirect_uris[0] - - @property - def default_scopes(self): - if self._default_scopes: - return self._default_scopes.split() - return [] - @staticmethod def get_by_client_id(client_id): with flask.current_app.db.session as session: @@ -327,18 +350,18 @@ def check_requested_scopes(self, scopes): return False return set(self.allowed_scopes).issuperset(scopes) - def check_token_endpoint_auth_method(self, method): + # Replaces Authlib method. Our logic does not actually look at token_auth_endpoint value + def check_endpoint_auth_method(self, method, endpoint): """ Only basic auth is supported. If anything else gets added, change this """ - protected_types = [ClientAuthType.basic.value, ClientAuthType.post.value] - return (self.is_confidential and method in protected_types) or ( - not self.is_confidential and method == ClientAuthType.none.value - ) + if endpoint == "token": + protected_types = [ClientAuthType.basic.value, ClientAuthType.post.value] + return (self.is_confidential and method in protected_types) or ( + not self.is_confidential and method == ClientAuthType.none.value + ) - def validate_scopes(self, scopes): - scopes = scopes[0].split(",") - return all(scope in self._scopes for scope in scopes) + return True def check_response_type(self, response_type): allowed_response_types = [] diff --git a/fence/oidc/endpoints.py b/fence/oidc/endpoints.py index b0ccbcacd..254d9ef69 100644 --- a/fence/oidc/endpoints.py +++ b/fence/oidc/endpoints.py @@ -7,7 +7,7 @@ from fence.errors import BlacklistingError import fence.jwt.blacklist - +import jwt logger = get_logger(__name__) @@ -18,20 +18,20 @@ class RevocationEndpoint(authlib.oauth2.rfc7009.RevocationEndpoint): server should handle requests for token revocation. """ - def query_token(self, token, token_type_hint, client): + def query_token(self, token, token_type_hint): """ Look up a token. Since all tokens are JWT, just return the token. """ - return token + return JWTToken(token) - def revoke_token(self, token): + def revoke_token(self, token, request): """ Revoke a token. """ try: - fence.jwt.blacklist.blacklist_encoded_token(token) + fence.jwt.blacklist.blacklist_encoded_token(token.encoded_string) except BlacklistingError as err: logger.info( "Token provided for revocation is not valid. " @@ -109,3 +109,25 @@ def create_revocation_response(self): finally: body = {"error": message} if message != "" else {} return (status, body, headers) + + +class JWTToken(object): + def __init__(self, token): + self.encoded_string = token + self.client_id = jwt.decode( + token, algorithms=["RS256"], options={"verify_signature": False} + ).get("azp") + + def check_client(self, client): + """ + Check if token is issued by the same client + Expected function by Authlib + + Args: + client: oidc client + + Returns: + boolean value whether client_id matches + """ + + return self.client_id == client.client_id diff --git a/fence/oidc/grants/__init__.py b/fence/oidc/grants/__init__.py index f3740ba19..c6e372bd7 100644 --- a/fence/oidc/grants/__init__.py +++ b/fence/oidc/grants/__init__.py @@ -1,4 +1,4 @@ from fence.oidc.grants.implicit_grant import ImplicitGrant -from fence.oidc.grants.oidc_code_grant import OpenIDCodeGrant +from fence.oidc.grants.oidc_code_grant import AuthorizationCodeGrant from fence.oidc.grants.refresh_token_grant import RefreshTokenGrant from fence.oidc.grants.client_credentials_grant import ClientCredentialsGrant diff --git a/fence/oidc/grants/implicit_grant.py b/fence/oidc/grants/implicit_grant.py index e1532b926..784f412c9 100644 --- a/fence/oidc/grants/implicit_grant.py +++ b/fence/oidc/grants/implicit_grant.py @@ -14,7 +14,7 @@ def exists_nonce(self, nonce, request): return True return False - def create_authorization_response(self, grant_user): + def create_authorization_response(self, redirect_uri, grant_user): """ Overrides method from authlib---authlib has some peculiarities here such as trying to access ``token["scope"]`` from the token response which is not @@ -22,6 +22,9 @@ def create_authorization_response(self, grant_user): here: https://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthResponse + + 2024-04-19 + TODO: Re-evaluate this whether if it is still necessary. """ state = self.request.state if grant_user: @@ -46,7 +49,7 @@ def create_authorization_response(self, grant_user): # http://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#ResponseModes return create_response_mode_response( - redirect_uri=self.redirect_uri, + redirect_uri=redirect_uri, params=params, response_mode=self.request.data.get( "response_mode", self.DEFAULT_RESPONSE_MODE diff --git a/fence/oidc/grants/oidc_code_grant.py b/fence/oidc/grants/oidc_code_grant.py index 771b6b59d..0514e68a5 100644 --- a/fence/oidc/grants/oidc_code_grant.py +++ b/fence/oidc/grants/oidc_code_grant.py @@ -1,27 +1,31 @@ from authlib.common.security import generate_token -from authlib.oidc.core import grants +from authlib.oauth2.rfc6749 import grants from authlib.oidc.core.errors import ( AccountSelectionRequiredError, ConsentRequiredError, LoginRequiredError, ) -from authlib.oauth2.rfc6749 import InvalidRequestError +from authlib.oauth2.rfc6749 import ( + InvalidRequestError, + UnauthorizedClientError, + InvalidGrantError, +) import flask from fence.utils import get_valid_expiration_from_request from fence.config import config from fence.models import AuthorizationCode, ClientAuthType, User +from cdislogging import get_logger + +logger = get_logger(__name__) -class OpenIDCodeGrant(grants.OpenIDCodeGrant): +class AuthorizationCodeGrant(grants.AuthorizationCodeGrant): TOKEN_ENDPOINT_AUTH_METHODS = [auth_type.value for auth_type in ClientAuthType] def __init__(self, *args, **kwargs): - super(OpenIDCodeGrant, self).__init__(*args, **kwargs) + super(AuthorizationCodeGrant, self).__init__(*args, **kwargs) # Override authlib validate_request_prompt with our own, to fix login prompt behavior - self._hooks["after_validate_consent_request"].discard( - grants.util.validate_request_prompt - ) self.register_hook( "after_validate_consent_request", self.validate_request_prompt ) @@ -60,12 +64,53 @@ def create_authorization_code(client, grant_user, request): return code.code + def save_authorization_code(self, code, request): + """Save authorization_code for later use. Must be implemented. + + Args: + code: authorization code string + request: HTTP request + + Returns: + authorization code string + """ + # requested lifetime (in seconds) for the refresh token + refresh_token_expires_in = get_valid_expiration_from_request( + expiry_param="refresh_token_expires_in", + max_limit=config["REFRESH_TOKEN_EXPIRES_IN"], + default=config["REFRESH_TOKEN_EXPIRES_IN"], + ) + + client = request.client + code = AuthorizationCode( + code=code, + client_id=client.client_id, + redirect_uri=request.redirect_uri, + scope=request.scope, + user_id=request.user.id, + nonce=request.data.get("nonce"), + refresh_token_expires_in=refresh_token_expires_in, + ) + + with flask.current_app.db.session as session: + session.add(code) + session.commit() + return code.code + def generate_token(self, *args, **kwargs): return self.server.generate_token(*args, **kwargs) def create_token_response(self): + """Generate Tokens + + Raises: + InvalidRequestError: if no user present in authorization code + + Returns: + HTTP status code, token, HTTP response header + """ client = self.request.client - authorization_code = self.request.credential + authorization_code = self.request.authorization_code user = self.authenticate_user(authorization_code) if not user: @@ -80,7 +125,7 @@ def create_token_response(self): self.GRANT_TYPE, user=user, scope=scope, - include_refresh_token=client.has_client_secret(), + include_refresh_token=bool(client.client_secret), nonce=nonce, refresh_token_expires_in=refresh_token_expires_in, ) @@ -92,7 +137,7 @@ def create_token_response(self): return 200, token, self.TOKEN_RESPONSE_HEADER @staticmethod - def parse_authorization_code(code, client): + def query_authorization_code(code, client): """ Search for an ``AuthorizationCode`` matching the given code string and client. @@ -142,7 +187,7 @@ def exists_nonce(self, nonce, request): return True return False - def validate_request_prompt(self, end_user): + def validate_request_prompt(self, end_user, redirect_uri): """ Override method in authlib to fix behavior with login prompt. """ @@ -175,3 +220,47 @@ def validate_request_prompt(self, end_user): self.prompt = prompt return self + + def validate_token_request(self): + """ + Validate token request by checking allowed grant type, + making sure authorization code is found, and redirect URI is valid + + Raises: + UnauthorizedClientError: if grant type is incorrect + InvalidRequestError: if authorization code is absent + InvalidGrantError: if authorization code is invalid + InvalidGrantError: if redirect_uri is invalid + """ + # authenticate the client if client authentication is included + logger.debug("Authenticating token client..") + client = self.authenticate_token_endpoint_client() + + logger.debug("Validate token request of %r", client) + if not client.check_grant_type(self.GRANT_TYPE): + raise UnauthorizedClientError( + f'The client is not authorized to use "grant_type={self.GRANT_TYPE}"' + ) + + code = self.request.data.get("code") + if code is None: + raise InvalidRequestError('Missing "code" in request.') + + # ensure that the authorization code was issued to the authenticated + # confidential client, or if the client is public, ensure that the + # code was issued to "client_id" in the request + authorization_code = self.query_authorization_code(code, client) + if not authorization_code: + raise InvalidGrantError('Invalid "code" in request.') + + # validate redirect_uri parameter + logger.debug("Validate token redirect_uri of %r", client) + redirect_uri = self.request.redirect_uri + original_redirect_uri = authorization_code.get_redirect_uri() + if original_redirect_uri and redirect_uri != original_redirect_uri: + raise InvalidGrantError('Invalid "redirect_uri" in request.') + + # save for create_token_response + self.request.client = client + self.request.authorization_code = authorization_code + self.execute_hook("after_validate_token_request") diff --git a/fence/oidc/grants/refresh_token_grant.py b/fence/oidc/grants/refresh_token_grant.py index 5c01a9a6a..b607bd9af 100644 --- a/fence/oidc/grants/refresh_token_grant.py +++ b/fence/oidc/grants/refresh_token_grant.py @@ -4,6 +4,7 @@ InvalidRequestError, InvalidScopeError, UnauthorizedClientError, + InvalidGrantError, ) from authlib.oauth2.rfc6749.grants import RefreshTokenGrant as AuthlibRefreshTokenGrant from authlib.oauth2.rfc6749.util import scope_to_list @@ -74,7 +75,7 @@ def validate_token_request(self): raise UnauthorizedClientError("invalid grant type") self.request.client = client self.authenticate_token_endpoint_client() - token = self._validate_request_token() + token = self._validate_request_token(client) self._validate_token_scope(token) self.request.credential = token @@ -141,7 +142,10 @@ def create_token_response(self): ##### end refresh token patch block ##### expires_in = credential["exp"] token = self.generate_token( - client, self.GRANT_TYPE, user=user, expires_in=expires_in, scope=scope + user=user, + scope=scope, + grant_type=self.GRANT_TYPE, + expires_in=expires_in, ) # replace the newly generated refresh token with the one provided @@ -154,14 +158,29 @@ def create_token_response(self): if self.GRANT_TYPE == "refresh_token": token["refresh_token"] = self.request.data.get("refresh_token", "") - # TODO - logger.info("") - self.request.user = user self.server.save_token(token, self.request) self.execute_hook("process_token", token=token) return 200, token, self.TOKEN_RESPONSE_HEADER + def _validate_request_token(self, client): + """ + OVERRIDES method from authlib. + + Why? Becuase our "token" is not a class with `check_client` method. + So we just need to treat it like a dictionary. + """ + refresh_token = self.request.data.get("refresh_token") + if refresh_token is None: + raise InvalidRequestError( + 'Missing "refresh_token" in request.', + ) + + token = self.authenticate_refresh_token(refresh_token) + if not token or not token["azp"] == client.get_client_id(): + raise InvalidGrantError() + return token + def _validate_token_scope(self, token): """ OVERRIDES method from authlib. diff --git a/fence/oidc/jwt_generator.py b/fence/oidc/jwt_generator.py index dfba556b6..2787add77 100644 --- a/fence/oidc/jwt_generator.py +++ b/fence/oidc/jwt_generator.py @@ -15,6 +15,7 @@ ) from fence.config import config +from fence.utils import validate_scopes def generate_token(client, grant_type, **kwargs): @@ -47,6 +48,10 @@ def generate_token(client, grant_type, **kwargs): claims (to avoid having to encode or decode the refresh token here) """ + # We need to validate scopes here because Authlib only check request scope against + # server's allowed_scopes + validate_scopes(kwargs["scope"], client) + if grant_type == "authorization_code" or grant_type == "refresh_token": return generate_token_response(client, grant_type, **kwargs) elif grant_type == "implicit": diff --git a/fence/oidc/oidc_server.py b/fence/oidc/oidc_server.py index 391e42d92..9b9aefad7 100644 --- a/fence/oidc/oidc_server.py +++ b/fence/oidc/oidc_server.py @@ -1,14 +1,25 @@ -from authlib.common.urls import urlparse, url_decode -from authlib.flask.oauth2 import AuthorizationServer +import flask + +from fence.oidc.errors import InvalidClientError +from fence.oidc.jwt_generator import generate_token + +from authlib.integrations.flask_oauth2 import AuthorizationServer from authlib.oauth2.rfc6749.authenticate_client import ( ClientAuthentication as AuthlibClientAuthentication, ) -from authlib.oauth2.rfc6749.errors import InvalidClientError as AuthlibClientError -import flask +from authlib.oauth2.rfc6749.errors import ( + InvalidClientError as AuthlibClientError, + OAuth2Error, + UnsupportedGrantTypeError, +) -from fence.oidc.errors import InvalidClientError -from fence.oidc.jwt_generator import generate_token +from fence import logger +from cdislogging import get_logger +from flask.wrappers import Request +from authlib.oauth2.rfc6749 import OAuth2Request + +logger = get_logger(__name__) class ClientAuthentication(AuthlibClientAuthentication): @@ -17,23 +28,30 @@ class ClientAuthentication(AuthlibClientAuthentication): in order to authenticate OAuth clients. """ - def authenticate(self, request, methods): + def authenticate(self, request, methods, endpoint): """ Override method from authlib """ - client = super(ClientAuthentication, self).authenticate(request, methods) + client = super(ClientAuthentication, self).authenticate( + request, methods, endpoint + ) + + logger.info("oidc_server.py clientAuthentioncation authenticate complete") # don't allow confidential clients to not use auth if client.is_confidential: m = list(methods) if "none" in m: m.remove("none") try: - client = super(ClientAuthentication, self).authenticate(request, m) + client = super(ClientAuthentication, self).authenticate( + request, m, endpoint + ) except AuthlibClientError: raise InvalidClientError( - "OAuth client failed to authenticate; client ID or secret is" + "Confidential OAuth client failed to authenticate; client ID or secret is" " missing or incorrect" ) + return client @@ -53,6 +71,57 @@ def init_app(self, app, query_client=None, save_token=None): self.save_token = save_token self.app = app self.generate_token = generate_token - self.init_jwt_config(app) if getattr(self, "query_client"): self.authenticate_client = ClientAuthentication(query_client) + + # 2023-09-29 + # Below code replaces authlib functions. It does the same thing as authlib 1.2.1 except it returns grant_scope from + # either args or forms. Authlib 1.2.1 forces grant_type to be part of post request body which isn't the current use case. + # https://github.com/lepture/authlib/blob/a6e89f8e6cf6f6bebd63dcdc2665b7d22cf0fde3/authlib/oauth2/rfc6749/requests.py#L59C10-L59C10 + def create_token_response(self, request=None): + """Validate token request and create token response. + + Args: + request: HTTP request instance + Returns: + HTTP response with token + """ + request = self.create_oauth2_request(request) + + try: + grant = self.get_token_grant(request) + except UnsupportedGrantTypeError as error: + return self.handle_error_response(request, error) + + logger.debug("Got grant succesfully") + + try: + grant.validate_token_request() + logger.debug("Token Request validated succesfully") + args = grant.create_token_response() + logger.debug("Token created succesfully") + return self.handle_response(*args) + except OAuth2Error as error: + return self.handle_error_response(request, error) + + def create_oauth2_request(self, request): + return FenceOAuth2Request(flask.request) + + +class FenceOAuth2Request(OAuth2Request): + def __init__(self, request: Request): + super().__init__(request.method, request.url, None, request.headers) + self._request = request + + @property + def args(self): + return self._request.args + + @property + def form(self): + return self._request.values + + # Get grant_type from either url or body + @property + def grant_type(self) -> str: + return self.data.get("grant_type") diff --git a/fence/oidc/server.py b/fence/oidc/server.py index 846fd4224..67d0a6089 100644 --- a/fence/oidc/server.py +++ b/fence/oidc/server.py @@ -9,7 +9,7 @@ from fence.oidc.client import authenticate_public_client, query_client from fence.oidc.endpoints import RevocationEndpoint from fence.oidc.grants import ( - OpenIDCodeGrant, + AuthorizationCodeGrant, ImplicitGrant, RefreshTokenGrant, ClientCredentialsGrant, @@ -18,7 +18,7 @@ server = OIDCServer(query_client=query_client, save_token=lambda *_: None) -server.register_grant(OpenIDCodeGrant) +server.register_grant(AuthorizationCodeGrant) server.register_grant(ImplicitGrant) server.register_grant(RefreshTokenGrant) server.register_grant(ClientCredentialsGrant) diff --git a/fence/resources/openid/idp_oauth2.py b/fence/resources/openid/idp_oauth2.py index 3c681b1e5..c2e497085 100644 --- a/fence/resources/openid/idp_oauth2.py +++ b/fence/resources/openid/idp_oauth2.py @@ -1,4 +1,4 @@ -from authlib.client import OAuth2Session +from authlib.integrations.requests_client import OAuth2Session from cached_property import cached_property from flask import current_app from jose import jwt diff --git a/fence/scripting/fence_create.py b/fence/scripting/fence_create.py index a513e9a1b..a4b15aff8 100644 --- a/fence/scripting/fence_create.py +++ b/fence/scripting/fence_create.py @@ -62,7 +62,7 @@ generate_client_credentials, get_SQLAlchemyDriver, ) - +from sqlalchemy.orm.attributes import flag_modified from gen3authz.client.arborist.client import ArboristClient logger = get_logger(__name__) @@ -100,15 +100,19 @@ def modify_client_action( if not clients: raise Exception("client {} does not exist".format(client_name)) for client in clients: + metadata = client.client_metadata if urls: if append: - client.redirect_uris += urls + metadata["redirect_uris"] += urls logger.info("Adding {} to urls".format(urls)) else: - client.redirect_uris = urls + if isinstance(urls, list): + metadata["redirect_uris"] = urls + else: + metadata["redirect_uris"] = [urls] logger.info("Changing urls to {}".format(urls)) if delete_urls: - client.redirect_uris = [] + metadata["redirect_uris"] = [] logger.info("Deleting urls") if set_auto_approve: client.auto_approve = True @@ -124,19 +128,21 @@ def modify_client_action( logger.info("Updating description to {}".format(description)) if allowed_scopes: if append: - new_scopes = client._allowed_scopes.split() + allowed_scopes - client._allowed_scopes = " ".join(new_scopes) + new_scopes = client.scope.split() + allowed_scopes + metadata["scope"] = " ".join(new_scopes) logger.info("Adding {} to allowed_scopes".format(allowed_scopes)) else: - client._allowed_scopes = " ".join(allowed_scopes) + metadata["scope"] = " ".join(allowed_scopes) logger.info("Updating allowed_scopes to {}".format(allowed_scopes)) if expires_in: client.expires_at = get_client_expires_at( - expires_in=expires_in, grant_types=client.grant_type + expires_in=expires_in, grant_types=client.grant_types ) + # Call setter on Json object to persist changes if any + client.set_client_metadata(metadata) s.commit() - if arborist is not None and policies: - arborist.update_client(client.client_id, policies) + if arborist is not None and policies: + arborist.update_client(client.client_id, policies) def create_client_action( @@ -210,10 +216,10 @@ def delete_expired_clients_action(DB, slack_webhook=None, warning_days=None): # to delete pass - def split_uris(uris): - if not uris: + def format_uris(uris): + if not uris or uris == [None]: return uris - return uris.split("\n") + return " ".join(uris) now = datetime.now().timestamp() driver = get_SQLAlchemyDriver(DB) @@ -229,7 +235,7 @@ def split_uris(uris): for client in clients: expired_messages.append( - f"Client '{client.name}' (ID '{client.client_id}') expired at {datetime.fromtimestamp(client.expires_at)} UTC. Redirect URIs: {split_uris(client.redirect_uri)})" + f"Client '{client.name}' (ID '{client.client_id}') expired at {datetime.fromtimestamp(client.expires_at)} UTC. Redirect URIs: {format_uris(client.redirect_uris)})" ) _remove_client_service_accounts(current_session, client) current_session.delete(client) @@ -251,7 +257,7 @@ def split_uris(uris): expiring_messages = ["Some OIDC clients are expiring soon!"] expiring_messages.extend( [ - f"Client '{client.name}' (ID '{client.client_id}') expires at {datetime.fromtimestamp(client.expires_at)} UTC. Redirect URIs: {split_uris(client.redirect_uri)}" + f"Client '{client.name}' (ID '{client.client_id}') expires at {datetime.fromtimestamp(client.expires_at)} UTC. Redirect URIs: {format_uris(client.redirect_uris)}" for client in expiring_clients ] ) @@ -312,7 +318,7 @@ def rotate_client_action(DB, client_name, expires_in=None): # the rest is identical to the client being rotated user=client.user, redirect_uris=client.redirect_uris, - _allowed_scopes=client._allowed_scopes, + allowed_scopes=client.scope, description=client.description, name=client.name, auto_approve=client.auto_approve, @@ -1107,7 +1113,7 @@ def _verify_google_service_account_member(session, access_group, member): class JWTCreator(object): required_kwargs = ["kid", "private_key", "username", "scopes"] - all_kwargs = required_kwargs + ["expires_in"] + all_kwargs = required_kwargs + ["expires_in", "client_id"] default_expiration = 3600 @@ -1121,6 +1127,7 @@ def __init__(self, db, base_url, **kwargs): self.private_key = None self.username = None self.scopes = None + self.client_id = None for required_kwarg in self.required_kwargs: if required_kwarg not in kwargs: @@ -1181,6 +1188,10 @@ def create_refresh_token(self): raise EnvironmentError( "no user found with given username: " + self.username ) + if not self.client_id: + raise EnvironmentError( + "no client id is provided. Required for creating refresh token" + ) jwt_result = generate_signed_refresh_token( self.kid, self.private_key, @@ -1188,6 +1199,7 @@ def create_refresh_token(self): self.expires_in, self.scopes, iss=self.base_url, + client_id=self.client_id, ) current_session.add( diff --git a/fence/utils.py b/fence/utils.py index 7024e87e3..463fb6f75 100644 --- a/fence/utils.py +++ b/fence/utils.py @@ -18,7 +18,8 @@ from fence.models import Client, User, query_for_user from fence.errors import NotFound, UserError from fence.config import config - +from authlib.oauth2.rfc6749.util import scope_to_list +from authlib.oauth2.rfc6749.errors import InvalidScopeError rng = SystemRandom() alphanumeric = string.ascii_uppercase + string.ascii_lowercase + string.digits @@ -107,7 +108,7 @@ def create_client( client_secret=hashed_secret, user=user, redirect_uris=urls, - _allowed_scopes=" ".join(allowed_scopes), + allowed_scopes=" ".join(allowed_scopes), description=description, name=name, auto_approve=auto_approve, @@ -433,3 +434,22 @@ def get_SQLAlchemyDriver(db_conn_url): "max_tries": config["DEFAULT_BACKOFF_SETTINGS_MAX_TRIES"], "giveup": exception_do_not_retry, } + + +def validate_scopes(request_scopes, client): + if not client: + raise Exception("Client object is None") + + if request_scopes: + scopes = scope_to_list(request_scopes) + # can we get some debug logs here that log the client, what scopes they have, and what scopes were requested + if not client.check_requested_scopes(set(scopes)): + logger.debug( + "Request Scope are " + + " ".join(scopes) + + " but client supported scopes are " + + client.scope + ) + raise InvalidScopeError("Failed to Authorize due to unsupported scope") + + return True diff --git a/migrations/models/migration_client.py b/migrations/models/migration_client.py new file mode 100644 index 000000000..bc818f09e --- /dev/null +++ b/migrations/models/migration_client.py @@ -0,0 +1,63 @@ +from authlib.integrations.sqla_oauth2 import OAuth2ClientMixin +from sqlalchemy import Boolean, Column, Integer, String, Text, func +from sqlalchemy.orm import Session, backref, relationship +from sqlalchemy.schema import ForeignKey +from userdatamodel import Base +from userdatamodel.models import User +import time + +# This needs to be in a different file +# Otherwise SqlAlchemy would import this multiple times and then complain about metadata conflict +class MigrationClient(Base): + + __tablename__ = "migration_client" + + client_id = Column(String(48), primary_key=True, index=True) + # this is hashed secret + client_secret = Column(String(120), unique=True, index=True, nullable=True) + + # human readable name + name = Column(String(40), nullable=False) + + # human readable description, not required + description = Column(String(400)) + + # required if you need to support client credential + user_id = Column(Integer) + + # this is for internal microservices to skip user grant + auto_approve = Column(Boolean, default=False) + + # public or confidential + is_confidential = Column(Boolean, default=True) + + issued_at = Column(Integer, nullable=False, default=lambda: int(time.time())) + + expires_at = Column(Integer, nullable=False, default=0) + + _redirect_uris = Column(Text) + + _allowed_scopes = Column(Text, nullable=False, default="") + + # Deprecated, keeping these around in case it is needed later + _default_scopes = Column(Text) + _scopes = ["compute", "storage", "user"] + + redirect_uri = Column(Text) + token_endpoint_auth_method = Column(String(48), default="client_secret_basic") + grant_type = Column(Text, nullable=False, default="") + response_type = Column(Text, nullable=False, default="") + scope = Column(Text, nullable=False, default="") + + client_name = Column(String(100)) + client_uri = Column(Text) + logo_uri = Column(Text) + contact = Column(Text) + tos_uri = Column(Text) + policy_uri = Column(Text) + jwks_uri = Column(Text) + jwks_text = Column(Text) + i18n_metadata = Column(Text) + + software_id = Column(String(36)) + software_version = Column(String(48)) diff --git a/migrations/versions/9b3a5a7145d7_authlib_update_1_2_1.py b/migrations/versions/9b3a5a7145d7_authlib_update_1_2_1.py new file mode 100644 index 000000000..43caadb28 --- /dev/null +++ b/migrations/versions/9b3a5a7145d7_authlib_update_1_2_1.py @@ -0,0 +1,296 @@ +"""authlib update 1.2.1 + +Revision ID: 9b3a5a7145d7 +Revises: a04a70296688 +Create Date: 2023-09-01 10:27:16.686456 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.orm import Session +from sqlalchemy.sql import text + +import json +from authlib.common.encoding import json_loads, json_dumps + +from migrations.models.migration_client import MigrationClient +from fence.models import Client + +# revision identifiers, used by Alembic. +revision = "9b3a5a7145d7" # pragma: allowlist secret +down_revision = "a04a70296688" # pragma: allowlist secret +branch_labels = None +depends_on = None + + +def upgrade(): + + temp_table_name = "migration_client" + # Make a copy of client table + copy_client_to_temp_table_and_clear_data(op, temp_table_name) + + # Add new columns for client table + op.add_column("client", sa.Column("client_metadata", sa.Text(), nullable=True)) + op.add_column( + "client", + sa.Column( + "client_secret_expires_at", sa.Integer(), nullable=False, server_default="0" + ), + ) + + # Modify columns for client table + op.alter_column("client", "issued_at", new_column_name="client_id_issued_at") + op.alter_column("client", "client_id", nullable=False, type_=sa.String(48)) + op.alter_column("client", "client_secret", nullable=True, type_=sa.String(120)) + + # Delete old columns for client table + op.drop_column("client", "redirect_uri") + op.drop_column("client", "token_endpoint_auth_method") + op.drop_column("client", "grant_type") + op.drop_column("client", "response_type") + op.drop_column("client", "scope") + op.drop_column("client", "client_name") + op.drop_column("client", "client_uri") + op.drop_column("client", "logo_uri") + op.drop_column("client", "contact") + op.drop_column("client", "tos_uri") + op.drop_column("client", "policy_uri") + op.drop_column("client", "jwks_uri") + op.drop_column("client", "jwks_text") + op.drop_column("client", "i18n_metadata") + op.drop_column("client", "software_id") + op.drop_column("client", "software_version") + op.drop_column("client", "_allowed_scopes") + op.drop_column("client", "_redirect_uris") + + transform_client_data(op) + + # Drop temp table + op.drop_table(temp_table_name) + + # Add New Columns for authorization_code Table + op.add_column( + "authorization_code", sa.Column("code_challenge", sa.Text(), nullable=True) + ) + op.add_column( + "authorization_code", + sa.Column("code_challenge_method", sa.String(length=48), nullable=True), + ) + + +def downgrade(): + + temp_table_name = "migration_client" + # Make a copy of client table + copy_client_to_temp_table_and_clear_data(op, temp_table_name) + + # Add Old Columns Back + op.add_column("client", sa.Column("redirect_uri", sa.Text(), nullable=True)) + op.add_column( + "client", + sa.Column("token_endpoint_auth_method", sa.String(length=48), nullable=True), + ) + op.add_column( + "client", sa.Column("grant_type", sa.Text(), nullable=False, server_default="") + ) + op.add_column( + "client", + sa.Column("response_type", sa.Text(), nullable=False, server_default=""), + ) + op.add_column( + "client", sa.Column("scope", sa.Text(), nullable=False, server_default="") + ) + op.add_column( + "client", sa.Column("client_name", sa.String(length=100), nullable=True) + ) + op.add_column("client", sa.Column("client_uri", sa.Text(), nullable=True)) + op.add_column("client", sa.Column("logo_uri", sa.Text(), nullable=True)) + op.add_column("client", sa.Column("contact", sa.Text(), nullable=True)) + op.add_column("client", sa.Column("tos_uri", sa.Text(), nullable=True)) + op.add_column("client", sa.Column("policy_uri", sa.Text(), nullable=True)) + op.add_column("client", sa.Column("jwks_uri", sa.Text(), nullable=True)) + op.add_column("client", sa.Column("jwks_text", sa.Text(), nullable=True)) + op.add_column("client", sa.Column("i18n_metadata", sa.Text(), nullable=True)) + op.add_column( + "client", sa.Column("software_id", sa.String(length=36), nullable=True) + ) + op.add_column( + "client", sa.Column("software_version", sa.String(length=48), nullable=True) + ) + op.add_column( + "client", + sa.Column("_allowed_scopes", sa.Text(), nullable=False, server_default=""), + ) + op.add_column("client", sa.Column("_redirect_uris", sa.Text(), nullable=True)) + + # Modify Columns for client Table + op.alter_column("client", "client_id_issued_at", new_column_name="issued_at") + op.alter_column("client", "client_id", nullable=False, type_=sa.String(40)) + op.alter_column("client", "client_secret", nullable=True, type_=sa.String(60)) + + # Drop New Columns for client Table + op.drop_column("client", "client_metadata") + op.drop_column("client", "client_secret_expires_at") + + # Set value of old columns + set_old_column_values() + op.drop_table(temp_table_name) + + # Remove New Columns for authorization_code Table + op.drop_column("authorization_code", "code_challenge") + op.drop_column("authorization_code", "code_challenge_method") + + +def copy_client_to_temp_table_and_clear_data(op, temp_table_name: str): + """Copy client table schema and data into temp table""" + conn = op.get_bind() + session = Session(bind=conn) + # Drop temp table if it already exists + # copy client table with all table metadata then copy all row data + session.execute("DROP TABLE IF EXISTS " + temp_table_name + ";") + session.execute("CREATE TABLE " + temp_table_name + " (LIKE client INCLUDING ALL);") + session.execute("INSERT INTO " + temp_table_name + " SELECT * FROM client;") + session.execute("Truncate client;") + session.commit() + + +def transform_client_data(op): + conn = op.get_bind() + session = Session(bind=conn) + + for client in session.query(MigrationClient).all(): + if client.i18n_metadata: + metadata = json.loads(client.i18n_metadata) + else: + metadata = {} + + if client.redirect_uri: + metadata["redirect_uris"] = client.redirect_uri.splitlines() + if client.token_endpoint_auth_method: + metadata["token_endpoint_auth_method"] = client.token_endpoint_auth_method + if client.grant_type: + metadata["grant_types"] = client.grant_type.splitlines() + if client.response_type: + metadata["response_types"] = client.response_type.splitlines() + if client.client_uri: + metadata["client_uri"] = client.client_uri + if client.logo_uri: + metadata["logo_uri"] = client.logo_uri + if client.contact: + metadata["contact"] = client.contact + if client.tos_uri: + metadata["tos_uri"] = client.tos_uri + if client.policy_uri: + metadata["policy_uri"] = client.policy_uri + if client.jwks_uri: + metadata["jwks_uri"] = client.jwks_uri + if client.jwks_text: + metadata["jwks_text"] = client.jwks_text + if client.software_id: + metadata["software_id"] = client.software_id + if client.software_version: + metadata["software_version"] = client.software_version + + new_client = Client( + client_id=client.client_id, + client_secret=client.client_secret, + name=client.name, + description=client.description, + allowed_scopes=client._allowed_scopes.split(" "), + user_id=client.user_id, + auto_approve=client.auto_approve, + is_confidential=client.is_confidential, + expires_at=client.expires_at, + _default_scopes=client._default_scopes, + grant_types=client.grant_type.splitlines(), + response_types=client.response_type.splitlines(), + client_id_issued_at=client.issued_at, + _client_metadata=json_dumps(metadata), + ) + + session.add(new_client) + + session.commit() + + +def set_old_column_values(): + conn = op.get_bind() + session = Session(bind=conn) + clientDatas = [] + + rs = session.execute("SELECT * FROM migration_client") + for client in rs: + data = {} + data["client_id"] = client.client_id + data["client_secret"] = client.client_secret + data["name"] = client.name + data["description"] = client.description + data["user_id"] = client.user_id + data["auto_approve"] = client.auto_approve + data["is_confidential"] = client.is_confidential + data["expires_at"] = client.expires_at + data["issued_at"] = client.client_id_issued_at + data["_default_scopes"] = client._default_scopes + data["_redirect_uris"] = None # Deprecated + data["scope"] = "" # Deprecated + data["client_name"] = None + + if client.client_metadata: + metadata = json_loads(client.client_metadata) + data["i18n_metadata"] = client.client_metadata + else: + metadata = {} + data["i18n_metadata"] = None + + if metadata.get("redirect_uris"): + data["redirect_uri"] = "\n".join( + [item for item in metadata.get("redirect_uris") if item] + ) + else: + data["redirect_uri"] = "" + + data["token_endpoint_auth_method"] = metadata.get("token_endpoint_auth_method") + data["_allowed_scopes"] = metadata.get("scope") + + if metadata.get("grant_types"): + data["grant_type"] = "\n".join( + [item for item in metadata.get("grant_types") if item] + ) + else: + data["grant_type"] = "" + + if metadata.get("response_types"): + data["response_type"] = "\n".join( + [item for item in metadata.get("response_types") if item] + ) + else: + data["response_type"] = "" + + data["client_uri"] = metadata.get("client_uri") + data["logo_uri"] = metadata.get("logo_uri") + data["contact"] = metadata.get("contact") + data["tos_uri"] = metadata.get("tos_uri") + data["policy_uri"] = metadata.get("policy_uri") + data["jwks_uri"] = metadata.get("jwks_uri") + data["jwks_text"] = metadata.get("jwks_text") + data["software_id"] = metadata.get("software_id") + data["software_version"] = metadata.get("software_version") + + clientDatas.append(data) + + statement = text( + """INSERT INTO client(client_id, client_secret, name, description, + user_id, auto_approve, is_confidential, issued_at, expires_at, _redirect_uris, _allowed_scopes, + _default_scopes, redirect_uri, token_endpoint_auth_method, grant_type, response_type, scope, + client_name,client_uri,logo_uri,contact,tos_uri,policy_uri,jwks_uri,jwks_text,i18n_metadata, + software_id,software_version) + VALUES( :client_id, :client_secret, :name, :description, :user_id, :auto_approve, :is_confidential, :issued_at, + :expires_at, :_redirect_uris, :_allowed_scopes, :_default_scopes, :redirect_uri, :token_endpoint_auth_method, + :grant_type, :response_type, :scope, :client_name, :client_uri, :logo_uri, :contact, :tos_uri, :policy_uri, + :jwks_uri, :jwks_text, :i18n_metadata, :software_id, :software_version)""" + ) + + for data in clientDatas: + conn.execute(statement, **data) + + session.commit() diff --git a/poetry.lock b/poetry.lock index 6fca33c54..4f2a43732 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "addict" @@ -46,13 +46,13 @@ dev = ["black", "coverage", "isort", "pre-commit", "pyenchant", "pylint"] [[package]] name = "anyio" -version = "4.3.0" +version = "4.4.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.8" files = [ - {file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"}, - {file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"}, + {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, + {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, ] [package.dependencies] @@ -96,46 +96,42 @@ tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] [[package]] -name = "Authlib" -version = "0.11" -description = "The ultimate Python library in building OAuth and OpenID Connect servers." +name = "authlib" +version = "1.3.1" +description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." optional = false -python-versions = "*" -files = [] -develop = false +python-versions = ">=3.8" +files = [ + {file = "Authlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:d35800b973099bbadc49b42b256ecb80041ad56b7fe1216a362c7943c088f377"}, + {file = "authlib-1.3.1.tar.gz", hash = "sha256:7ae843f03c06c5c0debd63c9db91f9fda64fa62a42a77419fa15fbb7e7a58917"}, +] [package.dependencies] cryptography = "*" -requests = "*" - -[package.source] -type = "git" -url = "https://github.com/uc-cdis/authlib" -reference = "v0.11_CVE_patch_v1" -resolved_reference = "80345f2877ec2a1a29468aa465c07623347c3ef6" [[package]] name = "authutils" -version = "6.2.2" +version = "6.2.5" description = "Gen3 auth utility functions" optional = false -python-versions = ">=3.9,<4.0" +python-versions = "<4.0,>=3.9" files = [ - {file = "authutils-6.2.2-py3-none-any.whl", hash = "sha256:df9b551b4ab561452f0f4b50edaddccc443905b4d77ee69ea7eea78938e7caed"}, - {file = "authutils-6.2.2.tar.gz", hash = "sha256:ded3e5c0e35160eab83bfb217976920396441e19ed977acacbb769e988323850"}, + {file = "authutils-6.2.5-py3-none-any.whl", hash = "sha256:ef91c9c7c750123c28b7376be9ca00b4e89b2d52fa183dec9bfe681d8eac6227"}, + {file = "authutils-6.2.5.tar.gz", hash = "sha256:0d496721e9f0d8c69b34aff8f6fccdc7768ca4f104504d68e70fd647d4c23b19"}, ] [package.dependencies] -authlib = "0.11.0" +authlib = ">=1.1.0" cached-property = ">=1.4,<2.0" cdiserrors = "<2.0.0" +cryptography = ">=41.0.6" httpx = ">=0.23.0,<1.0.0" pyjwt = {version = ">=2.4.0,<3.0", extras = ["crypto"]} xmltodict = ">=0.9,<1.0" [package.extras] fastapi = ["fastapi (>=0.65.2,<0.66.0)"] -flask = ["Flask (>=0.10.1)"] +flask = ["Flask (<=2.3.3)"] [[package]] name = "aws-xray-sdk" @@ -155,13 +151,13 @@ wrapt = "*" [[package]] name = "azure-core" -version = "1.30.1" +version = "1.30.2" description = "Microsoft Azure Core Library for Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "azure-core-1.30.1.tar.gz", hash = "sha256:26273a254131f84269e8ea4464f3560c731f29c0c1f69ac99010845f239c1a8f"}, - {file = "azure_core-1.30.1-py3-none-any.whl", hash = "sha256:7c5ee397e48f281ec4dd773d67a0a47a0962ed6fa833036057f9ea067f688e74"}, + {file = "azure-core-1.30.2.tar.gz", hash = "sha256:a14dc210efcd608821aa472d9fb8e8d035d29b68993819147bc290a8ac224472"}, + {file = "azure_core-1.30.2-py3-none-any.whl", hash = "sha256:cf019c1ca832e96274ae85abd3d9f752397194d9fea3b41487290562ac8abe4a"}, ] [package.dependencies] @@ -254,17 +250,17 @@ files = [ [[package]] name = "boto3" -version = "1.34.108" +version = "1.34.128" description = "The AWS SDK for Python" optional = false python-versions = ">=3.8" files = [ - {file = "boto3-1.34.108-py3-none-any.whl", hash = "sha256:3601267d76cac17f1d4595c3d8d968dc15be074b79bfa3985187a02b328a0a5f"}, - {file = "boto3-1.34.108.tar.gz", hash = "sha256:677723295151d29ff9b363598a20c1997c4e2af7e50669d9e428b757fe586a10"}, + {file = "boto3-1.34.128-py3-none-any.whl", hash = "sha256:a048ff980a81cd652724a73bc496c519b336fabe19cc8bfc6c53b2ff6eb22c7b"}, + {file = "boto3-1.34.128.tar.gz", hash = "sha256:43a6e99f53a8d34b3b4dbe424dbcc6b894350dc41a85b0af7c7bc24a7ec2cead"}, ] [package.dependencies] -botocore = ">=1.34.108,<1.35.0" +botocore = ">=1.34.128,<1.35.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.10.0,<0.11.0" @@ -273,13 +269,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.34.108" +version = "1.34.128" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.8" files = [ - {file = "botocore-1.34.108-py3-none-any.whl", hash = "sha256:b1b9d00804267669c5fcc36489269f7e9c43580c30f0885fbf669cf73cec720b"}, - {file = "botocore-1.34.108.tar.gz", hash = "sha256:384c9408c447631475dc41fdc9bf2e0f30c29c420d96bfe8b468bdc2bace3e13"}, + {file = "botocore-1.34.128-py3-none-any.whl", hash = "sha256:db67fda136c372ab3fa432580c819c89ba18d28a6152a4d2a7ea40d44082892e"}, + {file = "botocore-1.34.128.tar.gz", hash = "sha256:8d8e03f7c8c080ecafda72036eb3b482d649f8417c90b5dca33b7c2c47adb0c9"}, ] [package.dependencies] @@ -291,7 +287,7 @@ urllib3 = [ ] [package.extras] -crt = ["awscrt (==0.20.9)"] +crt = ["awscrt (==0.20.11)"] [[package]] name = "cached-property" @@ -388,13 +384,13 @@ resolved_reference = "74a607736ca4af5ec35f17830ab9b78b5db15837" [[package]] name = "certifi" -version = "2024.2.2" +version = "2024.6.2" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, - {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, + {file = "certifi-2024.6.2-py3-none-any.whl", hash = "sha256:ddc6c8ce995e6987e7faf5e3f1b02b302836a0e5d98ece18392cb1a36c72ad56"}, + {file = "certifi-2024.6.2.tar.gz", hash = "sha256:3cd43f1c6fa7dedc5899d69d3ad0398fd018ad1a17fba83ddaf78aa46c747516"}, ] [[package]] @@ -688,43 +684,43 @@ yaml = ["PyYAML (>=3.10)"] [[package]] name = "cryptography" -version = "42.0.7" +version = "42.0.8" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" files = [ - {file = "cryptography-42.0.7-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:a987f840718078212fdf4504d0fd4c6effe34a7e4740378e59d47696e8dfb477"}, - {file = "cryptography-42.0.7-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:bd13b5e9b543532453de08bcdc3cc7cebec6f9883e886fd20a92f26940fd3e7a"}, - {file = "cryptography-42.0.7-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a79165431551042cc9d1d90e6145d5d0d3ab0f2d66326c201d9b0e7f5bf43604"}, - {file = "cryptography-42.0.7-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a47787a5e3649008a1102d3df55424e86606c9bae6fb77ac59afe06d234605f8"}, - {file = "cryptography-42.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:02c0eee2d7133bdbbc5e24441258d5d2244beb31da5ed19fbb80315f4bbbff55"}, - {file = "cryptography-42.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5e44507bf8d14b36b8389b226665d597bc0f18ea035d75b4e53c7b1ea84583cc"}, - {file = "cryptography-42.0.7-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7f8b25fa616d8b846aef64b15c606bb0828dbc35faf90566eb139aa9cff67af2"}, - {file = "cryptography-42.0.7-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:93a3209f6bb2b33e725ed08ee0991b92976dfdcf4e8b38646540674fc7508e13"}, - {file = "cryptography-42.0.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e6b8f1881dac458c34778d0a424ae5769de30544fc678eac51c1c8bb2183e9da"}, - {file = "cryptography-42.0.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3de9a45d3b2b7d8088c3fbf1ed4395dfeff79d07842217b38df14ef09ce1d8d7"}, - {file = "cryptography-42.0.7-cp37-abi3-win32.whl", hash = "sha256:789caea816c6704f63f6241a519bfa347f72fbd67ba28d04636b7c6b7da94b0b"}, - {file = "cryptography-42.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:8cb8ce7c3347fcf9446f201dc30e2d5a3c898d009126010cbd1f443f28b52678"}, - {file = "cryptography-42.0.7-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:a3a5ac8b56fe37f3125e5b72b61dcde43283e5370827f5233893d461b7360cd4"}, - {file = "cryptography-42.0.7-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:779245e13b9a6638df14641d029add5dc17edbef6ec915688f3acb9e720a5858"}, - {file = "cryptography-42.0.7-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d563795db98b4cd57742a78a288cdbdc9daedac29f2239793071fe114f13785"}, - {file = "cryptography-42.0.7-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:31adb7d06fe4383226c3e963471f6837742889b3c4caa55aac20ad951bc8ffda"}, - {file = "cryptography-42.0.7-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:efd0bf5205240182e0f13bcaea41be4fdf5c22c5129fc7ced4a0282ac86998c9"}, - {file = "cryptography-42.0.7-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a9bc127cdc4ecf87a5ea22a2556cab6c7eda2923f84e4f3cc588e8470ce4e42e"}, - {file = "cryptography-42.0.7-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:3577d029bc3f4827dd5bf8bf7710cac13527b470bbf1820a3f394adb38ed7d5f"}, - {file = "cryptography-42.0.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2e47577f9b18723fa294b0ea9a17d5e53a227867a0a4904a1a076d1646d45ca1"}, - {file = "cryptography-42.0.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1a58839984d9cb34c855197043eaae2c187d930ca6d644612843b4fe8513c886"}, - {file = "cryptography-42.0.7-cp39-abi3-win32.whl", hash = "sha256:e6b79d0adb01aae87e8a44c2b64bc3f3fe59515280e00fb6d57a7267a2583cda"}, - {file = "cryptography-42.0.7-cp39-abi3-win_amd64.whl", hash = "sha256:16268d46086bb8ad5bf0a2b5544d8a9ed87a0e33f5e77dd3c3301e63d941a83b"}, - {file = "cryptography-42.0.7-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2954fccea107026512b15afb4aa664a5640cd0af630e2ee3962f2602693f0c82"}, - {file = "cryptography-42.0.7-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:362e7197754c231797ec45ee081f3088a27a47c6c01eff2ac83f60f85a50fe60"}, - {file = "cryptography-42.0.7-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4f698edacf9c9e0371112792558d2f705b5645076cc0aaae02f816a0171770fd"}, - {file = "cryptography-42.0.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5482e789294854c28237bba77c4c83be698be740e31a3ae5e879ee5444166582"}, - {file = "cryptography-42.0.7-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e9b2a6309f14c0497f348d08a065d52f3020656f675819fc405fb63bbcd26562"}, - {file = "cryptography-42.0.7-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d8e3098721b84392ee45af2dd554c947c32cc52f862b6a3ae982dbb90f577f14"}, - {file = "cryptography-42.0.7-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c65f96dad14f8528a447414125e1fc8feb2ad5a272b8f68477abbcc1ea7d94b9"}, - {file = "cryptography-42.0.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:36017400817987670037fbb0324d71489b6ead6231c9604f8fc1f7d008087c68"}, - {file = "cryptography-42.0.7.tar.gz", hash = "sha256:ecbfbc00bf55888edda9868a4cf927205de8499e7fabe6c050322298382953f2"}, + {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:81d8a521705787afe7a18d5bfb47ea9d9cc068206270aad0b96a725022e18d2e"}, + {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:961e61cefdcb06e0c6d7e3a1b22ebe8b996eb2bf50614e89384be54c48c6b63d"}, + {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3ec3672626e1b9e55afd0df6d774ff0e953452886e06e0f1eb7eb0c832e8902"}, + {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e599b53fd95357d92304510fb7bda8523ed1f79ca98dce2f43c115950aa78801"}, + {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5226d5d21ab681f432a9c1cf8b658c0cb02533eece706b155e5fbd8a0cdd3949"}, + {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6b7c4f03ce01afd3b76cf69a5455caa9cfa3de8c8f493e0d3ab7d20611c8dae9"}, + {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:2346b911eb349ab547076f47f2e035fc8ff2c02380a7cbbf8d87114fa0f1c583"}, + {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ad803773e9df0b92e0a817d22fd8a3675493f690b96130a5e24f1b8fabbea9c7"}, + {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2f66d9cd9147ee495a8374a45ca445819f8929a3efcd2e3df6428e46c3cbb10b"}, + {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d45b940883a03e19e944456a558b67a41160e367a719833c53de6911cabba2b7"}, + {file = "cryptography-42.0.8-cp37-abi3-win32.whl", hash = "sha256:a0c5b2b0585b6af82d7e385f55a8bc568abff8923af147ee3c07bd8b42cda8b2"}, + {file = "cryptography-42.0.8-cp37-abi3-win_amd64.whl", hash = "sha256:57080dee41209e556a9a4ce60d229244f7a66ef52750f813bfbe18959770cfba"}, + {file = "cryptography-42.0.8-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:dea567d1b0e8bc5764b9443858b673b734100c2871dc93163f58c46a97a83d28"}, + {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4783183f7cb757b73b2ae9aed6599b96338eb957233c58ca8f49a49cc32fd5e"}, + {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0608251135d0e03111152e41f0cc2392d1e74e35703960d4190b2e0f4ca9c70"}, + {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dc0fdf6787f37b1c6b08e6dfc892d9d068b5bdb671198c72072828b80bd5fe4c"}, + {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9c0c1716c8447ee7dbf08d6db2e5c41c688544c61074b54fc4564196f55c25a7"}, + {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fff12c88a672ab9c9c1cf7b0c80e3ad9e2ebd9d828d955c126be4fd3e5578c9e"}, + {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cafb92b2bc622cd1aa6a1dce4b93307792633f4c5fe1f46c6b97cf67073ec961"}, + {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:31f721658a29331f895a5a54e7e82075554ccfb8b163a18719d342f5ffe5ecb1"}, + {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b297f90c5723d04bcc8265fc2a0f86d4ea2e0f7ab4b6994459548d3a6b992a14"}, + {file = "cryptography-42.0.8-cp39-abi3-win32.whl", hash = "sha256:2f88d197e66c65be5e42cd72e5c18afbfae3f741742070e3019ac8f4ac57262c"}, + {file = "cryptography-42.0.8-cp39-abi3-win_amd64.whl", hash = "sha256:fa76fbb7596cc5839320000cdd5d0955313696d9511debab7ee7278fc8b5c84a"}, + {file = "cryptography-42.0.8-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ba4f0a211697362e89ad822e667d8d340b4d8d55fae72cdd619389fb5912eefe"}, + {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:81884c4d096c272f00aeb1f11cf62ccd39763581645b0812e99a91505fa48e0c"}, + {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c9bb2ae11bfbab395bdd072985abde58ea9860ed84e59dbc0463a5d0159f5b71"}, + {file = "cryptography-42.0.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7016f837e15b0a1c119d27ecd89b3515f01f90a8615ed5e9427e30d9cdbfed3d"}, + {file = "cryptography-42.0.8-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5a94eccb2a81a309806027e1670a358b99b8fe8bfe9f8d329f27d72c094dde8c"}, + {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dec9b018df185f08483f294cae6ccac29e7a6e0678996587363dc352dc65c842"}, + {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:343728aac38decfdeecf55ecab3264b015be68fc2816ca800db649607aeee648"}, + {file = "cryptography-42.0.8-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:013629ae70b40af70c9a7a5db40abe5d9054e6f4380e50ce769947b73bf3caad"}, + {file = "cryptography-42.0.8.tar.gz", hash = "sha256:8d09d05439ce7baa8e9e95b07ec5b6c886f548deb7e0f69ef25f64b3bce842f2"}, ] [package.dependencies] @@ -773,22 +769,23 @@ wmi = ["wmi (>=1.5.1)"] [[package]] name = "docker" -version = "7.0.0" +version = "7.1.0" description = "A Python library for the Docker Engine API." optional = false python-versions = ">=3.8" files = [ - {file = "docker-7.0.0-py3-none-any.whl", hash = "sha256:12ba681f2777a0ad28ffbcc846a69c31b4dfd9752b47eb425a274ee269c5e14b"}, - {file = "docker-7.0.0.tar.gz", hash = "sha256:323736fb92cd9418fc5e7133bc953e11a9da04f4483f828b527db553f1e7e5a3"}, + {file = "docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0"}, + {file = "docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c"}, ] [package.dependencies] -packaging = ">=14.0" pywin32 = {version = ">=304", markers = "sys_platform == \"win32\""} requests = ">=2.26.0" urllib3 = ">=1.26.0" [package.extras] +dev = ["coverage (==7.2.7)", "pytest (==7.4.2)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.1.0)", "ruff (==0.1.8)"] +docs = ["myst-parser (==0.18.0)", "sphinx (==5.1.1)"] ssh = ["paramiko (>=2.4.3)"] websockets = ["websocket-client (>=1.3.0)"] @@ -851,13 +848,13 @@ test = ["pytest (>=6)"] [[package]] name = "flask" -version = "2.3.2" +version = "3.0.3" description = "A simple framework for building complex web applications." optional = false python-versions = ">=3.8" files = [ - {file = "Flask-2.3.2-py3-none-any.whl", hash = "sha256:77fd4e1249d8c9923de34907236b747ced06e5467ecac1a7bb7115ae0e9670b0"}, - {file = "Flask-2.3.2.tar.gz", hash = "sha256:8c2f9abd47a9e8df7f0c3f091ce9497d011dc3b31effcf4c85a6e2b50f4114ef"}, + {file = "flask-3.0.3-py3-none-any.whl", hash = "sha256:34e815dfaa43340d1d15a5c3a02b8476004037eb4840b34910c6e21679d288f3"}, + {file = "flask-3.0.3.tar.gz", hash = "sha256:ceb27b0af3823ea2737928a4d99d125a06175b8512c445cbd9a9ce200ef76842"}, ] [package.dependencies] @@ -866,7 +863,7 @@ click = ">=8.1.3" importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""} itsdangerous = ">=2.1.2" Jinja2 = ">=3.1.2" -Werkzeug = ">=2.3.3" +Werkzeug = ">=3.0.0" [package.extras] async = ["asgiref (>=3.2)"] @@ -1030,13 +1027,13 @@ grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] [[package]] name = "google-api-python-client" -version = "2.129.0" +version = "2.133.0" description = "Google API Client Library for Python" optional = false python-versions = ">=3.7" files = [ - {file = "google-api-python-client-2.129.0.tar.gz", hash = "sha256:984cc8cc8eb4923468b1926d2b8effc5b459a4dda3c845896eb87c153b28ef84"}, - {file = "google_api_python_client-2.129.0-py2.py3-none-any.whl", hash = "sha256:d50f7e2dfdbb7fc2732f6a0cba1c54d7bb676390679526c6bb628c901e43ec86"}, + {file = "google-api-python-client-2.133.0.tar.gz", hash = "sha256:293092905b66a046d3187a99ac454e12b00cc2c70444f26eb2f1f9c1a82720b4"}, + {file = "google_api_python_client-2.133.0-py2.py3-none-any.whl", hash = "sha256:396fe676ea0dfed066654dcf9f8dea77a1342f9d9bb23bb88e45b7b81e773926"}, ] [package.dependencies] @@ -1048,13 +1045,13 @@ uritemplate = ">=3.0.1,<5" [[package]] name = "google-auth" -version = "2.29.0" +version = "2.30.0" description = "Google Authentication Library" optional = false python-versions = ">=3.7" files = [ - {file = "google-auth-2.29.0.tar.gz", hash = "sha256:672dff332d073227550ffc7457868ac4218d6c500b155fe6cc17d2b13602c360"}, - {file = "google_auth-2.29.0-py2.py3-none-any.whl", hash = "sha256:d452ad095688cd52bae0ad6fafe027f6a6d6f560e810fec20914e17a09526415"}, + {file = "google-auth-2.30.0.tar.gz", hash = "sha256:ab630a1320f6720909ad76a7dbdb6841cdf5c66b328d690027e4867bdfb16688"}, + {file = "google_auth-2.30.0-py2.py3-none-any.whl", hash = "sha256:8df7da660f62757388b8a7f249df13549b3373f24388cb5d2f1dd91cc18180b5"}, ] [package.dependencies] @@ -1104,13 +1101,13 @@ grpc = ["grpcio (>=1.38.0,<2.0dev)", "grpcio-status (>=1.38.0,<2.0.dev0)"] [[package]] name = "google-cloud-storage" -version = "2.16.0" +version = "2.17.0" description = "Google Cloud Storage API client library" optional = false python-versions = ">=3.7" files = [ - {file = "google-cloud-storage-2.16.0.tar.gz", hash = "sha256:dda485fa503710a828d01246bd16ce9db0823dc51bbca742ce96a6817d58669f"}, - {file = "google_cloud_storage-2.16.0-py2.py3-none-any.whl", hash = "sha256:91a06b96fb79cf9cdfb4e759f178ce11ea885c79938f89590344d079305f5852"}, + {file = "google-cloud-storage-2.17.0.tar.gz", hash = "sha256:49378abff54ef656b52dca5ef0f2eba9aa83dc2b2c72c78714b03a1a95fe9388"}, + {file = "google_cloud_storage-2.17.0-py2.py3-none-any.whl", hash = "sha256:5b393bc766b7a3bc6f5407b9e665b2450d36282614b7945e570b3480a456d1e1"}, ] [package.dependencies] @@ -1206,13 +1203,13 @@ testing = ["pytest"] [[package]] name = "google-resumable-media" -version = "2.7.0" +version = "2.7.1" description = "Utilities for Google Media Downloads and Resumable Uploads" optional = false -python-versions = ">= 3.7" +python-versions = ">=3.7" files = [ - {file = "google-resumable-media-2.7.0.tar.gz", hash = "sha256:5f18f5fa9836f4b083162064a1c2c98c17239bfda9ca50ad970ccf905f3e625b"}, - {file = "google_resumable_media-2.7.0-py2.py3-none-any.whl", hash = "sha256:79543cfe433b63fd81c0844b7803aba1bb8950b47bedf7d980c38fa123937e08"}, + {file = "google-resumable-media-2.7.1.tar.gz", hash = "sha256:eae451a7b2e2cdbaaa0fd2eb00cc8a1ee5e95e16b55597359cbc3d27d7d90e33"}, + {file = "google_resumable_media-2.7.1-py2.py3-none-any.whl", hash = "sha256:103ebc4ba331ab1bfdac0250f8033627a2cd7cde09e7ccff9181e31ba4315b2c"}, ] [package.dependencies] @@ -1224,17 +1221,17 @@ requests = ["requests (>=2.18.0,<3.0.0dev)"] [[package]] name = "googleapis-common-protos" -version = "1.63.0" +version = "1.63.1" description = "Common protobufs used in Google APIs" optional = false python-versions = ">=3.7" files = [ - {file = "googleapis-common-protos-1.63.0.tar.gz", hash = "sha256:17ad01b11d5f1d0171c06d3ba5c04c54474e883b66b949722b4938ee2694ef4e"}, - {file = "googleapis_common_protos-1.63.0-py2.py3-none-any.whl", hash = "sha256:ae45f75702f7c08b541f750854a678bd8f534a1a6bace6afe975f1d0a82d6632"}, + {file = "googleapis-common-protos-1.63.1.tar.gz", hash = "sha256:c6442f7a0a6b2a80369457d79e6672bb7dcbaab88e0848302497e3ec80780a6a"}, + {file = "googleapis_common_protos-1.63.1-py2.py3-none-any.whl", hash = "sha256:0e1c2cdfcbc354b76e4a211a35ea35d6926a835cba1377073c4861db904a1877"}, ] [package.dependencies] -protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0.dev0" +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" [package.extras] grpc = ["grpcio (>=1.44.0,<2.0.0.dev0)"] @@ -1475,13 +1472,13 @@ files = [ [[package]] name = "jsonpickle" -version = "3.0.4" -description = "Serialize any Python object to JSON" +version = "3.2.1" +description = "Python library for serializing arbitrary object graphs into JSON" optional = false python-versions = ">=3.7" files = [ - {file = "jsonpickle-3.0.4-py3-none-any.whl", hash = "sha256:04ae7567a14269579e3af66b76bda284587458d7e8a204951ca8f71a3309952e"}, - {file = "jsonpickle-3.0.4.tar.gz", hash = "sha256:a1b14c8d6221cd8f394f2a97e735ea1d7edc927fbd135b26f2f8700657c8c62b"}, + {file = "jsonpickle-3.2.1-py3-none-any.whl", hash = "sha256:ec291e4719674dd35d390fbdb521ac6517fbe9f541d361c8bffc8131133b1661"}, + {file = "jsonpickle-3.2.1.tar.gz", hash = "sha256:4b6d7640974199f7acf9035295365b5a1a71a91109effa15ba170fbb48cf871c"}, ] [package.extras] @@ -1616,13 +1613,13 @@ test = ["unittest2 (>=1.1.0)"] [[package]] name = "more-itertools" -version = "10.2.0" +version = "10.3.0" description = "More routines for operating on iterables, beyond itertools" optional = false python-versions = ">=3.8" files = [ - {file = "more-itertools-10.2.0.tar.gz", hash = "sha256:8fccb480c43d3e99a00087634c06dd02b0d50fbf088b380de5a41a015ec239e1"}, - {file = "more_itertools-10.2.0-py3-none-any.whl", hash = "sha256:686b06abe565edfab151cb8fd385a05651e1fdf8f0a14191e4439283421f8684"}, + {file = "more-itertools-10.3.0.tar.gz", hash = "sha256:e5d93ef411224fbcef366a6e8ddc4c5781bc6359d43412a65dd5964e46111463"}, + {file = "more_itertools-10.3.0-py3-none-any.whl", hash = "sha256:ea6a02e24a9161e51faad17a8782b92a0df82c12c1c8886fec7f0c3fa1a1b320"}, ] [[package]] @@ -1679,13 +1676,13 @@ six = ">=1.6.1" [[package]] name = "packaging" -version = "24.0" +version = "24.1" description = "Core utilities for Python packages" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, - {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] [[package]] @@ -1722,18 +1719,17 @@ files = [ [[package]] name = "pluggy" -version = "1.5.0" +version = "0.13.1" description = "plugin and hook calling mechanisms for python" optional = false -python-versions = ">=3.8" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, + {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, + {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] [package.extras] dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] [[package]] name = "prometheus-client" @@ -1797,8 +1793,6 @@ files = [ {file = "psycopg2-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:426f9f29bde126913a20a96ff8ce7d73fd8a216cfb323b1f04da402d452853c3"}, {file = "psycopg2-2.9.9-cp311-cp311-win32.whl", hash = "sha256:ade01303ccf7ae12c356a5e10911c9e1c51136003a9a1d92f7aa9d010fb98372"}, {file = "psycopg2-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:121081ea2e76729acfb0673ff33755e8703d45e926e416cb59bae3a86c6a4981"}, - {file = "psycopg2-2.9.9-cp312-cp312-win32.whl", hash = "sha256:d735786acc7dd25815e89cc4ad529a43af779db2e25aa7c626de864127e5a024"}, - {file = "psycopg2-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:a7653d00b732afb6fc597e29c50ad28087dcb4fbfb28e86092277a559ae4e693"}, {file = "psycopg2-2.9.9-cp37-cp37m-win32.whl", hash = "sha256:5e0d98cade4f0e0304d7d6f25bbfbc5bd186e07b38eac65379309c4ca3193efa"}, {file = "psycopg2-2.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:7e2dacf8b009a1c1e843b5213a87f7c544b2b042476ed7755be813eaf4e8347a"}, {file = "psycopg2-2.9.9-cp38-cp38-win32.whl", hash = "sha256:ff432630e510709564c01dafdbe996cb552e0b9f3f065eb89bdce5bd31fabf4c"}, @@ -1975,58 +1969,63 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" -version = "3.10.1" +version = "5.4.3" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.5" files = [ - {file = "pytest-3.10.1-py2.py3-none-any.whl", hash = "sha256:3f193df1cfe1d1609d4c583838bea3d532b18d6160fd3f55c9447fdca30848ec"}, - {file = "pytest-3.10.1.tar.gz", hash = "sha256:e246cf173c01169b9617fc07264b7b1316e78d7a650055235d6d897bc80d9660"}, + {file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, + {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, ] [package.dependencies] -atomicwrites = ">=1.0" +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} attrs = ">=17.4.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} more-itertools = ">=4.0.0" -pluggy = ">=0.7" +packaging = "*" +pluggy = ">=0.12,<1.0" py = ">=1.5.0" -setuptools = "*" -six = ">=1.10.0" +wcwidth = "*" + +[package.extras] +checkqa-mypy = ["mypy (==v0.761)"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] [[package]] name = "pytest-cov" -version = "2.9.0" +version = "2.12.1" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ - {file = "pytest-cov-2.9.0.tar.gz", hash = "sha256:b6a814b8ed6247bd81ff47f038511b57fe1ce7f4cc25b9106f1a4b106f1d9322"}, - {file = "pytest_cov-2.9.0-py2.py3-none-any.whl", hash = "sha256:c87dfd8465d865655a8213859f1b4749b43448b5fae465cb981e16d52a811424"}, + {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, + {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, ] [package.dependencies] -coverage = ">=4.4" -pytest = ">=3.6" +coverage = ">=5.2.1" +pytest = ">=4.6" +toml = "*" [package.extras] -testing = ["fields", "hunter", "process-tests (==2.0.2)", "pytest-xdist", "six", "virtualenv"] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] [[package]] name = "pytest-flask" -version = "0.15.1" +version = "1.3.0" description = "A set of py.test fixtures to test Flask applications." optional = false -python-versions = "*" +python-versions = ">=3.7" files = [ - {file = "pytest-flask-0.15.1.tar.gz", hash = "sha256:cbd8c5b9f8f1b83e9c159ac4294964807c4934317a5fba181739ac15e1b823e6"}, - {file = "pytest_flask-0.15.1-py2.py3-none-any.whl", hash = "sha256:9001f6128c5c4a0d243ce46c117f3691052828d2faf39ac151b8388657dce447"}, + {file = "pytest-flask-1.3.0.tar.gz", hash = "sha256:58be1c97b21ba3c4d47e0a7691eb41007748506c36bf51004f78df10691fa95e"}, + {file = "pytest_flask-1.3.0-py3-none-any.whl", hash = "sha256:c0e36e6b0fddc3b91c4362661db83fa694d1feb91fa505475be6732b5bc8c253"}, ] [package.dependencies] Flask = "*" -pytest = ">=3.6" -Werkzeug = ">=0.7" +pytest = ">=5.2" +Werkzeug = "*" [package.extras] docs = ["Sphinx", "sphinx-rtd-theme"] @@ -2125,7 +2124,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -2162,13 +2160,13 @@ files = [ [[package]] name = "requests" -version = "2.32.0" +version = "2.32.3" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" files = [ - {file = "requests-2.32.0-py3-none-any.whl", hash = "sha256:f2c3881dddb70d056c5bd7600a4fae312b2a300e39be6a118d30b90bd27262b5"}, - {file = "requests-2.32.0.tar.gz", hash = "sha256:fa5490319474c82ef1d2c9bc459d3652e3ae4ef4c4ebdd18a21145a47ca4b6b8"}, + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, ] [package.dependencies] @@ -2183,13 +2181,13 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "responses" -version = "0.25.0" +version = "0.25.3" description = "A utility library for mocking out the `requests` Python library." optional = false python-versions = ">=3.8" files = [ - {file = "responses-0.25.0-py3-none-any.whl", hash = "sha256:2f0b9c2b6437db4b528619a77e5d565e4ec2a9532162ac1a131a83529db7be1a"}, - {file = "responses-0.25.0.tar.gz", hash = "sha256:01ae6a02b4f34e39bffceb0fc6786b67a25eae919c6368d05eabc8d9576c2a66"}, + {file = "responses-0.25.3-py3-none-any.whl", hash = "sha256:521efcbc82081ab8daa588e08f7e8a64ce79b91c39f6e62199b19159bea7dbcb"}, + {file = "responses-0.25.3.tar.gz", hash = "sha256:617b9247abd9ae28313d57a75880422d55ec63c29d33d629697590a034358dba"}, ] [package.dependencies] @@ -2246,22 +2244,6 @@ botocore = ">=1.33.2,<2.0a.0" [package.extras] crt = ["botocore[crt] (>=1.33.2,<2.0a.0)"] -[[package]] -name = "setuptools" -version = "69.5.1" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "setuptools-69.5.1-py3-none-any.whl", hash = "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32"}, - {file = "setuptools-69.5.1.tar.gz", hash = "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] - [[package]] name = "six" version = "1.16.0" @@ -2363,15 +2345,26 @@ postgresql-psycopg2cffi = ["psycopg2cffi"] pymysql = ["pymysql", "pymysql (<1)"] sqlcipher = ["sqlcipher3_binary"] +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] + [[package]] name = "typing-extensions" -version = "4.11.0" +version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, - {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] [[package]] @@ -2387,13 +2380,13 @@ files = [ [[package]] name = "urllib3" -version = "1.26.18" +version = "1.26.19" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" files = [ - {file = "urllib3-1.26.18-py2.py3-none-any.whl", hash = "sha256:34b97092d7e0a3a8cf7cd10e386f401b3737364026c45e622aa02903dffe0f07"}, - {file = "urllib3-1.26.18.tar.gz", hash = "sha256:f8ecc1bba5667413457c529ab955bf8c67b45db799d159066261719e328580a0"}, + {file = "urllib3-1.26.19-py2.py3-none-any.whl", hash = "sha256:37a0344459b199fce0e80b0d3569837ec6b6937435c5244e7fd73fa6006830f3"}, + {file = "urllib3-1.26.19.tar.gz", hash = "sha256:3e3d753a8618b86d7de333b4223005f68720bcd6a7d2bcb9fbd2229ec7c1e429"}, ] [package.extras] @@ -2403,13 +2396,13 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "urllib3" -version = "2.2.1" +version = "2.2.2" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" files = [ - {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"}, - {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"}, + {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, + {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, ] [package.extras] @@ -2432,6 +2425,17 @@ files = [ cdislogging = "*" sqlalchemy = ">=1.3.3" +[[package]] +name = "wcwidth" +version = "0.2.13" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = "*" +files = [ + {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, + {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, +] + [[package]] name = "werkzeug" version = "3.0.3" @@ -2558,20 +2562,20 @@ files = [ [[package]] name = "zipp" -version = "3.18.2" +version = "3.19.2" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.8" files = [ - {file = "zipp-3.18.2-py3-none-any.whl", hash = "sha256:dce197b859eb796242b0622af1b8beb0a722d52aa2f57133ead08edd5bf5374e"}, - {file = "zipp-3.18.2.tar.gz", hash = "sha256:6278d9ddbcfb1f1089a88fde84481528b07b0e10474e09dcfe53dad4069fa059"}, + {file = "zipp-3.19.2-py3-none-any.whl", hash = "sha256:f091755f667055f2d02b32c53771a7a6c8b47e1fdbc4b72a8b9072b3eef8015c"}, + {file = "zipp-3.19.2.tar.gz", hash = "sha256:bf1dcf6450f873a13e952a29504887c89e6de7506209e5b1bcc3460135d4de19"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0.0" -content-hash = "9e2f3df721c8367c7d0054a034d9e33d84e3ec981c1304ce8a0199d413437b6f" +content-hash = "3d8d2736573ee2b07bc32fb6a4ad3d84366be2e4d5f9b4fe0dfd18286bc58c57" diff --git a/pyproject.toml b/pyproject.toml index a26dc447e..742a05c43 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "fence" -version = "9.3.4" +version = "10.0.0" description = "Gen3 AuthN/AuthZ OIDC Service" authors = ["CTDS UChicago "] license = "Apache-2.0" @@ -13,16 +13,8 @@ include = [ [tool.poetry.dependencies] python = ">=3.9,<4.0.0" alembic = "^1.7.7" - -# Temporarily override authlib with a modified, forked version -# where we've made a security patch that got applied in a much later -# version. -# -# This is temporary while we work on the upgrade to the latest version -authlib = {git = "https://github.com/uc-cdis/authlib", rev = "v0.11_CVE_patch_v1"} -# authlib = "*" # let authutils decide which version we're using - -authutils = "<7" +authlib = "*" #let authutils decide which version to use +authutils = "^6.2.3" bcrypt = "^3.1.4" boto3 = "*" botocore = "*" @@ -30,10 +22,8 @@ cached_property = "^1.5.1" cdiserrors = "<2.0.0" cdislogging = "^1.0.0" cdispyutils = "^2.0.1" +flask = ">=3.0.0" cryptography = ">=42.0.5" - -# this will be updated when authlib is updated -flask = "==2.3.2" flask-cors = ">=3.0.3" flask-restful = ">=0.3.8" email_validator = "^1.1.1" @@ -58,10 +48,14 @@ requests = ">=2.18.0" retry = "^0.9.2" sqlalchemy = "^1.3.3" userdatamodel = ">=2.4.3" +werkzeug = ">=3.0.0" cachelib = "^0.2.0" azure-storage-blob = "^12.6.0" Flask-WTF = "^1.0.0" boto = "*" +# NOTE: +# for testing with updated libaries as git repos: +# foobar = {git = "https://github.com/uc-cdis/some-repo", rev = "feat/test"} [tool.poetry.dev-dependencies] addict = "^2.2.1" @@ -70,9 +64,9 @@ codacy-coverage = "^1.3.11" coveralls = "^2.1.1" mock = "^2.0.0" moto = "^1.1.24" -pytest = "^3.2.3" +pytest = "^5.2.0" pytest-cov = "^2.5.1" -pytest-flask = ">=0.15.0" +pytest-flask = ">=1.3.0" [tool.poetry.scripts] fence-create = 'bin.fence_create:main' diff --git a/tests/admin/test_admin_users_endpoints.py b/tests/admin/test_admin_users_endpoints.py index 370942b14..5a6d3a746 100644 --- a/tests/admin/test_admin_users_endpoints.py +++ b/tests/admin/test_admin_users_endpoints.py @@ -110,14 +110,12 @@ def load_non_google_user_data(db_session, test_user_d): client = Client( client_id=userd_dict["client_id"], user_id=userd_dict["user_id"], - issued_at=420, - expires_at=42020, - redirect_uri="dclient.com", - grant_type="dgrant", - response_type="dresponse", - scope="dscope", + client_id_issued_at=420, + client_secret_expires_at=42020, + redirect_uris="dclient.com", + response_types="dresponse", name="dclientname", - _allowed_scopes="dallscopes", + allowed_scopes="dallscopes", ) grp = Group(id=userd_dict["group_id"]) usr_grp = UserToGroup( @@ -676,6 +674,7 @@ def assert_google_proxy_group_data_deleted(db_session): def test_delete_user_username( + app, client, admin_user, encoded_admin_jwt, diff --git a/tests/conftest.py b/tests/conftest.py index b6d07ed19..f7b9d432d 100755 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -86,6 +86,7 @@ def mock_get_bucket_location(self, bucket, config): def claims_refresh(): new_claims = tests.utils.default_claims() new_claims["pur"] = "refresh" + new_claims["azp"] = "test-client" return new_claims @@ -474,7 +475,6 @@ def app(kid, rsa_private_key, rsa_public_key): yield fence.app - alembic_main(["--raiseerr", "downgrade", "base"]) mocker.unmock_functions() @@ -542,7 +542,9 @@ def drop_all(): connection = app.db.engine.connect() connection.begin() for table in reversed(models.Base.metadata.sorted_tables): - connection.execute(table.delete()) + # Delete table only if it exists + if app.db.engine.dialect.has_table(connection, table): + connection.execute(table.delete()) connection.close() request.addfinalizer(drop_all) @@ -1358,7 +1360,7 @@ def oauth_client_B(app, request, db_session): @pytest.fixture(scope="function") -def oauth_client_public(app, db_session, oauth_user): +def oauth_client_public(app, db_session, oauth_user, get_all_shib_idps_patcher): """ Create a public OAuth2 client. """ @@ -1428,7 +1430,7 @@ def oauth_test_client_B(client, oauth_client_B): @pytest.fixture(scope="function") -def oauth_test_client_public(client, oauth_client_public): +def oauth_test_client_public(client, oauth_client_public, get_all_shib_idps_patcher): return OAuth2TestClient(client, oauth_client_public, confidential=False) diff --git a/tests/link/test_link.py b/tests/link/test_link.py index 36e53b9ea..ae0e61fae 100644 --- a/tests/link/test_link.py +++ b/tests/link/test_link.py @@ -177,9 +177,8 @@ def test_google_link_auth_return( # manually set cookie for initial session client.set_cookie( - domain="localhost", - key=config["SESSION_COOKIE_NAME"], - value=test_session_jwt, + config["SESSION_COOKIE_NAME"], + test_session_jwt, httponly=True, samesite="Lax", ) @@ -258,9 +257,8 @@ def test_patch_google_link( # manually set cookie for initial session client.set_cookie( - domain="localhost", - key=config["SESSION_COOKIE_NAME"], - value=test_session_jwt, + config["SESSION_COOKIE_NAME"], + test_session_jwt, httponly=True, samesite="Lax", ) @@ -362,9 +360,8 @@ def test_patch_google_link_account_not_in_token( # manually set cookie for initial session client.set_cookie( - domain="localhost", - key=config["SESSION_COOKIE_NAME"], - value=test_session_jwt, + config["SESSION_COOKIE_NAME"], + test_session_jwt, httponly=True, samesite="Lax", ) @@ -418,9 +415,8 @@ def test_patch_google_link_account_doesnt_exist( # manually set cookie for initial session client.set_cookie( - domain="localhost", - key=config["SESSION_COOKIE_NAME"], - value=test_session_jwt, + config["SESSION_COOKIE_NAME"], + test_session_jwt, httponly=True, samesite="Lax", ) @@ -487,9 +483,9 @@ def test_google_link_g_account_exists( # manually set cookie for initial session client.set_cookie( - domain="localhost", key=config["SESSION_COOKIE_NAME"], value=test_session_jwt, + domain="localhost", httponly=True, samesite="Lax", ) @@ -566,9 +562,8 @@ def test_google_link_g_account_access_extension( # manually set cookie for initial session client.set_cookie( - domain="localhost", - key=config["SESSION_COOKIE_NAME"], - value=test_session_jwt, + config["SESSION_COOKIE_NAME"], + test_session_jwt, httponly=True, samesite="Lax", ) @@ -653,9 +648,8 @@ def test_google_link_g_account_exists_linked_to_different_user( # manually set cookie for initial session client.set_cookie( - domain="localhost", - key=config["SESSION_COOKIE_NAME"], - value=test_session_jwt, + config["SESSION_COOKIE_NAME"], + test_session_jwt, httponly=True, samesite="Lax", ) @@ -721,9 +715,8 @@ def test_google_link_no_proxy_group( # manually set cookie for initial session client.set_cookie( - domain="localhost", - key=config["SESSION_COOKIE_NAME"], - value=test_session_jwt, + config["SESSION_COOKIE_NAME"], + test_session_jwt, httponly=True, samesite="Lax", ) @@ -807,9 +800,8 @@ def test_google_link_when_google_mocked( # manually set cookie for initial session client.set_cookie( - domain="localhost", - key=config["SESSION_COOKIE_NAME"], - value=test_session_jwt, + config["SESSION_COOKIE_NAME"], + test_session_jwt, httponly=True, samesite="Lax", ) diff --git a/tests/link/test_link_id_token.py b/tests/link/test_link_id_token.py index a1c54c96a..5f3dff90a 100644 --- a/tests/link/test_link_id_token.py +++ b/tests/link/test_link_id_token.py @@ -12,6 +12,7 @@ def test_google_id_token_not_linked(oauth_test_client): Test google email and link expiration are in id_token for a linked account """ data = {"confirm": "yes"} + oauth_test_client.authorize(data=data) tokens = oauth_test_client.token() id_token = jwt.decode( diff --git a/tests/login/test_fence_login.py b/tests/login/test_fence_login.py index 96495fe47..d3d60b314 100644 --- a/tests/login/test_fence_login.py +++ b/tests/login/test_fence_login.py @@ -44,6 +44,7 @@ def config_idp_in_client( ], "OPENID_CONNECT": { "fence": { + "name": "other_fence_client", "client_id": "other_fence_client_id", "client_secret": "other_fence_client_secret", "api_base_url": "http://other-fence", @@ -52,7 +53,10 @@ def config_idp_in_client( }, } ) - app.fence_client = OAuthClient(**config["OPENID_CONNECT"]["fence"]) + client = OAuthClient(app) + client.register(**config["OPENID_CONNECT"]["fence"]) + app.fence_client = client + app.config["OPENID_CONNECT"]["fence"] = config["OPENID_CONNECT"]["fence"] yield Dict( client_id=config["OPENID_CONNECT"]["fence"]["client_id"], diff --git a/tests/login/test_google_login.py b/tests/login/test_google_login.py index dfe6ecab5..f7550b47d 100644 --- a/tests/login/test_google_login.py +++ b/tests/login/test_google_login.py @@ -27,9 +27,8 @@ def test_google_login_http_headers_are_less_than_4k_for_user_with_many_projects( }, ) client.set_cookie( - domain="localhost", - key=config["SESSION_COOKIE_NAME"], - value=test_session_jwt, + config["SESSION_COOKIE_NAME"], + test_session_jwt, httponly=True, samesite="Lax", ) diff --git a/tests/login/test_login_redirect.py b/tests/login/test_login_redirect.py index 8e7742db5..6915fe1b2 100644 --- a/tests/login/test_login_redirect.py +++ b/tests/login/test_login_redirect.py @@ -91,10 +91,10 @@ def test_valid_redirect_base(app, client, idp, get_value_from_discovery_doc_patc """ if idp == "fence": mocked_generate_authorize_redirect = MagicMock( - return_value=("authorization_url", "state") + return_value={"url": "authorization_url", "state": "state"} ) mock = patch( - f"flask.current_app.fence_client.generate_authorize_redirect", + f"authlib.integrations.flask_client.apps.FlaskOAuth2App.create_authorization_url", mocked_generate_authorize_redirect, ).start() diff --git a/tests/migrations/README.md b/tests/migrations/README.md new file mode 100644 index 000000000..e2c3eb999 --- /dev/null +++ b/tests/migrations/README.md @@ -0,0 +1,7 @@ +## Migration Tests + +These tests are designed to test pre/post behavior of database migrations and making sure the changes are working as intended. + +Currently we only have upgrade tests because the latest version of authlib has undergone major changes and will *not* work with previous versions of database schema. + +For client class details, see fence/models.py diff --git a/tests/migrations/test_9b3a5a7145d7.py b/tests/migrations/test_9b3a5a7145d7.py new file mode 100644 index 000000000..3dca1b50d --- /dev/null +++ b/tests/migrations/test_9b3a5a7145d7.py @@ -0,0 +1,61 @@ +""" +"Non-unique client name" migration +""" + +from alembic.config import main as alembic_main +import pytest +from sqlalchemy.exc import IntegrityError + +from fence.models import Client +from fence.utils import random_str +import bcrypt + + +@pytest.fixture(scope="function", autouse=True) +def post_test_clean_up(app): + yield + + # clean up the client table + with app.db.session as db_session: + db_session.query(Client).delete() + + # go back to the latest state of the DB + alembic_main(["--raiseerr", "upgrade", "head"]) + + +def test_upgrade(app): + """Test Adding Client after performing Alembic Upgrade to this revision""" + alembic_main(["--raiseerr", "upgrade", "9b3a5a7145d7"]) # pragma: allowlist secret + + client_name = "client_name" + url = "https://oauth-client.net" + client_id = "test-client" + client_secret = random_str(50) + hashed_secret = bcrypt.hashpw( + client_secret.encode("utf-8"), bcrypt.gensalt() + ).decode("utf-8") + grant_types = ["refresh_token"] + allowed_scopes = ["openid", "user", "fence"] + with app.db.session as db_session: + db_session.add( + Client( + client_id=client_id, + client_secret=hashed_secret, + allowed_scopes=allowed_scopes, + redirect_uris=[url], + description="", + is_confidential=True, + name=client_name, + grant_types=grant_types, + ) + ) + db_session.commit() + query_result = db_session.query(Client).all() + + # make sure the client was created and the new _client_metadata field is populated and Authlib getters are working + assert len(query_result) == 1, query_result + assert query_result[0].name == client_name + assert query_result[0].client_secret == hashed_secret + assert query_result[0].scope == " ".join(allowed_scopes) + assert query_result[0].grant_types == grant_types + assert query_result[0].redirect_uris == [url] diff --git a/tests/migrations/test_a04a70296688.py b/tests/migrations/test_a04a70296688.py index ab3560420..bf1e8b6dc 100644 --- a/tests/migrations/test_a04a70296688.py +++ b/tests/migrations/test_a04a70296688.py @@ -23,36 +23,12 @@ def post_test_clean_up(app): def test_upgrade(app): - # state before migration - alembic_main(["--raiseerr", "downgrade", "ea7e1b843f82"]) + # This is the last version our current codebase will work with + alembic_main(["--raiseerr", "upgrade", "9b3a5a7145d7"]) # pragma: allowlist secret client_name = "non_unique_client_name" - # before the migration, it should not be possible to create 2 clients - # with the same name - with app.db.session as db_session: - db_session.add( - Client( - name=client_name, - client_id="client_id1", - grant_types="client_credentials", - ) - ) - db_session.add( - Client( - name=client_name, - client_id="client_id2", - grant_types="client_credentials", - ) - ) - with pytest.raises(IntegrityError): - db_session.commit() - db_session.rollback() - - # run the upgrade migration - alembic_main(["--raiseerr", "upgrade", "a04a70296688"]) - - # now it should be possible + # It should be possible to add 2 clients of the same name with app.db.session as db_session: db_session.add( Client( @@ -75,74 +51,3 @@ def test_upgrade(app): assert len(query_result) == 2, query_result assert query_result[0].name == client_name assert query_result[1].name == client_name - - -@pytest.mark.parametrize("expirations", [[1, 100], [0, 0], [0, 100]]) -def test_downgrade(app, expirations): - """ - Test the downgrade with the following expiration values: - - 1 and 100: we keep the row with the highest expiration (100) - - 0 and 0: both rows have no expiration: we keep any of the 2 - - 0 and 100: we keep the row that has an expiration (100) - """ - # state after migration - alembic_main(["--raiseerr", "downgrade", "a04a70296688"]) - - client_name = "non_unique_client_name" - - # it should be possible to create 2 clients with the same name - with app.db.session as db_session: - db_session.add( - Client( - name=client_name, - client_id="client_id1", - grant_types="client_credentials", - expires_in=expirations[0], - ) - ) - db_session.add( - Client( - name=client_name, - client_id="client_id2", - grant_types="client_credentials", - expires_in=expirations[1], - ) - ) - db_session.commit() - query_result = db_session.query(Client).all() - - assert len(query_result) == 2, query_result - assert query_result[0].name == client_name - expires_at1 = query_result[0].expires_at - assert query_result[1].name == client_name - expires_at2 = query_result[1].expires_at - - # run the downgrade migration - alembic_main(["--raiseerr", "downgrade", "ea7e1b843f82"]) - - # the duplicate row with the lowest expiration should have been deleted - with app.db.session as db_session: - query_result = db_session.query(Client).all() - assert len(query_result) == 1, query_result - assert query_result[0].name == client_name - assert query_result[0].expires_at == max(expires_at1, expires_at2) - - # now it should not be possible anymore to create 2 clients with the same name - with app.db.session as db_session: - db_session.add( - Client( - name=client_name, - client_id="client_id1", - grant_types="client_credentials", - ) - ) - db_session.add( - Client( - name=client_name, - client_id="client_id2", - grant_types="client_credentials", - ) - ) - with pytest.raises(IntegrityError): - db_session.commit() - db_session.rollback() diff --git a/tests/migrations/test_ea7e1b843f82.py b/tests/migrations/test_ea7e1b843f82.py deleted file mode 100644 index 57c9e129d..000000000 --- a/tests/migrations/test_ea7e1b843f82.py +++ /dev/null @@ -1,122 +0,0 @@ -""" -"Optional Client.redirect_uri" migration -""" - -from alembic.config import main as alembic_main -import pytest -from sqlalchemy.exc import IntegrityError - -from fence.models import Client - - -@pytest.fixture(scope="function", autouse=True) -def post_test_clean_up(app): - yield - - # clean up the client table - with app.db.session as db_session: - db_session.query(Client).delete() - - # go back to the latest state of the DB - alembic_main(["--raiseerr", "upgrade", "head"]) - - -def test_upgrade(app): - # state before migration - alembic_main(["--raiseerr", "downgrade", "e4c7b0ab68d3"]) - - # before the migration, it should not be possible to create a client - # without a redirect_uri - with app.db.session as db_session: - with pytest.raises(IntegrityError): - db_session.add( - Client( - client_id="client_without_redirect_uri", - name="client_without_redirect_uri_name", - grant_types="client_credentials", - ) - ) - db_session.commit() - db_session.rollback() - - # run the upgrade migration - alembic_main(["--raiseerr", "upgrade", "ea7e1b843f82"]) - - # now it should be possible - with app.db.session as db_session: - db_session.add( - Client( - client_id="client_without_redirect_uri", - name="client_without_redirect_uri_name", - grant_types="client_credentials", - ) - ) - db_session.commit() - query_result = db_session.query(Client).all() - - # make sure the client was created - assert len(query_result) == 1, query_result - assert query_result[0].client_id == "client_without_redirect_uri" - assert query_result[0].redirect_uri == None - - -def test_downgrade(app): - # state after migration - alembic_main(["--raiseerr", "downgrade", "ea7e1b843f82"]) - - with app.db.session as db_session: - # it should possible to create a client without a redirect_uri - db_session.add( - Client( - client_id="client_without_redirect_uri", - name="client_without_redirect_uri_name", - grant_types="client_credentials", - ) - ) - # also create a client with a redirect_uri - db_session.add( - Client( - client_id="client_with_redirect_uri", - name="client_with_redirect_uri_name", - grant_types="client_credentials", - redirect_uri="http://localhost/redirect", - ) - ) - query_result = db_session.query(Client).all() - - # make sure the clients were created - assert len(query_result) == 2, query_result - - client_without_redirect_uri = [ - c for c in query_result if c.client_id == "client_without_redirect_uri" - ] - assert len(client_without_redirect_uri) == 1 - assert client_without_redirect_uri[0].redirect_uri == None - - client_with_redirect_uri = [ - c for c in query_result if c.client_id == "client_with_redirect_uri" - ] - assert len(client_with_redirect_uri) == 1 - assert client_with_redirect_uri[0].redirect_uri == "http://localhost/redirect" - - # run the downgrade migration - alembic_main(["--raiseerr", "downgrade", "e4c7b0ab68d3"]) - - with app.db.session as db_session: - query_result = db_session.query(Client).all() - assert len(query_result) == 2, query_result - - # make sure the client without redirect was migrated to have an empty - # string as redirect_uri instead of null - client_without_redirect_uri = [ - c for c in query_result if c.client_id == "client_without_redirect_uri" - ] - assert len(client_without_redirect_uri) == 1 - assert client_without_redirect_uri[0].redirect_uri == "" - - # make sure the client with redirect is unchanged - client_with_redirect_uri = [ - c for c in query_result if c.client_id == "client_with_redirect_uri" - ] - assert len(client_with_redirect_uri) == 1 - assert client_with_redirect_uri[0].redirect_uri == "http://localhost/redirect" diff --git a/tests/oidc/core/token/test_validation.py b/tests/oidc/core/token/test_validation.py index b24f96990..d600406a5 100644 --- a/tests/oidc/core/token/test_validation.py +++ b/tests/oidc/core/token/test_validation.py @@ -35,7 +35,7 @@ def test_reuse_code_invalid(oauth_test_client): """ Test that an authorization code returned from the authorization endpoint can be used only once, and after that its attempted usage will return an - ``invalid_request`` error. + ``invalid_grant`` error. """ code = oauth_test_client.authorize(data={"confirm": "yes"}).code # Test that the first time using the code is fine. @@ -45,20 +45,20 @@ def test_reuse_code_invalid(oauth_test_client): response = oauth_test_client.token_response.response assert response.status_code == 400 assert "error" in response.json - assert response.json["error"] == "invalid_request" + assert response.json["error"] == "invalid_grant" def test_different_client_invalid(oauth_test_client, oauth_test_client_B): """ Test that one client cannot use an authorization code which was issued to a - different client, and the request fails with ``invalid_request``. + different client, and the request fails with ``invalid_grant``. """ code = oauth_test_client.authorize(data={"confirm": "yes"}).code # Have client B send the code to the token endpoint. response = oauth_test_client_B.token(code=code, do_asserts=False).response assert response.status_code == 400 assert "error" in response.json - assert response.json["error"] == "invalid_request" + assert response.json["error"] == "invalid_grant" def test_invalid_code(oauth_test_client): @@ -69,27 +69,27 @@ def test_invalid_code(oauth_test_client): response = oauth_test_client.token(code=code, do_asserts=False).response assert response.status_code == 400 assert "error" in response.json - assert response.json["error"] == "invalid_request" + assert response.json["error"] == "invalid_grant" def test_invalid_redirect_uri(oauth_test_client): """ Test that if the token request has a different redirect_uri than the one the client is suppsed to be using that an error is raised, with the - ``invalid_request`` code. + ``invalid_grant`` code. """ oauth_test_client.authorize(data={"confirm": "yes"}) data = {"redirect_uri": oauth_test_client.url + "/some-garbage"} response = oauth_test_client.token(data=data, do_asserts=False).response assert response.status_code == 400 assert "error" in response.json - assert response.json["error"] == "invalid_request" + assert response.json["error"] == "invalid_grant" def test_no_redirect_uri(client, oauth_test_client): """ Test that if the token request has no ``redirect_uri`` that an error is - raised, with the ``invalid_request`` code. + raised, with the ``invalid_grant`` code. """ code = oauth_test_client.authorize(data={"confirm": "yes"}).code headers = oauth_test_client._auth_header @@ -105,4 +105,4 @@ def test_no_redirect_uri(client, oauth_test_client): ) assert token_response.status_code == 400 assert "error" in token_response.json - assert token_response.json["error"] == "invalid_request" + assert token_response.json["error"] == "invalid_grant" diff --git a/tests/rfc6749/test_oauth2.py b/tests/rfc6749/test_oauth2.py index 11487d3e6..89879b550 100644 --- a/tests/rfc6749/test_oauth2.py +++ b/tests/rfc6749/test_oauth2.py @@ -28,7 +28,8 @@ def test_oauth2_authorize_incorrect_scope(oauth_test_client, method): auth_response = oauth_test_client.authorize( method=method, data=data, do_asserts=False ) - assert auth_response.response.status_code == 401 + # Check the status code is not a redirect code 3xx + assert str(auth_response.response.status_code)[0] != "3" @pytest.mark.parametrize("method", ["GET", "POST"]) diff --git a/tests/scripting/test_fence-create.py b/tests/scripting/test_fence-create.py index a6be9319a..deae90d34 100644 --- a/tests/scripting/test_fence-create.py +++ b/tests/scripting/test_fence-create.py @@ -113,7 +113,7 @@ def test_create_client_inits_default_allowed_scopes(db_session): def to_test(): saved_client = db_session.query(Client).filter_by(name=client_name).first() - assert saved_client._allowed_scopes == " ".join(config["CLIENT_ALLOWED_SCOPES"]) + assert saved_client.scope == " ".join(config["CLIENT_ALLOWED_SCOPES"]) create_client_action_wrapper( to_test, @@ -131,7 +131,7 @@ def test_create_client_inits_passed_allowed_scopes(db_session): def to_test(): saved_client = db_session.query(Client).filter_by(name=client_name).first() - assert saved_client._allowed_scopes == "openid user data" + assert saved_client.scope == "openid user data" create_client_action_wrapper( to_test, @@ -150,7 +150,7 @@ def test_create_client_adds_openid_when_not_in_allowed_scopes(db_session): def to_test(): saved_client = db_session.query(Client).filter_by(name=client_name).first() - assert saved_client._allowed_scopes == "user data openid" + assert saved_client.scope == "user data openid" create_client_action_wrapper( to_test, @@ -490,7 +490,7 @@ def test_client_rotate(db_session): for attr in [ "user", "redirect_uris", - "_allowed_scopes", + "scope", "description", "auto_approve", "grant_types", @@ -537,21 +537,23 @@ def test_client_rotate_and_actions(db_session, capsys): capsys.readouterr() # clear the buffer list_client_action(db_session) captured_logs = str(capsys.readouterr()) - assert captured_logs.count("'name': 'client_abc'") == 3 + assert captured_logs.count("'name\\': \\'client_abc\\'") == 3 for i in range(3): - assert captured_logs.count(f"'client_id': '{clients[i].client_id}'") == 1 + assert ( + captured_logs.count(f"\\'client_id\\': \\'{clients[i].client_id}\\'") == 1 + ) # check that `modify_client_action` updates all the rows description = "new description" url2 = "new url" modify_client_action( - db_session, client_name, description=description, urls=[url2], append=True + config["DB"], client_name, description=description, urls=[url2], append=True ) clients = db_session.query(Client).filter_by(name=client_name).all() assert len(clients) == 3 for i in range(3): assert clients[i].description == description - assert clients[i].redirect_uri == f"{url1}\n{url2}" + assert clients[i].redirect_uris == [url1, url2] # check that `delete_client_action` deletes all the rows delete_client_action(config["DB"], client_name) @@ -675,7 +677,7 @@ def test_create_refresh_token_with_found_user( BASE_URL = config["BASE_URL"] scopes = "openid,user" expires_in = 3600 - + client_id = "test-client" user = User(username=username) db_session.add(user) @@ -689,6 +691,7 @@ def test_create_refresh_token_with_found_user( scopes=scopes, expires_in=expires_in, private_key=rsa_private_key, + client_id=client_id, ).create_refresh_token() refresh_token_response = oauth_test_client.refresh( @@ -1717,7 +1720,7 @@ def test_modify_client_action_modify_allowed_scopes(db_session): client_id=client_id, client_secret="secret", # pragma: allowlist secret name=client_name, - _allowed_scopes="openid user data", + allowed_scopes="openid user data", user=User(username="client_user"), redirect_uris=["localhost"], ) @@ -1736,7 +1739,7 @@ def test_modify_client_action_modify_allowed_scopes(db_session): assert client.auto_approve == True assert client.name == "test321" assert client.description == "test client" - assert client._allowed_scopes == "openid user test" + assert client.scope == "openid user test" assert client.redirect_uris == ["test"] @@ -1747,7 +1750,7 @@ def test_modify_client_action_modify_allowed_scopes_append_true(db_session): client_id=client_id, client_secret="secret", # pragma: allowlist secret name=client_name, - _allowed_scopes="openid user data", + allowed_scopes="openid user data", user=User(username="client_user"), redirect_uris=["localhost"], ) @@ -1766,9 +1769,7 @@ def test_modify_client_action_modify_allowed_scopes_append_true(db_session): assert client.auto_approve == True assert client.name == "test321" assert client.description == "test client" - assert ( - client._allowed_scopes == "openid user data new_scope new_scope_2 new_scope_3" - ) + assert client.scope == "openid user data new_scope new_scope_2 new_scope_3" def test_modify_client_action_modify_append_url(db_session): @@ -1778,7 +1779,7 @@ def test_modify_client_action_modify_append_url(db_session): client_id=client_id, client_secret="secret", # pragma: allowlist secret name=client_name, - _allowed_scopes="openid user data", + allowed_scopes="openid user data", user=User(username="client_user"), redirect_uris="abcd", ) diff --git a/tests/session/test_session.py b/tests/session/test_session.py index 4eafb7643..cf8c812b0 100644 --- a/tests/session/test_session.py +++ b/tests/session/test_session.py @@ -24,7 +24,6 @@ def test_session_cookie_creation(app): with app.test_client() as client: with client.session_transaction(): pass - client_cookies = client.get_cookie(config["SESSION_COOKIE_NAME"]) assert not client_cookies @@ -52,9 +51,10 @@ def test_valid_session(app): # the username with app.test_client() as client: # manually set cookie for initial session + # domain is set to localhost be default client.set_cookie( - key=config["SESSION_COOKIE_NAME"], - value=test_session_jwt, + config["SESSION_COOKIE_NAME"], + test_session_jwt, httponly=True, samesite="Lax", ) @@ -75,8 +75,8 @@ def test_valid_session_modified(app): with app.test_client() as client: # manually set cookie for initial session client.set_cookie( - key=config["SESSION_COOKIE_NAME"], - value=test_session_jwt, + config["SESSION_COOKIE_NAME"], + test_session_jwt, httponly=True, samesite="Lax", ) @@ -104,8 +104,8 @@ def test_expired_session_lifetime(app): with app.test_client() as client: # manually set cookie for initial session client.set_cookie( - key=config["SESSION_COOKIE_NAME"], - value=test_session_jwt, + config["SESSION_COOKIE_NAME"], + test_session_jwt, httponly=True, samesite="Lax", ) @@ -135,8 +135,8 @@ def test_expired_session_timeout(app): with app.test_client() as client: # manually set cookie for initial session client.set_cookie( - key=config["SESSION_COOKIE_NAME"], - value=test_session_jwt, + config["SESSION_COOKIE_NAME"], + test_session_jwt, httponly=True, samesite="Lax", ) @@ -167,7 +167,6 @@ def test_session_cleared(app): session["username"] = username session.clear() assert session.get("username") != username - client_cookie = client.get_cookie(config["SESSION_COOKIE_NAME"]) assert not client_cookie @@ -180,8 +179,8 @@ def test_invalid_session_cookie(app): with app.test_client() as client: # manually set cookie for initial session client.set_cookie( - key=config["SESSION_COOKIE_NAME"], - value=test_session_jwt, + config["SESSION_COOKIE_NAME"], + test_session_jwt, httponly=True, samesite="Lax", ) @@ -223,14 +222,14 @@ def test_valid_session_valid_access_token( with app.test_client() as client: # manually set cookie for initial session client.set_cookie( - key=config["SESSION_COOKIE_NAME"], - value=test_session_jwt, + config["SESSION_COOKIE_NAME"], + test_session_jwt, httponly=True, samesite="Lax", ) client.set_cookie( - key=config["ACCESS_TOKEN_COOKIE_NAME"], - value=test_access_jwt, + config["ACCESS_TOKEN_COOKIE_NAME"], + test_access_jwt, httponly=True, samesite="Lax", ) @@ -274,14 +273,14 @@ def test_valid_session_valid_access_token_diff_user( with app.test_client() as client: # manually set cookie for initial session client.set_cookie( - key=config["SESSION_COOKIE_NAME"], - value=test_session_jwt, + config["SESSION_COOKIE_NAME"], + test_session_jwt, httponly=True, samesite="Lax", ) client.set_cookie( - key=config["ACCESS_TOKEN_COOKIE_NAME"], - value=test_access_jwt, + config["ACCESS_TOKEN_COOKIE_NAME"], + test_access_jwt, httponly=True, samesite="Lax", ) diff --git a/tests/test-fence-config.yaml b/tests/test-fence-config.yaml index 4a755d123..38ccbd147 100755 --- a/tests/test-fence-config.yaml +++ b/tests/test-fence-config.yaml @@ -120,6 +120,7 @@ OPENID_CONNECT: client_secret: '' redirect_url: '' fence: + name: 'fence IDP' client_id: '' client_secret: '' redirect_url: '{{BASE_URL}}/login/fence/login' @@ -232,16 +233,9 @@ LOGIN_OPTIONS: idp: generic2 # ////////////////////////////////////////////////////////////////////////////////////// -# LIBRARY CONFIGURATION (authlib & flask) +# LIBRARY CONFIGURATION (flask) # - Already contains reasonable defaults # ////////////////////////////////////////////////////////////////////////////////////// -# authlib-specific configs for OIDC flow and JWTs -# NOTE: the OAUTH2_JWT_KEY cfg gets set automatically by fence if keys are setup -# correctly -OAUTH2_JWT_ALG: 'RS256' -OAUTH2_JWT_ENABLED: true -OAUTH2_JWT_ISS: '{{BASE_URL}}' -OAUTH2_PROVIDER_ERROR_URI: '/api/oauth2/errors' # used for flask, "path mounted under by the application / web server" # since we deploy as microservices, fence is typically under {{base}}/user diff --git a/tests/test_audit_service.py b/tests/test_audit_service.py index b1d6568d9..cbf5c8cf2 100644 --- a/tests/test_audit_service.py +++ b/tests/test_audit_service.py @@ -450,7 +450,7 @@ def test_login_log_login_endpoint( elif idp == "fence": mocked_fetch_access_token = MagicMock(return_value={"id_token": jwt_string}) patch( - f"flask.current_app.fence_client.fetch_access_token", + f"authlib.integrations.flask_client.apps.FlaskOAuth2App.fetch_access_token", mocked_fetch_access_token, ).start() mocked_validate_jwt = MagicMock( @@ -490,7 +490,7 @@ def test_login_log_login_endpoint( data={}, status_code=201, ) - path = f"/login/{idp}/{callback_endpoint}" + path = f"/login/{idp}/{callback_endpoint}" # SEE fence/blueprints/login/fence_login.py L91 response = client.get(path, headers=headers) assert response.status_code == 200, response audit_service_requests.post.assert_called_once_with( diff --git a/tests/test_logout.py b/tests/test_logout.py index 363d9a2f4..eb0f8f538 100644 --- a/tests/test_logout.py +++ b/tests/test_logout.py @@ -78,9 +78,10 @@ def test_logout_fence(app, client, user_with_fence_provider, monkeypatch): with mock.patch("fence.allowed_login_redirects", return_value={"some_site.com"}): # manually set cookie for initial session client.set_cookie( - domain="localhost", key=config["SESSION_COOKIE_NAME"], value=test_session_jwt, + # domain is used in client.get_cookie, it defaults to locahost anyway + domain="localhost", httponly=True, samesite="Lax", )