Coalescing Requirements Errors

A common pattern for applications which want to provide the best possible user experience is to defer raising errors until all discoverable issues are identified. Instead of immediately, eagerly raising an error, the application can provide a superior interface by collecting all of the errors at once and presenting them to the user to resolve.

In the context of Globus Auth applications, this can manifest as multiple interactions – direct or indirect – which produce Globus Auth Requirements Errors (represented in the SDK as the globus_sdk.gare.GARE class). Each globus_sdk.gare.GARE can be resolved by a separate login flow, but for an application which collects 3 GAREs, this would mean asking the user to login 3 times in a row!

Applications which collect multiple such errors want to present the user with the fewest possible login flows. Unfortunately, GAREs are capable of expressing constraints which cannot be safely merged into a single login flow – requirements may be mutually exclusive, or they may overlap in ill-defined ways. Merging or “coalescing” GAREs is difficult to define in the general case, but if you have more knowledge of your applications’ requirements, you may be able to do more than is generally safe.

In this doc, we’ll share some theoretical cases of GAREs which

  • definitely can be merged together safely

  • definitely cannot be merged together safely

  • possibly can be merged together

For readers who prefer to start with complete working examples, jump ahead to the example script which shows a simple and safe merge procedure – although it might not simplify all possible combinations.

GAREs Which Can Safely Merge

Two of the fields in a GARE are arrays of values which are always combined with “and” semantics. As a result, they can always be safely combined with array concatenation. These fields are:

  • required_scopes

  • session_required_policies

Additionally, the code field is defined as a non-semantic hint, and is therefore safe to rewrite. Although services may use other values in practice, there are only two well-defined values for the code string:

  • ConsentRequired: indicates that the user must consent to additional scopes in order to authorize the resource server(s) to complete the requested action.

  • AuthorizationRequired: indicates that this is a Globus Auth Requirements Error that is not more specifically described by any other code.

Because AuthorizationRequired is generic, it can always be safely used for a GARE produced from other requirements.

Safe Merge Example

For example,

{
  "code": "ConsentRequired",
  "authorization_parameters": {
    "required_scopes": ["foo"]
  }
}

and

{
  "code": "AuthorizationRequired",
  "authorization_parameters": {
    "required_scopes": ["bar"],
    "session_required_policies": [
      "f2047039-2f07-4f13-b21b-b2edf7f9d329",
      "2fc6d9a3-9322-48a1-ad39-5dcf63a593a7"
    ]
  }
}

can safely merge into

{
  "code": "AuthorizationRequired",
  "authorization_parameters": {
    "required_scopes": ["foo", "bar"],
    "session_required_policies": [
      "f2047039-2f07-4f13-b21b-b2edf7f9d329",
      "2fc6d9a3-9322-48a1-ad39-5dcf63a593a7"
    ]
  }
}

GAREs Which Cannot Safely Merge

There are no strictly defined merge semantics for any of the fields in GAREs, but in particular we can see problems when trying to merge together session_required_single_domain.

This field expresses the idea that a user must have an identity from one of the listed domains, meaning it uses “or” semantics. However, two separate GAREs naturally communicate “and” semantics, so merging two such lists together produces an incorrect result.

Unsafe Merge Example

For example,

{
  "code": "AuthorizationRequired",
  "authorization_parameters": {
    "session_required_single_domain": ["umich.edu"]
  }
}

and

{
  "code": "AuthorizationRequired",
  "authorization_parameters": {
    "session_required_single_domain": ["stanford.edu"]
  }
}

cannot merge together!

As a pair, these documents express “the user must have an in-session umich.edu identity” and “the user must have an in-session stanford.edu identity”.

If we combine them into

{
  "code": "AuthorizationRequired",
  "authorization_parameters": {
    "session_required_single_domain": ["umich.edu", "stanford.edu"]
  }
}

we accidentally express the idea that “the user must have an in-session umich.edu identity” or “the user must have an in-session stanford.edu identity”.

We have accidentally transformed the “and” into an “or”!

GAREs Which Can Safely Merge Sometimes

Some fields in GAREs cannot be merged without some decision being made. For example, session_message is a string message to display to the user. When combining two GAREs with distinct messages, how should the result be formulated? Should the messages be joined with some separator? Should a new message be composed?

There is no strictly correct answer. However! An application which is, itself, replacing the session_message can safely ignore this conflict because it was planning to change the message anyway.

Maybe Safe Merge Example

For example,

{
  "code": "ConsentRequired",
  "authorization_parameters": {
    "session_message": "You need authorization for Service Foo!",
    "required_scopes": ["foo"]
  }
}

and

{
  "code": "ConsentRequired",
  "authorization_parameters": {
    "session_message": "You need authorization for Service Bar!",
    "required_scopes": ["bar"]
  }
}

Could be combined into

{
  "code": "ConsentRequired",
  "authorization_parameters": {
    "session_message": "You need to authorize use of Services Foo and Bar.",
    "required_scopes": ["foo", "bar"]
  }
}

This is safe because session_message is known to be non-semantic for the purpose of authorizing the user.

Summary: Complete Example

Even knowing that GAREs cannot be safely combined in many cases, as established in the introduction above, doing so is highly desirable for some applications.

This example merges together these documents, represented as globus_sdk.gare.GARE objects, only in cases which are defined to be safe. If session_message or prompt values are supplied, they will override any values for these fields present in the GAREs, allowing the GAREs to merge more aggressively.

coalesce_gares.py [download]
import globus_sdk.gare


