| // Copyright 2018 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "chrome/credential_provider/gaiacp/scoped_user_profile.h" |
| |
| // Must appear before gdiplus.h |
| // gdiplus.h requires global functions min and max to exist. But the usage of |
| // these functions occurs in the Gdiplus namespace only so we just declare |
| // std::min and std::max in this namespace so that gdiplus can find them. |
| // This is similar to what was done in: |
| // https://cs.chromium.org/chromium/src/third_party/pdfium/core/fxge/win32/fx_win32_gdipext.cpp?type=cs&q=gdiplus.h&g=0&l=29 |
| namespace Gdiplus { |
| using std::max; |
| using std::min; |
| } // namespace Gdiplus |
| |
| #include <Windows.h> |
| |
| #include <atlcomcli.h> |
| #include <atlconv.h> |
| #include <dpapi.h> |
| #include <gdiplus.h> |
| #include <objidl.h> |
| #include <security.h> |
| #include <shlobj.h> |
| #include <shlwapi.h> |
| #include <userenv.h> |
| |
| #include "base/bind.h" |
| #include "base/callback.h" |
| #include "base/files/file_util.h" |
| #include "base/stl_util.h" |
| #include "base/strings/string_split.h" |
| #include "base/strings/stringprintf.h" |
| #include "base/strings/utf_string_conversions.h" |
| #include "base/win/registry.h" |
| #include "base/win/windows_version.h" |
| #include "chrome/credential_provider/common/gcp_strings.h" |
| #include "chrome/credential_provider/gaiacp/gcp_utils.h" |
| #include "chrome/credential_provider/gaiacp/logging.h" |
| #include "chrome/credential_provider/gaiacp/reg_utils.h" |
| #include "chrome/credential_provider/gaiacp/win_http_url_fetcher.h" |
| |
| namespace credential_provider { |
| |
| namespace { |
| |
| // Retry count when attempting to determine if the user's OS profile has |
| // been created. In slow envrionments, like VMs used for testing, it may |
| // take some time to create the OS profile so checks are done periodically. |
| // Ideally the OS would send out a notification when a profile is created and |
| // retrying would not be needed, but this notification does not exist. |
| const int kWaitForProfileCreationRetryCount = 30; |
| |
| constexpr int kProfilePictureSizes[] = { |
| 32, 40, 48, 96, 192, 240, kLargestProfilePictureSize}; |
| |
| std::string GetEncryptedRefreshToken( |
| base::win::ScopedHandle::Handle logon_handle, |
| const base::DictionaryValue& properties) { |
| std::string refresh_token = GetDictStringUTF8(&properties, kKeyRefreshToken); |
| if (refresh_token.empty()) { |
| LOGFN(ERROR) << "Refresh token is empty"; |
| return std::string(); |
| } |
| |
| if (!::ImpersonateLoggedOnUser(logon_handle)) { |
| HRESULT hr = HRESULT_FROM_WIN32(::GetLastError()); |
| LOGFN(ERROR) << "ImpersonateLoggedOnUser hr=" << putHR(hr); |
| return std::string(); |
| } |
| |
| // Don't include null character in ciphertext. |
| DATA_BLOB plaintext; |
| plaintext.pbData = |
| reinterpret_cast<BYTE*>(const_cast<char*>(refresh_token.c_str())); |
| plaintext.cbData = static_cast<DWORD>(refresh_token.length()); |
| |
| DATA_BLOB ciphertext; |
| BOOL success = |
| ::CryptProtectData(&plaintext, L"Gaia refresh token", nullptr, nullptr, |
| nullptr, CRYPTPROTECT_UI_FORBIDDEN, &ciphertext); |
| HRESULT hr = success ? S_OK : HRESULT_FROM_WIN32(::GetLastError()); |
| ::RevertToSelf(); |
| if (!success) { |
| LOGFN(ERROR) << "CryptProtectData hr=" << putHR(hr); |
| return std::string(); |
| } |
| |
| // NOTE: return value is binary data, not null-terminate string. |
| std::string encrypted_data(reinterpret_cast<char*>(ciphertext.pbData), |
| ciphertext.cbData); |
| ::LocalFree(ciphertext.pbData); |
| return encrypted_data; |
| } |
| |
| HRESULT GetEncoderClsidByExtension(const base::string16& desired_extension, |
| CLSID* clsid_out) { |
| DCHECK(clsid_out); |
| // Number of image encoders. |
| UINT num = 0; |
| // Size of the image encoder array in bytes. |
| UINT size = 0; |
| |
| Gdiplus::ImageCodecInfo* image_codec_info = nullptr; |
| |
| Gdiplus::GetImageEncodersSize(&num, &size); |
| if (size == 0) |
| return E_FAIL; |
| |
| std::unique_ptr<char[]> encoder_buffer(new char[size]); |
| |
| image_codec_info = |
| reinterpret_cast<Gdiplus::ImageCodecInfo*>(encoder_buffer.get()); |
| |
| if (image_codec_info == nullptr) |
| return E_FAIL; |
| |
| Gdiplus::GetImageEncoders(num, size, image_codec_info); |
| |
| for (UINT j = 0; j < num; ++j) { |
| // FilenameExtension is a semicolon separated list of extensions recognized |
| // by the codec. Each extension is in the format "*.{ext}" so the * needs to |
| // be removed to get the real extension. |
| std::vector<base::string16> codec_extensions = base::SplitString( |
| base::StringPiece16(image_codec_info[j].FilenameExtension), L";", |
| base::TRIM_WHITESPACE, base::SPLIT_WANT_NONEMPTY); |
| |
| for (auto& extension : codec_extensions) { |
| size_t first_period = extension.find_last_of('.'); |
| if (first_period != base::string16::npos && |
| base::EqualsCaseInsensitiveASCII(extension.substr(first_period), |
| desired_extension)) { |
| *clsid_out = image_codec_info[j].Clsid; |
| return S_OK; |
| } |
| } |
| } |
| |
| return E_FAIL; |
| } |
| |
| HRESULT ConvertImageToDesiredFormat(const std::vector<char>& image_buffer, |
| const base::FilePath& converted_path) { |
| // Only support conversion to |kCredentialLogoPictureFileExtension| for now. |
| DCHECK(base::EqualsCaseInsensitiveASCII(converted_path.Extension(), |
| kCredentialLogoPictureFileExtension)); |
| |
| // Initialize GDI+. |
| Gdiplus::GdiplusStartupInput gdiplus_startup_input; |
| ULONG_PTR gdiplus_token; |
| Gdiplus::GdiplusStartup(&gdiplus_token, &gdiplus_startup_input, NULL); |
| |
| // Load the image stream into memory. Gdiplus::Image can automatically detect |
| // the file type and load the correct contents. Note that gaia returns a |
| // picture url with a .jpg extension but when the url is downloaded the image |
| // is actually a .png format and the Image class can handle this case |
| // correctly here. |
| CComPtr<IStream> buffer_stream; |
| buffer_stream.Attach(::SHCreateMemStream( |
| reinterpret_cast<const BYTE*>(image_buffer.data()), image_buffer.size())); |
| std::unique_ptr<Gdiplus::Image> image = |
| std::make_unique<Gdiplus::Image>(buffer_stream); |
| |
| if (image->GetType() == Gdiplus::ImageTypeUnknown) { |
| LOGFN(ERROR) << "Unknown image type when loading image stream"; |
| Gdiplus::GdiplusShutdown(gdiplus_token); |
| return E_FAIL; |
| } |
| |
| // Get the CLSID of the encoder to the desired file type. |
| CLSID encoder_clsid; |
| HRESULT hr = |
| GetEncoderClsidByExtension(converted_path.Extension(), &encoder_clsid); |
| if (FAILED(hr)) { |
| LOGFN(ERROR) << "GetEncoderClsid hr=" << putHR(hr); |
| Gdiplus::GdiplusShutdown(gdiplus_token); |
| return hr; |
| } |
| |
| Gdiplus::Status stat = |
| image->Save(converted_path.value().c_str(), &encoder_clsid, nullptr); |
| Gdiplus::GdiplusShutdown(gdiplus_token); |
| |
| if (stat != Gdiplus::Ok) { |
| LOGFN(ERROR) << "image->Save stat=" << stat; |
| return E_FAIL; |
| } |
| |
| return S_OK; |
| } |
| |
| using ImageProcessor = |
| base::OnceCallback<HRESULT(const base::FilePath& picture_path, |
| const std::vector<char>& picture_buffer)>; |
| |
| HRESULT SaveProcessedProfilePictureToDisk( |
| const base::FilePath& picture_path, |
| const std::vector<char>& picture_buffer, |
| ImageProcessor processor_function) { |
| DCHECK(processor_function); |
| |
| // Make the file visible in case it is hidden or else WriteFile will fail |
| // to overwrite the existing file. |
| DWORD file_attributes = ::GetFileAttributes(picture_path.value().c_str()); |
| if (file_attributes != INVALID_FILE_ATTRIBUTES) { |
| if (!::SetFileAttributes(picture_path.value().c_str(), |
| file_attributes & ~FILE_ATTRIBUTE_HIDDEN)) { |
| LOGFN(ERROR) << "SetFileAttributes(remove hidden) err=" |
| << ::GetLastError(); |
| } |
| } |
| |
| HRESULT hr = std::move(processor_function).Run(picture_path, picture_buffer); |
| if (SUCCEEDED(hr)) { |
| // Make the picture file hidden just like the system would normally. |
| file_attributes = ::GetFileAttributes(picture_path.value().c_str()); |
| if (file_attributes != INVALID_FILE_ATTRIBUTES) { |
| if (!::SetFileAttributes(picture_path.value().c_str(), |
| file_attributes | FILE_ATTRIBUTE_HIDDEN)) { |
| LOGFN(ERROR) << "SetFileAttributes(add hidden) err=" |
| << ::GetLastError(); |
| } |
| } |
| } |
| |
| return hr; |
| } |
| |
| HRESULT UpdateProfilePicturesForWindows8AndNewer( |
| const base::string16& sid, |
| const base::string16& picture_url, |
| bool force_update) { |
| DCHECK(!sid.empty()); |
| DCHECK(!picture_url.empty()); |
| DCHECK(base::win::GetVersion() >= base::win::VERSION_WIN8); |
| |
| // Try to download profile pictures of all required sizes for windows. |
| // Needed profile picture sizes are in |kProfilePictureSizes|. |
| // The way Windows8+ stores profile pictures is the following: |
| // In |reg_utils.cc:kAccountPicturesRootRegKey| there is a registry key |
| // for each resolution of profile picture needed. The keys are names |
| // "Image[x]" where [x] is the resolution of the picture. |
| // Each key points to a profile picture of the correct resolution on disk. |
| // Generally the profile pictures are stored under: |
| // FOLDERID_PublicUserTiles\\{user sid} |
| |
| base::string16 picture_url_path = base::UTF8ToUTF16(GURL(picture_url).path()); |
| if (picture_url_path.size() <= 1) { |
| LOGFN(ERROR) << "Invalid picture url=" << picture_url; |
| return E_FAIL; |
| } |
| |
| base::FilePath account_picture_path; |
| HRESULT hr = GetUserAccountPicturePath(sid, &account_picture_path); |
| if (FAILED(hr)) { |
| LOGFN(ERROR) << "Failed to get account picture known folder=" << putHR(hr); |
| return E_FAIL; |
| } |
| |
| if (!base::PathExists(account_picture_path) && |
| !base::CreateDirectory(account_picture_path)) { |
| LOGFN(ERROR) << "Failed to create profile picture directory=" |
| << account_picture_path; |
| return E_FAIL; |
| } |
| |
| base::string16 base_picture_extension = kDefaultProfilePictureFileExtension; |
| |
| size_t last_period = picture_url_path.find_last_of('.'); |
| if (last_period != std::string::npos) |
| base_picture_extension = picture_url_path.substr(last_period); |
| |
| for (auto image_size : kProfilePictureSizes) { |
| base::FilePath target_picture_path = GetUserSizedAccountPictureFilePath( |
| account_picture_path, image_size, base_picture_extension); |
| base::FilePath bmp_picture_path = target_picture_path.ReplaceExtension( |
| kCredentialLogoPictureFileExtension); |
| bool needs_to_save_original = |
| force_update || !base::PathExists(target_picture_path); |
| bool needs_to_save_bitmap = |
| force_update || (image_size == kLargestProfilePictureSize && |
| !base::PathExists(bmp_picture_path)); |
| |
| // Skip if the file already exists and an update is not forced. |
| if (!needs_to_save_original && !needs_to_save_bitmap) { |
| // Update the reg string for the image if it is not up to date. |
| wchar_t old_picture_path[MAX_PATH]; |
| ULONG path_size = base::size(old_picture_path); |
| HRESULT hr = GetAccountPictureRegString(sid, image_size, old_picture_path, |
| &path_size); |
| if (FAILED(hr) || target_picture_path.value() != old_picture_path) { |
| HRESULT hr = SetAccountPictureRegString(sid, image_size, |
| target_picture_path.value()); |
| if (FAILED(hr)) |
| LOGFN(ERROR) << "SetAccountPictureRegString(pic) hr=" << putHR(hr); |
| } |
| continue; |
| } |
| |
| std::string current_picture_url = base::UTF16ToUTF8(picture_url) + |
| base::StringPrintf("?sz=%i", image_size); |
| |
| auto fetcher = WinHttpUrlFetcher::Create(GURL(current_picture_url)); |
| if (!fetcher) { |
| LOGFN(ERROR) << "Failed to create fetcher for=" << current_picture_url; |
| continue; |
| } |
| |
| std::vector<char> response; |
| HRESULT hr = fetcher->Fetch(&response); |
| if (FAILED(hr)) { |
| LOGFN(INFO) << "fetcher.Fetch hr=" << putHR(hr); |
| continue; |
| } |
| |
| if (needs_to_save_original) { |
| SaveProcessedProfilePictureToDisk( |
| target_picture_path, response, |
| base::BindOnce( |
| [](const base::string16& sid, int image_size, |
| const base::FilePath& picture_path, |
| const std::vector<char>& picture_buffer) { |
| HRESULT hr = S_OK; |
| if (base::WriteFile(picture_path, picture_buffer.data(), |
| picture_buffer.size()) != |
| static_cast<int>(picture_buffer.size())) { |
| LOGFN(ERROR) << "Failed to write profile picture to file=" |
| << picture_path; |
| hr = HRESULT_FROM_WIN32(::GetLastError()); |
| } else { |
| // Finally update the registry to point to this profile |
| // picture. |
| HRESULT reg_hr = SetAccountPictureRegString( |
| sid, image_size, picture_path.value()); |
| if (FAILED(reg_hr)) |
| LOGFN(ERROR) << "SetAccountPictureRegString(pic) hr=" |
| << putHR(reg_hr); |
| } |
| return hr; |
| }, |
| sid, image_size)); |
| } |
| |
| if (needs_to_save_bitmap) { |
| SaveProcessedProfilePictureToDisk( |
| bmp_picture_path, response, |
| base::BindOnce([](const base::FilePath& picture_path, |
| const std::vector<char>& picture_buffer) { |
| HRESULT hr = |
| ConvertImageToDesiredFormat(picture_buffer, picture_path); |
| if (FAILED(hr)) |
| LOGFN(ERROR) << "ConvertImageToDesiredFormat(pic) hr=" |
| << putHR(hr); |
| |
| return hr; |
| })); |
| } |
| } |
| |
| return S_OK; |
| } |
| |
| } // namespace |
| |
| // static |
| ScopedUserProfile::CreatorCallback* |
| ScopedUserProfile::GetCreatorFunctionStorage() { |
| static CreatorCallback creator_for_testing; |
| return &creator_for_testing; |
| } |
| |
| // static |
| std::unique_ptr<ScopedUserProfile> ScopedUserProfile::Create( |
| const base::string16& sid, |
| const base::string16& domain, |
| const base::string16& username, |
| const base::string16& password) { |
| if (!GetCreatorFunctionStorage()->is_null()) |
| return GetCreatorFunctionStorage()->Run(sid, domain, username, password); |
| |
| std::unique_ptr<ScopedUserProfile> scoped( |
| new ScopedUserProfile(sid, domain, username, password)); |
| return scoped->IsValid() ? std::move(scoped) : nullptr; |
| } |
| |
| ScopedUserProfile::ScopedUserProfile(const base::string16& sid, |
| const base::string16& domain, |
| const base::string16& username, |
| const base::string16& password) { |
| LOGFN(INFO); |
| // Load the user's profile so that their regsitry hive is available. |
| base::win::ScopedHandle::Handle handle; |
| |
| if (!::LogonUserW(username.c_str(), domain.c_str(), password.c_str(), |
| LOGON32_LOGON_INTERACTIVE, LOGON32_PROVIDER_DEFAULT, |
| &handle)) { |
| HRESULT hr = HRESULT_FROM_WIN32(::GetLastError()); |
| LOGFN(ERROR) << "LogonUserW hr=" << putHR(hr); |
| return; |
| } |
| token_.Set(handle); |
| |
| if (!WaitForProfileCreation(sid)) |
| token_.Close(); |
| } |
| |
| ScopedUserProfile::~ScopedUserProfile() {} |
| |
| bool ScopedUserProfile::IsValid() { |
| return token_.IsValid(); |
| } |
| |
| HRESULT ScopedUserProfile::ExtractAssociationInformation( |
| const base::DictionaryValue& properties, |
| base::string16* sid, |
| base::string16* id, |
| base::string16* email, |
| base::string16* token_handle) { |
| DCHECK(sid); |
| DCHECK(id); |
| DCHECK(email); |
| DCHECK(token_handle); |
| |
| *sid = GetDictString(&properties, kKeySID); |
| if (sid->empty()) { |
| LOGFN(ERROR) << "SID is empty"; |
| return E_INVALIDARG; |
| } |
| |
| *id = GetDictString(&properties, kKeyId); |
| if (id->empty()) { |
| LOGFN(ERROR) << "Id is empty"; |
| return E_INVALIDARG; |
| } |
| |
| *email = GetDictString(&properties, kKeyEmail); |
| if (email->empty()) { |
| LOGFN(ERROR) << "Email is empty"; |
| return E_INVALIDARG; |
| } |
| |
| *token_handle = GetDictString(&properties, kKeyTokenHandle); |
| if (token_handle->empty()) { |
| LOGFN(ERROR) << "Token handle is empty"; |
| return E_INVALIDARG; |
| } |
| |
| return S_OK; |
| } |
| |
| HRESULT ScopedUserProfile::RegisterAssociation( |
| const base::string16& sid, |
| const base::string16& id, |
| const base::string16& email, |
| const base::string16& token_handle) { |
| // Save token handle. This handle will be used later to determine if the |
| // the user has changed their password since the account was created. |
| HRESULT hr = SetUserProperty(sid, kUserTokenHandle, token_handle); |
| if (FAILED(hr)) { |
| LOGFN(ERROR) << "SetUserProperty(th) hr=" << putHR(hr); |
| return hr; |
| } |
| |
| hr = SetUserProperty(sid, kUserId, id); |
| if (FAILED(hr)) { |
| LOGFN(ERROR) << "SetUserProperty(id) hr=" << putHR(hr); |
| return hr; |
| } |
| |
| hr = SetUserProperty(sid, kUserEmail, email); |
| if (FAILED(hr)) { |
| LOGFN(ERROR) << "SetUserProperty(email) hr=" << putHR(hr); |
| return hr; |
| } |
| |
| return S_OK; |
| } |
| |
| HRESULT ScopedUserProfile::SaveAccountInfo( |
| const base::DictionaryValue& properties) { |
| LOGFN(INFO); |
| |
| base::string16 sid; |
| base::string16 id; |
| base::string16 email; |
| base::string16 token_handle; |
| |
| HRESULT hr = ExtractAssociationInformation(properties, &sid, &id, &email, |
| &token_handle); |
| if (FAILED(hr)) |
| return hr; |
| |
| hr = RegisterAssociation(sid, id, email, token_handle); |
| if (FAILED(hr)) |
| return hr; |
| |
| // Write account information to the user's hive. |
| // NOTE: regular users cannot access the registry entry of other users, |
| // but administrators and SYSTEM can. |
| { |
| wchar_t key_name[128]; |
| swprintf_s(key_name, base::size(key_name), L"%s\\%s\\%s", sid.c_str(), |
| kRegHkcuAccountsPath, id.c_str()); |
| LOGFN(INFO) << "HKU\\" << key_name; |
| |
| base::win::RegKey key; |
| LONG sts = key.Create(HKEY_USERS, key_name, KEY_READ | KEY_WRITE); |
| if (sts != ERROR_SUCCESS) { |
| HRESULT hr = HRESULT_FROM_WIN32(sts); |
| LOGFN(ERROR) << "key.Create(" << id << ") hr=" << putHR(hr); |
| return hr; |
| } |
| |
| sts = key.WriteValue(base::ASCIIToUTF16(kKeyEmail).c_str(), email.c_str()); |
| if (sts != ERROR_SUCCESS) { |
| HRESULT hr = HRESULT_FROM_WIN32(sts); |
| LOGFN(ERROR) << "key.WriteValue(" << sid << ", email) hr=" << putHR(hr); |
| return hr; |
| } |
| |
| // NOTE: |encrypted_data| is binary data, not null-terminate string. |
| std::string encrypted_data = |
| GetEncryptedRefreshToken(token_.Get(), properties); |
| if (encrypted_data.empty()) { |
| LOGFN(ERROR) << "GetEncryptedRefreshToken returned empty string"; |
| return E_UNEXPECTED; |
| } |
| |
| sts = key.WriteValue( |
| base::ASCIIToUTF16(kKeyRefreshToken).c_str(), encrypted_data.c_str(), |
| static_cast<ULONG>(encrypted_data.length()), REG_BINARY); |
| if (sts != ERROR_SUCCESS) { |
| HRESULT hr = HRESULT_FROM_WIN32(sts); |
| LOGFN(ERROR) << "key.WriteValue(" << sid << ", RT) hr=" << putHR(hr); |
| return hr; |
| } |
| } |
| |
| // This code for setting profile pictures is specific for windows 8+. |
| if (base::win::GetVersion() >= base::win::VERSION_WIN8) { |
| base::string16 picture_url = GetDictString(&properties, kKeyPicture); |
| if (!picture_url.empty() && !sid.empty()) { |
| wchar_t old_picture_url[512]; |
| ULONG url_size = base::size(old_picture_url); |
| hr = GetUserProperty(sid, kUserPictureUrl, old_picture_url, &url_size); |
| |
| UpdateProfilePicturesForWindows8AndNewer( |
| sid, picture_url, FAILED(hr) || old_picture_url != picture_url); |
| hr = SetUserProperty(sid.c_str(), kUserPictureUrl, picture_url.c_str()); |
| if (FAILED(hr)) { |
| LOGFN(ERROR) << "SetUserProperty(pic) hr=" << putHR(hr); |
| return hr; |
| } |
| } |
| } |
| |
| return S_OK; |
| } |
| |
| ScopedUserProfile::ScopedUserProfile() {} |
| |
| bool ScopedUserProfile::WaitForProfileCreation(const base::string16& sid) { |
| LOGFN(INFO); |
| wchar_t profile_dir[MAX_PATH]; |
| bool created = false; |
| |
| for (int i = 0; i < kWaitForProfileCreationRetryCount; ++i) { |
| ::Sleep(1000); |
| DWORD length = base::size(profile_dir); |
| if (::GetUserProfileDirectoryW(token_.Get(), profile_dir, &length)) { |
| LOGFN(INFO) << "GetUserProfileDirectoryW " << i << " " << profile_dir; |
| created = true; |
| break; |
| } else { |
| HRESULT hr = HRESULT_FROM_WIN32(::GetLastError()); |
| LOGFN(INFO) << "GetUserProfileDirectoryW hr=" << putHR(hr); |
| } |
| } |
| |
| if (!created) |
| LOGFN(INFO) << "Profile not created yet???"; |
| |
| created = false; |
| |
| // Write account information to the user's hive. |
| // NOTE: regular users cannot access the registry entry of other users, |
| // but administrators and SYSTEM can. |
| base::win::RegKey key; |
| wchar_t key_name[128]; |
| swprintf_s(key_name, base::size(key_name), L"%s\\%s", sid.c_str(), |
| kRegHkcuAccountsPath); |
| LOGFN(INFO) << "HKU\\" << key_name; |
| |
| for (int i = 0; i < kWaitForProfileCreationRetryCount; ++i) { |
| ::Sleep(1000); |
| LONG sts = key.Create(HKEY_USERS, key_name, KEY_READ | KEY_WRITE); |
| if (sts == ERROR_SUCCESS) { |
| LOGFN(INFO) << "Registry hive created " << i; |
| created = true; |
| break; |
| } |
| } |
| |
| if (!created) |
| LOGFN(ERROR) << "Profile not created really???"; |
| |
| return created; |
| } |
| |
| } // namespace credential_provider |