diff --git a/README.md b/README.md
index f570672c..eb134674 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,12 @@ Quick links:
| [Getting Started](https://docs.microsoft.com/azure/active-directory/develop/quickstart-v2-python-webapp) | [Docs](https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki) | [Samples](https://aka.ms/aaddevsamplesv2) | [Support](README.md#community-help-and-support) | [Feedback](https://forms.office.com/r/TMjZkDbzjY) |
| --- | --- | --- | --- | --- |
+## Scenarios supported
+
+Click on the following thumbnail to visit a large map with clickable links to proper samples.
+
+[![Map effect won't work inside github's markdown file, so we have to use a thumbnail here to lure audience to a real static website](docs/thumbnail.png)](https://msal-python.readthedocs.io/en/latest/)
+
## Installation
You can find MSAL Python on [Pypi](https://pypi.org/project/msal/).
diff --git a/docs/daemon-app.svg b/docs/daemon-app.svg
new file mode 100644
index 00000000..8f1af659
--- /dev/null
+++ b/docs/daemon-app.svg
@@ -0,0 +1,1074 @@
+
+
+
+
\ No newline at end of file
diff --git a/docs/index.rst b/docs/index.rst
index 439ca0ee..95b89b98 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -10,17 +10,67 @@ MSAL Python documentation
GitHub Repository
You can find high level conceptual documentations in the project
-`README `_
-and
-`workable samples inside the project code base
-`_
-.
+`README `_.
+
+Scenarios
+=========
+
+There are many `different application scenarios `_.
+MSAL Python supports some of them.
+**The following diagram serves as a map. Locate your application scenario on the map.**
+**If the corresponding icon is clickable, it will bring you to an MSAL Python sample for that scenario.**
+
+* Most authentication scenarios acquire tokens on behalf of signed-in users.
+
+ .. raw:: html
+
+
+
+
+
+
+* There are also daemon apps. In these scenarios, applications acquire tokens on behalf of themselves with no user.
+
+ .. raw:: html
+
+
+
+
+
+
+* There are other less common samples, such for ADAL-to-MSAL migration,
+ `available inside the project code base
+ `_.
-The documentation hosted here is for API Reference.
API
===
+The following section is the API Reference of MSAL Python.
+
+.. note::
+
+ Only APIs and their parameters documented in this section are part of public API,
+ with guaranteed backward compatibility for the entire 1.x series.
+
+ Other modules in the source code are all considered as internal helpers,
+ which could change at anytime in the future, without prior notice.
+
MSAL proposes a clean separation between
`public client applications and confidential client applications
`_.
@@ -35,6 +85,8 @@ PublicClientApplication
:members:
:inherited-members:
+ .. automethod:: __init__
+
ConfidentialClientApplication
-----------------------------
@@ -42,6 +94,8 @@ ConfidentialClientApplication
:members:
:inherited-members:
+ .. automethod:: __init__
+
TokenCache
----------
diff --git a/docs/scenarios-with-users.svg b/docs/scenarios-with-users.svg
new file mode 100644
index 00000000..fffdec47
--- /dev/null
+++ b/docs/scenarios-with-users.svg
@@ -0,0 +1,2789 @@
+
+
+
+
\ No newline at end of file
diff --git a/docs/thumbnail.png b/docs/thumbnail.png
new file mode 100644
index 00000000..e1606e91
Binary files /dev/null and b/docs/thumbnail.png differ
diff --git a/msal/__init__.py b/msal/__init__.py
index 824363fb..4e2faaed 100644
--- a/msal/__init__.py
+++ b/msal/__init__.py
@@ -31,5 +31,6 @@
ConfidentialClientApplication,
PublicClientApplication,
)
+from .oauth2cli.oidc import Prompt
from .token_cache import TokenCache, SerializableTokenCache
diff --git a/msal/application.py b/msal/application.py
index 6ab23dfe..cb2ee87a 100644
--- a/msal/application.py
+++ b/msal/application.py
@@ -19,40 +19,14 @@
from .wstrust_response import *
from .token_cache import TokenCache
import msal.telemetry
+from .region import _detect_region
# The __init__.py will import this. Not the other way around.
-__version__ = "1.11.0"
+__version__ = "1.12.0"
logger = logging.getLogger(__name__)
-def decorate_scope(
- scopes, client_id,
- reserved_scope=frozenset(['openid', 'profile', 'offline_access'])):
- if not isinstance(scopes, (list, set, tuple)):
- raise ValueError("The input scopes should be a list, tuple, or set")
- scope_set = set(scopes) # Input scopes is typically a list. Copy it to a set.
- if scope_set & reserved_scope:
- # These scopes are reserved for the API to provide good experience.
- # We could make the developer pass these and then if they do they will
- # come back asking why they don't see refresh token or user information.
- raise ValueError(
- "API does not accept {} value as user-provided scopes".format(
- reserved_scope))
- if client_id in scope_set:
- if len(scope_set) > 1:
- # We make developers pass their client id, so that they can express
- # the intent that they want the token for themselves (their own
- # app).
- # If we do not restrict them to passing only client id then they
- # could write code where they expect an id token but end up getting
- # access_token.
- raise ValueError("Client Id can only be provided as a single scope")
- decorated = set(reserved_scope) # Make a writable copy
- else:
- decorated = scope_set | reserved_scope
- return list(decorated)
-
def extract_certs(public_cert_content):
# Parses raw public certificate file contents and returns a list of strings
@@ -108,6 +82,8 @@ class ClientApplication(object):
GET_ACCOUNTS_ID = "902"
REMOVE_ACCOUNT_ID = "903"
+ ATTEMPT_REGION_DISCOVERY = True # "TryAutoDetect"
+
def __init__(
self, client_id,
client_credential=None, authority=None, validate_authority=True,
@@ -115,7 +91,13 @@ def __init__(
http_client=None,
verify=True, proxies=None, timeout=None,
client_claims=None, app_name=None, app_version=None,
- client_capabilities=None):
+ client_capabilities=None,
+ azure_region=None, # Note: We choose to add this param in this base class,
+ # despite it is currently only needed by ConfidentialClientApplication.
+ # This way, it holds the same positional param place for PCA,
+ # when we would eventually want to add this feature to PCA in future.
+ exclude_scopes=None,
+ ):
"""Create an instance of application.
:param str client_id: Your app has a client_id after you register it on AAD.
@@ -220,11 +202,75 @@ def __init__(
MSAL will combine them into
`claims parameter `_.
+
+ 4. An app which already onboard to the region's allow-list.
+
+ MSAL's default value is None, which means region behavior remains off.
+ If enabled, the `acquire_token_for_client()`-relevant traffic
+ would remain inside that region.
+
+ App developer can opt in to a regional endpoint,
+ by provide its region name, such as "westus", "eastus2".
+ You can find a full list of regions by running
+ ``az account list-locations -o table``, or referencing to
+ `this doc `_.
+
+ An app running inside Azure Functions and Azure VM can use a special keyword
+ ``ClientApplication.ATTEMPT_REGION_DISCOVERY`` to auto-detect region.
+
+ .. note::
+
+ Setting ``azure_region`` to non-``None`` for an app running
+ outside of Azure Function/VM could hang indefinitely.
+
+ You should consider opting in/out region behavior on-demand,
+ by loading ``azure_region=None`` or ``azure_region="westus"``
+ or ``azure_region=True`` (which means opt-in and auto-detect)
+ from your per-deployment configuration, and then do
+ ``app = ConfidentialClientApplication(..., azure_region=azure_region)``.
+
+ Alternatively, you can configure a short timeout,
+ or provide a custom http_client which has a short timeout.
+ That way, the latency would be under your control,
+ but still less performant than opting out of region feature.
+ :param list[str] exclude_scopes: (optional)
+ Historically MSAL hardcodes `offline_access` scope,
+ which would allow your app to have prolonged access to user's data.
+ If that is unnecessary or undesirable for your app,
+ now you can use this parameter to supply an exclusion list of scopes,
+ such as ``exclude_scopes = ["offline_access"]``.
"""
self.client_id = client_id
self.client_credential = client_credential
self.client_claims = client_claims
self._client_capabilities = client_capabilities
+
+ if exclude_scopes and not isinstance(exclude_scopes, list):
+ raise ValueError(
+ "Invalid exclude_scopes={}. It need to be a list of strings.".format(
+ repr(exclude_scopes)))
+ self._exclude_scopes = frozenset(exclude_scopes or [])
+ if "openid" in self._exclude_scopes:
+ raise ValueError(
+ 'Invalid exclude_scopes={}. You can not opt out "openid" scope'.format(
+ repr(exclude_scopes)))
+
if http_client:
self.http_client = http_client
else:
@@ -244,22 +290,93 @@ def __init__(
self.app_name = app_name
self.app_version = app_version
- self.authority = Authority(
+
+ # Here the self.authority will not be the same type as authority in input
+ try:
+ self.authority = Authority(
authority or "https://login.microsoftonline.com/common/",
self.http_client, validate_authority=validate_authority)
- # Here the self.authority is not the same type as authority in input
+ except ValueError: # Those are explicit authority validation errors
+ raise
+ except Exception: # The rest are typically connection errors
+ if validate_authority and azure_region:
+ # Since caller opts in to use region, here we tolerate connection
+ # errors happened during authority validation at non-region endpoint
+ self.authority = Authority(
+ authority or "https://login.microsoftonline.com/common/",
+ self.http_client, validate_authority=False)
+ else:
+ raise
+
self.token_cache = token_cache or TokenCache()
- self.client = self._build_client(client_credential, self.authority)
+ self._region_configured = azure_region
+ self._region_detected = None
+ self.client, self._regional_client = self._build_client(
+ client_credential, self.authority)
self.authority_groups = None
self._telemetry_buffer = {}
self._telemetry_lock = Lock()
+ def _decorate_scope(
+ self, scopes,
+ reserved_scope=frozenset(['openid', 'profile', 'offline_access'])):
+ if not isinstance(scopes, (list, set, tuple)):
+ raise ValueError("The input scopes should be a list, tuple, or set")
+ scope_set = set(scopes) # Input scopes is typically a list. Copy it to a set.
+ if scope_set & reserved_scope:
+ # These scopes are reserved for the API to provide good experience.
+ # We could make the developer pass these and then if they do they will
+ # come back asking why they don't see refresh token or user information.
+ raise ValueError(
+ "API does not accept {} value as user-provided scopes".format(
+ reserved_scope))
+ if self.client_id in scope_set:
+ if len(scope_set) > 1:
+ # We make developers pass their client id, so that they can express
+ # the intent that they want the token for themselves (their own
+ # app).
+ # If we do not restrict them to passing only client id then they
+ # could write code where they expect an id token but end up getting
+ # access_token.
+ raise ValueError("Client Id can only be provided as a single scope")
+ decorated = set(reserved_scope) # Make a writable copy
+ else:
+ decorated = scope_set | reserved_scope
+ decorated -= self._exclude_scopes
+ return list(decorated)
+
def _build_telemetry_context(
self, api_id, correlation_id=None, refresh_reason=None):
return msal.telemetry._TelemetryContext(
self._telemetry_buffer, self._telemetry_lock, api_id,
correlation_id=correlation_id, refresh_reason=refresh_reason)
+ def _get_regional_authority(self, central_authority):
+ is_region_specified = bool(self._region_configured
+ and self._region_configured != self.ATTEMPT_REGION_DISCOVERY)
+ self._region_detected = self._region_detected or _detect_region(
+ self.http_client if self._region_configured is not None else None)
+ if (is_region_specified and self._region_configured != self._region_detected):
+ logger.warning('Region configured ({}) != region detected ({})'.format(
+ repr(self._region_configured), repr(self._region_detected)))
+ region_to_use = (
+ self._region_configured if is_region_specified else self._region_detected)
+ if region_to_use:
+ logger.info('Region to be used: {}'.format(repr(region_to_use)))
+ regional_host = ("{}.login.microsoft.com".format(region_to_use)
+ if central_authority.instance in (
+ # The list came from https://github.com/AzureAD/microsoft-authentication-library-for-python/pull/358/files#r629400328
+ "login.microsoftonline.com",
+ "login.windows.net",
+ "sts.windows.net",
+ )
+ else "{}.{}".format(region_to_use, central_authority.instance))
+ return Authority(
+ "https://{}/{}".format(regional_host, central_authority.tenant),
+ self.http_client,
+ validate_authority=False) # The central_authority has already been validated
+ return None
+
def _build_client(self, client_credential, authority):
client_assertion = None
client_assertion_type = None
@@ -298,15 +415,15 @@ def _build_client(self, client_credential, authority):
client_assertion_type = Client.CLIENT_ASSERTION_TYPE_JWT
else:
default_body['client_secret'] = client_credential
- server_configuration = {
+ central_configuration = {
"authorization_endpoint": authority.authorization_endpoint,
"token_endpoint": authority.token_endpoint,
"device_authorization_endpoint":
authority.device_authorization_endpoint or
urljoin(authority.token_endpoint, "devicecode"),
}
- return Client(
- server_configuration,
+ central_client = Client(
+ central_configuration,
self.client_id,
http_client=self.http_client,
default_headers=default_headers,
@@ -318,6 +435,31 @@ def _build_client(self, client_credential, authority):
on_removing_rt=self.token_cache.remove_rt,
on_updating_rt=self.token_cache.update_rt)
+ regional_client = None
+ if client_credential: # Currently regional endpoint only serves some CCA flows
+ regional_authority = self._get_regional_authority(authority)
+ if regional_authority:
+ regional_configuration = {
+ "authorization_endpoint": regional_authority.authorization_endpoint,
+ "token_endpoint": regional_authority.token_endpoint,
+ "device_authorization_endpoint":
+ regional_authority.device_authorization_endpoint or
+ urljoin(regional_authority.token_endpoint, "devicecode"),
+ }
+ regional_client = Client(
+ regional_configuration,
+ self.client_id,
+ http_client=self.http_client,
+ default_headers=default_headers,
+ default_body=default_body,
+ client_assertion=client_assertion,
+ client_assertion_type=client_assertion_type,
+ on_obtaining_tokens=lambda event: self.token_cache.add(dict(
+ event, environment=authority.instance)),
+ on_removing_rt=self.token_cache.remove_rt,
+ on_updating_rt=self.token_cache.update_rt)
+ return central_client, regional_client
+
def initiate_auth_code_flow(
self,
scopes, # type: list[str]
@@ -382,7 +524,7 @@ def initiate_auth_code_flow(
flow = client.initiate_auth_code_flow(
redirect_uri=redirect_uri, state=state, login_hint=login_hint,
prompt=prompt,
- scope=decorate_scope(scopes, self.client_id),
+ scope=self._decorate_scope(scopes),
domain_hint=domain_hint,
claims=_merge_claims_challenge_and_capabilities(
self._client_capabilities, claims_challenge),
@@ -464,7 +606,7 @@ def get_authorization_request_url(
response_type=response_type,
redirect_uri=redirect_uri, state=state, login_hint=login_hint,
prompt=prompt,
- scope=decorate_scope(scopes, self.client_id),
+ scope=self._decorate_scope(scopes),
nonce=nonce,
domain_hint=domain_hint,
claims=_merge_claims_challenge_and_capabilities(
@@ -527,7 +669,7 @@ def authorize(): # A controller in a web app
response =_clean_up(self.client.obtain_token_by_auth_code_flow(
auth_code_flow,
auth_response,
- scope=decorate_scope(scopes, self.client_id) if scopes else None,
+ scope=self._decorate_scope(scopes) if scopes else None,
headers=telemetry_context.generate_headers(),
data=dict(
kwargs.pop("data", {}),
@@ -599,7 +741,7 @@ def acquire_token_by_authorization_code(
self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID)
response = _clean_up(self.client.obtain_token_by_authorization_code(
code, redirect_uri=redirect_uri,
- scope=decorate_scope(scopes, self.client_id),
+ scope=self._decorate_scope(scopes),
headers=telemetry_context.generate_headers(),
data=dict(
kwargs.pop("data", {}),
@@ -634,6 +776,13 @@ def get_accounts(self, username=None):
lowercase_username = username.lower()
accounts = [a for a in accounts
if a["username"].lower() == lowercase_username]
+ if not accounts:
+ logger.warning((
+ "get_accounts(username='{}') finds no account. "
+ "If tokens were acquired without 'profile' scope, "
+ "they would contain no username for filtering. "
+ "Consider calling get_accounts(username=None) instead."
+ ).format(username))
# Does not further filter by existing RTs here. It probably won't matter.
# Because in most cases Accounts and RTs co-exist.
# Even in the rare case when an RT is revoked and then removed,
@@ -642,10 +791,25 @@ def get_accounts(self, username=None):
return accounts
def _find_msal_accounts(self, environment):
- return [a for a in self.token_cache.find(
- TokenCache.CredentialType.ACCOUNT, query={"environment": environment})
+ grouped_accounts = {
+ a.get("home_account_id"): # Grouped by home tenant's id
+ { # These are minimal amount of non-tenant-specific account info
+ "home_account_id": a.get("home_account_id"),
+ "environment": a.get("environment"),
+ "username": a.get("username"),
+
+ # The following fields for backward compatibility, for now
+ "authority_type": a.get("authority_type"),
+ "local_account_id": a.get("local_account_id"), # Tenant-specific
+ "realm": a.get("realm"), # Tenant-specific
+ }
+ for a in self.token_cache.find(
+ TokenCache.CredentialType.ACCOUNT,
+ query={"environment": environment})
if a["authority_type"] in (
- TokenCache.AuthorityType.ADFS, TokenCache.AuthorityType.MSSTS)]
+ TokenCache.AuthorityType.ADFS, TokenCache.AuthorityType.MSSTS)
+ }
+ return list(grouped_accounts.values())
def _get_authority_aliases(self, instance):
if not self.authority_groups:
@@ -875,7 +1039,7 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
assert refresh_reason, "It should have been established at this point"
try:
result = _clean_up(self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(
- authority, decorate_scope(scopes, self.client_id), account,
+ authority, self._decorate_scope(scopes), account,
refresh_reason=refresh_reason, claims_challenge=claims_challenge,
**kwargs))
if (result and "error" not in result) or (not access_token_from_cache):
@@ -938,7 +1102,7 @@ def _acquire_token_silent_by_finding_specific_refresh_token(
# target=scopes, # AAD RTs are scope-independent
query=query)
logger.debug("Found %d RTs matching %s", len(matches), query)
- client = self._build_client(self.client_credential, authority)
+ client, _ = self._build_client(self.client_credential, authority)
response = None # A distinguishable value to mean cache is empty
telemetry_context = self._build_telemetry_context(
@@ -1021,7 +1185,7 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes, **kwargs):
refresh_reason=msal.telemetry.FORCE_REFRESH)
response = _clean_up(self.client.obtain_token_by_refresh_token(
refresh_token,
- scope=decorate_scope(scopes, self.client_id),
+ scope=self._decorate_scope(scopes),
headers=telemetry_context.generate_headers(),
rt_getter=lambda rt: rt,
on_updating_rt=False,
@@ -1052,7 +1216,7 @@ def acquire_token_by_username_password(
- A successful response would contain "access_token" key,
- an error response would contain "error" and usually "error_description".
"""
- scopes = decorate_scope(scopes, self.client_id)
+ scopes = self._decorate_scope(scopes)
telemetry_context = self._build_telemetry_context(
self.ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID)
headers = telemetry_context.generate_headers()
@@ -1113,7 +1277,13 @@ def _acquire_token_by_username_password_federated(
self.client.grant_assertion_encoders.setdefault( # Register a non-standard type
grant_type, self.client.encode_saml_assertion)
return self.client.obtain_token_by_assertion(
- wstrust_result["token"], grant_type, scope=scopes, **kwargs)
+ wstrust_result["token"], grant_type, scope=scopes,
+ on_obtaining_tokens=lambda event: self.token_cache.add(dict(
+ event,
+ environment=self.authority.instance,
+ username=username, # Useful in case IDT contains no such info
+ )),
+ **kwargs)
class PublicClientApplication(ClientApplication): # browser app or mobile app
@@ -1192,7 +1362,7 @@ def acquire_token_interactive(
telemetry_context = self._build_telemetry_context(
self.ACQUIRE_TOKEN_INTERACTIVE)
response = _clean_up(self.client.obtain_token_by_browser(
- scope=decorate_scope(scopes, self.client_id) if scopes else None,
+ scope=self._decorate_scope(scopes) if scopes else None,
extra_scope_to_consent=extra_scopes_to_consent,
redirect_uri="http://localhost:{port}".format(
# Hardcode the host, for now. AAD portal rejects 127.0.0.1 anyway
@@ -1223,7 +1393,7 @@ def initiate_device_flow(self, scopes=None, **kwargs):
"""
correlation_id = msal.telemetry._get_new_correlation_id()
flow = self.client.initiate_device_flow(
- scope=decorate_scope(scopes or [], self.client_id),
+ scope=self._decorate_scope(scopes or []),
headers={msal.telemetry.CLIENT_REQUEST_ID: correlation_id},
**kwargs)
flow[self.DEVICE_FLOW_CORRELATION_ID] = correlation_id
@@ -1289,7 +1459,8 @@ def acquire_token_for_client(self, scopes, claims_challenge=None, **kwargs):
self._validate_ssh_cert_input_data(kwargs.get("data", {}))
telemetry_context = self._build_telemetry_context(
self.ACQUIRE_TOKEN_FOR_CLIENT_ID)
- response = _clean_up(self.client.obtain_token_for_client(
+ client = self._regional_client or self.client
+ response = _clean_up(client.obtain_token_for_client(
scope=scopes, # This grant flow requires no scope decoration
headers=telemetry_context.generate_headers(),
data=dict(
@@ -1333,7 +1504,7 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No
response = _clean_up(self.client.obtain_token_by_assertion( # bases on assertion RFC 7521
user_assertion,
self.client.GRANT_TYPE_JWT, # IDTs and AAD ATs are all JWTs
- scope=decorate_scope(scopes, self.client_id), # Decoration is used for:
+ scope=self._decorate_scope(scopes), # Decoration is used for:
# 1. Explicitly requesting an RT, without relying on AAD default
# behavior, even though it currently still issues an RT.
# 2. Requesting an IDT (which would otherwise be unavailable)
diff --git a/msal/oauth2cli/oauth2.py b/msal/oauth2cli/oauth2.py
index 267bdc44..04a6b70d 100644
--- a/msal/oauth2cli/oauth2.py
+++ b/msal/oauth2cli/oauth2.py
@@ -770,7 +770,6 @@ def obtain_token_by_refresh_token(self, token_item, scope=None,
rt_getter=lambda token_item: token_item["refresh_token"],
on_removing_rt=None,
on_updating_rt=None,
- on_obtaining_tokens=None,
**kwargs):
# type: (Union[str, dict], Union[str, list, set, tuple], Callable) -> dict
"""This is an overload which will trigger token storage callbacks.
diff --git a/msal/oauth2cli/oidc.py b/msal/oauth2cli/oidc.py
index 75f23276..114693b1 100644
--- a/msal/oauth2cli/oidc.py
+++ b/msal/oauth2cli/oidc.py
@@ -83,6 +83,19 @@ def _nonce_hash(nonce):
return hashlib.sha256(nonce.encode("ascii")).hexdigest()
+class Prompt(object):
+ """This class defines the constant strings for prompt parameter.
+
+ The values are based on
+ https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
+ """
+ NONE = "none"
+ LOGIN = "login"
+ CONSENT = "consent"
+ SELECT_ACCOUNT = "select_account"
+ CREATE = "create" # Defined in https://openid.net/specs/openid-connect-prompt-create-1_0.html#PromptParameter
+
+
class Client(oauth2.Client):
"""OpenID Connect is a layer on top of the OAuth2.
@@ -217,6 +230,8 @@ def obtain_token_by_browser(
`OIDC `_.
:param string prompt: Defined in
`OIDC `_.
+ You can find the valid string values defined in :class:`oidc.Prompt`.
+
:param int max_age: Defined in
`OIDC `_.
:param string ui_locales: Defined in
@@ -232,7 +247,7 @@ def obtain_token_by_browser(
for descriptions on other parameters and return value.
"""
filtered_params = {k:v for k, v in dict(
- prompt=prompt,
+ prompt=" ".join(prompt) if isinstance(prompt, (list, tuple)) else prompt,
display=display,
max_age=max_age,
ui_locales=ui_locales,
diff --git a/msal/region.py b/msal/region.py
new file mode 100644
index 00000000..6ad84c45
--- /dev/null
+++ b/msal/region.py
@@ -0,0 +1,47 @@
+import os
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def _detect_region(http_client=None):
+ region = _detect_region_of_azure_function() # It is cheap, so we do it always
+ if http_client and not region:
+ return _detect_region_of_azure_vm(http_client) # It could hang for minutes
+ return region
+
+
+def _detect_region_of_azure_function():
+ return os.environ.get("REGION_NAME")
+
+
+def _detect_region_of_azure_vm(http_client):
+ url = (
+ "http://169.254.169.254/metadata/instance"
+
+ # Utilize the "route parameters" feature to obtain region as a string
+ # https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service?tabs=linux#route-parameters
+ "/compute/location?format=text"
+
+ # Location info is available since API version 2017-04-02
+ # https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service?tabs=linux#response-1
+ "&api-version=2021-01-01"
+ )
+ logger.info(
+ "Connecting to IMDS {}. "
+ "It may take a while if you are running outside of Azure. "
+ "You should consider opting in/out region behavior on-demand, "
+ 'by loading a boolean flag "is_deployed_in_azure" '
+ 'from your per-deployment config and then do '
+ '"app = ConfidentialClientApplication(..., '
+ 'azure_region=is_deployed_in_azure)"'.format(url))
+ try:
+ # https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service?tabs=linux#instance-metadata
+ resp = http_client.get(url, headers={"Metadata": "true"})
+ except:
+ logger.info(
+ "IMDS {} unavailable. Perhaps not running in Azure VM?".format(url))
+ return None
+ else:
+ return resp.text.strip()
+
diff --git a/msal/token_cache.py b/msal/token_cache.py
index b0731278..5b31b299 100644
--- a/msal/token_cache.py
+++ b/msal/token_cache.py
@@ -108,11 +108,13 @@ def wipe(dictionary, sensitive_fields): # Masks sensitive info
if sensitive in dictionary:
dictionary[sensitive] = "********"
wipe(event.get("data", {}),
- ("password", "client_secret", "refresh_token", "assertion", "username"))
+ ("password", "client_secret", "refresh_token", "assertion"))
try:
return self.__add(event, now=now)
finally:
- wipe(event.get("response", {}), ("access_token", "refresh_token"))
+ wipe(event.get("response", {}), ( # These claims were useful during __add()
+ "access_token", "refresh_token", "id_token", "username"))
+ wipe(event, ["username"]) # Needed for federated ROPC
logger.debug("event=%s", json.dumps(
# We examined and concluded that this log won't have Log Injection risk,
# because the event payload is already in JSON so CR/LF will be escaped.
@@ -184,6 +186,8 @@ def __add(self, event, now=None):
"oid", id_token_claims.get("sub")),
"username": id_token_claims.get("preferred_username") # AAD
or id_token_claims.get("upn") # ADFS 2019
+ or data.get("username") # Falls back to ROPC username
+ or event.get("username") # Falls back to Federated ROPC username
or "", # The schema does not like null
"authority_type":
self.AuthorityType.ADFS if realm == "adfs"
diff --git a/sample/interactive_sample.py b/sample/interactive_sample.py
index 6aafd160..b5f3950f 100644
--- a/sample/interactive_sample.py
+++ b/sample/interactive_sample.py
@@ -62,7 +62,9 @@
# after already extracting the username from an earlier sign-in
# by using the preferred_username claim from returned id_token_claims.
- #prompt="select_account", # Optional. It forces to show account selector page
+ #prompt=msal.Prompt.SELECT_ACCOUNT, # Or simply "select_account". Optional. It forces to show account selector page
+ #prompt=msal.Prompt.CREATE, # Or simply "create". Optional. It brings user to a self-service sign-up flow.
+ # Prerequisite: https://docs.microsoft.com/en-us/azure/active-directory/external-identities/self-service-sign-up-user-flow
)
if "access_token" in result:
diff --git a/setup.py b/setup.py
index 0555c667..79bdda3e 100644
--- a/setup.py
+++ b/setup.py
@@ -58,11 +58,11 @@
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
- 'Programming Language :: Python :: 3.3',
- 'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
+ 'Programming Language :: Python :: 3.8',
+ 'Programming Language :: Python :: 3.9',
'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent',
],
@@ -84,6 +84,7 @@
# We will go with "<4" for now, which is also what our another dependency,
# pyjwt, currently use.
+ "mock;python_version<'3.3'",
]
)
diff --git a/tests/test_application.py b/tests/test_application.py
index f4787e2c..ea98b16f 100644
--- a/tests/test_application.py
+++ b/tests/test_application.py
@@ -555,3 +555,42 @@ def mock_post(url, headers=None, *args, **kwargs):
result = self.app.acquire_token_on_behalf_of("assertion", ["s"], post=mock_post)
self.assertEqual(at, result.get("access_token"))
+
+class TestClientApplicationWillGroupAccounts(unittest.TestCase):
+ def test_get_accounts(self):
+ client_id = "my_app"
+ scopes = ["scope_1", "scope_2"]
+ environment = "login.microsoftonline.com"
+ uid = "home_oid"
+ utid = "home_tenant_guid"
+ username = "Jane Doe"
+ cache = msal.SerializableTokenCache()
+ for tenant in ["contoso", "fabrikam"]:
+ cache.add({
+ "client_id": client_id,
+ "scope": scopes,
+ "token_endpoint":
+ "https://{}/{}/oauth2/v2.0/token".format(environment, tenant),
+ "response": TokenCacheTestCase.build_response(
+ uid=uid, utid=utid, access_token="at", refresh_token="rt",
+ id_token=TokenCacheTestCase.build_id_token(
+ aud=client_id,
+ sub="oid_in_" + tenant,
+ preferred_username=username,
+ ),
+ ),
+ })
+ app = ClientApplication(
+ client_id,
+ authority="https://{}/common".format(environment),
+ token_cache=cache)
+ accounts = app.get_accounts()
+ self.assertEqual(1, len(accounts), "Should return one grouped account")
+ account = accounts[0]
+ self.assertEqual("{}.{}".format(uid, utid), account["home_account_id"])
+ self.assertEqual(environment, account["environment"])
+ self.assertEqual(username, account["username"])
+ self.assertIn("authority_type", account, "Backward compatibility")
+ self.assertIn("local_account_id", account, "Backward compatibility")
+ self.assertIn("realm", account, "Backward compatibility")
+
diff --git a/tests/test_e2e.py b/tests/test_e2e.py
index b9886257..b7280e8f 100644
--- a/tests/test_e2e.py
+++ b/tests/test_e2e.py
@@ -4,11 +4,15 @@
import time
import unittest
import sys
+try:
+ from unittest.mock import patch, ANY
+except:
+ from mock import patch, ANY
import requests
import msal
-from tests.http_client import MinimalHttpClient
+from tests.http_client import MinimalHttpClient, MinimalResponse
from msal.oauth2cli import AuthCodeReceiver
logger = logging.getLogger(__name__)
@@ -58,6 +62,12 @@ def assertLoosely(self, response, assertion=None,
def assertCacheWorksForUser(
self, result_from_wire, scope, username=None, data=None):
+ logger.debug(
+ "%s: cache = %s, id_token_claims = %s",
+ self.id(),
+ json.dumps(self.app.token_cache._cache, indent=4),
+ json.dumps(result_from_wire.get("id_token_claims"), indent=4),
+ )
# You can filter by predefined username, or let end user to choose one
accounts = self.app.get_accounts(username=username)
self.assertNotEqual(0, len(accounts))
@@ -76,10 +86,15 @@ def assertCacheWorksForUser(
result_from_wire['access_token'], result_from_cache['access_token'],
"We should get a cached AT")
- # Going to test acquire_token_silent(...) to obtain an AT by a RT from cache
- self.app.token_cache._cache["AccessToken"] = {} # A hacky way to clear ATs
+ if "refresh_token" in result_from_wire:
+ # Going to test acquire_token_silent(...) to obtain an AT by a RT from cache
+ self.app.token_cache._cache["AccessToken"] = {} # A hacky way to clear ATs
result_from_cache = self.app.acquire_token_silent(
scope, account=account, data=data or {})
+ if "refresh_token" not in result_from_wire:
+ self.assertEqual(
+ result_from_cache["access_token"], result_from_wire["access_token"],
+ "The previously cached AT should be returned")
self.assertIsNotNone(result_from_cache,
"We should get a result from acquire_token_silent(...) call")
self.assertIsNotNone(
@@ -93,6 +108,12 @@ def assertCacheWorksForUser(
"We should get an AT from acquire_token_silent(...) call")
def assertCacheWorksForApp(self, result_from_wire, scope):
+ logger.debug(
+ "%s: cache = %s, id_token_claims = %s",
+ self.id(),
+ json.dumps(self.app.token_cache._cache, indent=4),
+ json.dumps(result_from_wire.get("id_token_claims"), indent=4),
+ )
# Going to test acquire_token_silent(...) to locate an AT from cache
result_from_cache = self.app.acquire_token_silent(scope, account=None)
self.assertIsNotNone(result_from_cache)
@@ -111,10 +132,9 @@ def _test_username_password(self,
result = self.app.acquire_token_by_username_password(
username, password, scopes=scope)
self.assertLoosely(result)
- # self.assertEqual(None, result.get("error"), str(result))
self.assertCacheWorksForUser(
result, scope,
- username=username if ".b2clogin.com" not in authority else None,
+ username=username, # Our implementation works even when "profile" scope was not requested, or when profile claims is unavailable in B2C
)
def _test_device_flow(
@@ -164,12 +184,6 @@ def _test_acquire_token_interactive(