diff --git a/.changeset/breezy-mugs-shake.md b/.changeset/breezy-mugs-shake.md new file mode 100644 index 000000000..0642a526e --- /dev/null +++ b/.changeset/breezy-mugs-shake.md @@ -0,0 +1,5 @@ +--- +"@rnx-kit/react-native-test-app-msal": minor +--- + +Added support for Android diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 955e0b743..3b37edcaa 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -90,6 +90,31 @@ jobs: yarn bundle+esbuild shell: bash working-directory: packages/test-app + build-android: + name: "Build Android" + runs-on: ubuntu-latest + steps: + - name: Set up Node 14 + uses: actions/setup-node@v2.4.1 + with: + node-version: 14 + - name: Checkout + uses: actions/checkout@v2.4.0 + with: + fetch-depth: 0 + - name: Cache /.yarn-offline-mirror + uses: actions/cache@v2.1.7 + with: + path: .yarn-offline-mirror + key: ${{ runner.os }}-${{ hashFiles('yarn.lock') }}-2 + - name: Install npm dependencies + run: yarn ci + env: + CI_SKIP_GO: 1 + - name: Build Android app + run: | + ./gradlew clean build + working-directory: packages/test-app/android build-ios: name: "Build iOS" runs-on: macos-11 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..d2cb99554 --- /dev/null +++ b/packages/react-native-test-app-msal/android/build.gradle @@ -0,0 +1,162 @@ +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', 23) + 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: false, + 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 'androidx.activity:activity-ktx:1.4.0' + 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..0700ba429 --- /dev/null +++ b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/AccountType.kt @@ -0,0 +1,31 @@ +package com.microsoft.reacttestapp.msal + +import com.microsoft.identity.client.IAccount + +enum class AccountType(val type: String) { + MICROSOFT_ACCOUNT("MicrosoftAccount"), + ORGANIZATIONAL("Organizational"); + + companion object { + // Source: https://docs.microsoft.com/en-us/azure/active-directory/develop/id-tokens + private const val MSA_TENANT = "9188040d-6c67-4c5b-b112-36a304b66dad" + + fun fromIssuer(issuer: String): AccountType { + return if (issuer.contains(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/AccountsViewModel.kt b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/AccountsViewModel.kt new file mode 100644 index 000000000..d27daf010 --- /dev/null +++ b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/AccountsViewModel.kt @@ -0,0 +1,22 @@ +package com.microsoft.reacttestapp.msal + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel + +class AccountsViewModel : ViewModel() { + val accounts: MutableLiveData> by lazy { + MutableLiveData>() + } + + val canAddAccount: MutableLiveData by lazy { + MutableLiveData(true) + } + + val canSignOut: MutableLiveData by lazy { + MutableLiveData(false) + } + + val selectedAccount: MutableLiveData by lazy { + MutableLiveData() + } +} 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..2fa9f1705 --- /dev/null +++ b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/Config.kt @@ -0,0 +1,20 @@ +package com.microsoft.reacttestapp.msal + +class Config { + companion object { + val msaScopes: Array + get() = BuildConfig.ReactTestAppMSAL_msaScopes + val orgScopes: Array + get() = BuildConfig.ReactTestAppMSAL_orgScopes + + fun authorityFor(accountType: AccountType): String = when (accountType) { + AccountType.MICROSOFT_ACCOUNT -> "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize" + AccountType.ORGANIZATIONAL -> "https://login.microsoftonline.com/common/" + } + + 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/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..dca3108c9 --- /dev/null +++ b/packages/react-native-test-app-msal/android/src/main/java/com/microsoft/reacttestapp/msal/MicrosoftAccountsActivity.kt @@ -0,0 +1,272 @@ +package com.microsoft.reacttestapp.msal + +import android.content.ClipData +import android.content.ClipboardManager +import android.os.Bundle +import android.widget.AdapterView +import android.widget.AutoCompleteTextView +import android.widget.Button +import android.widget.TextView +import androidx.activity.viewModels +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import androidx.lifecycle.Observer +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() { + companion object { + private const val ACCOUNT_TYPE_KEY = "selected_account_type" + private const val USERNAME_KEY = "selected_user_principal_name" + } + + private val accounts: MutableList = mutableListOf() + + private val accountsDropdown: TextInputLayout by lazy { + findViewById(R.id.accounts_dropdown) + } + + private val executorService: ExecutorService by lazy { + Executors.newSingleThreadExecutor() + } + + private val viewModel: AccountsViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(R.layout.microsoft_accounts) + + val accountsAdapter = AccountsAdapter(this, accounts) + (accountsDropdown.editText as? AutoCompleteTextView)?.apply { + onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ -> + onAccountSelected(position) + } + setAdapter(accountsAdapter) + } + + findViewById