From f0aa15348ddc5d558c8a43e950efda80fd567c1c Mon Sep 17 00:00:00 2001 From: Tommy Nguyen <4123478+tido64@users.noreply.github.com> Date: Wed, 17 Nov 2021 15:52:53 +0100 Subject: [PATCH 01/17] feat(react-native-test-app-msal): add support for Android --- ...-7b96fc17-5f2a-4ca3-b8d4-b04785e41e42.json | 7 + packages/react-native-test-app-msal/README.md | 25 ++- .../android/build.gradle | 161 +++++++++++++++ .../android/gradle.properties | 3 + .../android/src/main/AndroidManifest.xml | 24 +++ .../microsoft/reacttestapp/msal/Account.kt | 18 ++ .../reacttestapp/msal/AccountType.kt | 28 +++ .../reacttestapp/msal/AccountsAdapter.kt | 31 +++ .../microsoft/reacttestapp/msal/AuthError.kt | 15 ++ .../reacttestapp/msal/AuthErrorType.kt | 41 ++++ .../microsoft/reacttestapp/msal/AuthResult.kt | 17 ++ .../com/microsoft/reacttestapp/msal/Config.kt | 15 ++ .../reacttestapp/msal/IAccountsHandler.kt | 8 + .../msal/MicrosoftAccountsActivity.kt | 192 ++++++++++++++++++ .../reacttestapp/msal/MsalPackage.kt | 19 ++ .../reacttestapp/msal/TokenBroker.kt | 168 +++++++++++++++ .../destructive_btn_bg_color_selector.xml | 5 + .../destructive_btn_text_color_selector.xml | 5 + .../main/res/drawable/ic_person_add_24dp.xml | 21 ++ .../src/main/res/layout/account_item.xml | 40 ++++ .../main/res/layout/microsoft_accounts.xml | 67 ++++++ .../main/res/values/destructive_button.xml | 9 + .../android/src/main/res/values/strings.xml | 14 ++ .../react-native-test-app-msal/package.json | 1 + packages/test-app/android/build.gradle | 1 + packages/test-app/app.json | 13 +- 26 files changed, 942 insertions(+), 6 deletions(-) create mode 100644 change/@rnx-kit-react-native-test-app-msal-7b96fc17-5f2a-4ca3-b8d4-b04785e41e42.json create mode 100644 packages/react-native-test-app-msal/android/build.gradle create mode 100644 packages/react-native-test-app-msal/android/gradle.properties create mode 100644 packages/react-native-test-app-msal/android/src/main/AndroidManifest.xml create mode 100644 packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/Account.kt create mode 100644 packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/AccountType.kt create mode 100644 packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/AccountsAdapter.kt create mode 100644 packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/AuthError.kt create mode 100644 packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/AuthErrorType.kt create mode 100644 packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/AuthResult.kt create mode 100644 packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/Config.kt create mode 100644 packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/IAccountsHandler.kt create mode 100644 packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/MicrosoftAccountsActivity.kt create mode 100644 packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/MsalPackage.kt create mode 100644 packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/TokenBroker.kt create mode 100644 packages/react-native-test-app-msal/android/src/main/res/color/destructive_btn_bg_color_selector.xml create mode 100644 packages/react-native-test-app-msal/android/src/main/res/color/destructive_btn_text_color_selector.xml create mode 100644 packages/react-native-test-app-msal/android/src/main/res/drawable/ic_person_add_24dp.xml create mode 100644 packages/react-native-test-app-msal/android/src/main/res/layout/account_item.xml create mode 100644 packages/react-native-test-app-msal/android/src/main/res/layout/microsoft_accounts.xml create mode 100644 packages/react-native-test-app-msal/android/src/main/res/values/destructive_button.xml create mode 100644 packages/react-native-test-app-msal/android/src/main/res/values/strings.xml diff --git a/change/@rnx-kit-react-native-test-app-msal-7b96fc17-5f2a-4ca3-b8d4-b04785e41e42.json b/change/@rnx-kit-react-native-test-app-msal-7b96fc17-5f2a-4ca3-b8d4-b04785e41e42.json new file mode 100644 index 000000000..cc085a91c --- /dev/null +++ b/change/@rnx-kit-react-native-test-app-msal-7b96fc17-5f2a-4ca3-b8d4-b04785e41e42.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Adds support for Android", + "packageName": "@rnx-kit/react-native-test-app-msal", + "email": "4123478+tido64@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-native-test-app-msal/README.md b/packages/react-native-test-app-msal/README.md index 57b4adc2b..8bd7f1616 100644 --- a/packages/react-native-test-app-msal/README.md +++ b/packages/react-native-test-app-msal/README.md @@ -51,7 +51,12 @@ Add an entry for the account switcher in your `app.json`, e.g.: "appKey": "MyTestApp", + }, + { -+ "appKey": "MicrosoftAccounts" ++ "appKey": "com.microsoft.reacttestapp.msal.MicrosoftAccountsActivity", ++ "displayName": "MicrosoftAccounts (Android)" ++ }, ++ { ++ "appKey": "MicrosoftAccounts", ++ "displayName": "MicrosoftAccounts (iOS/macOS)" } ], "resources": { @@ -77,16 +82,28 @@ then fill out the following fields in `app.json`: "appKey": "MyTestApp", }, { - "appKey": "MicrosoftAccounts" + "appKey": "com.microsoft.reacttestapp.msal.MicrosoftAccountsActivity", + "displayName": "MicrosoftAccounts (Android)" + }, + { + "appKey": "MicrosoftAccounts", + "displayName": "MicrosoftAccounts (iOS/macOS)" } ], ++ "android": { ++ "package": "com.contoso.MyTestApp" ++ }, + "ios": { + "bundleIdentifier": "com.contoso.MyTestApp" + }, ++ "macos": { ++ "bundleIdentifier": "com.contoso.MyTestApp" ++ }, + "react-native-test-app-msal": { -+ "clientId": "00000000-0000-0000-0000-000000000000", ++ "clientId": "4b0db8c2-9f26-4417-8bde-3f0e3656f8e0", + "msaScopes": ["user.read"], -+ "orgScopes": ["/scope"] ++ "orgScopes": ["user.read"], ++ "signatureHash": "1wIqXSqBj7w+h11ZifsnqwgyKrY=" + }, "resources": { "android": ["dist/res", "dist/main.android.jsbundle"], diff --git a/packages/react-native-test-app-msal/android/build.gradle b/packages/react-native-test-app-msal/android/build.gradle new file mode 100644 index 000000000..8414a2e5e --- /dev/null +++ b/packages/react-native-test-app-msal/android/build.gradle @@ -0,0 +1,161 @@ +import groovy.json.JsonOutput +import groovy.json.JsonSlurper + +import java.nio.file.Paths + +buildscript { + ext.ensureProperty = { config, property -> + if (!config.containsKey(property)) { + throw new MissingPropertyException("Missing '$property' in 'react-native-test-app-msal' config") + } + return config[property] + } + + ext.findFile = { fileName -> + def currentDirPath = rootDir == null ? null : rootDir.toString() + + while (currentDirPath != null) { + def currentDir = file(currentDirPath); + def requestedFile = Paths.get(currentDirPath, fileName).toFile() + + if (requestedFile.exists()) { + return requestedFile + } + + currentDirPath = currentDir.getParent() + } + + return null + } + + ext.findNodeModulesPath = { packageName -> + return findFile(Paths.get('node_modules', packageName).toString()) + } + + ext.getExtProp = { prop, defaultValue -> + return rootProject.ext.has(prop) ? rootProject.ext.get(prop) : defaultValue + } + + ext.getStringArray = { config, property -> + return (!config.containsKey(property) || config[property].size() == 0) + ? "" + : "\"${config[property].join('", "')}\"" + } + + ext.kotlinVersion = getExtProp('kotlinVersion', '1.5.31') + + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "com.android.tools.build:gradle:${getExtProp('androidPluginVersion', '4.2.2')}" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" + } +} + +plugins { + id "com.android.library" + id "kotlin-android" +} + +repositories { + maven { + url("${findNodeModulesPath('react-native')}/android") + } + + google() + mavenCentral() + + // https://github.com/AzureAD/microsoft-authentication-library-for-android#step-1-declare-dependency-on-msal + maven { + url 'https://pkgs.dev.azure.com/MicrosoftDeviceSDK/DuoSDK-Public/_packaging/Duo-SDK-Feed/maven/v1' + } +} + +android { + def manifest = new JsonSlurper().parseText(findFile('app.json').text) + def config = manifest["react-native-test-app-msal"] + if (config == null) { + throw new MissingPropertyException("Missing 'react-native-test-app-msal' field in 'app.json'") + } + + def signatureHash = ensureProperty(config, "signatureHash") + + compileSdkVersion getExtProp('compileSdkVersion', 31) + defaultConfig { + minSdkVersion getExtProp('minSdkVersion', 21) + targetSdkVersion getExtProp('targetSdkVersion', 29) + + buildConfigField "String[]", + "ReactTestAppMSAL_msaScopes", + "new String[]{${getStringArray(config, "msaScopes")}}" + buildConfigField "String[]", + "ReactTestAppMSAL_orgScopes", + "new String[]{${getStringArray(config, "orgScopes")}}" + + manifestPlaceholders = [ + msalRedirectUriPath: "/$signatureHash" + ] + } + sourceSets { + def clientId = ensureProperty(config, "clientId") + + def appProject = rootProject.subprojects.find { it.name == "app" } + def applicationId = appProject.android.defaultConfig.applicationId + def redirectUri = "msauth://$applicationId/${URLEncoder.encode(signatureHash, "UTF-8")}" + + def generatedResDir = file("$buildDir/generated/react-native-test-app-msal/src/main/res/") + generatedResDir.mkdirs() + + task copyMsalConfig(type: Copy) { + def generatedRawDir = file("$generatedResDir/raw") + generatedRawDir.mkdirs() + + def msalConfig = file("$temporaryDir/msal_config.json") + msalConfig.withWriter { + it << JsonOutput.toJson([ + authorities : [ + [ + type : "AAD", + audience: [ + type: "AzureADandPersonalMicrosoftAccount" + ], + default : true + ], + [ + type : "AAD", + audience: [ + type: "PersonalMicrosoftAccount" + ], + ], + ], + client_id : clientId, + redirect_uri : redirectUri, + broker_redirect_uri_registered: true, + account_mode : "MULTIPLE" + ]) + } + + from msalConfig + into generatedRawDir + } + + preBuild.dependsOn(copyMsalConfig) + + main.res.srcDirs += generatedResDir + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" + implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" + + implementation 'com.google.android.material:material:1.4.0' + implementation 'com.microsoft.identity.client:msal:2.2.1' + + //noinspection GradleDynamicVersion + implementation 'com.facebook.react:react-native:+' +} diff --git a/packages/react-native-test-app-msal/android/gradle.properties b/packages/react-native-test-app-msal/android/gradle.properties new file mode 100644 index 000000000..08bd26ebc --- /dev/null +++ b/packages/react-native-test-app-msal/android/gradle.properties @@ -0,0 +1,3 @@ +# These properties are required to enable AndroidX for the test app. +android.useAndroidX=true +android.enableJetifier=true diff --git a/packages/react-native-test-app-msal/android/src/main/AndroidManifest.xml b/packages/react-native-test-app-msal/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..be7379def --- /dev/null +++ b/packages/react-native-test-app-msal/android/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + diff --git a/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/Account.kt b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/Account.kt new file mode 100644 index 000000000..85d68bf92 --- /dev/null +++ b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/Account.kt @@ -0,0 +1,18 @@ +package com.microsoft.reacttestapp.msal + +import com.microsoft.identity.client.IAccount + +data class Account( + val userPrincipalName: String, + val accountType: AccountType +) { + override fun toString(): String { + return "$userPrincipalName (${accountType.description()})" + } +} + +fun List.find(userPrincipalName: String, accountType: AccountType): IAccount? { + return find { + it.username == userPrincipalName && it.accountType() == accountType + } +} diff --git a/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/AccountType.kt b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/AccountType.kt new file mode 100644 index 000000000..d787e1c15 --- /dev/null +++ b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/AccountType.kt @@ -0,0 +1,28 @@ +package com.microsoft.reacttestapp.msal + +import com.microsoft.identity.client.IAccount + +enum class AccountType(val type: String) { + MICROSOFT_ACCOUNT("MicrosoftAccount"), + ORGANIZATIONAL("Organizational"); + + companion object { + fun fromIssuer(issuer: String): AccountType { + return if (issuer.contains(TokenBroker.MSA_TENANT)) + MICROSOFT_ACCOUNT + else + ORGANIZATIONAL + } + } + + fun description(): String { + return when (this) { + MICROSOFT_ACCOUNT -> "personal" + ORGANIZATIONAL -> "work" + } + } +} + +fun IAccount.accountType(): AccountType { + return AccountType.fromIssuer(claims?.get("iss").toString()) +} diff --git a/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/AccountsAdapter.kt b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/AccountsAdapter.kt new file mode 100644 index 000000000..e6bd25c49 --- /dev/null +++ b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/AccountsAdapter.kt @@ -0,0 +1,31 @@ +package com.microsoft.reacttestapp.msal + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.TextView + +class AccountsAdapter( + context: Context, + private val accounts: MutableList +) : ArrayAdapter(context, R.layout.account_item, accounts) { + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val layoutInflater = LayoutInflater.from(parent.context) + val view = convertView ?: layoutInflater.inflate(R.layout.account_item, parent, false) + + val (userPrincipalName, accountType) = accounts[position] + view.findViewById(R.id.username).text = userPrincipalName + + val accountTypeText = parent.context.resources.getString(R.string.account_type) + view.findViewById(R.id.account_type).text = + String.format(accountTypeText, accountType.description()) + + return view + } + + override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { + return getView(position, convertView, parent) + } +} diff --git a/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/AuthError.kt b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/AuthError.kt new file mode 100644 index 000000000..e0fcd3656 --- /dev/null +++ b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/AuthError.kt @@ -0,0 +1,15 @@ +package com.microsoft.reacttestapp.msal + +import com.microsoft.identity.common.exception.BaseException + +data class AuthError( + val type: AuthErrorType, + val correlationId: String, + val message: String? +) { + constructor(exception: BaseException) : this( + AuthErrorType.fromMsalException(exception), + exception.correlationId ?: TokenBroker.EMPTY_GUID, + exception.message + ) +} diff --git a/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/AuthErrorType.kt b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/AuthErrorType.kt new file mode 100644 index 000000000..9df433b4a --- /dev/null +++ b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/AuthErrorType.kt @@ -0,0 +1,41 @@ +package com.microsoft.reacttestapp.msal + +import com.microsoft.identity.common.exception.BaseException +import com.microsoft.identity.common.exception.DeviceRegistrationRequiredException +import com.microsoft.identity.common.exception.IntuneAppProtectionPolicyRequiredException +import com.microsoft.identity.common.exception.ServiceException +import com.microsoft.identity.common.exception.UserCancelException + +enum class AuthErrorType(val type: String) { + UNKNOWN("Unknown"), + BAD_REFRESH_TOKEN("BadRefreshToken"), + CONDITIONAL_ACCESS_BLOCKED("ConditionalAccessBlocked"), + INTERACTION_REQUIRED("InteractionRequired"), + NO_RESPONSE("NoResponse"), + PRECONDITION_VIOLATED("PreconditionViolated"), + SERVER_DECLINED_SCOPES("ServerDeclinedScopes"), + SERVER_PROTECTION_POLICIES_REQUIRED("ServerProtectionPoliciesRequired"), + TIMEOUT("Timeout"), + USER_CANCELED("UserCanceled"), + WORKPLACE_JOIN_REQUIRED("WorkplaceJoinRequired"); + + companion object { + fun fromMsalException(exception: BaseException?): AuthErrorType { + // Try to map known exceptions found in + // https://github.com/AzureAD/microsoft-authentication-library-common-for-android/tree/dev/common4j/src/main/com/microsoft/identity/common/java/exception + return when (exception) { + is DeviceRegistrationRequiredException -> WORKPLACE_JOIN_REQUIRED + is IntuneAppProtectionPolicyRequiredException -> SERVER_PROTECTION_POLICIES_REQUIRED + is ServiceException -> when (exception.errorCode) { + ServiceException.ACCESS_DENIED -> BAD_REFRESH_TOKEN + ServiceException.INVALID_SCOPE -> SERVER_DECLINED_SCOPES + ServiceException.REQUEST_TIMEOUT -> TIMEOUT + ServiceException.SERVICE_NOT_AVAILABLE -> NO_RESPONSE + else -> UNKNOWN + } + is UserCancelException -> USER_CANCELED + else -> UNKNOWN + } + } + } +} diff --git a/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/AuthResult.kt b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/AuthResult.kt new file mode 100644 index 000000000..f1962add2 --- /dev/null +++ b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/AuthResult.kt @@ -0,0 +1,17 @@ +package com.microsoft.reacttestapp.msal + +import com.microsoft.identity.client.IAuthenticationResult + +data class AuthResult( + val accessToken: String, + val username: String, + val expirationTime: Int, + val redirectUri: String +) { + constructor(result: IAuthenticationResult, redirectUri: String) : this( + result.accessToken, + result.account.username, + (result.expiresOn.time / 1000).toInt(), + redirectUri + ) +} diff --git a/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/Config.kt b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/Config.kt new file mode 100644 index 000000000..d9f06e133 --- /dev/null +++ b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/Config.kt @@ -0,0 +1,15 @@ +package com.microsoft.reacttestapp.msal + +class Config { + companion object { + val msaScopes: Array + get() = BuildConfig.ReactTestAppMSAL_msaScopes + val orgScopes: Array + get() = BuildConfig.ReactTestAppMSAL_orgScopes + + fun scopesFor(accountType: AccountType): Array = when (accountType) { + AccountType.MICROSOFT_ACCOUNT -> msaScopes + AccountType.ORGANIZATIONAL -> orgScopes + } + } +} diff --git a/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/IAccountsHandler.kt b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/IAccountsHandler.kt new file mode 100644 index 000000000..444765116 --- /dev/null +++ b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/IAccountsHandler.kt @@ -0,0 +1,8 @@ +package com.microsoft.reacttestapp.msal + +interface IAccountsHandler { + fun onAddAccount() + fun onSignOut() + fun onSignOutAllAccounts() + fun onSwitchAccount(index: Int) +} diff --git a/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/MicrosoftAccountsActivity.kt b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/MicrosoftAccountsActivity.kt new file mode 100644 index 000000000..834d4adde --- /dev/null +++ b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/MicrosoftAccountsActivity.kt @@ -0,0 +1,192 @@ +package com.microsoft.reacttestapp.msal + +import android.os.Bundle +import android.widget.AdapterView +import android.widget.AutoCompleteTextView +import android.widget.Button +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.textfield.TextInputLayout +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +@Suppress("unused") +class MicrosoftAccountsActivity : AppCompatActivity() { + private val accounts: MutableList = mutableListOf( + Account("arnold@contoso.com", AccountType.MICROSOFT_ACCOUNT) + ) + + private val accountsAdapter: AccountsAdapter by lazy { + AccountsAdapter(this, accounts) + } + + private val accountsDropdown: TextInputLayout by lazy { + findViewById(R.id.accounts_dropdown) + } + + private val executorService: ExecutorService by lazy { + Executors.newSingleThreadExecutor() + } + + private val signOutButton: Button by lazy { + findViewById(R.id.sign_out) + } + + private val signOutAllButton: Button by lazy { + findViewById(R.id.sign_out_all) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(R.layout.microsoft_accounts) + + (accountsDropdown.editText as? AutoCompleteTextView)?.apply { + onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ -> + onSwitchAccount(position) + } + setAdapter(accountsAdapter) + } + findViewById(R.id.add_account).apply { + setOnClickListener { + this@MicrosoftAccountsActivity.onAddAccount() + } + } + signOutButton.setOnClickListener { + onSignOut() + } + signOutAllButton.setOnClickListener { + onSignOutAllAccounts() + } + } + + private fun addAccount(accountType: AccountType) { + withTokenBroker { tokenBroker -> + tokenBroker.acquireToken( + this, + Config.scopesFor(accountType), + null, + accountType + ) { result: AuthResult?, error: AuthError? -> + when { + error != null -> showErrorMessage( + error.message ?: resources.getString(R.string.error_sign_in) + ) + result == null -> showErrorMessage(R.string.error_sign_in) + else -> { + val allAccounts = tokenBroker.allAccounts() + tokenBroker.currentAccount = allAccounts.findLast { + it.accountType == accountType && it.userPrincipalName == result.username + } + accounts.clear() + accounts.addAll(allAccounts) + accountsAdapter.notifyDataSetChanged() + + runOnUiThread { + accountsDropdown.isEnabled = allAccounts.isNotEmpty() + signOutAllButton.isEnabled = allAccounts.size > 1 + } + } + } + } + } + } + + private fun onAddAccount() { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.select_account_type) + .setNegativeButton(R.string.account_type_personal) { _, _ -> + addAccount(AccountType.MICROSOFT_ACCOUNT) + } + .setPositiveButton(R.string.account_type_work) { _, _ -> + addAccount(AccountType.ORGANIZATIONAL) + } + .setCancelable(true) + .show() + } + + private fun onSignOut() { + signOutButton.isEnabled = false + + withTokenBroker { tokenBroker -> + tokenBroker.signOut { exception -> + when (exception) { + null -> { + val index = accounts.indexOf(tokenBroker.currentAccount) + accounts.removeAt(index) + accountsAdapter.notifyDataSetChanged() + + runOnUiThread { + accountsDropdown.editText?.text?.clear() + accountsDropdown.isEnabled = accounts.isNotEmpty() + signOutAllButton.isEnabled = accounts.size > 1 + } + } + else -> showErrorMessage(R.string.error_sign_out) + } + } + } + } + + private fun onSignOutAllAccounts() { + accountsDropdown.editText?.text?.clear() + accountsDropdown.isEnabled = false + signOutButton.isEnabled = false + signOutAllButton.isEnabled = false + + withTokenBroker { tokenBroker -> + tokenBroker.removeAllAccounts() + accounts.clear() + accountsAdapter.notifyDataSetChanged() + } + } + + private fun onSwitchAccount(index: Int) { + withTokenBroker { tokenBroker -> + runOnUiThread { + signOutButton.isEnabled = true + } + + val account = accounts[index] + tokenBroker.currentAccount = account + + val scopes = Config.scopesFor(account.accountType) + if (scopes.isNotEmpty()) { + tokenBroker.acquireToken( + this, + scopes, + account.userPrincipalName, + account.accountType + ) { result: AuthResult?, error: AuthError? -> + when { + error != null -> showErrorMessage( + error.message ?: resources.getString(R.string.error_refresh) + ) + result == null -> showErrorMessage(R.string.error_refresh) + } + } + } + } + } + + private fun showErrorMessage(message: String) { + Snackbar.make(accountsDropdown, message, Snackbar.LENGTH_LONG).show() + } + + private fun showErrorMessage(@StringRes resId: Int) { + Snackbar.make(accountsDropdown, resId, Snackbar.LENGTH_LONG).show() + } + + private fun withTokenBroker(execute: (tokenBroker: TokenBroker) -> Unit) { + executorService.execute { + try { + execute(TokenBroker.getInstance(this)) + } catch (e: Exception) { + showErrorMessage(e.message ?: "Unknown error") + } + } + } +} diff --git a/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/MsalPackage.kt b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/MsalPackage.kt new file mode 100644 index 000000000..7b685f033 --- /dev/null +++ b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/MsalPackage.kt @@ -0,0 +1,19 @@ +package com.microsoft.reacttestapp.msal + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +/** + * Dummy `ReactPackage` implementation used solely for auto-linking purposes. + */ +class MsalPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return emptyList() + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return emptyList() + } +} diff --git a/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/TokenBroker.kt b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/TokenBroker.kt new file mode 100644 index 000000000..124ed7427 --- /dev/null +++ b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/TokenBroker.kt @@ -0,0 +1,168 @@ +package com.microsoft.reacttestapp.msal + +import android.app.Activity +import android.content.Context +import com.microsoft.identity.client.AcquireTokenParameters +import com.microsoft.identity.client.AuthenticationCallback +import com.microsoft.identity.client.IAuthenticationResult +import com.microsoft.identity.client.IMultipleAccountPublicClientApplication +import com.microsoft.identity.client.PublicClientApplication +import com.microsoft.identity.client.exception.MsalException +import com.microsoft.identity.common.exception.BaseException +import com.microsoft.identity.common.exception.UiRequiredException + +typealias TokenAcquiredHandler = (result: AuthResult?, error: AuthError?) -> Unit + +class TokenBroker private constructor(context: Context) { + companion object { + const val EMPTY_GUID = "00000000-0000-0000-0000-000000000000" + + // Source: https://docs.microsoft.com/en-us/azure/active-directory/develop/id-tokens + const val MSA_TENANT = "9188040d-6c67-4c5b-b112-36a304b66dad" + + @Volatile + private var INSTANCE: TokenBroker? = null + + fun getInstance(context: Context): TokenBroker = + INSTANCE ?: synchronized(this) { + INSTANCE ?: TokenBroker(context).also { + INSTANCE = it + } + } + } + + var currentAccount: Account? = null + + private var multiAccountApp: IMultipleAccountPublicClientApplication + + init { + val configFileResourceId = context.resources + .getIdentifier("raw/msal_config", null, context.packageName) + multiAccountApp = PublicClientApplication.createMultipleAccountPublicClientApplication( + context, + configFileResourceId + ) + } + + fun acquireToken( + activity: Activity, + scopes: Array, + userPrincipalName: String?, + accountType: AccountType, + onTokenAcquired: TokenAcquiredHandler + ) { + acquireTokenSilent( + activity, + scopes, + userPrincipalName, + accountType, + onTokenAcquired + ) + } + + fun allAccounts(): List = multiAccountApp.accounts.map { + Account( + it.username, + AccountType.fromIssuer(it.claims?.get("iss").toString()) + ) + } + + fun removeAllAccounts() { + currentAccount = null + multiAccountApp.accounts.forEach { + multiAccountApp.removeAccount(it) + } + } + + fun signOut(onCompleted: (exception: Exception?) -> Unit) { + val account = currentAccount?.let { + multiAccountApp.accounts.find(it.userPrincipalName, it.accountType) + } + when (account) { + null -> onCompleted(null) + else -> multiAccountApp.removeAccount( + account, + object : IMultipleAccountPublicClientApplication.RemoveAccountCallback { + override fun onRemoved() { + onCompleted(null) + } + + override fun onError(exception: MsalException) { + onCompleted(exception) + } + } + ) + } + currentAccount = null + } + + private fun acquireTokenInteractive( + activity: Activity, + scopes: Array, + userPrincipalName: String?, + accountType: AccountType, + onTokenAcquired: TokenAcquiredHandler + ) { + val parameters = AcquireTokenParameters.Builder() + .startAuthorizationFromActivity(activity) + .withScopes(scopes.toMutableList()) + .withCallback(object : AuthenticationCallback { + override fun onSuccess(result: IAuthenticationResult) { + val redirectUri = multiAccountApp.configuration.redirectUri + onTokenAcquired(AuthResult(result, redirectUri), null) + } + + override fun onError(exception: MsalException) { + onTokenAcquired(null, AuthError(exception)) + } + + override fun onCancel() { + onTokenAcquired( + null, + AuthError(AuthErrorType.USER_CANCELED, EMPTY_GUID, null) + ) + } + }) + userPrincipalName?.let { parameters.withLoginHint(it) } + multiAccountApp.acquireToken(parameters.build()) + } + + private fun acquireTokenSilent( + activity: Activity, + scopes: Array, + userPrincipalName: String?, + accountType: AccountType, + onTokenAcquired: TokenAcquiredHandler + ) { + val account = userPrincipalName?.let { + multiAccountApp.accounts.find(userPrincipalName, accountType) + } + if (account == null) { + acquireTokenInteractive( + activity, + scopes, + userPrincipalName, + accountType, + onTokenAcquired + ) + return + } + + val authority = multiAccountApp.configuration.defaultAuthority.authorityURL.toString() + try { + val result = multiAccountApp.acquireTokenSilent(scopes, account, authority) + val redirectUri = multiAccountApp.configuration.redirectUri + onTokenAcquired(AuthResult(result, redirectUri), null) + } catch (_: UiRequiredException) { + acquireTokenInteractive( + activity, + scopes, + userPrincipalName, + accountType, + onTokenAcquired + ) + } catch (exception: BaseException) { + onTokenAcquired(null, AuthError(exception)) + } + } +} diff --git a/packages/react-native-test-app-msal/android/src/main/res/color/destructive_btn_bg_color_selector.xml b/packages/react-native-test-app-msal/android/src/main/res/color/destructive_btn_bg_color_selector.xml new file mode 100644 index 000000000..8c7d9dc92 --- /dev/null +++ b/packages/react-native-test-app-msal/android/src/main/res/color/destructive_btn_bg_color_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/react-native-test-app-msal/android/src/main/res/color/destructive_btn_text_color_selector.xml b/packages/react-native-test-app-msal/android/src/main/res/color/destructive_btn_text_color_selector.xml new file mode 100644 index 000000000..855983f8c --- /dev/null +++ b/packages/react-native-test-app-msal/android/src/main/res/color/destructive_btn_text_color_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/react-native-test-app-msal/android/src/main/res/drawable/ic_person_add_24dp.xml b/packages/react-native-test-app-msal/android/src/main/res/drawable/ic_person_add_24dp.xml new file mode 100644 index 000000000..b6f806f9f --- /dev/null +++ b/packages/react-native-test-app-msal/android/src/main/res/drawable/ic_person_add_24dp.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/packages/react-native-test-app-msal/android/src/main/res/layout/account_item.xml b/packages/react-native-test-app-msal/android/src/main/res/layout/account_item.xml new file mode 100644 index 000000000..2285eefae --- /dev/null +++ b/packages/react-native-test-app-msal/android/src/main/res/layout/account_item.xml @@ -0,0 +1,40 @@ + + + + + + + + diff --git a/packages/react-native-test-app-msal/android/src/main/res/layout/microsoft_accounts.xml b/packages/react-native-test-app-msal/android/src/main/res/layout/microsoft_accounts.xml new file mode 100644 index 000000000..98d096708 --- /dev/null +++ b/packages/react-native-test-app-msal/android/src/main/res/layout/microsoft_accounts.xml @@ -0,0 +1,67 @@ + + + + + + + + + +