Example #1
0
    def update_user_visas(self, user, db_session=current_session):
        """
        Updates user's RAS refresh token and uses the new access token to retrieve new visas from
        RAS's /userinfo endpoint and update the db with the new visa.
        - delete user's visas from db if we're not able to get a new access_token
        - delete user's visas from db if we're not able to get a new visa
        """
        user.ga4gh_visas_v1 = []
        db_session.commit()

        try:
            token_endpoint = self.get_value_from_discovery_doc(
                "token_endpoint", "")
            userinfo_endpoint = self.get_value_from_discovery_doc(
                "userinfo_endpoint", "")
            token = self.get_access_token(user, token_endpoint, db_session)
            userinfo = self.get_userinfo(token, userinfo_endpoint)
            encoded_visas = userinfo.get("ga4gh_passport_v1", [])
        except Exception as e:
            err_msg = "Could not retrieve visa"
            self.logger.exception("{}: {}".format(err_msg, e))
            raise

        for encoded_visa in encoded_visas:
            try:
                # TODO: These visas must be validated!!!
                decoded_visa = jwt.decode(encoded_visa, verify=False)
                visa = GA4GHVisaV1(
                    user=user,
                    source=decoded_visa["ga4gh_visa_v1"]["source"],
                    type=decoded_visa["ga4gh_visa_v1"]["type"],
                    asserted=int(decoded_visa["ga4gh_visa_v1"]["asserted"]),
                    expires=int(decoded_visa["exp"]),
                    ga4gh_visa=encoded_visa,
                )

                current_db_session = db_session.object_session(visa)

                current_db_session.add(visa)
            except Exception as e:
                err_msg = (
                    f"Could not process visa '{encoded_visa}' - skipping this visa"
                )
                self.logger.exception("{}: {}".format(err_msg, e),
                                      exc_info=True)
            db_session.commit()
Example #2
0
    def post_login(self, user, token_result):

        # TODO: I'm not convinced this code should be in post_login.
        # Just putting it in here for now, but might refactor later.
        # This saves us a call to RAS /userinfo, but will not make sense
        # when there is more than one visa issuer.

        # Clear all of user's visas, to avoid having duplicate visas
        # where only iss/exp/jti differ
        # TODO: This is not IdP-specific and will need a rethink when
        # we have multiple IdPs
        user.ga4gh_visas_v1 = []
        current_session.commit()

        encoded_visas = flask.g.userinfo.get("ga4gh_passport_v1", [])

        for encoded_visa in encoded_visas:
            # TODO: These visas must be validated!!!
            # i.e. (Remove `verify=False` in jwt.decode call)
            # But: need a routine for getting public keys per visa.
            # And we probably want to cache them.
            # Also needs any ga4gh-specific validation.
            # For now just read them without validation:
            decoded_visa = jwt.decode(encoded_visa, verify=False)

            visa = GA4GHVisaV1(
                user=user,
                source=decoded_visa["ga4gh_visa_v1"]["source"],
                type=decoded_visa["ga4gh_visa_v1"]["type"],
                asserted=int(decoded_visa["ga4gh_visa_v1"]["asserted"]),
                expires=int(decoded_visa["exp"]),
                ga4gh_visa=encoded_visa,
            )

            current_session.add(visa)
            current_session.commit()

        # Store refresh token in db
        refresh_token = flask.g.tokens.get("refresh_token")
        id_token = flask.g.tokens.get("id_token")
        decoded_id = jwt.decode(id_token, verify=False)
        # Add 15 days to iat to calculate refresh token expiration time
        expires = int(decoded_id.get("iat")) + config["RAS_REFRESH_EXPIRATION"]
        flask.current_app.ras_client.store_refresh_token(
            user=user, refresh_token=refresh_token, expires=expires)