def coalesce(
    *gares: globus_sdk.gare.GARE,
    session_message: str | None = None,
    prompt: str | None = None,
) -> list[globus_sdk.gare.GARE]:
    # build a list of GARE fields which are allowed to merge
    safe_fields = ["session_required_policies", "required_scopes"]
    if session_message is not None:
        safe_fields.append("session_message")
    if prompt is not None:
        safe_fields.append("prompt")

    # Build lists of GAREs that can and cannot be merged
    candidates, non_candidates = [], []
    for g in gares:
        if _is_candidate(g, safe_fields):
            candidates.append(g)
        else:
            non_candidates.append(g)

    # if no GAREs were safe to merge, return early
    if not candidates:
        return non_candidates

    # merge safe GAREs and override any provided field values
    combined = _safe_combine(candidates)
    if session_message is not None:
        combined.authorization_parameters.session_message = session_message
    if prompt is not None:
        combined.authorization_parameters.prompt = prompt

    # return the reduced list of GAREs
    return [combined] + non_candidates


def _is_candidate(g: globus_sdk.gare.GARE, safe_fields: list[str]) -> bool:
    params = g.authorization_parameters

    # check all of the supported GARE fields
    for field_name in (
        "session_message",
        "session_required_identities",
        "session_required_policies",
        "session_required_single_domain",
        "session_required_mfa",
        "required_scopes",
        "prompt",
    ):
        # if the field is considered safe, ignore it
        if field_name in safe_fields:
            continue
        # if the field isn't considered safe and it is set,
        # then the GARE shouldn't be merged
        if getattr(params, field_name) is not None:
            return False

    # if we didn't find any invalidating fields, it must be safe to merge
    return True


def _safe_combine(mergeable_gares: list[globus_sdk.gare.GARE]) -> globus_sdk.gare.GARE:
    code = "AuthorizationRequired"
    if all(g.code == "ConsentRequired" for g in mergeable_gares):
        code = "ConsentRequired"

    combined_params = globus_sdk.gare.GlobusAuthorizationParameters(
        session_required_policies=_concat(
            [
                g.authorization_parameters.session_required_policies
                for g in mergeable_gares
            ]
        ),
        required_scopes=_concat(
            [g.authorization_parameters.required_scopes for g in mergeable_gares]
        ),
    )

    return globus_sdk.gare.GARE(code=code, authorization_parameters=combined_params)


def _concat(values: list[list[str] | None]) -> list[str] | None:
    if all(v is None for v in values):
        return None

    return [element for value in values if value is not None for element in value]


if __name__ == "__main__":
    # these are example errors
    case1 = globus_sdk.gare.to_gare(
        {
            "code": "ConsentRequired",
            "authorization_parameters": {"required_scopes": ["foo"]},
        }
    )
    case2 = globus_sdk.gare.to_gare(
        {
            "code": "ConsentRequired",
            "authorization_parameters": {"required_scopes": ["bar"]},
        }
    )
    case3 = globus_sdk.gare.to_gare(
        {
            "code": "AuthorizationRequired",
            "authorization_parameters": {"required_scopes": ["baz"]},
        }
    )
    case4 = globus_sdk.gare.to_gare(
        {
            "code": "AuthorizationRequired",
            "authorization_parameters": {
                "session_required_policies": [
                    "f2047039-2f07-4f13-b21b-b2edf7f9d329",
                    "2fc6d9a3-9322-48a1-ad39-5dcf63a593a7",
                ],
            },
        }
    )
    case5 = globus_sdk.gare.to_gare(
        {
            "code": "AuthorizationRequired",
            "authorization_parameters": {
                "session_required_policies": [
                    "f2047039-2f07-4f13-b21b-b2edf7f9d329",
                    "2fc6d9a3-9322-48a1-ad39-5dcf63a593a7",
                ],
                "session_required_mfa": True,
            },
        }
    )
    case6 = globus_sdk.gare.to_gare(
        {
            "code": "AuthorizationRequired",
            "authorization_parameters": {
                "session_required_policies": ["ba10f6f1-5b23-4703-bfb7-4fdd7b529546"],
                "session_message": "needs a policy",
            },
        }
    )
    case7 = globus_sdk.gare.to_gare(
        {
            "code": "AuthorizationRequired",
            "authorization_parameters": {
                "session_required_policies": ["ba10f6f1-5b23-4703-bfb7-4fdd7b529546"],
                "prompt": "login",
            },
        }
    )

    print("\n--full merge--\n")
    print("\ncombining two:")
    for g in coalesce(case1, case2):
        print(" -", g)
    print("\ncombining three:")
    for g in coalesce(case1, case2, case3):
        print(" -", g)
    print("\ncombining four:")
    for g in coalesce(case1, case2, case3, case4):
        print(" -", g)

    print("\n--no merge--\n")
    print("\ncombining two:")
    for g in coalesce(case1, case5):
        print(" -", g)
    print("\ncombining two:")
    for g in coalesce(case4, case5):
        print(" -", g)
    print("\ncombining two:")
    for g in coalesce(case2, case6):
        print(" -", g)
    print("\ncombining two:")
    for g in coalesce(case4, case7):
        print(" -", g)

    print("\n--merge due to explicit param--\n")
    print("\ncombining two:")
    for g in coalesce(case1, case6, session_message="explicit message"):
        print(" -", g)
    print("\ncombining two:")
    for g in coalesce(case4, case7, prompt="login"):
        print(" -", g)

Note

This example discards extra fields, which a GARE may store, but which are not part of the format specification.