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_scopessession_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.
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.