-
Notifications
You must be signed in to change notification settings - Fork 3.2k
identity_vscode_credential #10840
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
identity_vscode_credential #10840
Changes from 71 commits
0002805
b1e79de
13fd676
d356ba6
41b0394
526470d
dc671f2
5a47894
c797482
25de404
f48fdd4
a77e336
9f5ad5d
d155f80
015f1f7
bfa2ea1
16873bd
58844b9
05694a9
671d5ea
921b7a2
3911b88
f313ba4
283a055
9974361
4e7b9c4
bbe7a20
94b152b
657617c
426dea7
f9a2abf
593a62f
0805e74
eaf10cb
e1dab12
9c33cfd
d072e20
6024a9a
a63e157
a8d45c0
2fca38a
3866a93
ebb5df2
595cdea
246ab2d
af482e6
57953ba
022f376
7e15ddd
975f6ed
767906f
1e10649
cf54345
e43a74f
4fe1be3
0fd89e6
5524ea9
ff9d532
e6b83ca
9232a3b
9ea845e
77f8838
0f69230
ec1f0a3
4fadaf3
435fdce
cb74ce2
a402f3b
853dd3e
54d1b48
75624b8
ed1db0c
6fc8708
7d36ad5
6f8ae61
42270b8
5c8bcec
77cc4d8
10a9e43
ff8e1c7
8efee5c
a5dc253
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| # ------------------------------------ | ||
| # Copyright (c) Microsoft Corporation. | ||
| # Licensed under the MIT License. | ||
| # ------------------------------------ | ||
| import os | ||
| import json | ||
| import ctypes as ct | ||
| from .._constants import VSCODE_CREDENTIALS_SECTION | ||
|
|
||
|
|
||
| def _c_str(string): | ||
| return ct.c_char_p(string.encode('utf-8')) | ||
|
|
||
|
|
||
| try: | ||
| _libsecret = ct.cdll.LoadLibrary('libsecret-1.so.0') | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we want a message if the lib is not available, to do something like
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In fact, this credential is usually used in DefaultCredential which is a chain of credentials. If one does not work, it will try next one. So if libsecret-1-0 is not installed, we prefer just skip this one and go to next credential rather than failing.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In other words, if libsecret-1-0 is not installed, the feature is disabled silently.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Re-thinking this, I guess if they have vscode the lib is available, or maybe vscode embed it to save their credentials?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Azure connectors (e.g. azure-account, azure-storage, etc.) are vs code extensions. User can use VS code w/o them. Given this case, user uses vs code w/o Azure extensions. (and libsecret-1-0 is not installed). We should just not do anything, right?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I got my answer: This means libsecret will be installed if you have vscode |
||
| _libsecret.secret_schema_new.argtypes = \ | ||
| [ct.c_char_p, ct.c_uint, ct.c_char_p, ct.c_uint, ct.c_char_p, ct.c_uint, ct.c_void_p] | ||
| _libsecret.secret_password_lookup_sync.argtypes = \ | ||
lmazuel marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| [ct.c_void_p, ct.c_void_p, ct.c_void_p, ct.c_char_p, ct.c_char_p, ct.c_char_p, ct.c_char_p, ct.c_void_p] | ||
| except OSError: | ||
| _libsecret = None | ||
|
|
||
|
|
||
| def _get_user_settings_path(): | ||
| app_data_folder = os.environ['HOME'] | ||
annatisch marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return os.path.join(app_data_folder, ".config", "Code", "User", "settings.json") | ||
|
|
||
|
|
||
| def _get_user_settings(): | ||
| path = _get_user_settings_path() | ||
| try: | ||
| with open(path) as file: | ||
| data = json.load(file) | ||
| environment_name = data.get("azure.cloud", "Azure") | ||
| return environment_name | ||
| except IOError: | ||
| return "Azure" | ||
|
|
||
|
|
||
| def _get_refresh_token(service_name, account_name): | ||
| # _libsecret.secret_password_lookup_sync raises segment fault on Python 2.7 | ||
| # temporarily disable it on 2.7 | ||
| import sys | ||
| if sys.version_info[0] < 3: | ||
| return None | ||
xiangyan99 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| if not _libsecret: | ||
| return None | ||
| schema = _libsecret.secret_schema_new(_c_str("org.freedesktop.Secret.Generic"), 2, | ||
xiangyan99 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| _c_str("service"), 0, _c_str("account"), 0, None) | ||
| p_str = _libsecret.secret_password_lookup_sync(schema, None, None, _c_str("service"), _c_str(service_name), | ||
|
||
| _c_str("account"), _c_str(account_name), None) | ||
| return ct.c_char_p(p_str).value.decode('utf-8') | ||
|
||
|
|
||
|
|
||
| def get_credentials(): | ||
| try: | ||
| environment_name = _get_user_settings() | ||
| credentials = _get_refresh_token(VSCODE_CREDENTIALS_SECTION, environment_name) | ||
| return credentials | ||
| except Exception: #pylint: disable=broad-except | ||
| return None | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| # ------------------------------------ | ||
| # Copyright (c) Microsoft Corporation. | ||
| # Licensed under the MIT License. | ||
| # ------------------------------------ | ||
| import os | ||
| import json | ||
| from msal_extensions.osx import Keychain, KeychainError | ||
| from .._constants import VSCODE_CREDENTIALS_SECTION | ||
|
|
||
|
|
||
| def _get_user_settings_path(): | ||
| app_data_folder = os.environ['USER'] | ||
| return os.path.join(app_data_folder, "Library", "Application Support", "Code", "User", "settings.json") | ||
|
|
||
|
|
||
| def _get_user_settings(): | ||
| path = _get_user_settings_path() | ||
| try: | ||
| with open(path) as file: | ||
| data = json.load(file) | ||
| environment_name = data.get("azure.cloud", "Azure") | ||
| return environment_name | ||
| except IOError: | ||
| return "Azure" | ||
|
|
||
|
|
||
| def _get_refresh_token(service_name, account_name): | ||
| key_chain = Keychain() | ||
| try: | ||
| return key_chain.get_generic_password(service_name, account_name) | ||
| except KeychainError: | ||
| return None | ||
|
|
||
|
|
||
| def get_credentials(): | ||
| try: | ||
| environment_name = _get_user_settings() | ||
| credentials = _get_refresh_token(VSCODE_CREDENTIALS_SECTION, environment_name) | ||
| return credentials | ||
| except Exception: #pylint: disable=broad-except | ||
| return None |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| # ------------------------------------ | ||
| # Copyright (c) Microsoft Corporation. | ||
| # Licensed under the MIT License. | ||
| # ------------------------------------ | ||
| import sys | ||
| from typing import TYPE_CHECKING | ||
| from .._exceptions import CredentialUnavailableError | ||
| from .._constants import AZURE_VSCODE_CLIENT_ID | ||
| from .._internal.aad_client import AadClient | ||
| if sys.platform.startswith('win'): | ||
| from .win_vscode_adapter import get_credentials | ||
| elif sys.platform.startswith('darwin'): | ||
| from .macos_vscode_adapter import get_credentials | ||
| else: | ||
| from .linux_vscode_adapter import get_credentials | ||
|
|
||
| if TYPE_CHECKING: | ||
| # pylint:disable=unused-import,ungrouped-imports | ||
| from typing import Any, Iterable, Optional | ||
| from azure.core.credentials import AccessToken | ||
|
|
||
|
|
||
| class VSCodeCredential(object): | ||
| """Authenticates by redeeming a refresh token previously saved by VS Code | ||
|
|
||
| """ | ||
| def __init__(self, **kwargs): | ||
| self._client = kwargs.pop("_client", None) or AadClient("organizations", AZURE_VSCODE_CLIENT_ID, **kwargs) | ||
|
|
||
| def get_token(self, *scopes, **kwargs): | ||
| # type: (*str, **Any) -> AccessToken | ||
| """Request an access token for `scopes`. | ||
|
|
||
| .. note:: This method is called by Azure SDK clients. It isn't intended for use in application code. | ||
|
|
||
| When this method is called, the credential will try to get the refresh token saved by VS Code. If a refresh | ||
| token can be found, it will redeem the refresh token for an access token and return the access token. | ||
|
|
||
| :param str scopes: desired scopes for the access token. This method requires at least one scope. | ||
| :rtype: :class:`azure.core.credentials.AccessToken` | ||
| :raises ~azure.identity.CredentialUnavailableError: fail to get refresh token. | ||
| """ | ||
| if not scopes: | ||
| raise ValueError("'get_token' requires at least one scope") | ||
|
|
||
| refresh_token = get_credentials() | ||
| if not refresh_token: | ||
| raise CredentialUnavailableError( | ||
| message="No Azure user is logged in to Visual Studio Code." | ||
| ) | ||
| token = self._client.get_cached_access_token(scopes) \ | ||
| or self._client.obtain_token_by_refresh_token(refresh_token, scopes, **kwargs) | ||
| return token |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| # ------------------------------------ | ||
| # Copyright (c) Microsoft Corporation. | ||
| # Licensed under the MIT License. | ||
| # ------------------------------------ | ||
| import os | ||
| import json | ||
| import ctypes as ct | ||
| from .._constants import VSCODE_CREDENTIALS_SECTION | ||
| try: | ||
| import ctypes.wintypes as wt | ||
| except (IOError, ValueError): | ||
| pass | ||
|
|
||
|
|
||
| SUPPORTED_CREDKEYS = set(( | ||
| 'Type', 'TargetName', 'Persist', | ||
| 'UserName', 'Comment', 'CredentialBlob')) | ||
|
|
||
|
|
||
| class _CREDENTIAL(ct.Structure): | ||
| _fields_ = [ | ||
| ("Flags", wt.DWORD), | ||
| ("Type", wt.DWORD), | ||
| ("TargetName", ct.c_wchar_p), | ||
| ("Comment", ct.c_wchar_p), | ||
| ("LastWritten", wt.FILETIME), | ||
| ("CredentialBlobSize", wt.DWORD), | ||
| ("CredentialBlob", wt.LPBYTE), | ||
| ("Persist", wt.DWORD), | ||
| ("AttributeCount", wt.DWORD), | ||
| ("Attributes", ct.c_void_p), | ||
| ("TargetAlias", ct.c_wchar_p), | ||
| ("UserName", ct.c_wchar_p)] | ||
|
|
||
|
|
||
| _PCREDENTIAL = ct.POINTER(_CREDENTIAL) | ||
|
|
||
| _advapi = ct.WinDLL('advapi32') | ||
| _advapi.CredReadW.argtypes = [wt.LPCWSTR, wt.DWORD, wt.DWORD, ct.POINTER(_PCREDENTIAL)] | ||
| _advapi.CredReadW.restype = wt.BOOL | ||
| _advapi.CredFree.argtypes = [_PCREDENTIAL] | ||
|
|
||
|
|
||
| def _read_credential(service_name, account_name): | ||
| target = "{}/{}".format(service_name, account_name) | ||
| cred_ptr = _PCREDENTIAL() | ||
| if _advapi.CredReadW(target, 1, 0, ct.byref(cred_ptr)): | ||
| cred_blob = cred_ptr.contents.CredentialBlob | ||
| cred_blob_size = cred_ptr.contents.CredentialBlobSize | ||
| password_as_list = [int.from_bytes(cred_blob[pos:pos + 1], 'little') | ||
| for pos in range(0, cred_blob_size)] | ||
| cred = ''.join(map(chr, password_as_list)) | ||
| _advapi.CredFree(cred_ptr) | ||
| return cred | ||
| return None | ||
|
|
||
|
|
||
| def _get_user_settings_path(): | ||
| app_data_folder = os.environ['APPDATA'] | ||
| return os.path.join(app_data_folder, "Code", "User", "settings.json") | ||
|
|
||
|
|
||
| def _get_user_settings(): | ||
| path = _get_user_settings_path() | ||
| try: | ||
| with open(path) as file: | ||
| data = json.load(file) | ||
| environment_name = data.get("azure.cloud", "Azure") | ||
| return environment_name | ||
| except IOError: | ||
| return "Azure" | ||
|
|
||
|
|
||
| def _get_refresh_token(service_name, account_name): | ||
| return _read_credential(service_name, account_name) | ||
|
|
||
|
|
||
| def get_credentials(): | ||
| try: | ||
| environment_name = _get_user_settings() | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looking at the .NET code, the environment name apparently indicates which cloud the user authenticated in. If that's the case, the credential needs it to decide where to send the token request. Using this information in the credential is awkward (though still possible) because the client's authority is set at construction. The client's token cache shares this behavior though, so I'm thinking we'll need to use (and document) a workaround initially: a user can set
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes. In our current design, it is on user to make sure AZURE_AUTHORITY_HOST or authority=KnownAuthorities... match the setting in vscode. |
||
| credentials = _get_refresh_token(VSCODE_CREDENTIALS_SECTION, environment_name) | ||
| return credentials | ||
| except Exception: #pylint: disable=broad-except | ||
| return None | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| # ------------------------------------ | ||
| # Copyright (c) Microsoft Corporation. | ||
| # Licensed under the MIT License. | ||
| # ------------------------------------ | ||
| from typing import TYPE_CHECKING | ||
| from ..._exceptions import CredentialUnavailableError | ||
| from .._credentials.base import AsyncCredentialBase | ||
| from ..._constants import AZURE_VSCODE_CLIENT_ID | ||
| from .._internal.aad_client import AadClient | ||
| from ..._credentials.vscode_credential import get_credentials | ||
| if TYPE_CHECKING: | ||
| # pylint:disable=unused-import,ungrouped-imports | ||
| from typing import Any, Iterable, Optional | ||
| from azure.core.credentials import AccessToken | ||
|
|
||
| class VSCodeCredential(AsyncCredentialBase): | ||
| """Authenticates by redeeming a refresh token previously saved by VS Code | ||
|
|
||
| """ | ||
| def __init__(self, **kwargs): | ||
| self._client = kwargs.pop("_client", None) or AadClient("organizations", AZURE_VSCODE_CLIENT_ID, **kwargs) | ||
|
|
||
| async def __aenter__(self): | ||
| if self._client: | ||
| await self._client.__aenter__() | ||
| return self | ||
|
|
||
| async def close(self): | ||
| """Close the credential's transport session.""" | ||
|
|
||
| if self._client: | ||
| await self._client.__aexit__() | ||
|
|
||
| async def get_token(self, *scopes, **kwargs): | ||
| # type: (*str, **Any) -> AccessToken | ||
| """Request an access token for `scopes`. | ||
|
|
||
| .. note:: This method is called by Azure SDK clients. It isn't intended for use in application code. | ||
|
|
||
| When this method is called, the credential will try to get the refresh token saved by VS Code. If a refresh | ||
| token can be found, it will redeem the refresh token for an access token and return the access token. | ||
|
|
||
| :param str scopes: desired scopes for the access token. This method requires at least one scope. | ||
| :rtype: :class:`azure.core.credentials.AccessToken` | ||
| :raises ~azure.identity.CredentialUnavailableError: fail to get refresh token. | ||
| """ | ||
| if not scopes: | ||
| raise ValueError("'get_token' requires at least one scope") | ||
|
|
||
xiangyan99 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| refresh_token = get_credentials() | ||
| if not refresh_token: | ||
| raise CredentialUnavailableError( | ||
| message="No Azure user is logged in to Visual Studio Code." | ||
| ) | ||
| token = self._client.get_cached_access_token(scopes) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't this go before trying to get a refresh token?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we fail to get a refresh token (which means the user may have logged out), why we should still return a valid token if there is one stored?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The point is perhaps moot because Still, you ask a good question. Here's a couple things to think about in answering it:
There's another interesting case here. The user can change the signed in identity between two calls to I'm trying to get all the food for thought here down on this page, sorry for all the words. Personally I now lean toward:
Anyone else have thoughts?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I think the choices are failing immediately or failing in a few minutes. I lean toward failing immediately because
|
||
| if not token: | ||
| token = await self._client.obtain_token_by_refresh_token(refresh_token, scopes, **kwargs) | ||
| return token | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems this is used for service name and account name - service name is probably okay, but is there any change that an account name might not be utf8?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a good question and I don't have an answer. I opened #11135 to track it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I assume that would be utf8
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. I discussed with Anna offline. We assume this is utf8. We will try if we can find a way to test it.