Example #3
0
def add_visa_manually(db_session,
                      user,
                      rsa_private_key,
                      kid,
                      expires=None,
                      sub=None):
    expires = expires or int(time.time()) + 1000
    make_invalid = False

    if getattr(user, "username", user) == "expired_visa_user":
        expires -= 100000
    if getattr(user, "username", user) == "invalid_visa_user":
        make_invalid = True
    if getattr(user, "username", user) == "TESTUSERD":
        make_invalid = True

    encoded_visa, decoded_visa, expires = get_test_encoded_decoded_visa_and_exp(
        db_session,
        user,
        rsa_private_key,
        kid,
        expires=expires,
        make_invalid=make_invalid,
        sub=sub,
    )

    visa = GA4GHVisaV1(
        user=user,
        source=decoded_visa["ga4gh_visa_v1"]["source"],
        type=decoded_visa["ga4gh_visa_v1"]["type"],
        asserted=int(decoded_visa["ga4gh_visa_v1"]["asserted"]),
        expires=expires,
        ga4gh_visa=encoded_visa,
    )

    db_session.add(visa)
    db_session.commit()

    return encoded_visa, visa
Example #4
0
def add_visa_manually(db_session, user, rsa_private_key, kid):

    headers = {"kid": kid}

    decoded_visa = {
        "iss": "https://stsstg.nih.gov",
        "sub": "abcde12345aspdij",
        "iat": int(time.time()),
        "exp": int(time.time()) + 1000,
        "scope": "openid ga4gh_passport_v1 email profile",
        "jti": "jtiajoidasndokmasdl",
        "txn": "sapidjspa.asipidja",
        "name": "",
        "ga4gh_visa_v1": {
            "type": "https://ras/visa/v1",
            "asserted": int(time.time()),
            "value": "https://nig/passport/dbgap",
            "source": "https://ncbi/gap",
        },
    }

    encoded_visa = jwt.encode(decoded_visa,
                              key=rsa_private_key,
                              headers=headers,
                              algorithm="RS256").decode("utf-8")

    visa = GA4GHVisaV1(
        user=user,
        source=decoded_visa["ga4gh_visa_v1"]["source"],
        type=decoded_visa["ga4gh_visa_v1"]["type"],
        asserted=int(decoded_visa["ga4gh_visa_v1"]["asserted"]),
        expires=int(decoded_visa["exp"]),
        ga4gh_visa=encoded_visa,
    )

    db_session.add(visa)
    db_session.commit()
