Skip to content

Commit

Permalink
Implement token autorefresh (Azure#21834)
Browse files Browse the repository at this point in the history
* removed options bag, enabled and fixed tests

* fix build problems

* initial implementation of configurable autorefresh

* python 2.7 compat changes

* py27 compat changes

* fixed linting problems + comments

* py27 fixed flaky test

* linting issues

* CommunicationTokenCredential async implemenation & tests are added

* split async code not to break py27

* lock issue for python 3.10 is fixed

* asyncio.sleep in async tests are removed

* test refactored

* updates in _shared duplicated in chat

* updates in _shared duplicated in sms

* updates in _shared duplicated in networktraversal

* updates in _shared duplicated in phonenumbers

* lint issue fix in utils

* python 2 compatibility fix for generate_token_with_custom_expiry & fixed sync tests termination

* removed unneccasary user credential tests from sms,chat, networktraversal,phonenumber

* reduced the default refresh interval (api review)

* time renamed to interval (api review)

* removed config for refresh time interval

* sync changes across modalities

* linting issues

* linting issues

* implemented fractional backoff + fixed tests

* unify test with the sync version

* fractional backoff tests + linting

* added changelog records + bumped versions

* Removed ayncio.Lock workaround for a bug in Python 3.10

* fixed linting issues

* phonenumbers changelog updated

* fixed PR comments

* removed user_token_refresh_options from communication SDKs

* fix cspell issues

* type hinting fix

* reverted back type hint fix

* PR comment fix

* reflected changes to the identity package & updated tests

* added samples for CommunicationTokenCredential

* renaming proactive refresh flag

* latest PR comments fix

* samples are refactored

* reflecting shared folder changes to other modalitites

* fixed a typo

* fix for pypy threading issue

* fixed test files

* fixed latest PR comments

Co-authored-by: Aigerim Beishenbekova <aigerimb@WIN-8O5AC9CE1AP.reddog.microsoft.com>
Co-authored-by: Aigerim Beishenbekova <aigerimb@Aigerims-MacBook-Pro-2.local>
Co-authored-by: Aigerim <aykobb@gmail.com>
Co-authored-by: Aigerim Beishenbekova <aigerimb@microsoft.com>
  • Loading branch information
5 people authored and sarkar-rajarshi committed Jun 9, 2022
1 parent 4e862ff commit 7461ffd
Show file tree
Hide file tree
Showing 57 changed files with 2,550 additions and 739 deletions.
6 changes: 4 additions & 2 deletions .vscode/cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@
"msrest",
"msrestazure",
"MSSQL",
"mutex",
"myacr",
"nazsdk",
"noarch",
Expand Down Expand Up @@ -383,13 +384,14 @@
]
},
{
"filename": "sdk/communication/azure-communication-identity/tests/*.py",
"filename": "sdk/communication/azure-communication-identity/tests/**",
"words": [
"XVCJ",
"Njgw",
"FNNHHJT",
"Zwiz",
"nypg"
"nypg",
"PBOF"
]
},
{
Expand Down
20 changes: 19 additions & 1 deletion sdk/communication/azure-communication-chat/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## 1.2.0 (Unreleased)

- Added support for proactive refreshing of tokens
- `CommunicationTokenCredential` exposes a new boolean keyword argument `proactive_refresh` that defaults to `False`. If set to `True`, the refreshing of the token will be scheduled in the background ensuring continuous authentication state.
- Added disposal function `close` for `CommunicationTokenCredential`.

### Features Added

### Breaking Changes
Expand All @@ -12,16 +16,20 @@
Python 2.7 is no longer supported. Please use Python version 3.6 or later.

## 1.1.0 (2021-09-15)

- Updated `azure-communication-chat` version.

## 1.1.0b1 (2021-08-16)

### Added

- Added support to add `metadata` for `message`
- Added support to add `sender_display_name` for `ChatThreadClient.send_typing_notification`

## 1.0.0 (2021-03-29)

### Breaking Changes

- Renamed `ChatThread` to `ChatThreadProperties`.
- Renamed `get_chat_thread` to `get_properties`.
- Moved `get_properties` under `ChatThreadClient`.
Expand All @@ -37,22 +45,29 @@ Python 2.7 is no longer supported. Please use Python version 3.6 or later.
- Refactored implementation of `CommunicationUserIdentifier`, `PhoneNumberIdentifier`, `MicrosoftTeamsUserIdentifier`, `UnknownIdentifier` to use a `dict` property bag.

## 1.0.0b5 (2021-03-09)

### Breaking Changes

- Added support for communication identifiers instead of raw strings.
- Changed return type of `create_chat_thread`: `ChatThreadClient -> CreateChatThreadResult`
- Changed return types `add_participants`: `None -> list[(ChatThreadParticipant, CommunicationError)]`
- Added check for failure in `add_participant`
- Dropped support for Python 3.5

### Added

- Removed nullable references from method signatures.

## 1.0.0b4 (2021-02-09)

### Breaking Changes

- Uses `CommunicationUserIdentifier` and `CommunicationIdentifier` in place of `CommunicationUser`, and `CommunicationTokenCredential` instead of `CommunicationUserCredential`.
- Removed priority field (ChatMessage.Priority).
- Renamed PhoneNumber to PhoneNumberIdentifier.

### Added

- Support for CreateChatThreadResult and AddChatParticipantsResult to handle partial errors in batch calls.
- Added idempotency identifier parameter for chat creation calls.
- Added support for readreceipts and getparticipants pagination.
Expand All @@ -61,10 +76,13 @@ Python 2.7 is no longer supported. Please use Python version 3.6 or later.
- Added `MicrosoftTeamsUserIdentifier`.

## 1.0.0b3 (2020-11-16)

- Updated `azure-communication-chat` version.

## 1.0.0b2 (2020-10-06)

- Updated `azure-communication-chat` version.

## 1.0.0b1 (2020-09-22)
- Add ChatClient and ChatThreadClient.

- Add ChatClient and ChatThreadClient.
Original file line number Diff line number Diff line change
Expand Up @@ -3,84 +3,143 @@
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------
from threading import Lock, Condition
from datetime import timedelta
from typing import ( # pylint: disable=unused-import
cast,
Tuple,
)

from threading import Lock, Condition, Timer, TIMEOUT_MAX, Event
from datetime import timedelta
from typing import Any
import six
from .utils import get_current_utc_as_int
from .user_token_refresh_options import CommunicationTokenRefreshOptions
from .utils import create_access_token


class CommunicationTokenCredential(object):
"""Credential type used for authenticating to an Azure Communication service.
:param str token: The token used to authenticate to an Azure Communication service
:keyword token_refresher: The token refresher to provide capacity to fetch fresh token
:raises: TypeError
:param str token: The token used to authenticate to an Azure Communication service.
:keyword token_refresher: The sync token refresher to provide capacity to fetch a fresh token.
The returned token must be valid (expiration date must be in the future).
:paramtype token_refresher: Callable[[], AccessToken]
:keyword bool proactive_refresh: Whether to refresh the token proactively or not.
If the proactive refreshing is enabled ('proactive_refresh' is true), the credential will use
a background thread to attempt to refresh the token within 10 minutes before the cached token expires,
the proactive refresh will request a new token by calling the 'token_refresher' callback.
When 'proactive_refresh' is enabled, the Credential object must be either run within a context manager
or the 'close' method must be called once the object usage has been finished.
:raises: TypeError if paramater 'token' is not a string
:raises: ValueError if the 'proactive_refresh' is enabled without providing the 'token_refresher' callable.
"""

_ON_DEMAND_REFRESHING_INTERVAL_MINUTES = 2

def __init__(self,
token, # type: str
**kwargs
):
token_refresher = kwargs.pop('token_refresher', None)
communication_token_refresh_options = CommunicationTokenRefreshOptions(token=token,
token_refresher=token_refresher)
self._token = communication_token_refresh_options.get_token()
self._token_refresher = communication_token_refresh_options.get_token_refresher()
_DEFAULT_AUTOREFRESH_INTERVAL_MINUTES = 10

def __init__(self, token: str, **kwargs: Any):
if not isinstance(token, six.string_types):
raise TypeError("Token must be a string.")
self._token = create_access_token(token)
self._token_refresher = kwargs.pop('token_refresher', None)
self._proactive_refresh = kwargs.pop('proactive_refresh', False)
if(self._proactive_refresh and self._token_refresher is None):
raise ValueError("When 'proactive_refresh' is True, 'token_refresher' must not be None.")
self._timer = None
self._lock = Condition(Lock())
self._some_thread_refreshing = False
self._is_closed = Event()

def get_token(self, *scopes, **kwargs): # pylint: disable=unused-argument
# type (*str, **Any) -> AccessToken
"""The value of the configured token.
:rtype: ~azure.core.credentials.AccessToken
"""
if self._proactive_refresh and self._is_closed.is_set():
raise RuntimeError("An instance of CommunicationTokenCredential cannot be reused once it has been closed.")

if not self._token_refresher or not self._token_expiring():
if not self._token_refresher or not self._is_token_expiring_soon(self._token):
return self._token
self._update_token_and_reschedule()
return self._token

def _update_token_and_reschedule(self):
should_this_thread_refresh = False

with self._lock:
while self._token_expiring():
while self._is_token_expiring_soon(self._token):
if self._some_thread_refreshing:
if self._is_currenttoken_valid():
if self._is_token_valid(self._token):
return self._token

self._wait_till_inprogress_thread_finish_refreshing()
self._wait_till_lock_owner_finishes_refreshing()
else:
should_this_thread_refresh = True
self._some_thread_refreshing = True
break

if should_this_thread_refresh:
try:
newtoken = self._token_refresher() # pylint:disable=not-callable

new_token = self._token_refresher()
if not self._is_token_valid(new_token):
raise ValueError(
"The token returned from the token_refresher is expired.")
with self._lock:
self._token = newtoken
self._token = new_token
self._some_thread_refreshing = False
self._lock.notify_all()
except:
with self._lock:
self._some_thread_refreshing = False
self._lock.notify_all()

raise
if self._proactive_refresh:
self._schedule_refresh()
return self._token

def _wait_till_inprogress_thread_finish_refreshing(self):
def _schedule_refresh(self):
if self._is_closed.is_set():
return
if self._timer is not None:
self._timer.cancel()

token_ttl = self._token.expires_on - get_current_utc_as_int()

if self._is_token_expiring_soon(self._token):
# Schedule the next refresh for when it reaches a certain percentage of the remaining lifetime.
timespan = token_ttl // 2
else:
# Schedule the next refresh for when it gets in to the soon-to-expire window.
timespan = token_ttl - timedelta(
minutes=self._DEFAULT_AUTOREFRESH_INTERVAL_MINUTES).total_seconds()
if timespan <= TIMEOUT_MAX:
self._timer = Timer(timespan, self._update_token_and_reschedule)
self._timer.daemon = True
self._timer.start()

def _wait_till_lock_owner_finishes_refreshing(self):
self._lock.release()
self._lock.acquire()

def _token_expiring(self):
return self._token.expires_on - get_current_utc_as_int() <\
timedelta(minutes=self._ON_DEMAND_REFRESHING_INTERVAL_MINUTES).total_seconds()

def _is_currenttoken_valid(self):
return get_current_utc_as_int() < self._token.expires_on
def _is_token_expiring_soon(self, token):
if self._proactive_refresh:
interval = timedelta(
minutes=self._DEFAULT_AUTOREFRESH_INTERVAL_MINUTES)
else:
interval = timedelta(
minutes=self._ON_DEMAND_REFRESHING_INTERVAL_MINUTES)
return ((token.expires_on - get_current_utc_as_int())
< interval.total_seconds())

@classmethod
def _is_token_valid(cls, token):
return get_current_utc_as_int() < token.expires_on

def __enter__(self):
if self._proactive_refresh:
if self._is_closed.is_set():
raise RuntimeError(
"An instance of CommunicationTokenCredential cannot be reused once it has been closed.")
self._schedule_refresh()
return self

def __exit__(self, *args):
self.close()

def close(self) -> None:
if self._timer is not None:
self._timer.cancel()
self._timer = None
self._is_closed.set()
Loading

0 comments on commit 7461ffd

Please sign in to comment.