Example #5
0
File: ras.py Project: rolinge/fence
    def post_login(self, user=None, token_result=None):
        # TODO: I'm not convinced this code should be in post_login.
        # Just putting it in here for now, but might refactor later.
        # This saves us a call to RAS /userinfo, but will not make sense
        # when there is more than one visa issuer.

        # Clear all of user's visas, to avoid having duplicate visas
        # where only iss/exp/jti differ
        # TODO: This is not IdP-specific and will need a rethink when
        # we have multiple IdPs
        user.ga4gh_visas_v1 = []
        current_session.commit()

        encoded_visas = flask.g.userinfo.get("ga4gh_passport_v1", [])

        for encoded_visa in encoded_visas:
            # TODO: These visas must be validated!!!
            # i.e. (Remove `verify=False` in jwt.decode call)
            # But: need a routine for getting public keys per visa.
            # And we probably want to cache them.
            # Also needs any ga4gh-specific validation.
            # For now just read them without validation:
            decoded_visa = jwt.decode(encoded_visa, verify=False)

            visa = GA4GHVisaV1(
                user=user,
                source=decoded_visa["ga4gh_visa_v1"]["source"],
                type=decoded_visa["ga4gh_visa_v1"]["type"],
                asserted=int(decoded_visa["ga4gh_visa_v1"]["asserted"]),
                expires=int(decoded_visa["exp"]),
                ga4gh_visa=encoded_visa,
            )
            current_session.add(visa)
            current_session.commit()

        # Store refresh token in db
        assert "refresh_token" in flask.g.tokens, "No refresh_token in user tokens"
        refresh_token = flask.g.tokens["refresh_token"]
        assert "id_token" in flask.g.tokens, "No id_token in user tokens"
        id_token = flask.g.tokens["id_token"]
        decoded_id = jwt.decode(id_token, verify=False)

        # Add 15 days to iat to calculate refresh token expiration time
        issued_time = int(decoded_id.get("iat"))
        expires = config["RAS_REFRESH_EXPIRATION"]

        # User definied RAS refresh token expiration time
        parsed_url = urlparse(flask.session.get("redirect"))
        query_params = parse_qs(parsed_url.query)
        if query_params.get("upstream_expires_in"):
            custom_refresh_expiration = query_params.get(
                "upstream_expires_in")[0]
            expires = get_valid_expiration(
                custom_refresh_expiration,
                expires,
                expires,
            )

        flask.current_app.ras_client.store_refresh_token(
            user=user,
            refresh_token=refresh_token,
            expires=expires + issued_time)

        usersync = config.get("USERSYNC", {})
        sync_from_visas = usersync.get("sync_from_visas", False)
        # Check if user has any project_access from a previous session or from usersync AND if fence is configured to use visas as authZ source
        # if not do an on-the-fly usersync for this user to give them instant access after logging in through RAS
        if not user.project_access and sync_from_visas:
            # Close previous db sessions. Leaving it open causes a race condition where we're viewing user.project_access while trying to update it in usersync
            # not closing leads to partially updated records
            current_session.close()
            DB = os.environ.get("FENCE_DB") or config.get("DB")
            if DB is None:
                try:
                    from fence.settings import DB
                except ImportError:
                    pass
            dbGaP = os.environ.get("dbGaP") or config.get("dbGaP")
            if not isinstance(dbGaP, list):
                dbGaP = [dbGaP]

            sync = init_syncer(
                dbGaP,
                None,
                DB,
            )
            sync.sync_single_user_visas(user, current_session)

        super(RASCallback, self).post_login()
Example #6
0
def add_visa_manually(db_session, user, rsa_private_key, kid):

    headers = {"kid": kid}

    decoded_visa = {
        "iss": "https://stsstg.nih.gov",
        "sub": "abcde12345aspdij",
        "iat": int(time.time()),
        "exp": int(time.time()) + 1000,
        "scope": "openid ga4gh_passport_v1 email profile",
        "jti": "jtiajoidasndokmasdl",
        "txn": "sapidjspa.asipidja",
        "name": "",
        "ga4gh_visa_v1": {
            "type": "https://ras.nih.gov/visas/v1",
            "asserted": int(time.time()),
            "value": "https://nig/passport/dbgap",
            "source": "https://ncbi/gap",
        },
        "ras_dbgap_permissions": [
            {
                "consent_name": "Health/Medical/Biomedical",
                "phs_id": "phs000991",
                "version": "v1",
                "participant_set": "p1",
                "consent_group": "c1",
                "role": "designated user",
                "expiration": "2020-11-14 00:00:00",
            },
            {
                "consent_name": "General Research Use (IRB, PUB)",
                "phs_id": "phs000961",
                "version": "v1",
                "participant_set": "p1",
                "consent_group": "c1",
                "role": "designated user",
                "expiration": "2020-11-14 00:00:00",
            },
            {
                "consent_name": "Disease-Specific (Cardiovascular Disease)",
                "phs_id": "phs000279",
                "version": "v2",
                "participant_set": "p1",
                "consent_group": "c1",
                "role": "designated user",
                "expiration": "2020-11-14 00:00:00",
            },
            {
                "consent_name": "Health/Medical/Biomedical (IRB)",
                "phs_id": "phs000286",
                "version": "v6",
                "participant_set": "p2",
                "consent_group": "c3",
                "role": "designated user",
                "expiration": "2020-11-14 00:00:00",
            },
            {
                "consent_name": "Disease-Specific (Focused Disease Only, IRB, NPU)",
                "phs_id": "phs000289",
                "version": "v6",
                "participant_set": "p2",
                "consent_group": "c2",
                "role": "designated user",
                "expiration": "2020-11-14 00:00:00",
            },
            {
                "consent_name": "Disease-Specific (Autism Spectrum Disorder)",
                "phs_id": "phs000298",
                "version": "v4",
                "participant_set": "p3",
                "consent_group": "c1",
                "role": "designated user",
                "expiration": "2020-11-14 00:00:00",
            },
        ],
    }

    encoded_visa = jwt.encode(
        decoded_visa, key=rsa_private_key, headers=headers, algorithm="RS256"
    ).decode("utf-8")

    expires = int(decoded_visa["exp"])
    if user.username == "expired_visa_user":
        expires -= 100000
    if user.username == "invalid_visa_user":
        encoded_visa = encoded_visa[: len(encoded_visa) // 2]
    if user.username == "TESTUSERD":
        encoded_visa = encoded_visa[: len(encoded_visa) // 2]

    visa = GA4GHVisaV1(
        user=user,
        source=decoded_visa["ga4gh_visa_v1"]["source"],
        type=decoded_visa["ga4gh_visa_v1"]["type"],
        asserted=int(decoded_visa["ga4gh_visa_v1"]["asserted"]),
        expires=expires,
        ga4gh_visa=encoded_visa,
    )

    db_session.add(visa)
    db_session.commit()
Example #7
0
def sync_gen3_users_authz_from_ga4gh_passports(
    passports,
    pkey_cache=None,
    db_session=None,
):
    """
    Validate passports and embedded visas, using each valid visa's identity
    established by <iss, sub> combination to possibly create and definitely
    determine a Fence user who is added to the list returned by this
    function. In the process of determining Fence users from visas, visa
    authorization information is also persisted in Fence and synced to
    Arborist.

    Args:
        passports (list): a list of raw encoded passport strings, each
                          including header, payload, and signature

    Return:
        list: a list of users, each corresponding to a valid visa identity
              embedded within the passports passed in
    """
    db_session = db_session or current_session

    # {"username": user, "username2": user2}
    users_from_all_passports = {}
    for passport in passports:
        try:
            cached_usernames = get_gen3_usernames_for_passport_from_cache(
                passport=passport, db_session=db_session)
            if cached_usernames:
                # there's a chance a given username exists in the cache but no longer in
                # the database. if not all are in db, ignore the cache and actually parse
                # and validate the passport
                all_users_exist_in_db = True
                usernames_to_update = {}
                for username in cached_usernames:
                    user = query_for_user(session=db_session,
                                          username=username)
                    if not user:
                        all_users_exist_in_db = False
                        continue
                    usernames_to_update[user.username] = user

                if all_users_exist_in_db:
                    users_from_all_passports.update(usernames_to_update)
                    # existence in the cache and a user in db means that this passport
                    # was validated previously (expiration was also checked)
                    continue

            # below function also validates passport (or raises exception)
            raw_visas = get_unvalidated_visas_from_valid_passport(
                passport, pkey_cache=pkey_cache)
        except Exception as exc:
            logger.warning(
                f"Invalid passport provided, ignoring. Error: {exc}")
            continue

        # an empty raw_visas list means that either the current passport is
        # invalid or that it has no visas. in both cases, the current passport
        # is ignored and we move on to the next passport
        if not raw_visas:
            continue

        identity_to_visas = collections.defaultdict(list)
        min_visa_expiration = int(
            time.time()) + datetime.timedelta(hours=1).seconds
        for raw_visa in raw_visas:
            try:
                validated_decoded_visa = validate_visa(raw_visa,
                                                       pkey_cache=pkey_cache)
                identity_to_visas[(
                    validated_decoded_visa.get("iss"),
                    validated_decoded_visa.get("sub"),
                )].append((raw_visa, validated_decoded_visa))
                min_visa_expiration = min(min_visa_expiration,
                                          validated_decoded_visa.get("exp"))
            except Exception as exc:
                logger.warning(
                    f"Invalid visa provided, ignoring. Error: {exc}")
                continue

        expired_authz_removal_job_freq_in_seconds = config[
            "EXPIRED_AUTHZ_REMOVAL_JOB_FREQ_IN_SECONDS"]
        min_visa_expiration -= expired_authz_removal_job_freq_in_seconds
        if min_visa_expiration <= int(time.time()):
            logger.warning(
                "The passport's earliest valid visa expiration time is set to "
                f"occur within {expired_authz_removal_job_freq_in_seconds} "
                "seconds from now, which is too soon an expiration to handle.")
            continue

        users_from_current_passport = []
        for (issuer, subject_id), visas in identity_to_visas.items():
            gen3_user = get_or_create_gen3_user_from_iss_sub(
                issuer, subject_id, db_session=db_session)

            ga4gh_visas = [
                GA4GHVisaV1(
                    user=gen3_user,
                    source=validated_decoded_visa["ga4gh_visa_v1"]["source"],
                    type=validated_decoded_visa["ga4gh_visa_v1"]["type"],
                    asserted=int(
                        validated_decoded_visa["ga4gh_visa_v1"]["asserted"]),
                    expires=int(validated_decoded_visa["exp"]),
                    ga4gh_visa=raw_visa,
                ) for raw_visa, validated_decoded_visa in visas
            ]
            # NOTE: does not validate, assumes validation occurs above.
            #       This adds the visas to the database session but doesn't commit until
            #       the end of this function
            _sync_validated_visa_authorization(
                gen3_user=gen3_user,
                ga4gh_visas=ga4gh_visas,
                expiration=min_visa_expiration,
                db_session=db_session,
            )
            users_from_current_passport.append(gen3_user)

        for user in users_from_current_passport:
            users_from_all_passports[user.username] = user

        put_gen3_usernames_for_passport_into_cache(
            passport=passport,
            user_ids_from_passports=list(users_from_all_passports.keys()),
            expires_at=min_visa_expiration,
            db_session=db_session,
        )

    db_session.commit()

    logger.info(
        f"Got Gen3 usernames from passport(s): {list(users_from_all_passports.keys())}"
    )
    return users_from_all_passports
Example #8
0
    def post_login(self, user=None, token_result=None):
        # TODO: I'm not convinced this code should be in post_login.
        # Just putting it in here for now, but might refactor later.
        # This saves us a call to RAS /userinfo, but will not make sense
        # when there is more than one visa issuer.

        # Clear all of user's visas, to avoid having duplicate visas
        # where only iss/exp/jti differ
        # TODO: This is not IdP-specific and will need a rethink when
        # we have multiple IdPs
        user.ga4gh_visas_v1 = []

        current_session.commit()

        encoded_visas = []

        try:
            encoded_visas = flask.current_app.ras_client.get_encoded_visas_v11_userinfo(
                flask.g.userinfo
            )
        except Exception as e:
            err_msg = "Could not retrieve visas"
            logger.error("{}: {}".format(e, err_msg))
            raise

        for encoded_visa in encoded_visas:
            try:
                # Do not move out of loop unless we can assume every visa has same issuer and kid
                public_key = get_public_key_for_token(
                    encoded_visa, attempt_refresh=True
                )
            except Exception as e:
                # (But don't log the visa contents!)
                logger.error(
                    "Could not get public key to validate visa: {}. Discarding visa.".format(
                        e
                    )
                )
                continue

            try:
                # Validate the visa per GA4GH AAI "Embedded access token" format rules.
                # pyjwt also validates signature and expiration.
                decoded_visa = validate_jwt(
                    encoded_visa,
                    public_key,
                    # Embedded token must not contain aud claim
                    aud=None,
                    # Embedded token must contain scope claim, which must include openid
                    scope={"openid"},
                    issuers=config.get("GA4GH_VISA_ISSUER_ALLOWLIST", []),
                    # Embedded token must contain iss, sub, iat, exp claims
                    # options={"require": ["iss", "sub", "iat", "exp"]},
                    # ^ FIXME 2021-05-13: Above needs pyjwt>=v2.0.0, which requires cryptography>=3.
                    # Once we can unpin and upgrade cryptography and pyjwt, switch to above "options" arg.
                    # For now, pyjwt 1.7.1 is able to require iat and exp;
                    # authutils' validate_jwt (i.e. the function being called) checks issuers already (see above);
                    # and we will check separately for sub below.
                    options={
                        "require_iat": True,
                        "require_exp": True,
                    },
                )

                # Also require 'sub' claim (see note above about pyjwt and the options arg).
                if "sub" not in decoded_visa:
                    raise JWTError("Visa is missing the 'sub' claim.")
            except Exception as e:
                logger.error("Visa failed validation: {}. Discarding visa.".format(e))
                continue

            visa = GA4GHVisaV1(
                user=user,
                source=decoded_visa["ga4gh_visa_v1"]["source"],
                type=decoded_visa["ga4gh_visa_v1"]["type"],
                asserted=int(decoded_visa["ga4gh_visa_v1"]["asserted"]),
                expires=int(decoded_visa["exp"]),
                ga4gh_visa=encoded_visa,
            )
            current_session.add(visa)
            current_session.commit()

        # Store refresh token in db
        assert "refresh_token" in flask.g.tokens, "No refresh_token in user tokens"
        refresh_token = flask.g.tokens["refresh_token"]
        assert "id_token" in flask.g.tokens, "No id_token in user tokens"
        id_token = flask.g.tokens["id_token"]
        decoded_id = jwt.decode(id_token, verify=False)

        # Add 15 days to iat to calculate refresh token expiration time
        issued_time = int(decoded_id.get("iat"))
        expires = config["RAS_REFRESH_EXPIRATION"]

        # User definied RAS refresh token expiration time
        parsed_url = urlparse(flask.session.get("redirect"))
        query_params = parse_qs(parsed_url.query)
        if query_params.get("upstream_expires_in"):
            custom_refresh_expiration = query_params.get("upstream_expires_in")[0]
            expires = get_valid_expiration(
                custom_refresh_expiration,
                expires,
                expires,
            )

        flask.current_app.ras_client.store_refresh_token(
            user=user, refresh_token=refresh_token, expires=expires + issued_time
        )

        global_parse_visas_on_login = config["GLOBAL_PARSE_VISAS_ON_LOGIN"]
        usersync = config.get("USERSYNC", {})
        sync_from_visas = usersync.get("sync_from_visas", False)
        parse_visas = global_parse_visas_on_login or (
            global_parse_visas_on_login == None
            and (
                strtobool(query_params.get("parse_visas")[0])
                if query_params.get("parse_visas")
                else False
            )
        )
        # if sync_from_visas and (global_parse_visas_on_login or global_parse_visas_on_login == None):
        # Check if user has any project_access from a previous session or from usersync AND if fence is configured to use visas as authZ source
        # if not do an on-the-fly usersync for this user to give them instant access after logging in through RAS
        # If GLOBAL_PARSE_VISAS_ON_LOGIN is true then we want to run it regardless of whether or not the client sent parse_visas on request
        if sync_from_visas and parse_visas and not user.project_access:
            # Close previous db sessions. Leaving it open causes a race condition where we're viewing user.project_access while trying to update it in usersync
            # not closing leads to partially updated records
            current_session.close()

            DB = os.environ.get("FENCE_DB") or config.get("DB")
            if DB is None:
                try:
                    from fence.settings import DB
                except ImportError:
                    pass

            arborist = ArboristClient(
                arborist_base_url=config["ARBORIST"],
                logger=get_logger("user_syncer.arborist_client"),
                authz_provider="user-sync",
            )
            dbGaP = os.environ.get("dbGaP") or config.get("dbGaP")
            if not isinstance(dbGaP, list):
                dbGaP = [dbGaP]

            sync = init_syncer(
                dbGaP,
                None,
                DB,
                arborist=arborist,
            )
            sync.sync_single_user_visas(user, current_session)

        super(RASCallback, self).post_login()
Example #9
0
    def update_user_visas(self, user, pkey_cache, db_session=current_session):
        """
        Updates user's RAS refresh token and uses the new access token to retrieve new visas from
        RAS's /userinfo endpoint and update the db with the new visa.
        - delete user's visas from db if we're not able to get a new access_token
        - delete user's visas from db if we're not able to get new visas
        - only visas which pass validation are added to the database
        """
        # Note: in the cronjob this is called per-user per-visa.
        # So it should be noted that when there are more clients than just RAS,
        # this code as it stands can remove visas that the user has from other clients.
        user.ga4gh_visas_v1 = []
        db_session.commit()

        try:
            token_endpoint = self.get_value_from_discovery_doc(
                "token_endpoint", "")
            token = self.get_access_token(user, token_endpoint, db_session)
            userinfo = self.get_userinfo(token)
            encoded_visas = self.get_encoded_visas_v11_userinfo(
                userinfo, pkey_cache)

        except Exception as e:
            err_msg = "Could not retrieve visas"
            self.logger.exception("{}: {}".format(err_msg, e))
            raise

        for encoded_visa in encoded_visas:
            try:
                visa_issuer = get_iss(encoded_visa)
                visa_kid = get_kid(encoded_visa)
            except Exception as e:
                self.logger.error(
                    "Could not get issuer or kid from visa: {}. Discarding visa."
                    .format(e))
                continue  # Not raise: If visa malformed, does not make sense to retry

            # See if pkey is in cronjob cache; if not, update cache.
            public_key = pkey_cache.get(visa_issuer, {}).get(visa_kid)
            if not public_key:
                try:
                    public_key = self.refresh_cronjob_pkey_cache(
                        visa_issuer, visa_kid, pkey_cache)
                except Exception as e:
                    self.logger.error(
                        "Could not refresh public key cache: {}".format(e))
                    continue
            if not public_key:
                self.logger.error(
                    "Could not get public key to validate visa: Successfully fetched "
                    "issuer's keys but did not find the visa's key id among them. Discarding visa."
                )
                continue  # Not raise: If issuer not publishing pkey, does not make sense to retry

            try:
                # Validate the visa per GA4GH AAI "Embedded access token" format rules.
                # pyjwt also validates signature and expiration.
                decoded_visa = validate_jwt(
                    encoded_visa,
                    public_key,
                    # Embedded token must not contain aud claim
                    aud=None,
                    # Embedded token must contain scope claim, which must include openid
                    scope={"openid"},
                    issuers=config.get("GA4GH_VISA_ISSUER_ALLOWLIST", []),
                    # Embedded token must contain iss, sub, iat, exp claims
                    # options={"require": ["iss", "sub", "iat", "exp"]},
                    # ^ FIXME 2021-05-13: Above needs pyjwt>=v2.0.0, which requires cryptography>=3.
                    # Once we can unpin and upgrade cryptography and pyjwt, switch to above "options" arg.
                    # For now, pyjwt 1.7.1 is able to require iat and exp;
                    # authutils' validate_jwt (i.e. the function being called) checks issuers already (see above);
                    # and we will check separately for sub below.
                    options={
                        "require_iat": True,
                        "require_exp": True,
                    },
                )

                # Also require 'sub' claim (see note above about pyjwt and the options arg).
                if "sub" not in decoded_visa:
                    raise JWTError("Visa is missing the 'sub' claim.")
            except Exception as e:
                self.logger.error(
                    "Visa failed validation: {}. Discarding visa.".format(e))
                continue

            visa = GA4GHVisaV1(
                user=user,
                source=decoded_visa["ga4gh_visa_v1"]["source"],
                type=decoded_visa["ga4gh_visa_v1"]["type"],
                asserted=int(decoded_visa["ga4gh_visa_v1"]["asserted"]),
                expires=int(decoded_visa["exp"]),
                ga4gh_visa=encoded_visa,
            )

            current_db_session = db_session.object_session(visa)
            current_db_session.add(visa)
            db_session.commit()